useoutlet 0.1.0__py3-none-any.whl

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.
useoutlet/__init__.py ADDED
@@ -0,0 +1,79 @@
1
+ """useoutlet: connect your users' AI accounts to your app.
2
+
3
+ Status: direct mode is live today. Vault mode (connect / refresh / status /
4
+ revoke) is open. Register your app at useoutlet.dev/register.
5
+
6
+ from useoutlet import Outlet
7
+
8
+ outlet = Outlet(app_id="app_yourapp", app_secret=os.environ["OUTLET_APP_SECRET"])
9
+ request = outlet.connect(providers=["openai"], requested_cap_usd=10)
10
+ # send the user to request.grant_url, then
11
+ session = outlet.wait(request.id)
12
+
13
+ # use the official provider SDK: Outlet is not in the data path
14
+ ai = OpenAI(api_key=session.keys["openai"])
15
+
16
+ A connection that ends (capped, revoked or expired) is a ConnectionEndedError
17
+ from status(), refresh() and wait(). The Connect your AI button, the CLI and
18
+ the MCP docs server live in the npm package, @useoutlet/sdk.
19
+ """
20
+
21
+ from ._version import __version__
22
+ from .client import AsyncOutlet, Outlet
23
+ from .direct import direct
24
+ from .grants import refresh, revoke, status
25
+ from .pkce import PkceChallenge, create_grant, exchange_code, pkce_challenge
26
+ from .providers import (
27
+ KeyShape,
28
+ ProviderCheck,
29
+ ProviderEntry,
30
+ ProviderKind,
31
+ ProviderMode,
32
+ ProviderModes,
33
+ get_provider,
34
+ provider_ids,
35
+ providers,
36
+ )
37
+ from .types import (
38
+ CapReason,
39
+ ConnectionEndedError,
40
+ EndReason,
41
+ GrantInfo,
42
+ GrantRequest,
43
+ GrantStatus,
44
+ Mode,
45
+ OutletError,
46
+ OutletSession,
47
+ )
48
+
49
+ __all__ = [
50
+ "AsyncOutlet",
51
+ "CapReason",
52
+ "ConnectionEndedError",
53
+ "EndReason",
54
+ "GrantInfo",
55
+ "GrantRequest",
56
+ "GrantStatus",
57
+ "KeyShape",
58
+ "Mode",
59
+ "Outlet",
60
+ "OutletError",
61
+ "OutletSession",
62
+ "PkceChallenge",
63
+ "ProviderCheck",
64
+ "ProviderEntry",
65
+ "ProviderKind",
66
+ "ProviderMode",
67
+ "ProviderModes",
68
+ "__version__",
69
+ "create_grant",
70
+ "direct",
71
+ "exchange_code",
72
+ "get_provider",
73
+ "pkce_challenge",
74
+ "provider_ids",
75
+ "providers",
76
+ "refresh",
77
+ "revoke",
78
+ "status",
79
+ ]
useoutlet/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
useoutlet/client.py ADDED
@@ -0,0 +1,304 @@
1
+ """The Outlet client: the grant flow for a server, with the credential held
2
+ once. A confidential client (app secret) starts a connection request, sends
3
+ the user to its grant URL and waits for the approval. A public client (no
4
+ secret) starts it with PKCE and finishes on the return address with
5
+ exchange(). refresh(), status() and revoke() then use the secret or the
6
+ grant's refresh token, whichever the client holds; a rotated token replaces
7
+ the old one in the client."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import time
13
+ from collections.abc import Sequence
14
+ from dataclasses import dataclass
15
+
16
+ from . import grants as _grants
17
+ from . import http as _http
18
+ from .direct import direct
19
+ from .ended import ended
20
+ from .http import DEFAULT_TIMEOUT, Auth, api
21
+ from .pkce import create_grant, exchange_code
22
+ from .types import GrantInfo, GrantRequest, OutletError, OutletSession
23
+
24
+ # A connection request the user never finished is forgotten after this long.
25
+ _PENDING_TTL = 3600.0
26
+
27
+ _WAIT_NEEDS_SECRET = (
28
+ "wait() needs the app secret. A public client finishes the connection with "
29
+ "exchange(code, state) on the return address."
30
+ )
31
+
32
+
33
+ @dataclass
34
+ class _Pending:
35
+ grant_request_id: str
36
+ verifier: str
37
+ started: float
38
+
39
+
40
+ class Outlet:
41
+ """The Outlet client.
42
+
43
+ app_id: your app's registered Outlet id (app_..., public).
44
+ app_secret: your confidential app secret (apps_...), server-side only.
45
+ Leave it out for a public client (PKCE, SPEC section 7.1).
46
+ base_url: the vault to talk to. Defaults to https://api.useoutlet.dev/v0.
47
+ timeout: seconds one vault call may take.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ app_id: str,
53
+ app_secret: str | None = None,
54
+ *,
55
+ base_url: str | None = None,
56
+ timeout: float = DEFAULT_TIMEOUT,
57
+ ) -> None:
58
+ self.app_id = app_id
59
+ self.app_secret = app_secret or None
60
+ self._base_url = base_url
61
+ self.timeout = timeout
62
+ # grant id -> the refresh token the vault last issued (public clients)
63
+ self._tokens: dict[str, str] = {}
64
+ # state -> the PKCE values of a connection request not yet exchanged
65
+ self._pending: dict[str, _Pending] = {}
66
+
67
+ @property
68
+ def base_url(self) -> str:
69
+ return self._base_url or _http.DEFAULT_BASE_URL
70
+
71
+ @property
72
+ def public(self) -> bool:
73
+ """True for a public client: no app secret, PKCE instead."""
74
+ return self.app_secret is None
75
+
76
+ # The grant flow
77
+
78
+ def connect(
79
+ self,
80
+ providers: Sequence[str],
81
+ requested_cap_usd: float | None = None,
82
+ redirect_uri: str | None = None,
83
+ ) -> GrantRequest:
84
+ """Start a connection request. Send the user to the returned grant_url.
85
+
86
+ providers: the provider the user connects. The vault takes one provider
87
+ per connection request; use a separate request for each provider.
88
+ requested_cap_usd: your proposed monthly Vault cap in USD. The user
89
+ sets the cap on the approval card.
90
+ redirect_uri: the return address (public clients: required, and it
91
+ must be registered for the app).
92
+
93
+ A confidential client then calls wait(); a public client calls
94
+ exchange() with the code and state from the return address.
95
+ """
96
+ if isinstance(providers, str):
97
+ providers = [providers]
98
+ if self.public:
99
+ started = create_grant(
100
+ self.app_id,
101
+ providers,
102
+ redirect_uri or "",
103
+ requested_cap_usd,
104
+ base_url=self.base_url,
105
+ timeout=self.timeout,
106
+ )
107
+ self._remember(started)
108
+ return started
109
+ r = api(
110
+ self.base_url,
111
+ "/grants",
112
+ method="POST",
113
+ body={
114
+ "app_id": self.app_id,
115
+ "providers": list(providers),
116
+ "requested_cap_usd": requested_cap_usd,
117
+ "redirect_uri": redirect_uri,
118
+ },
119
+ auth=Auth(app_secret=self.app_secret),
120
+ timeout=self.timeout,
121
+ )
122
+ # The vault answers this path in camelCase and the PKCE path in
123
+ # snake_case (SPEC section 7.1). Both spellings are read.
124
+ return GrantRequest(
125
+ id=str(r.get("grantRequestId") or r.get("grant_request_id") or ""),
126
+ grant_url=str(r.get("grantUrl") or r.get("grant_url") or ""),
127
+ )
128
+
129
+ def wait(
130
+ self,
131
+ grant_request_id: str,
132
+ *,
133
+ interval: float = 1.5,
134
+ timeout: float | None = None,
135
+ ) -> OutletSession:
136
+ """Confidential clients: wait for the user to approve. Polls the grant
137
+ request every `interval` seconds, as the npm package's connect() does,
138
+ and returns the session once the vault delivers the key. A request that
139
+ ends before approval (revoked, capped) raises ConnectionEndedError.
140
+ timeout: seconds to wait in all; None waits until the vault answers."""
141
+ if self.public:
142
+ raise OutletError(_WAIT_NEEDS_SECRET, "app_secret_required")
143
+ deadline = None if timeout is None else time.monotonic() + timeout
144
+ while True:
145
+ r = api(
146
+ self.base_url,
147
+ f"/grants/{grant_request_id}",
148
+ auth=Auth(app_secret=self.app_secret),
149
+ timeout=self.timeout,
150
+ )
151
+ state = r.get("status") if isinstance(r, dict) else None
152
+ if state == "complete":
153
+ return OutletSession.from_wire(r)
154
+ if state in ("capped", "revoked"):
155
+ raise ended(state, str(r.get("grantId") or grant_request_id), status=409)
156
+ if deadline is not None and time.monotonic() >= deadline:
157
+ raise OutletError(
158
+ f"The connection request was not approved within {timeout:g} seconds.",
159
+ "approval_timeout",
160
+ )
161
+ time.sleep(interval)
162
+
163
+ def exchange(self, code: str, state: str, request: GrantRequest | None = None) -> OutletSession:
164
+ """Public clients: finish the connection on the return address. Verifies
165
+ state against the request this client started, exchanges the code and
166
+ verifier over the back channel, and holds the session's refresh token
167
+ for refresh(), status() and revoke(). Pass `request` when your app
168
+ kept the GrantRequest itself (another process, say)."""
169
+ self._forget_stale()
170
+ if request is not None:
171
+ if not request.verifier:
172
+ raise OutletError("No PKCE transaction in progress.", "no_pkce_txn")
173
+ pending: _Pending | None = _Pending(request.id, request.verifier, time.monotonic())
174
+ expected = request.state
175
+ else:
176
+ if not self._pending:
177
+ raise OutletError("No PKCE transaction in progress.", "no_pkce_txn")
178
+ pending = self._pending.get(state) if state else None
179
+ expected = state if pending else None
180
+ if not code:
181
+ raise OutletError("No authorization code in the redirect URL.", "no_code")
182
+ if not state or pending is None or state != expected:
183
+ raise OutletError("State mismatch. Possible CSRF; aborting.", "state_mismatch")
184
+ session = exchange_code(
185
+ pending.grant_request_id,
186
+ code,
187
+ pending.verifier,
188
+ base_url=self.base_url,
189
+ timeout=self.timeout,
190
+ )
191
+ self._pending.pop(state, None)
192
+ if session.refresh_token:
193
+ self._tokens[session.grant_id] = session.refresh_token
194
+ return session
195
+
196
+ # After connect
197
+
198
+ def refresh(self, grant_id: str, refresh_token: str | None = None) -> OutletSession:
199
+ """Re-fetch (and possibly rotate) the keys for a grant. A public client's
200
+ token rotates: the new one replaces the old in this client, and the
201
+ session carries it for your own store. Pass `refresh_token` to seed a
202
+ token this client has not seen (one you stored earlier). Raises
203
+ ConnectionEndedError when the connection is capped, revoked or expired."""
204
+ token = refresh_token or self._tokens.get(grant_id)
205
+ session = _grants.refresh(
206
+ grant_id,
207
+ app_secret=self.app_secret,
208
+ refresh_token=token,
209
+ base_url=self.base_url,
210
+ timeout=self.timeout,
211
+ )
212
+ if session.refresh_token:
213
+ self._tokens[grant_id] = session.refresh_token
214
+ return session
215
+
216
+ def status(self, grant_id: str, refresh_token: str | None = None) -> GrantInfo:
217
+ """Current status and spend for a grant, never key material. Raises
218
+ ConnectionEndedError for a capped or revoked connection."""
219
+ return _grants.status(
220
+ grant_id,
221
+ app_secret=self.app_secret,
222
+ refresh_token=refresh_token or self._tokens.get(grant_id),
223
+ base_url=self.base_url,
224
+ timeout=self.timeout,
225
+ )
226
+
227
+ def revoke(self, grant_id: str, refresh_token: str | None = None) -> None:
228
+ """Revoke a grant from the app's side. The client forgets its token."""
229
+ _grants.revoke(
230
+ grant_id,
231
+ app_secret=self.app_secret,
232
+ refresh_token=refresh_token or self._tokens.get(grant_id),
233
+ base_url=self.base_url,
234
+ timeout=self.timeout,
235
+ )
236
+ self._tokens.pop(grant_id, None)
237
+
238
+ # Direct mode, for symmetry with the npm package's Outlet.direct()
239
+ direct = staticmethod(direct)
240
+
241
+ # Pending PKCE requests
242
+
243
+ def _remember(self, started: GrantRequest) -> None:
244
+ self._forget_stale()
245
+ if started.state and started.verifier:
246
+ self._pending[started.state] = _Pending(started.id, started.verifier, time.monotonic())
247
+
248
+ def _forget_stale(self) -> None:
249
+ cutoff = time.monotonic() - _PENDING_TTL
250
+ for state in [s for s, p in self._pending.items() if p.started < cutoff]:
251
+ del self._pending[state]
252
+
253
+
254
+ class AsyncOutlet:
255
+ """The same client for asyncio apps. Each call runs the sync client in a
256
+ worker thread (asyncio.to_thread), so the event loop is never blocked. The
257
+ standard library has no async HTTP client, and this package adds none."""
258
+
259
+ def __init__(
260
+ self,
261
+ app_id: str,
262
+ app_secret: str | None = None,
263
+ *,
264
+ base_url: str | None = None,
265
+ timeout: float = DEFAULT_TIMEOUT,
266
+ ) -> None:
267
+ self.sync = Outlet(app_id, app_secret, base_url=base_url, timeout=timeout)
268
+
269
+ @property
270
+ def public(self) -> bool:
271
+ return self.sync.public
272
+
273
+ async def connect(
274
+ self,
275
+ providers: Sequence[str],
276
+ requested_cap_usd: float | None = None,
277
+ redirect_uri: str | None = None,
278
+ ) -> GrantRequest:
279
+ return await asyncio.to_thread(
280
+ self.sync.connect, providers, requested_cap_usd, redirect_uri
281
+ )
282
+
283
+ async def wait(
284
+ self, grant_request_id: str, *, interval: float = 1.5, timeout: float | None = None
285
+ ) -> OutletSession:
286
+ return await asyncio.to_thread(
287
+ self.sync.wait, grant_request_id, interval=interval, timeout=timeout
288
+ )
289
+
290
+ async def exchange(
291
+ self, code: str, state: str, request: GrantRequest | None = None
292
+ ) -> OutletSession:
293
+ return await asyncio.to_thread(self.sync.exchange, code, state, request)
294
+
295
+ async def refresh(self, grant_id: str, refresh_token: str | None = None) -> OutletSession:
296
+ return await asyncio.to_thread(self.sync.refresh, grant_id, refresh_token)
297
+
298
+ async def status(self, grant_id: str, refresh_token: str | None = None) -> GrantInfo:
299
+ return await asyncio.to_thread(self.sync.status, grant_id, refresh_token)
300
+
301
+ async def revoke(self, grant_id: str, refresh_token: str | None = None) -> None:
302
+ await asyncio.to_thread(self.sync.revoke, grant_id, refresh_token)
303
+
304
+ direct = staticmethod(direct)
useoutlet/direct.py ADDED
@@ -0,0 +1,164 @@
1
+ """Direct (paste) mode. Vault mode is open: register your app at useoutlet.dev/register.
2
+
3
+ The user pastes their own provider API key; direct() checks it locally and
4
+ returns the same OutletSession shape that the vault flow returns, so upgrading
5
+ later is a one-line change. No network: the key never leaves the process and
6
+ is never sent to Outlet.
7
+
8
+ session = direct({"openai": user_pasted_key})
9
+ ai = OpenAI(api_key=session.keys["openai"])
10
+
11
+ Works with any provider: pass its key under that provider's id. For an
12
+ OpenAI-compatible one (for example {"groq": key}), point the OpenAI SDK at the
13
+ provider's base URL. The provider registry (providers.py) names the nineteen
14
+ with a named Direct screen. Its key shapes and format hints are for the person
15
+ pasting a key. direct() refuses no key over them.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import secrets
21
+ import time
22
+ from collections.abc import Mapping
23
+ from typing import NoReturn
24
+
25
+ from .providers import get_provider
26
+ from .types import OutletError, OutletSession
27
+
28
+ # First-class providers with well-defined key shapes get strict checks.
29
+ # Order within openai matters: the generic "sk-" entry must come last.
30
+ _ACCEPTED: dict[str, list[str]] = {
31
+ "openai": ["sk-proj-", "sk-svcacct-", "sk-"],
32
+ "anthropic": ["sk-ant-"],
33
+ "google": ["AIza"],
34
+ }
35
+
36
+ _BASE36 = "0123456789abcdefghijklmnopqrstuvwxyz"
37
+
38
+
39
+ def _display_name(provider: str) -> str:
40
+ """The registry's display name for nicer error messages. A provider outside
41
+ the registry falls back to the raw id the developer passed."""
42
+ entry = get_provider(provider)
43
+ return entry.display_name if entry else provider
44
+
45
+
46
+ def _fail(message: str, code: str) -> NoReturn:
47
+ raise OutletError(message, code)
48
+
49
+
50
+ def _clean(raw: str) -> str:
51
+ """Pasted keys arrive with the user's clipboard noise: surrounding whitespace,
52
+ sometimes quotes from a config file. Clean before judging, so honest pastes
53
+ do not fail format checks."""
54
+ return raw.strip().strip("\"'")
55
+
56
+
57
+ def _validate_key(provider: str, raw: str) -> str:
58
+ key = _clean(raw)
59
+ name = _display_name(provider)
60
+
61
+ if not key:
62
+ _fail(f"Empty {name} key.", "invalid_key_format")
63
+
64
+ # Admin keys are the user's root org credential. Direct mode exists
65
+ # precisely so apps never hold one. Refuse loudly for ANY provider, not
66
+ # just the named.
67
+ if key.startswith("sk-ant-admin"):
68
+ _fail(
69
+ "This is an Anthropic ADMIN key. It controls the whole organization. "
70
+ "Never paste an admin key into an app. Use a regular API key "
71
+ "(starts with sk-ant-api).",
72
+ "admin_key_rejected",
73
+ )
74
+ if key.startswith("sk-admin-"):
75
+ _fail(
76
+ "This is an OpenAI ADMIN key. It controls the whole organization. "
77
+ "Never paste an admin key into an app. Use a project API key "
78
+ "(starts with sk-proj-).",
79
+ "admin_key_rejected",
80
+ )
81
+
82
+ # Provider-exclusive prefixes pasted into the wrong field are a mistake no
83
+ # matter which provider you named: "sk-ant-" is Anthropic-only, "AIza" is
84
+ # Google-only. The generic "sk-" is NOT exclusive (DeepSeek, Qwen, Moonshot
85
+ # and others use it), so it is only judged among the first-class three.
86
+ if provider != "anthropic" and key.startswith("sk-ant-"):
87
+ _fail(
88
+ f"That looks like an ANTHROPIC key, but it was pasted into the {name} field.",
89
+ "wrong_provider_key",
90
+ )
91
+ if provider != "google" and key.startswith("AIza"):
92
+ _fail(
93
+ f"That looks like a GOOGLE key, but it was pasted into the {name} field.",
94
+ "wrong_provider_key",
95
+ )
96
+
97
+ # First-class providers (OpenAI, Anthropic, Google): strict format and mix-up.
98
+ if provider in _ACCEPTED:
99
+ if provider != "openai" and key.startswith("sk-") and not key.startswith("sk-ant-"):
100
+ _fail(
101
+ f"That looks like an OPENAI key, but it was pasted into the {name} field.",
102
+ "wrong_provider_key",
103
+ )
104
+ prefixes = _ACCEPTED[provider]
105
+ if not any(key.startswith(p) for p in prefixes):
106
+ _fail(
107
+ f"This doesn't look like a {name} API key (expected it to start with "
108
+ f"{' or '.join(prefixes)}).",
109
+ "invalid_key_format",
110
+ )
111
+ return key
112
+
113
+ # Any other provider's key is an opaque value: a token, a JWT, a generic
114
+ # "sk-", or two parts around a colon or a dot (fal, Higgsfield, Z.ai). We
115
+ # accept any non-empty, non-admin key whole rather than reject a valid key
116
+ # we cannot model. The registry's key shapes are never enforced here.
117
+ return key
118
+
119
+
120
+ def _base36(n: int) -> str:
121
+ digits = ""
122
+ while n:
123
+ n, r = divmod(n, 36)
124
+ digits = _BASE36[r] + digits
125
+ return digits or "0"
126
+
127
+
128
+ def _local_id() -> str:
129
+ """Not a secret: a local handle apps can log and store safely."""
130
+ tail = "".join(secrets.choice(_BASE36) for _ in range(6))
131
+ return f"direct_{_base36(int(time.time() * 1000))}{tail}"
132
+
133
+
134
+ def direct(keys: Mapping[str, str]) -> OutletSession:
135
+ """Check pasted provider keys and return a session, entirely locally.
136
+
137
+ keys: the provider API keys the user pasted, by provider id. Checked
138
+ locally (format and provider mix-ups for OpenAI, Anthropic and Google; a
139
+ safety check that refuses admin keys for everyone), never transmitted
140
+ anywhere by the SDK.
141
+ """
142
+ if not keys:
143
+ _fail("direct() needs at least one provider key.", "no_keys")
144
+ checked = {provider: _validate_key(provider, raw) for provider, raw in keys.items()}
145
+ return OutletSession(
146
+ grant_id=_local_id(),
147
+ keys=checked,
148
+ # No Outlet meter in direct mode; real caps arrive with vault mode.
149
+ cap_usd=float("inf"),
150
+ # Direct keys live until the user revokes them in their provider console.
151
+ expires_at="9999-12-31T23:59:59Z",
152
+ mode="direct",
153
+ )
154
+
155
+
156
+ def assert_vault_grant(grant_id: str) -> None:
157
+ """Vault operations (refresh, status, revoke) have nothing to act on for a
158
+ direct-mode session. Fail with directions rather than a confusing 404."""
159
+ if grant_id.startswith("direct_"):
160
+ _fail(
161
+ "This is a direct-mode session: there is no vault grant behind it. "
162
+ "To revoke, delete the key on the provider's website.",
163
+ "direct_mode_session",
164
+ )
useoutlet/ended.py ADDED
@@ -0,0 +1,59 @@
1
+ """Connection ends: the vault's refusals and status answers turned into
2
+ ConnectionEndedError. Every end passes through ended(). The npm package also
3
+ tells the page's Connect your AI button; a server has no button to tell."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from .providers import get_provider
8
+ from .types import ConnectionEndedError, EndReason, GrantInfo, OutletError
9
+
10
+
11
+ def ended(
12
+ reason: EndReason,
13
+ grant_id: str,
14
+ *,
15
+ info: GrantInfo | None = None,
16
+ provider: str | None = None,
17
+ status: int | None = None,
18
+ ) -> ConnectionEndedError:
19
+ """The typed error for a connection that ended."""
20
+ message = None
21
+ if reason == "refused" and provider:
22
+ entry = get_provider(provider)
23
+ name = entry.display_name if entry else provider
24
+ message = f"{name} refused this Direct API key. Ask the user for a new one."
25
+ return ConnectionEndedError(
26
+ reason, grant_id, info=info, provider=provider, status=status, message=message
27
+ )
28
+
29
+
30
+ def ended_from_vault(e: OutletError, grant_id: str) -> ConnectionEndedError | None:
31
+ """The connection end behind a vault refusal, or None when the code is not
32
+ one: 409 grant_capped, 409 grant_revoked, and 401 unauthorized for a refresh
33
+ token the vault no longer knows (rotated away or gone)."""
34
+ if e.code == "grant_capped":
35
+ return ended("capped", grant_id, status=e.status)
36
+ if e.code == "grant_revoked":
37
+ return ended("revoked", grant_id, status=e.status)
38
+ if e.code == "unauthorized":
39
+ return ended("expired", grant_id, status=e.status)
40
+ return None
41
+
42
+
43
+ def ended_from_info(info: GrantInfo) -> ConnectionEndedError | None:
44
+ """The connection end a status answer reports, or None while it is open."""
45
+ if info.status not in ("capped", "revoked"):
46
+ return None
47
+ provider = info.providers[0] if info.providers else None
48
+ return ended(info.status, info.grant_id, info=info, provider=provider, status=409)
49
+
50
+
51
+ def is_connection_ended(e: object) -> bool:
52
+ """True for a ConnectionEndedError, from this package or another copy of it."""
53
+ if isinstance(e, ConnectionEndedError):
54
+ return True
55
+ return (
56
+ getattr(e, "code", None) == "connection_ended"
57
+ and isinstance(getattr(e, "reason", None), str)
58
+ and isinstance(getattr(e, "grant_id", None), str)
59
+ )