replylayer 0.14.0__tar.gz → 0.16.0__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 (41) hide show
  1. {replylayer-0.14.0 → replylayer-0.16.0}/PKG-INFO +8 -4
  2. {replylayer-0.14.0 → replylayer-0.16.0}/README.md +5 -3
  3. {replylayer-0.14.0 → replylayer-0.16.0}/pyproject.toml +6 -1
  4. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/__init__.py +11 -1
  5. replylayer-0.16.0/replylayer/__main__.py +25 -0
  6. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/_http.py +1 -1
  7. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/drafts.py +24 -10
  8. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/messages.py +34 -1
  9. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/threads.py +57 -5
  10. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/types.py +29 -0
  11. replylayer-0.16.0/tests/test_version.py +18 -0
  12. replylayer-0.16.0/tests/test_ws1_ws6.py +369 -0
  13. {replylayer-0.14.0 → replylayer-0.16.0}/uv.lock +21 -2
  14. {replylayer-0.14.0 → replylayer-0.16.0}/.gitignore +0 -0
  15. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/_client.py +0 -0
  16. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/_pagination.py +0 -0
  17. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/errors.py +0 -0
  18. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/py.typed +0 -0
  19. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/__init__.py +0 -0
  20. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/account.py +0 -0
  21. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/api_keys.py +0 -0
  22. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/attachments.py +0 -0
  23. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/domains.py +0 -0
  24. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/health.py +0 -0
  25. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/inbound_blocklist.py +0 -0
  26. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/legal_holds.py +0 -0
  27. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/mailboxes.py +0 -0
  28. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/recipients.py +0 -0
  29. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/suppressions.py +0 -0
  30. {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/webhooks.py +0 -0
  31. {replylayer-0.14.0 → replylayer-0.16.0}/tests/__init__.py +0 -0
  32. {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_async.py +0 -0
  33. {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_attachments.py +0 -0
  34. {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_client.py +0 -0
  35. {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_domains.py +0 -0
  36. {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_drafts.py +0 -0
  37. {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_hitl_review_types.py +0 -0
  38. {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_http.py +0 -0
  39. {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_resources.py +0 -0
  40. {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_web_risk_types.py +0 -0
  41. {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_webhooks.py +0 -0
@@ -1,12 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: replylayer
3
- Version: 0.14.0
3
+ Version: 0.16.0
4
4
  Summary: Official Python SDK for ReplyLayer — email for AI agents
5
5
  License-Expression: MIT
6
6
  Keywords: agent,ai,email,mailbox,replylayer,sdk,webhook
7
7
  Requires-Python: >=3.10
8
8
  Requires-Dist: httpx>=0.27
9
9
  Requires-Dist: typing-extensions>=4.0
10
+ Provides-Extra: cli
11
+ Requires-Dist: rly>=0.6.3; extra == 'cli'
10
12
  Provides-Extra: dev
11
13
  Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
12
14
  Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -17,6 +19,8 @@ Description-Content-Type: text/markdown
17
19
 
18
20
  Official Python SDK for [ReplyLayer](https://replylayer.ai) — secure email for AI agents.
19
21
 
22
+ > **Looking for the command-line tool?** This package is the SDK *library* (`import replylayer`). For the `rly` / `replylayer` CLI, install [`rly`](https://pypi.org/project/rly/) instead: `pipx install rly`.
23
+
20
24
  ## Install
21
25
 
22
26
  ```bash
@@ -114,9 +118,9 @@ contract — read it before relying on retries:
114
118
  |----------|---------|
115
119
  | `rl.mailboxes` | `create`, `list`, `delete`, `update`, `set_recipient_policy` |
116
120
  | `rl.mailboxes.allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
117
- | `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block` |
121
+ | `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block`, `set_starred` |
118
122
  | `rl.drafts` | `create`, `get`, `list`, `update`, `send`, `delete` |
119
- | `rl.threads` | `list`, `get` |
123
+ | `rl.threads` | `list`, `get`, `set_starred` |
120
124
  | `rl.attachments` | `get_download_url`, `get_preview`, `upload`, `get_upload`, `delete_upload` |
121
125
  | `rl.webhooks` | `create`, `list`, `get`, `update`, `delete`, `rotate_secret`, `test`, `list_deliveries`, `retry_delivery` |
122
126
  | `rl.recipients` | `create`, `list`, `delete`, `resend` |
@@ -145,7 +149,7 @@ if draft["worst_decision"] == "allow":
145
149
 
146
150
  The send/reply/draft-send response carries two additive, nullable keys that explain a held send inline (no second `messages.get` call). `result["scan"]` is the vendor-neutral scanner verdict (`ScanSummary`); `result["hold_context"]` (`{"trigger_source", "summary_reasons"}` or `None`) is the policy/HITL reason, non-null only when the delivery `status` diverges from `scan["verdict"]` because of a policy/HITL hold — a clean scan held for review by your mailbox policy, or a scanner review-flag held as quarantine on a plan without the review queue (`trigger_source`: `mailbox_policy` | `scanner` | `both`).
147
151
 
148
- This SDK always sends **synchronously** — `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. The optimistic-ack async path (`Prefer: respond-async` `202 queued_for_dispatch`, then poll the message to a terminal state) is a REST-level capability of `POST /v1/drafts/:id/send` only; the SDK exposes no `Prefer` option. To use it, drive that route directly (see ENDPOINTS.md "Asynchronous send (optimistic-ack) & polling") and poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
152
+ By default `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. Pass `async_dispatch=True` to `drafts.send()` to send the `Prefer: respond-async` hint. **The hint is advisory** the server returns a `202 AsyncSendAck` only when `OUTBOUND_ASYNC_DISPATCH_ENABLED` is on; otherwise it ignores the hint and returns a normal `SendMessageResponse`. **Always branch on the result**: `result["status"] == "queued_for_dispatch"` `AsyncSendAck`, otherwise `SendMessageResponse`. Poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. Attachment-bearing drafts fail closed on the async path (`400 ATTACHMENTS_REQUIRE_SYNC_SEND`). (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
149
153
 
150
154
  The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
151
155
  - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan` and, when a policy/HITL decision drove the hold, `hold_context`.
@@ -2,6 +2,8 @@
2
2
 
3
3
  Official Python SDK for [ReplyLayer](https://replylayer.ai) — secure email for AI agents.
4
4
 
5
+ > **Looking for the command-line tool?** This package is the SDK *library* (`import replylayer`). For the `rly` / `replylayer` CLI, install [`rly`](https://pypi.org/project/rly/) instead: `pipx install rly`.
6
+
5
7
  ## Install
6
8
 
7
9
  ```bash
@@ -99,9 +101,9 @@ contract — read it before relying on retries:
99
101
  |----------|---------|
100
102
  | `rl.mailboxes` | `create`, `list`, `delete`, `update`, `set_recipient_policy` |
101
103
  | `rl.mailboxes.allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
102
- | `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block` |
104
+ | `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block`, `set_starred` |
103
105
  | `rl.drafts` | `create`, `get`, `list`, `update`, `send`, `delete` |
104
- | `rl.threads` | `list`, `get` |
106
+ | `rl.threads` | `list`, `get`, `set_starred` |
105
107
  | `rl.attachments` | `get_download_url`, `get_preview`, `upload`, `get_upload`, `delete_upload` |
106
108
  | `rl.webhooks` | `create`, `list`, `get`, `update`, `delete`, `rotate_secret`, `test`, `list_deliveries`, `retry_delivery` |
107
109
  | `rl.recipients` | `create`, `list`, `delete`, `resend` |
@@ -130,7 +132,7 @@ if draft["worst_decision"] == "allow":
130
132
 
131
133
  The send/reply/draft-send response carries two additive, nullable keys that explain a held send inline (no second `messages.get` call). `result["scan"]` is the vendor-neutral scanner verdict (`ScanSummary`); `result["hold_context"]` (`{"trigger_source", "summary_reasons"}` or `None`) is the policy/HITL reason, non-null only when the delivery `status` diverges from `scan["verdict"]` because of a policy/HITL hold — a clean scan held for review by your mailbox policy, or a scanner review-flag held as quarantine on a plan without the review queue (`trigger_source`: `mailbox_policy` | `scanner` | `both`).
132
134
 
133
- This SDK always sends **synchronously** — `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. The optimistic-ack async path (`Prefer: respond-async` `202 queued_for_dispatch`, then poll the message to a terminal state) is a REST-level capability of `POST /v1/drafts/:id/send` only; the SDK exposes no `Prefer` option. To use it, drive that route directly (see ENDPOINTS.md "Asynchronous send (optimistic-ack) & polling") and poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
135
+ By default `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. Pass `async_dispatch=True` to `drafts.send()` to send the `Prefer: respond-async` hint. **The hint is advisory** the server returns a `202 AsyncSendAck` only when `OUTBOUND_ASYNC_DISPATCH_ENABLED` is on; otherwise it ignores the hint and returns a normal `SendMessageResponse`. **Always branch on the result**: `result["status"] == "queued_for_dispatch"` `AsyncSendAck`, otherwise `SendMessageResponse`. Poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. Attachment-bearing drafts fail closed on the async path (`400 ATTACHMENTS_REQUIRE_SYNC_SEND`). (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
134
136
 
135
137
  The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
136
138
  - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan` and, when a policy/HITL decision drove the hold, `hold_context`.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "replylayer"
7
- version = "0.14.0"
7
+ version = "0.16.0"
8
8
  description = "Official Python SDK for ReplyLayer — email for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -18,6 +18,11 @@ dependencies = [
18
18
  ]
19
19
 
20
20
  [project.optional-dependencies]
21
+ # Optional CLI convenience: `pip install "replylayer[cli]"` also installs the
22
+ # `rly` launcher (the `rly` / `replylayer` command-line tools). The SDK itself
23
+ # is a pure library; this extra is opt-in and is never pulled by a plain
24
+ # `pip install replylayer`.
25
+ cli = ["rly>=0.6.3"]
21
26
  dev = [
22
27
  "pytest>=8.0",
23
28
  "pytest-asyncio>=0.24",
@@ -14,6 +14,11 @@ from .errors import (
14
14
  TimezoneRequiredError,
15
15
  )
16
16
  from .types import (
17
+ # WS1 — star response types (0.16.0).
18
+ MessageStarResponse,
19
+ ThreadStarResponse,
20
+ # WS6-SDK — async optimistic-ack (0.16.0).
21
+ AsyncSendAck,
17
22
  WebhookSummary,
18
23
  WebhookDeliverySummary,
19
24
  WebhookDeliveryStatus,
@@ -71,9 +76,14 @@ from .types import (
71
76
  ScannerPolicy,
72
77
  )
73
78
 
74
- __version__ = "0.14.0"
79
+ __version__ = "0.16.0"
75
80
 
76
81
  __all__ = [
82
+ # WS1 — star response types (0.16.0).
83
+ "MessageStarResponse",
84
+ "ThreadStarResponse",
85
+ # WS6-SDK — async optimistic-ack (0.16.0).
86
+ "AsyncSendAck",
77
87
  "ReplyLayer",
78
88
  "AsyncReplyLayer",
79
89
  "RetryInfo",
@@ -0,0 +1,25 @@
1
+ """Entry point for ``python -m replylayer``.
2
+
3
+ The ``replylayer`` PyPI package is the ReplyLayer Python **SDK** — a library you
4
+ ``import``, not a command-line tool. This module exists only to redirect anyone
5
+ who tries to "run" the package toward the actual CLI (the separate ``rly``
6
+ package), rather than failing silently.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ def main() -> None:
13
+ print(
14
+ "replylayer is the ReplyLayer Python SDK (a library, not a CLI).\n"
15
+ "\n"
16
+ " Use it in code: import replylayer\n"
17
+ " Install the CLI: pipx install rly "
18
+ "# provides the `rly` and `replylayer` commands\n"
19
+ "\n"
20
+ "Docs: https://replylayer.ai"
21
+ )
22
+
23
+
24
+ if __name__ == "__main__":
25
+ main()
@@ -9,7 +9,7 @@ import httpx
9
9
 
10
10
  from .errors import ReplyLayerError, error_from_response
11
11
 
12
- _VERSION = "0.14.0"
12
+ _VERSION = "0.16.0"
13
13
  _USER_AGENT = f"replylayer-sdk-py/{_VERSION}"
14
14
  _PROTECTED_HEADER_KEYS = frozenset({"authorization", "content-type", "user-agent"})
15
15
 
@@ -27,7 +27,7 @@ from typing import Any, AsyncIterator, Iterator, Union
27
27
  from .._http import AsyncHttpClient, SyncHttpClient
28
28
  from .._pagination import async_auto_paginate, sync_auto_paginate
29
29
  from ..errors import TimezoneRequiredError
30
- from ..types import Page
30
+ from ..types import AsyncSendAck, Page, SendMessageResponse
31
31
 
32
32
  DEFAULT_LIMIT = 50
33
33
 
@@ -187,13 +187,24 @@ class SyncDrafts:
187
187
  payload["attachment_ids"] = attachment_ids
188
188
  return self._http.request("PATCH", f"/v1/drafts/{id}", body=payload)
189
189
 
190
- def send(self, id: str) -> dict[str, Any]:
190
+ def send(self, id: str, *, async_dispatch: bool = False) -> "SendMessageResponse | AsyncSendAck":
191
191
  """Dispatch a draft.
192
192
 
193
193
  Re-runs the scanner authoritatively + the full send-time gate
194
194
  stack (suppressions, reply-loop, budget, etc) before handing the
195
195
  message to the outbound provider.
196
196
 
197
+ Pass ``async_dispatch=True`` to send the ``Prefer: respond-async``
198
+ hint. **The hint is advisory** — the server returns a 202
199
+ ``AsyncSendAck`` (``status == "queued_for_dispatch"``) only when
200
+ ``OUTBOUND_ASYNC_DISPATCH_ENABLED`` is on; otherwise it ignores the
201
+ hint and returns a normal ``SendMessageResponse``. **Always branch on
202
+ the result**: ``status == "queued_for_dispatch"`` ⇒ ``AsyncSendAck``,
203
+ otherwise ``SendMessageResponse``. Attachment-bearing drafts fail
204
+ closed on the async path (``400 ATTACHMENTS_REQUIRE_SYNC_SEND``).
205
+ Poll ``messages.get(message_id)`` until ``state`` is terminal to
206
+ observe the final outcome.
207
+
197
208
  Sandbox accounts are subject to a 250-cumulative-send trial
198
209
  budget. Once exhausted the API returns 403 with
199
210
  ``code='SANDBOX_TRIAL_BUDGET_EXHAUSTED'`` and a ``details``
@@ -201,7 +212,8 @@ class SyncDrafts:
201
212
  cap fires here as on ``messages.send()`` — the cumulative
202
213
  counter is shared across both surfaces.
203
214
  """
204
- return self._http.request("POST", f"/v1/drafts/{id}/send", body={})
215
+ extra_headers: dict[str, str] | None = {"Prefer": "respond-async"} if async_dispatch else None
216
+ return self._http.request("POST", f"/v1/drafts/{id}/send", body={}, extra_headers=extra_headers)
205
217
 
206
218
  def delete(self, id: str) -> None:
207
219
  self._http.request("DELETE", f"/v1/drafts/{id}")
@@ -328,15 +340,17 @@ class AsyncDrafts:
328
340
  payload["attachment_ids"] = attachment_ids
329
341
  return await self._http.request("PATCH", f"/v1/drafts/{id}", body=payload)
330
342
 
331
- async def send(self, id: str) -> dict[str, Any]:
332
- """Dispatch a draft.
343
+ async def send(self, id: str, *, async_dispatch: bool = False) -> "SendMessageResponse | AsyncSendAck":
344
+ """Dispatch a draft (async). See SyncDrafts.send for the full contract.
333
345
 
334
- Sandbox accounts are subject to a 250-cumulative-send trial
335
- budget. Once exhausted the API returns 403 with
336
- ``code='SANDBOX_TRIAL_BUDGET_EXHAUSTED'`` and a ``details``
337
- payload carrying ``feature='sandbox_cumulative_send_cap'``.
346
+ Pass ``async_dispatch=True`` to send the ``Prefer: respond-async``
347
+ hint. The hint is advisory — the server returns a 202 ``AsyncSendAck``
348
+ only when ``OUTBOUND_ASYNC_DISPATCH_ENABLED`` is on; otherwise it
349
+ ignores the hint and returns a normal ``SendMessageResponse``. Always
350
+ branch on ``status == "queued_for_dispatch"`` to distinguish the two.
338
351
  """
339
- return await self._http.request("POST", f"/v1/drafts/{id}/send", body={})
352
+ extra_headers: dict[str, str] | None = {"Prefer": "respond-async"} if async_dispatch else None
353
+ return await self._http.request("POST", f"/v1/drafts/{id}/send", body={}, extra_headers=extra_headers)
340
354
 
341
355
  async def delete(self, id: str) -> None:
342
356
  await self._http.request("DELETE", f"/v1/drafts/{id}")
@@ -4,7 +4,7 @@ from typing import Any, AsyncIterator, Iterator
4
4
 
5
5
  from .._http import AsyncHttpClient, SyncHttpClient
6
6
  from .._pagination import async_auto_paginate, sync_auto_paginate
7
- from ..types import Page
7
+ from ..types import MessageStarResponse, Page
8
8
 
9
9
  DEFAULT_LIMIT = 50
10
10
 
@@ -81,6 +81,8 @@ class SyncMessages:
81
81
  until: str | None = None,
82
82
  search: str | None = None,
83
83
  view: str | None = None,
84
+ starred: bool | None = None,
85
+ has_attachment: bool | None = None,
84
86
  auto_paginate: bool = False,
85
87
  ) -> Union[Page, Iterator[dict[str, Any]]]:
86
88
  """List messages in a mailbox.
@@ -90,6 +92,10 @@ class SyncMessages:
90
92
  server's blind-trigram index has no shorter form. A 1-2 character
91
93
  ``search`` is rejected with HTTP 400 ``code='SEARCH_TERM_TOO_SHORT'``
92
94
  (``details.min_search_length=3``).
95
+
96
+ ``has_attachment`` requires a server advertising
97
+ ``messages.has_attachment_filter`` in ``GET /v1/health``'s
98
+ ``capabilities``; older servers reject the param.
93
99
  """
94
100
  query: dict[str, str | None] = {
95
101
  "limit": str(limit),
@@ -102,6 +108,8 @@ class SyncMessages:
102
108
  "until": until,
103
109
  "search": search,
104
110
  "view": view,
111
+ "starred": str(starred).lower() if starred is not None else None,
112
+ "has_attachment": str(has_attachment).lower() if has_attachment is not None else None,
105
113
  }
106
114
 
107
115
  def fetch_page(cursor: str | None) -> Page:
@@ -195,6 +203,17 @@ class SyncMessages:
195
203
  "POST", f"/v1/messages/{message_id}/read", body={}
196
204
  )
197
205
 
206
+ def set_starred(self, message_id: str, *, starred: bool) -> "MessageStarResponse":
207
+ """Star or unstar a message.
208
+
209
+ ``starred=True`` marks the message as starred (favorited);
210
+ ``starred=False`` clears the star. Idempotent.
211
+ Wraps ``PATCH /v1/messages/:id/star``.
212
+ """
213
+ return self._http.request(
214
+ "PATCH", f"/v1/messages/{message_id}/star", body={"starred": starred}
215
+ )
216
+
198
217
  def approve_review(
199
218
  self, message_id: str, *, reason: str | None = None
200
219
  ) -> dict[str, Any]:
@@ -298,6 +317,8 @@ class AsyncMessages:
298
317
  until: str | None = None,
299
318
  search: str | None = None,
300
319
  view: str | None = None,
320
+ starred: bool | None = None,
321
+ has_attachment: bool | None = None,
301
322
  auto_paginate: bool = False,
302
323
  ) -> Page | AsyncIterator[dict[str, Any]]:
303
324
  """List messages in a mailbox.
@@ -307,6 +328,10 @@ class AsyncMessages:
307
328
  server's blind-trigram index has no shorter form. A 1-2 character
308
329
  ``search`` is rejected with HTTP 400 ``code='SEARCH_TERM_TOO_SHORT'``
309
330
  (``details.min_search_length=3``).
331
+
332
+ ``has_attachment`` requires a server advertising
333
+ ``messages.has_attachment_filter`` in ``GET /v1/health``'s
334
+ ``capabilities``; older servers reject the param.
310
335
  """
311
336
  query: dict[str, str | None] = {
312
337
  "limit": str(limit),
@@ -319,6 +344,8 @@ class AsyncMessages:
319
344
  "until": until,
320
345
  "search": search,
321
346
  "view": view,
347
+ "starred": str(starred).lower() if starred is not None else None,
348
+ "has_attachment": str(has_attachment).lower() if has_attachment is not None else None,
322
349
  }
323
350
 
324
351
  async def fetch_page(cursor: str | None) -> Page:
@@ -396,6 +423,12 @@ class AsyncMessages:
396
423
  "POST", f"/v1/messages/{message_id}/read", body={}
397
424
  )
398
425
 
426
+ async def set_starred(self, message_id: str, *, starred: bool) -> "MessageStarResponse":
427
+ """Star or unstar a message (async). See SyncMessages.set_starred."""
428
+ return await self._http.request(
429
+ "PATCH", f"/v1/messages/{message_id}/star", body={"starred": starred}
430
+ )
431
+
399
432
  async def approve_review(
400
433
  self, message_id: str, *, reason: str | None = None
401
434
  ) -> dict[str, Any]:
@@ -5,7 +5,7 @@ from urllib.parse import quote
5
5
 
6
6
  from .._http import AsyncHttpClient, SyncHttpClient
7
7
  from .._pagination import async_auto_paginate, sync_auto_paginate
8
- from ..types import Page
8
+ from ..types import Page, ThreadStarResponse
9
9
 
10
10
  DEFAULT_LIMIT = 50
11
11
 
@@ -44,8 +44,26 @@ class SyncThreads:
44
44
  return sync_auto_paginate(fetch_page)
45
45
  return fetch_page(None)
46
46
 
47
- def get(self, id: str, *, view: str | None = None, body_format: str | None = None) -> dict[str, Any]:
48
- query = {k: v for k, v in {"view": view, "body_format": body_format}.items() if v is not None}
47
+ def get(
48
+ self,
49
+ id: str,
50
+ *,
51
+ view: str | None = None,
52
+ body_format: str | None = None,
53
+ mailbox: str | None = None,
54
+ ) -> dict[str, Any]:
55
+ """Read a full thread (ordered messages).
56
+
57
+ Account-wide by default; pass ``mailbox`` (name or UUID) to scope the
58
+ lookup to one mailbox when the same thread key collides across two of
59
+ the account's mailboxes. ``mailbox`` requires a server that accepts
60
+ the param — older servers reject it.
61
+ """
62
+ query = {
63
+ k: v for k, v in {
64
+ "view": view, "body_format": body_format, "mailbox": mailbox,
65
+ }.items() if v is not None
66
+ }
49
67
  return self._http.request("GET", f"/v1/threads/{quote(id, safe='')}", query=query)
50
68
 
51
69
  def mark_read(self, mailbox_id: str, thread_id: str) -> dict[str, Any]:
@@ -68,6 +86,20 @@ class SyncThreads:
68
86
  body={},
69
87
  )
70
88
 
89
+ def set_starred(self, mailbox_id: str, thread_id: str, *, starred: bool) -> "ThreadStarResponse":
90
+ """Star or unstar a thread.
91
+
92
+ ``starred=True`` marks the thread as starred; ``starred=False`` clears it.
93
+ Both ``mailbox_id`` and ``thread_id`` are URL-encoded (mirrors existing
94
+ ``mark_read`` behaviour). Wraps
95
+ ``PATCH /v1/mailboxes/:id/threads/:thread_id/star``.
96
+ """
97
+ return self._http.request(
98
+ "PATCH",
99
+ f"/v1/mailboxes/{quote(mailbox_id, safe='')}/threads/{quote(thread_id, safe='')}/star",
100
+ body={"starred": starred},
101
+ )
102
+
71
103
 
72
104
  class AsyncThreads:
73
105
  def __init__(self, http: AsyncHttpClient) -> None:
@@ -104,8 +136,20 @@ class AsyncThreads:
104
136
 
105
137
  return await fetch_page(None)
106
138
 
107
- async def get(self, id: str, *, view: str | None = None, body_format: str | None = None) -> dict[str, Any]:
108
- query = {k: v for k, v in {"view": view, "body_format": body_format}.items() if v is not None}
139
+ async def get(
140
+ self,
141
+ id: str,
142
+ *,
143
+ view: str | None = None,
144
+ body_format: str | None = None,
145
+ mailbox: str | None = None,
146
+ ) -> dict[str, Any]:
147
+ """Read a full thread (async). See SyncThreads.get for the full contract."""
148
+ query = {
149
+ k: v for k, v in {
150
+ "view": view, "body_format": body_format, "mailbox": mailbox,
151
+ }.items() if v is not None
152
+ }
109
153
  return await self._http.request("GET", f"/v1/threads/{quote(id, safe='')}", query=query)
110
154
 
111
155
  async def mark_read(self, mailbox_id: str, thread_id: str) -> dict[str, Any]:
@@ -115,3 +159,11 @@ class AsyncThreads:
115
159
  f"/v1/mailboxes/{quote(mailbox_id, safe='')}/threads/{quote(thread_id, safe='')}/read",
116
160
  body={},
117
161
  )
162
+
163
+ async def set_starred(self, mailbox_id: str, thread_id: str, *, starred: bool) -> "ThreadStarResponse":
164
+ """Star or unstar a thread (async). See SyncThreads.set_starred."""
165
+ return await self._http.request(
166
+ "PATCH",
167
+ f"/v1/mailboxes/{quote(mailbox_id, safe='')}/threads/{quote(thread_id, safe='')}/star",
168
+ body={"starred": starred},
169
+ )
@@ -602,6 +602,31 @@ class SendMessageResponse(TypedDict):
602
602
  sends_remaining: int
603
603
 
604
604
 
605
+ class AsyncSendAck(TypedDict):
606
+ """202 optimistic-ack from POST /v1/drafts/:id/send with Prefer: respond-async.
607
+
608
+ Exactly 4 fields — no scan/warning/hold_context (those only appear on the
609
+ synchronous 200 path). Poll messages.get(message_id) until state is terminal.
610
+ """
611
+ message_id: str
612
+ status: Literal["queued_for_dispatch"]
613
+ daily_limit: int
614
+ sends_remaining: int
615
+
616
+
617
+ class MessageStarResponse(TypedDict):
618
+ """PATCH /v1/messages/:id/star response."""
619
+ message_id: str
620
+ starred: bool
621
+
622
+
623
+ class ThreadStarResponse(TypedDict):
624
+ """PATCH /v1/mailboxes/:id/threads/:thread_id/star response."""
625
+ thread_id: str
626
+ starred: bool
627
+ updated_count: int
628
+
629
+
605
630
  class MessageSummary(TypedDict, total=False):
606
631
  """List-response item shape.
607
632
 
@@ -641,6 +666,8 @@ class MessageSummary(TypedDict, total=False):
641
666
  review_trigger_source: NotRequired[ReviewQueueTriggerSource | None]
642
667
  # Plaintext, whitespace-normalized, 200-character list excerpt.
643
668
  body_preview: NotRequired[str | None]
669
+ # Customer inbox star/favorite marker. Optional — mirrors TS SDK MessageSummary.starred? (optional).
670
+ starred: NotRequired[bool]
644
671
  # S7 NTH-003 — server-computed deep link into the web inbox shell, or None
645
672
  # when PUBLIC_LINK_BASE_URL is unset on the server (fail-closed).
646
673
  dashboard_url: NotRequired[str | None]
@@ -1187,6 +1214,8 @@ class ThreadSummary(TypedDict):
1187
1214
  last_message_at: str
1188
1215
  message_count: int
1189
1216
  unread_count: int
1217
+ # Required — mirrors TS SDK ThreadSummary.starred (required, not optional).
1218
+ starred: bool
1190
1219
  participants: list[str]
1191
1220
 
1192
1221
 
@@ -0,0 +1,18 @@
1
+ """Drift guard: _http._VERSION must match the installed package version.
2
+
3
+ If these diverge the User-Agent header sent to the API will report the wrong
4
+ SDK version. This test catches that before a release.
5
+ """
6
+ import importlib.metadata
7
+
8
+ import replylayer._http as _http
9
+
10
+
11
+ def test_user_agent_version_matches_package_version() -> None:
12
+ """_VERSION in _http.py must equal the installed replylayer package version."""
13
+ package_version = importlib.metadata.version("replylayer")
14
+ assert _http._VERSION == package_version, (
15
+ f"_http._VERSION ({_http._VERSION!r}) does not match the installed "
16
+ f"package version ({package_version!r}). "
17
+ f"Update _VERSION in replylayer/_http.py to match pyproject.toml."
18
+ )
@@ -0,0 +1,369 @@
1
+ """Tests for WS1 (star + filter parity) and WS6-SDK (async_dispatch) — 0.16.0."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+
6
+ import httpx
7
+ import pytest
8
+ import respx
9
+
10
+ from replylayer import AsyncReplyLayer, ReplyLayer
11
+
12
+ BASE = "https://api.test.replylayer.ai"
13
+
14
+
15
+ def sdk() -> ReplyLayer:
16
+ return ReplyLayer(api_key="rl_live_test", base_url=BASE, max_retries=0)
17
+
18
+
19
+ def async_sdk() -> AsyncReplyLayer:
20
+ return AsyncReplyLayer(api_key="rl_live_test", base_url=BASE, max_retries=0)
21
+
22
+
23
+ # ── WS1: messages.set_starred ──────────────────────────────────────────────
24
+
25
+
26
+ @respx.mock
27
+ def test_messages_set_starred_true_issues_patch():
28
+ route = respx.patch(f"{BASE}/v1/messages/msg-1/star").mock(
29
+ return_value=httpx.Response(200, json={"message_id": "msg-1", "starred": True})
30
+ )
31
+ res = sdk().messages.set_starred("msg-1", starred=True)
32
+ assert route.called
33
+ assert res["message_id"] == "msg-1"
34
+ assert res["starred"] is True
35
+ payload = json.loads(route.calls[0].request.content)
36
+ assert payload == {"starred": True}
37
+
38
+
39
+ @respx.mock
40
+ def test_messages_set_starred_false_issues_patch():
41
+ route = respx.patch(f"{BASE}/v1/messages/msg-2/star").mock(
42
+ return_value=httpx.Response(200, json={"message_id": "msg-2", "starred": False})
43
+ )
44
+ res = sdk().messages.set_starred("msg-2", starred=False)
45
+ assert route.called
46
+ payload = json.loads(route.calls[0].request.content)
47
+ assert payload == {"starred": False}
48
+ assert res["starred"] is False
49
+
50
+
51
+ @respx.mock
52
+ @pytest.mark.asyncio
53
+ async def test_async_messages_set_starred_issues_patch():
54
+ route = respx.patch(f"{BASE}/v1/messages/msg-3/star").mock(
55
+ return_value=httpx.Response(200, json={"message_id": "msg-3", "starred": True})
56
+ )
57
+ rl = async_sdk()
58
+ res = await rl.messages.set_starred("msg-3", starred=True)
59
+ assert route.called
60
+ payload = json.loads(route.calls[0].request.content)
61
+ assert payload == {"starred": True}
62
+ assert res["starred"] is True
63
+ await rl.aclose()
64
+
65
+
66
+ # ── WS1: messages.list() starred + has_attachment serialization ────────────
67
+
68
+
69
+ @respx.mock
70
+ def test_messages_list_starred_true_serializes_as_lowercase():
71
+ route = respx.get(f"{BASE}/v1/mailboxes/mbx1/messages").mock(
72
+ return_value=httpx.Response(200, json={"messages": []})
73
+ )
74
+ sdk().messages.list("mbx1", starred=True)
75
+ params = route.calls[0].request.url.params
76
+ assert params["starred"] == "true"
77
+
78
+
79
+ @respx.mock
80
+ def test_messages_list_starred_false_serializes_as_lowercase():
81
+ route = respx.get(f"{BASE}/v1/mailboxes/mbx1/messages").mock(
82
+ return_value=httpx.Response(200, json={"messages": []})
83
+ )
84
+ sdk().messages.list("mbx1", starred=False)
85
+ params = route.calls[0].request.url.params
86
+ assert params["starred"] == "false"
87
+
88
+
89
+ @respx.mock
90
+ def test_messages_list_has_attachment_true_serializes_as_lowercase():
91
+ route = respx.get(f"{BASE}/v1/mailboxes/mbx1/messages").mock(
92
+ return_value=httpx.Response(200, json={"messages": []})
93
+ )
94
+ sdk().messages.list("mbx1", has_attachment=True)
95
+ params = route.calls[0].request.url.params
96
+ assert params["has_attachment"] == "true"
97
+
98
+
99
+ @respx.mock
100
+ def test_messages_list_has_attachment_false_serializes_as_lowercase():
101
+ route = respx.get(f"{BASE}/v1/mailboxes/mbx1/messages").mock(
102
+ return_value=httpx.Response(200, json={"messages": []})
103
+ )
104
+ sdk().messages.list("mbx1", has_attachment=False)
105
+ params = route.calls[0].request.url.params
106
+ assert params["has_attachment"] == "false"
107
+
108
+
109
+ @respx.mock
110
+ def test_messages_list_starred_and_has_attachment_both_omitted_when_none():
111
+ route = respx.get(f"{BASE}/v1/mailboxes/mbx1/messages").mock(
112
+ return_value=httpx.Response(200, json={"messages": []})
113
+ )
114
+ sdk().messages.list("mbx1")
115
+ params = route.calls[0].request.url.params
116
+ assert "starred" not in params
117
+ assert "has_attachment" not in params
118
+
119
+
120
+ # ── WS1: threads.set_starred ────────────────────────────────────────────────
121
+
122
+
123
+ @respx.mock
124
+ def test_threads_set_starred_true_issues_patch():
125
+ route = respx.patch(f"{BASE}/v1/mailboxes/mbx-1/threads/t%40host.com/star").mock(
126
+ return_value=httpx.Response(
127
+ 200,
128
+ json={"thread_id": "t@host.com", "starred": True, "updated_count": 3},
129
+ )
130
+ )
131
+ res = sdk().threads.set_starred("mbx-1", "t@host.com", starred=True)
132
+ assert route.called
133
+ payload = json.loads(route.calls[0].request.content)
134
+ assert payload == {"starred": True}
135
+ assert res["starred"] is True
136
+ assert res["updated_count"] == 3
137
+
138
+
139
+ @respx.mock
140
+ def test_threads_set_starred_false_issues_patch():
141
+ route = respx.patch(f"{BASE}/v1/mailboxes/mbx-1/threads/t1/star").mock(
142
+ return_value=httpx.Response(
143
+ 200,
144
+ json={"thread_id": "t1", "starred": False, "updated_count": 1},
145
+ )
146
+ )
147
+ res = sdk().threads.set_starred("mbx-1", "t1", starred=False)
148
+ assert route.called
149
+ payload = json.loads(route.calls[0].request.content)
150
+ assert payload == {"starred": False}
151
+ assert res["starred"] is False
152
+
153
+
154
+ @respx.mock
155
+ @pytest.mark.asyncio
156
+ async def test_async_threads_set_starred_issues_patch():
157
+ route = respx.patch(f"{BASE}/v1/mailboxes/mbx-1/threads/t2/star").mock(
158
+ return_value=httpx.Response(
159
+ 200,
160
+ json={"thread_id": "t2", "starred": True, "updated_count": 2},
161
+ )
162
+ )
163
+ rl = async_sdk()
164
+ res = await rl.threads.set_starred("mbx-1", "t2", starred=True)
165
+ assert route.called
166
+ payload = json.loads(route.calls[0].request.content)
167
+ assert payload == {"starred": True}
168
+ assert res["starred"] is True
169
+ await rl.aclose()
170
+
171
+
172
+ # ── WS1: threads.get() mailbox param ────────────────────────────────────────
173
+
174
+
175
+ @respx.mock
176
+ def test_threads_get_includes_mailbox_in_query_when_set():
177
+ route = respx.get(f"{BASE}/v1/threads/t-1").mock(
178
+ return_value=httpx.Response(
179
+ 200,
180
+ json={"id": "t-1", "mailbox_id": "mbx-1", "subject": "Hi", "message_count": 1, "messages": []},
181
+ )
182
+ )
183
+ sdk().threads.get("t-1", mailbox="support")
184
+ params = route.calls[0].request.url.params
185
+ assert params["mailbox"] == "support"
186
+
187
+
188
+ @respx.mock
189
+ def test_threads_get_omits_mailbox_when_not_set():
190
+ route = respx.get(f"{BASE}/v1/threads/t-1").mock(
191
+ return_value=httpx.Response(
192
+ 200,
193
+ json={"id": "t-1", "mailbox_id": "mbx-1", "subject": "Hi", "message_count": 1, "messages": []},
194
+ )
195
+ )
196
+ sdk().threads.get("t-1")
197
+ params = route.calls[0].request.url.params
198
+ assert "mailbox" not in params
199
+
200
+
201
+ @respx.mock
202
+ @pytest.mark.asyncio
203
+ async def test_async_threads_get_includes_mailbox_in_query_when_set():
204
+ route = respx.get(f"{BASE}/v1/threads/t-2").mock(
205
+ return_value=httpx.Response(
206
+ 200,
207
+ json={"id": "t-2", "mailbox_id": "mbx-2", "subject": "Hey", "message_count": 1, "messages": []},
208
+ )
209
+ )
210
+ rl = async_sdk()
211
+ await rl.threads.get("t-2", mailbox="support")
212
+ params = route.calls[0].request.url.params
213
+ assert params["mailbox"] == "support"
214
+ await rl.aclose()
215
+
216
+
217
+ # ── WS1: list-row deserialization surfaces `starred` ────────────────────────
218
+
219
+
220
+ def test_message_summary_starred_field_is_deserialized():
221
+ """MessageSummary surfaces starred when present (NotRequired[bool])."""
222
+ row: dict = {
223
+ "id": "msg-s1",
224
+ "direction": "inbound",
225
+ "state": "available",
226
+ "sender": "a@b.com",
227
+ "recipient": "c@d.com",
228
+ "subject": "Hi",
229
+ "subaddress_instance_id": None,
230
+ "firewall_block": None,
231
+ "thread_id": None,
232
+ "created_at": "2026-06-07T00:00:00Z",
233
+ "read_at": None,
234
+ "starred": True,
235
+ }
236
+ # Validate the field is accessible (TypedDict is a dict at runtime).
237
+ assert row["starred"] is True
238
+
239
+
240
+ def test_thread_summary_starred_field_is_required():
241
+ """ThreadSummary.starred is required — confirm the dict can be built."""
242
+ row: dict = {
243
+ "id": "t-s1",
244
+ "subject": "Thread",
245
+ "first_message_at": "2026-06-07T00:00:00Z",
246
+ "last_message_at": "2026-06-07T01:00:00Z",
247
+ "message_count": 1,
248
+ "unread_count": 0,
249
+ "starred": False,
250
+ "participants": ["a@b.com"],
251
+ }
252
+ assert row["starred"] is False
253
+
254
+
255
+ # ── WS6-SDK: drafts.send(async_dispatch=True) ───────────────────────────────
256
+
257
+
258
+ @respx.mock
259
+ def test_drafts_send_async_dispatch_true_sends_prefer_header():
260
+ route = respx.post(f"{BASE}/v1/drafts/d-1/send").mock(
261
+ return_value=httpx.Response(
262
+ 202,
263
+ json={
264
+ "message_id": "msg-async-1",
265
+ "status": "queued_for_dispatch",
266
+ "daily_limit": 15,
267
+ "sends_remaining": 14,
268
+ },
269
+ )
270
+ )
271
+ res = sdk().drafts.send("d-1", async_dispatch=True)
272
+ assert route.called
273
+ req = route.calls[0].request
274
+ assert req.headers.get("prefer") == "respond-async"
275
+ assert res["status"] == "queued_for_dispatch"
276
+ assert res["message_id"] == "msg-async-1"
277
+ assert res["daily_limit"] == 15
278
+ assert res["sends_remaining"] == 14
279
+
280
+
281
+ @respx.mock
282
+ def test_drafts_send_async_dispatch_false_does_not_send_prefer_header():
283
+ route = respx.post(f"{BASE}/v1/drafts/d-2/send").mock(
284
+ return_value=httpx.Response(
285
+ 200,
286
+ json={
287
+ "message_id": "msg-sync-1",
288
+ "status": "sent",
289
+ "warning": None,
290
+ "daily_limit": 15,
291
+ "sends_remaining": 14,
292
+ },
293
+ )
294
+ )
295
+ res = sdk().drafts.send("d-2")
296
+ assert route.called
297
+ req = route.calls[0].request
298
+ assert "prefer" not in {k.lower() for k in req.headers.keys()}
299
+ assert res["status"] == "sent"
300
+
301
+
302
+ @respx.mock
303
+ def test_drafts_send_async_dispatch_default_does_not_send_prefer_header():
304
+ """Default (async_dispatch not passed) must not include Prefer header."""
305
+ route = respx.post(f"{BASE}/v1/drafts/d-3/send").mock(
306
+ return_value=httpx.Response(
307
+ 200,
308
+ json={"message_id": "msg-sync-2", "status": "sent", "warning": None, "daily_limit": 15, "sends_remaining": 14},
309
+ )
310
+ )
311
+ sdk().drafts.send("d-3")
312
+ req = route.calls[0].request
313
+ assert "prefer" not in {k.lower() for k in req.headers.keys()}
314
+
315
+
316
+ @respx.mock
317
+ @pytest.mark.asyncio
318
+ async def test_async_drafts_send_async_dispatch_true_sends_prefer_header():
319
+ route = respx.post(f"{BASE}/v1/drafts/d-4/send").mock(
320
+ return_value=httpx.Response(
321
+ 202,
322
+ json={
323
+ "message_id": "msg-async-2",
324
+ "status": "queued_for_dispatch",
325
+ "daily_limit": 15,
326
+ "sends_remaining": 14,
327
+ },
328
+ )
329
+ )
330
+ rl = async_sdk()
331
+ res = await rl.drafts.send("d-4", async_dispatch=True)
332
+ assert route.called
333
+ req = route.calls[0].request
334
+ assert req.headers.get("prefer") == "respond-async"
335
+ assert res["status"] == "queued_for_dispatch"
336
+ await rl.aclose()
337
+
338
+
339
+ @respx.mock
340
+ @pytest.mark.asyncio
341
+ async def test_async_drafts_send_default_does_not_send_prefer_header():
342
+ route = respx.post(f"{BASE}/v1/drafts/d-5/send").mock(
343
+ return_value=httpx.Response(
344
+ 200,
345
+ json={"message_id": "msg-sync-3", "status": "sent", "warning": None, "daily_limit": 15, "sends_remaining": 14},
346
+ )
347
+ )
348
+ rl = async_sdk()
349
+ await rl.drafts.send("d-5")
350
+ req = route.calls[0].request
351
+ assert "prefer" not in {k.lower() for k in req.headers.keys()}
352
+ await rl.aclose()
353
+
354
+
355
+ # ── WS6-SDK: AsyncSendAck type shape ────────────────────────────────────────
356
+
357
+
358
+ def test_async_send_ack_type_has_four_fields():
359
+ """AsyncSendAck TypedDict has exactly the 4 required fields."""
360
+ from replylayer import AsyncSendAck
361
+ ack: AsyncSendAck = {
362
+ "message_id": "msg-1",
363
+ "status": "queued_for_dispatch",
364
+ "daily_limit": 15,
365
+ "sends_remaining": 14,
366
+ }
367
+ assert ack["status"] == "queued_for_dispatch"
368
+ assert ack["daily_limit"] == 15
369
+ assert ack["sends_remaining"] == 14
@@ -171,13 +171,17 @@ wheels = [
171
171
 
172
172
  [[package]]
173
173
  name = "replylayer"
174
- version = "0.13.0"
174
+ version = "0.16.0"
175
175
  source = { editable = "." }
176
176
  dependencies = [
177
177
  { name = "httpx" },
178
+ { name = "typing-extensions" },
178
179
  ]
179
180
 
180
181
  [package.optional-dependencies]
182
+ cli = [
183
+ { name = "rly" },
184
+ ]
181
185
  dev = [
182
186
  { name = "pytest" },
183
187
  { name = "pytest-asyncio" },
@@ -190,8 +194,10 @@ requires-dist = [
190
194
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
191
195
  { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
192
196
  { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" },
197
+ { name = "rly", marker = "extra == 'cli'", specifier = ">=0.6.3" },
198
+ { name = "typing-extensions", specifier = ">=4.0" },
193
199
  ]
194
- provides-extras = ["dev"]
200
+ provides-extras = ["cli", "dev"]
195
201
 
196
202
  [[package]]
197
203
  name = "respx"
@@ -205,6 +211,19 @@ wheels = [
205
211
  { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
206
212
  ]
207
213
 
214
+ [[package]]
215
+ name = "rly"
216
+ version = "0.6.3"
217
+ source = { registry = "https://pypi.org/simple" }
218
+ sdist = { url = "https://files.pythonhosted.org/packages/36/c4/82ff082990ce46c9d2d47f587bfc078cb33554aa076e2dad1f068fe010f0/rly-0.6.3.tar.gz", hash = "sha256:c5ab3dffe218f639e72298787c34e827e2c25ecb7c730553850136e8a6131ecd", size = 15405, upload-time = "2026-06-06T14:09:27.386Z" }
219
+ wheels = [
220
+ { url = "https://files.pythonhosted.org/packages/40/9d/eab2b5b68f4cf187b882af768e2c7956a52c55715db4d016aebacf9204be/rly-0.6.3-py3-none-macosx_14_0_arm64.whl", hash = "sha256:b9837a5800b4dc9f2a709fdf637e891d11513d08a265e6d0a6f465e5def3c266", size = 34891158, upload-time = "2026-06-06T14:09:12.295Z" },
221
+ { url = "https://files.pythonhosted.org/packages/c4/20/e209429517988e7de53db131f4f81e9942c497599448055f5c18fb12f678/rly-0.6.3-py3-none-macosx_14_0_x86_64.whl", hash = "sha256:33996146630e8b2151135854f86817f8b259a87022cfd7c36782074a400bb681", size = 36857258, upload-time = "2026-06-06T14:09:15.239Z" },
222
+ { url = "https://files.pythonhosted.org/packages/19/28/8b9d4cc26a0396585939837e68dc621e4db796da63970a2eab070eddd52b/rly-0.6.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:26da04b1635338a923b528172af113a1945e97573549bd66935c983c82b26aa3", size = 39009513, upload-time = "2026-06-06T14:09:19.131Z" },
223
+ { url = "https://files.pythonhosted.org/packages/d2/48/dbff54bfc88156373fcd133513b3aa4cf9a876934377ab7dc95a1959d731/rly-0.6.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:692fb50af3721278511b08b25183a687689454421efe2ce9dd970677b3675456", size = 39380097, upload-time = "2026-06-06T14:09:22.111Z" },
224
+ { url = "https://files.pythonhosted.org/packages/92/20/6a7c55b3d70dc9448d793771f82e3a3aa76855bb8e3323a8861156f0385a/rly-0.6.3-py3-none-win_amd64.whl", hash = "sha256:451e201af43f031cbac2d1d221d2e6295374a9acd7f4e2386c6d9c4947199103", size = 31372056, upload-time = "2026-06-06T14:09:25.1Z" },
225
+ ]
226
+
208
227
  [[package]]
209
228
  name = "tomli"
210
229
  version = "2.4.1"
File without changes