From 80093f03c56a14a452cad2e08eb21162d14b1c46 Mon Sep 17 00:00:00 2001
From: Daniel Yudelevich
Date: Sun, 30 Aug 2026 08:42:43 -0700
Subject: [PATCH 01/13] refactor(sdk): hand OAuth token requests to Authlib
1.8.0
Authlib 1.8.0 (2026-08-30) ships httpx2 support, so PKCE challenge
generation, the authorization URL, code exchange, and refresh now go
through its OAuth2Client instead of hand-rolled request building. The
point is to stop owning protocol code: Authlib maintains RFC 6749/7636
handling, we keep only what it lacks.
What stays ours, deliberately:
- DiscolikeAuth (httpx2.Auth): single 401 replay, cross-process
credential reload (PropelAuth revokes the whole refresh family on a
replayed refresh token, so two CLI processes must not both refresh),
sync+async locks, and the API-key branch. Authlib's sync client has
no lock and refreshes on expiry only.
- Dynamic client registration and metadata discovery: Authlib has no
RFC 7591 client (authlib/authlib#526, open since 2023).
TokenClient/AsyncTokenClient override parse_response_token so error
types, status codes, and token-redacted payloads are unchanged for
callers. Refreshes use a dedicated token-endpoint client (30s timeout)
rather than the SDK http_client, so proxies configured there no longer
reach the token endpoint; noted in the changelog.
Verified live against prod: login (reused DCR client), status, usage,
proactive refresh with token rotation, and 401 replay.
Also: README sign-up links to discolike.com/signup.
---
CHANGELOG.md | 4 +
README.md | 4 +-
packages/discolike-cli/pyproject.toml | 4 +-
.../discolike-cli/src/discolike_cli/auth.py | 6 +-
.../discolike-cli/tests/test_auth_oauth.py | 12 +-
packages/discolike/pyproject.toml | 3 +-
packages/discolike/src/discolike/_auth.py | 63 ++----
packages/discolike/src/discolike/_oauth.py | 173 ++++++++-------
packages/discolike/src/discolike/_version.py | 2 +-
packages/discolike/tests/test_auth_flow.py | 5 +-
packages/discolike/tests/test_client.py | 3 +
packages/discolike/tests/test_oauth.py | 104 ++++++---
packages/discolike/tests/test_package.py | 2 +-
uv.lock | 205 +++++++++++++++++-
14 files changed, 421 insertions(+), 169 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2aedd8..e4e1923 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## 0.3.1
+
+- SDK: OAuth token requests (PKCE authorization URL, code exchange, refresh) now go through [Authlib](https://authlib.org) 1.8.0's httpx2 client instead of hand-rolled request building; `authlib` is a new dependency. Bearer handling, proactive refresh, the single 401 replay, config persistence, and error types are unchanged. Refreshes use a dedicated token-endpoint client with a 30s timeout rather than the SDK's `http_client`, so proxies or transports configured on `http_client=` no longer apply to the token endpoint.
+
## 0.3.0 (2026-08-29)
- SDK: OAuth login. `Discolike(auth=...)` / `AsyncDiscolike(auth=...)` accept an `ApiKeyCredential` or `OAuthCredential` (both exported from `discolike`); `api_key=`, `DISCOLIKE_API_KEY`, and the config file keep working unchanged, and `auth=` wins over all of them. OAuth credentials send `Authorization: Bearer`, refresh proactively within 60s of expiry and once more after a 401, and write rotated refresh tokens back to the config file when they were loaded from it (an injected `auth=` is never persisted). A refresh that fails raises `AuthenticationError("OAuth session expired; run `discolike auth login`")`. Config file gains the shape `{"auth_method": "oauth", "oauth": {...}}` next to the existing `api_key` shape.
diff --git a/README.md b/README.md
index 89196de..5a5a14b 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@
Website ·
API Docs ·
Get an API key ·
- Sign up ·
+ Sign up ·
Book a demo ·
Blog
@@ -288,7 +288,7 @@ Committed request models track the dev spec (`--spec-url https://api.dev.discoli
## Support & contact
- **API documentation**: [docs.discolike.com](https://docs.discolike.com)
-- **Sign up**: [auth.discolike.com/en/signup](https://auth.discolike.com/en/signup)
+- **Sign up**: [discolike.com/signup](https://discolike.com/signup)
- **Book a demo**: [calendly.com/discolike/introductory-call](https://calendly.com/discolike/introductory-call)
- **LinkedIn**: [linkedin.com/company/discolike](https://www.linkedin.com/company/discolike/)
- **Issues with this SDK**: [GitHub issues](https://github.com/Discolike/discolike-python/issues)
diff --git a/packages/discolike-cli/pyproject.toml b/packages/discolike-cli/pyproject.toml
index acb5f6f..66fe6cc 100644
--- a/packages/discolike-cli/pyproject.toml
+++ b/packages/discolike-cli/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "discolike-cli"
-version = "0.3.0"
+version = "0.3.1"
description = "Official CLI for the DiscoLike API"
readme = "README.md"
license = "MIT"
@@ -12,7 +12,7 @@ license-files = ["LICENSE"]
requires-python = ">=3.10"
authors = [{ name = "DiscoLike", email = "support@discolike.com" }]
dependencies = [
- "discolike==0.3.0",
+ "discolike==0.3.1",
"typer>=0.12",
"rich>=13.0",
]
diff --git a/packages/discolike-cli/src/discolike_cli/auth.py b/packages/discolike-cli/src/discolike_cli/auth.py
index 597385d..7a22238 100644
--- a/packages/discolike-cli/src/discolike_cli/auth.py
+++ b/packages/discolike-cli/src/discolike_cli/auth.py
@@ -33,7 +33,6 @@
from discolike._oauth import build_authorization_url
from discolike._oauth import discover
from discolike._oauth import exchange_code
-from discolike._oauth import pkce_pair
from discolike._oauth import register_client
from discolike_cli._loopback import CallbackServer
from discolike_cli._output import emit
@@ -135,13 +134,11 @@ def _authorize(
open_browser: bool,
http: httpx2.Client,
) -> OAuthCredential:
- verifier, challenge = pkce_pair()
state = secrets.token_urlsafe(STATE_BYTES)
- url = build_authorization_url(
+ url, verifier = build_authorization_url(
metadata,
client_id=registration.client_id,
redirect_uri=registration.redirect_uri,
- code_challenge=challenge,
state=state,
resource=resource,
)
@@ -168,7 +165,6 @@ def _authorize(
code_verifier=verifier,
redirect_uri=registration.redirect_uri,
resource=resource,
- client=http,
)
except OAuthError as exc:
if exc.error in DEAD_CLIENT_ERRORS:
diff --git a/packages/discolike-cli/tests/test_auth_oauth.py b/packages/discolike-cli/tests/test_auth_oauth.py
index a241008..d2e0347 100644
--- a/packages/discolike-cli/tests/test_auth_oauth.py
+++ b/packages/discolike-cli/tests/test_auth_oauth.py
@@ -75,18 +75,18 @@ def register_client(self, metadata: AuthServerMetadata, *, redirect_uris: list[s
self.register_calls.append(redirect_uris)
return "client-1"
- def exchange_code(self, metadata: AuthServerMetadata, *, client: httpx2.Client, **kwargs: Any) -> OAuthCredential:
+ def exchange_code(self, metadata: AuthServerMetadata, **kwargs: Any) -> OAuthCredential:
self.exchange_calls.append(kwargs)
if self.exchange_failures:
raise self.exchange_failures.pop(0)
return CREDENTIAL
- def build_authorization_url(self, metadata: AuthServerMetadata, **kwargs: Any) -> str:
- url = self.real_build_authorization_url(metadata, **kwargs)
+ def build_authorization_url(self, metadata: AuthServerMetadata, **kwargs: Any) -> tuple[str, str]:
+ url, verifier = self.real_build_authorization_url(metadata, **kwargs)
query = {key: values[0] for key, values in parse_qs(urlparse(url).query).items()}
callback = f"{query['redirect_uri']}?{self.callback_query(query)}"
threading.Thread(target=lambda: urlopen(callback).read(), daemon=True).start() # noqa: S310 -- loopback test server
- return url
+ return url, verifier
def open(self, url: str) -> bool:
self.opened_urls.append(url)
@@ -170,7 +170,9 @@ def test_login_timeout_exits_1(
) -> None:
install_build_client(_usage_ok)
monkeypatch.setattr(auth_module, "LOGIN_TIMEOUT_SECONDS", 0.2)
- monkeypatch.setattr(auth_module, "build_authorization_url", lambda metadata, **kwargs: "https://auth.test/never")
+ monkeypatch.setattr(
+ auth_module, "build_authorization_url", lambda metadata, **kwargs: ("https://auth.test/never", "v")
+ )
result = runner.invoke(app, ["auth", "login"])
assert result.exit_code == 1
assert "Timed out" in json.loads(result.stderr.splitlines()[-1])["message"]
diff --git a/packages/discolike/pyproject.toml b/packages/discolike/pyproject.toml
index cb245d2..d4cf14c 100644
--- a/packages/discolike/pyproject.toml
+++ b/packages/discolike/pyproject.toml
@@ -12,6 +12,7 @@ license-files = ["LICENSE"]
requires-python = ">=3.10"
authors = [{ name = "DiscoLike", email = "support@discolike.com" }]
dependencies = [
+ "authlib>=1.8.0",
"httpx2>=2.9",
"pydantic>=2.7",
"typing-extensions>=4.1",
@@ -33,7 +34,7 @@ classifiers = [
]
[project.optional-dependencies]
-cli = ["discolike-cli==0.3.0"]
+cli = ["discolike-cli==0.3.1"]
[project.urls]
Homepage = "https://www.discolike.com"
diff --git a/packages/discolike/src/discolike/_auth.py b/packages/discolike/src/discolike/_auth.py
index e77c2ae..01c435b 100644
--- a/packages/discolike/src/discolike/_auth.py
+++ b/packages/discolike/src/discolike/_auth.py
@@ -12,14 +12,9 @@
from discolike._credentials import ApiKeyCredential
from discolike._credentials import Credential
from discolike._credentials import OAuthCredential
-from discolike._exceptions import AuthenticationError
from discolike._oauth import REFRESH_LEEWAY_SECONDS
-from discolike._oauth import SESSION_EXPIRED_MESSAGE
-from discolike._oauth import parse_refresh_response
-from discolike._oauth import refresh_request
-
-# TODO: replace this module and _oauth.py with authlib's httpx2 OAuth2Client once a release
-# includes authlib/authlib@e4fb941 (httpx2 support merged 2026-08-27; 1.7.2 predates it).
+from discolike._oauth import refresh
+from discolike._oauth import refresh_async
API_KEY_HEADER = "X-discolike-key"
UNAUTHORIZED = 401
@@ -32,7 +27,8 @@ def _set_bearer(request: httpx2.Request, credential: OAuthCredential) -> None:
class DiscolikeAuth(httpx2.Auth):
"""Sends the API key header, or a bearer token that is refreshed before expiry and once after a 401.
- Refreshes go through the same client as the request they precede, so tests drive them via ``MockTransport``.
+ Refreshes go to the token endpoint through authlib's client, not the SDK client; ``token_transport``
+ lets tests intercept them.
"""
requires_response_body = False
@@ -43,10 +39,12 @@ def __init__(
*,
on_update: Callable[[OAuthCredential], None] | None = None,
reload: Callable[[], Credential | None] | None = None,
+ token_transport: httpx2.BaseTransport | httpx2.AsyncBaseTransport | None = None,
) -> None:
self._credential = credential
self.on_update = on_update
self.reload = reload
+ self._token_transport = token_transport
self._lock = threading.Lock()
self._async_lock = asyncio.Lock()
@@ -71,13 +69,7 @@ def _adopt_stored(self, credential: OAuthCredential) -> OAuthCredential | None:
self._credential = stored
return stored
- def _store(self, response: httpx2.Response, *, credential: OAuthCredential) -> OAuthCredential:
- try:
- rotated = parse_refresh_response(response, credential=credential)
- except AuthenticationError as exc:
- raise AuthenticationError(
- SESSION_EXPIRED_MESSAGE, status_code=exc.status_code, payload=exc.payload
- ) from exc
+ def _store(self, rotated: OAuthCredential) -> OAuthCredential:
self._credential = rotated
if self.on_update is not None:
self.on_update(rotated)
@@ -92,11 +84,9 @@ def sync_auth_flow(self, request: httpx2.Request) -> Generator[httpx2.Request, h
with self._lock:
credential = self._latest(credential)
if credential.expires_within(REFRESH_LEEWAY_SECONDS):
- adopted = self._adopt_stored(credential)
- if adopted is not None:
- credential = adopted
- else:
- credential = yield from self._sync_refresh(credential)
+ credential = self._adopt_stored(credential) or self._store(
+ refresh(credential, transport=self._token_transport)
+ )
_set_bearer(request, credential)
response = yield request
if response.status_code != UNAUTHORIZED:
@@ -104,19 +94,12 @@ def sync_auth_flow(self, request: httpx2.Request) -> Generator[httpx2.Request, h
with self._lock:
latest = self._latest(credential)
if latest is credential:
- adopted = self._adopt_stored(credential)
- if adopted is not None:
- latest = adopted
- else:
- latest = yield from self._sync_refresh(credential)
+ latest = self._adopt_stored(credential) or self._store(
+ refresh(credential, transport=self._token_transport)
+ )
_set_bearer(request, latest)
yield request
- def _sync_refresh(self, credential: OAuthCredential) -> Generator[httpx2.Request, httpx2.Response, OAuthCredential]:
- response = yield refresh_request(credential)
- response.read()
- return self._store(response, credential=credential)
-
async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
credential = self._credential
if isinstance(credential, ApiKeyCredential):
@@ -126,13 +109,9 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
async with self._async_lock:
credential = self._latest(credential)
if credential.expires_within(REFRESH_LEEWAY_SECONDS):
- adopted = self._adopt_stored(credential)
- if adopted is not None:
- credential = adopted
- else:
- response = yield refresh_request(credential)
- await response.aread()
- credential = self._store(response, credential=credential)
+ credential = self._adopt_stored(credential) or self._store(
+ await refresh_async(credential, transport=self._token_transport)
+ )
_set_bearer(request, credential)
response = yield request
if response.status_code != UNAUTHORIZED:
@@ -140,12 +119,8 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
async with self._async_lock:
latest = self._latest(credential)
if latest is credential:
- adopted = self._adopt_stored(credential)
- if adopted is not None:
- latest = adopted
- else:
- response = yield refresh_request(credential)
- await response.aread()
- latest = self._store(response, credential=credential)
+ latest = self._adopt_stored(credential) or self._store(
+ await refresh_async(credential, transport=self._token_transport)
+ )
_set_bearer(request, latest)
yield request
diff --git a/packages/discolike/src/discolike/_oauth.py b/packages/discolike/src/discolike/_oauth.py
index fb295b2..7289cad 100644
--- a/packages/discolike/src/discolike/_oauth.py
+++ b/packages/discolike/src/discolike/_oauth.py
@@ -1,27 +1,27 @@
from __future__ import annotations
-import base64
-import hashlib
-import secrets
-import time
from dataclasses import dataclass
from typing import Any
-from urllib.parse import urlencode
import httpx2
+from authlib.common.security import generate_token
+from authlib.integrations.httpx_client import AsyncOAuth2Client
+from authlib.integrations.httpx_client import OAuth2Client
+from authlib.oauth2.client import OAuth2Client as BaseOAuth2Client
from discolike._credentials import OAuthCredential
from discolike._exceptions import AuthenticationError
OAUTH_SCOPE = "offline_access"
REFRESH_LEEWAY_SECONDS = 60.0
+TOKEN_TIMEOUT_SECONDS = 30.0
CLIENT_NAME = "discolike-cli"
METADATA_PATH = "/.well-known/oauth-authorization-server"
GRANT_TYPES = ["authorization_code", "refresh_token"]
RESPONSE_TYPES = ["code"]
PKCE_METHOD = "S256"
-PKCE_VERIFIER_BYTES = 32
-TOKEN_HEADERS = {"Accept": "application/json"}
+PKCE_VERIFIER_LENGTH = 64
+PUBLIC_CLIENT_AUTH = "none"
TOKEN_KEYS = frozenset({"access_token", "refresh_token", "id_token"})
SESSION_EXPIRED_MESSAGE = "OAuth session expired; run `discolike auth login`"
@@ -49,15 +49,6 @@ class AuthServerMetadata:
issuer: str
-def _b64url(raw: bytes) -> str:
- return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
-
-
-def pkce_pair() -> tuple[str, str]:
- verifier = _b64url(secrets.token_bytes(PKCE_VERIFIER_BYTES))
- return verifier, _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
-
-
def _payload(response: httpx2.Response) -> dict[str, Any]:
try:
payload = response.json()
@@ -83,25 +74,58 @@ def _require(payload: dict[str, Any], key: str) -> str:
return str(payload[key])
-def _redacted(payload: dict[str, Any]) -> dict[str, Any]:
- return {key: value for key, value in payload.items() if key not in TOKEN_KEYS}
+class _StrictTokenParsing(BaseOAuth2Client):
+ """Authlib swallows HTTP status and non-`error` failures; raise the SDK's exceptions before it parses."""
+
+ def parse_response_token(self, resp: httpx2.Response) -> dict[str, Any]:
+ _payload(resp)
+ return super().parse_response_token(resp)
+
+
+class TokenClient(_StrictTokenParsing, OAuth2Client):
+ pass
+
+
+class AsyncTokenClient(_StrictTokenParsing, AsyncOAuth2Client):
+ pass
-def _credential_from_token_payload(
- payload: dict[str, Any], *, client_id: str, token_endpoint: str, fallback_refresh_token: str | None
+def _client_kwargs(
+ *,
+ client_id: str,
+ redirect_uri: str | None = None,
+ scope: str | None = None,
+ transport: httpx2.BaseTransport | httpx2.AsyncBaseTransport | None = None,
+) -> dict[str, Any]:
+ kwargs: dict[str, Any] = {
+ "client_id": client_id,
+ "redirect_uri": redirect_uri,
+ "scope": scope,
+ "code_challenge_method": PKCE_METHOD,
+ "token_endpoint_auth_method": PUBLIC_CLIENT_AUTH,
+ "timeout": TOKEN_TIMEOUT_SECONDS,
+ }
+ if transport is not None:
+ kwargs["transport"] = transport
+ return kwargs
+
+
+def _credential_from_token(
+ token: dict[str, Any], *, client_id: str, token_endpoint: str, fallback_refresh_token: str | None
) -> OAuthCredential:
# Exceptions raised here may be logged by SDK consumers; never attach live tokens to them.
- safe_payload = _redacted(payload)
- refresh_token = payload.get("refresh_token") or fallback_refresh_token
+ safe_payload = {key: value for key, value in token.items() if key not in TOKEN_KEYS}
+ refresh_token = token.get("refresh_token") or fallback_refresh_token
if not refresh_token:
raise AuthenticationError("OAuth token response has no `refresh_token`", payload=safe_payload)
- for key in ("access_token", "expires_in"):
- if key not in payload:
- raise AuthenticationError(f"OAuth server response is missing `{key}`", payload=safe_payload)
+ if "access_token" not in token:
+ raise AuthenticationError("OAuth server response is missing `access_token`", payload=safe_payload)
+ if "expires_at" not in token:
+ raise AuthenticationError("OAuth server response is missing `expires_in`", payload=safe_payload)
return OAuthCredential(
- access_token=str(payload["access_token"]),
+ access_token=str(token["access_token"]),
refresh_token=str(refresh_token),
- expires_at=time.time() + float(payload["expires_in"]),
+ expires_at=float(token["expires_at"]),
client_id=client_id,
token_endpoint=token_endpoint,
)
@@ -123,35 +147,21 @@ def register_client(metadata: AuthServerMetadata, *, redirect_uris: list[str], c
"redirect_uris": redirect_uris,
"grant_types": GRANT_TYPES,
"response_types": RESPONSE_TYPES,
- "token_endpoint_auth_method": "none",
+ "token_endpoint_auth_method": PUBLIC_CLIENT_AUTH,
}
return _require(_payload(client.post(metadata.registration_endpoint, json=body)), "client_id")
def build_authorization_url(
- metadata: AuthServerMetadata,
- *,
- client_id: str,
- redirect_uri: str,
- code_challenge: str,
- state: str,
- resource: str,
- scope: str = OAUTH_SCOPE,
-) -> str:
- query = urlencode(
- {
- "response_type": "code",
- "client_id": client_id,
- "redirect_uri": redirect_uri,
- "code_challenge": code_challenge,
- "code_challenge_method": PKCE_METHOD,
- "state": state,
- "resource": resource,
- "scope": scope,
- }
- )
- separator = "&" if "?" in metadata.authorization_endpoint else "?"
- return f"{metadata.authorization_endpoint}{separator}{query}"
+ metadata: AuthServerMetadata, *, client_id: str, redirect_uri: str, state: str, resource: str
+) -> tuple[str, str]:
+ """Returns the URL to open and the PKCE code verifier to present at `exchange_code`."""
+ code_verifier = generate_token(PKCE_VERIFIER_LENGTH)
+ with TokenClient(**_client_kwargs(client_id=client_id, redirect_uri=redirect_uri, scope=OAUTH_SCOPE)) as client:
+ url, _ = client.create_authorization_url(
+ metadata.authorization_endpoint, state=state, code_verifier=code_verifier, resource=resource
+ )
+ return str(url), code_verifier
def exchange_code(
@@ -162,35 +172,44 @@ def exchange_code(
code_verifier: str,
redirect_uri: str,
resource: str,
- client: httpx2.Client,
+ transport: httpx2.BaseTransport | None = None,
) -> OAuthCredential:
- form = {
- "grant_type": "authorization_code",
- "client_id": client_id,
- "code": code,
- "code_verifier": code_verifier,
- "redirect_uri": redirect_uri,
- "resource": resource,
- }
- response = client.post(metadata.token_endpoint, data=form, headers=TOKEN_HEADERS)
- return _credential_from_token_payload(
- _payload(response), client_id=client_id, token_endpoint=metadata.token_endpoint, fallback_refresh_token=None
+ kwargs = _client_kwargs(client_id=client_id, redirect_uri=redirect_uri, scope=OAUTH_SCOPE, transport=transport)
+ with TokenClient(**kwargs) as client:
+ token = client.fetch_token(metadata.token_endpoint, code=code, code_verifier=code_verifier, resource=resource)
+ return _credential_from_token(
+ token, client_id=client_id, token_endpoint=metadata.token_endpoint, fallback_refresh_token=None
)
-def refresh_request(credential: OAuthCredential) -> httpx2.Request:
- form = {
- "grant_type": "refresh_token",
- "refresh_token": credential.refresh_token,
- "client_id": credential.client_id,
- }
- return httpx2.Request("POST", credential.token_endpoint, data=form, headers=TOKEN_HEADERS)
+def refresh(
+ credential: OAuthCredential, *, transport: httpx2.BaseTransport | httpx2.AsyncBaseTransport | None = None
+) -> OAuthCredential:
+ """Rotates the tokens; any failure means the session is gone and the user must log in again."""
+ try:
+ with TokenClient(**_client_kwargs(client_id=credential.client_id, transport=transport)) as client:
+ token = client.refresh_token(credential.token_endpoint, refresh_token=credential.refresh_token)
+ return _credential_from_token(
+ token,
+ client_id=credential.client_id,
+ token_endpoint=credential.token_endpoint,
+ fallback_refresh_token=credential.refresh_token,
+ )
+ except AuthenticationError as exc:
+ raise AuthenticationError(SESSION_EXPIRED_MESSAGE, status_code=exc.status_code, payload=exc.payload) from exc
-def parse_refresh_response(response: httpx2.Response, *, credential: OAuthCredential) -> OAuthCredential:
- return _credential_from_token_payload(
- _payload(response),
- client_id=credential.client_id,
- token_endpoint=credential.token_endpoint,
- fallback_refresh_token=credential.refresh_token,
- )
+async def refresh_async(
+ credential: OAuthCredential, *, transport: httpx2.BaseTransport | httpx2.AsyncBaseTransport | None = None
+) -> OAuthCredential:
+ try:
+ async with AsyncTokenClient(**_client_kwargs(client_id=credential.client_id, transport=transport)) as client:
+ token = await client.refresh_token(credential.token_endpoint, refresh_token=credential.refresh_token)
+ return _credential_from_token(
+ token,
+ client_id=credential.client_id,
+ token_endpoint=credential.token_endpoint,
+ fallback_refresh_token=credential.refresh_token,
+ )
+ except AuthenticationError as exc:
+ raise AuthenticationError(SESSION_EXPIRED_MESSAGE, status_code=exc.status_code, payload=exc.payload) from exc
diff --git a/packages/discolike/src/discolike/_version.py b/packages/discolike/src/discolike/_version.py
index 493f741..260c070 100644
--- a/packages/discolike/src/discolike/_version.py
+++ b/packages/discolike/src/discolike/_version.py
@@ -1 +1 @@
-__version__ = "0.3.0"
+__version__ = "0.3.1"
diff --git a/packages/discolike/tests/test_auth_flow.py b/packages/discolike/tests/test_auth_flow.py
index a4eb24b..9da17e0 100644
--- a/packages/discolike/tests/test_auth_flow.py
+++ b/packages/discolike/tests/test_auth_flow.py
@@ -55,10 +55,12 @@ def __call__(self, request: httpx2.Request) -> httpx2.Response:
def sync_client(auth: DiscolikeAuth, handler) -> httpx2.Client:
+ auth._token_transport = httpx2.MockTransport(handler)
return httpx2.Client(transport=httpx2.MockTransport(handler), base_url=API, auth=auth)
def async_client(auth: DiscolikeAuth, handler) -> httpx2.AsyncClient:
+ auth._token_transport = httpx2.MockTransport(handler)
return httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url=API, auth=auth)
@@ -102,7 +104,8 @@ def test_refresh_request_shape() -> None:
sync_client(DiscolikeAuth(make_oauth(expires_in=0)), server).get("/usage")
token_request = server.token_calls[0]
assert token_request.method == "POST"
- assert token_request.headers["Content-Type"] == "application/x-www-form-urlencoded"
+ assert token_request.headers["Content-Type"].startswith("application/x-www-form-urlencoded")
+ assert token_request.headers["Accept"] == "application/json"
assert token_request.content.decode() == "grant_type=refresh_token&refresh_token=rt-1&client_id=client-1"
diff --git a/packages/discolike/tests/test_client.py b/packages/discolike/tests/test_client.py
index fecf942..1b13e1b 100644
--- a/packages/discolike/tests/test_client.py
+++ b/packages/discolike/tests/test_client.py
@@ -137,6 +137,9 @@ def handler(request: httpx2.Request) -> httpx2.Response:
http = httpx2.Client(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1")
with Discolike(base_url="https://api.test/v1", http_client=http) as client:
+ auth = client._transport._client.auth
+ assert isinstance(auth, DiscolikeAuth)
+ auth._token_transport = httpx2.MockTransport(handler)
assert client.account.usage().requests_mtd == 1
stored = load_credential()
assert isinstance(stored, OAuthCredential)
diff --git a/packages/discolike/tests/test_oauth.py b/packages/discolike/tests/test_oauth.py
index daa4fb0..d3ad1c0 100644
--- a/packages/discolike/tests/test_oauth.py
+++ b/packages/discolike/tests/test_oauth.py
@@ -15,9 +15,8 @@
from discolike._oauth import build_authorization_url
from discolike._oauth import discover
from discolike._oauth import exchange_code
-from discolike._oauth import parse_refresh_response
-from discolike._oauth import pkce_pair
-from discolike._oauth import refresh_request
+from discolike._oauth import refresh
+from discolike._oauth import refresh_async
from discolike._oauth import register_client
BASE_URL = "https://api.test/v1"
@@ -33,16 +32,12 @@ def client_for(handler) -> httpx2.Client:
return httpx2.Client(transport=httpx2.MockTransport(handler))
-def form(request: httpx2.Request) -> dict[str, str]:
- return {key: values[0] for key, values in parse_qs(request.content.decode()).items()}
+def transport_for(handler) -> httpx2.MockTransport:
+ return httpx2.MockTransport(handler)
-def test_pkce_pair_is_s256() -> None:
- verifier, challenge = pkce_pair()
- expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
- assert challenge == expected
- assert "=" not in verifier
- assert 43 <= len(verifier) <= 128
+def form(request: httpx2.Request) -> dict[str, str]:
+ return {key: values[0] for key, values in parse_qs(request.content.decode()).items()}
def test_discover_reads_well_known_under_base_url() -> None:
@@ -93,14 +88,12 @@ def handler(request: httpx2.Request) -> httpx2.Response:
def test_build_authorization_url_carries_pkce_state_and_resource() -> None:
- url = build_authorization_url(
- METADATA,
- client_id="client-abc",
- redirect_uri="http://127.0.0.1:9999/callback",
- code_challenge="chal",
- state="st",
- resource=BASE_URL,
+ url, verifier = build_authorization_url(
+ METADATA, client_id="client-abc", redirect_uri="http://127.0.0.1:9999/callback", state="st", resource=BASE_URL
)
+ assert verifier.isalnum()
+ assert 43 <= len(verifier) <= 128
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
parsed = urlparse(url)
assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == METADATA.authorization_endpoint
query = {key: values[0] for key, values in parse_qs(parsed.query).items()}
@@ -108,7 +101,7 @@ def test_build_authorization_url_carries_pkce_state_and_resource() -> None:
"response_type": "code",
"client_id": "client-abc",
"redirect_uri": "http://127.0.0.1:9999/callback",
- "code_challenge": "chal",
+ "code_challenge": challenge,
"code_challenge_method": "S256",
"state": "st",
"resource": BASE_URL,
@@ -133,10 +126,10 @@ def handler(request: httpx2.Request) -> httpx2.Response:
code_verifier="ver",
redirect_uri="http://127.0.0.1:9999/callback",
resource=BASE_URL,
- client=client_for(handler),
+ transport=transport_for(handler),
)
request = seen[0]
- assert request.headers["Content-Type"] == "application/x-www-form-urlencoded"
+ assert request.headers["Content-Type"].startswith("application/x-www-form-urlencoded")
assert form(request) == {
"grant_type": "authorization_code",
"client_id": "client-abc",
@@ -149,7 +142,7 @@ def handler(request: httpx2.Request) -> httpx2.Response:
assert credential.refresh_token == "rt"
assert credential.client_id == "client-abc"
assert credential.token_endpoint == METADATA.token_endpoint
- assert before + 3600 <= credential.expires_at <= time.time() + 3600
+ assert int(before) + 3600 <= credential.expires_at <= time.time() + 3600
def test_exchange_code_without_refresh_token_raises() -> None:
@@ -164,7 +157,7 @@ def handler(request: httpx2.Request) -> httpx2.Response:
code_verifier="v",
redirect_uri="http://127.0.0.1:1/callback",
resource=BASE_URL,
- client=client_for(handler),
+ transport=transport_for(handler),
)
@@ -180,7 +173,7 @@ def handler(request: httpx2.Request) -> httpx2.Response:
code_verifier="v",
redirect_uri="http://127.0.0.1:1/callback",
resource=BASE_URL,
- client=client_for(handler),
+ transport=transport_for(handler),
)
assert info.value.payload == {"token_type": "Bearer"}
@@ -197,7 +190,7 @@ def handler(request: httpx2.Request) -> httpx2.Response:
code_verifier="v",
redirect_uri="http://127.0.0.1:1/callback",
resource=BASE_URL,
- client=client_for(handler),
+ transport=transport_for(handler),
)
assert exc_info.value.status_code == 400
assert exc_info.value.error == "invalid_grant"
@@ -225,8 +218,7 @@ def rotating(request: httpx2.Request) -> httpx2.Response:
seen.append(request)
return httpx2.Response(200, json={"access_token": "new", "refresh_token": "rt-new", "expires_in": 60})
- with client_for(rotating) as client:
- rotated = parse_refresh_response(client.send(refresh_request(credential)), credential=credential)
+ rotated = refresh(credential, transport=transport_for(rotating))
assert form(seen[0]) == {"grant_type": "refresh_token", "refresh_token": "rt-old", "client_id": "c"}
assert (rotated.access_token, rotated.refresh_token) == ("new", "rt-new")
assert rotated.client_id == "c"
@@ -235,11 +227,65 @@ def rotating(request: httpx2.Request) -> httpx2.Response:
def not_rotating(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, json={"access_token": "newer", "expires_in": 60})
- with client_for(not_rotating) as client:
- kept = parse_refresh_response(client.send(refresh_request(rotated)), credential=rotated)
+ kept = refresh(rotated, transport=transport_for(not_rotating))
assert (kept.access_token, kept.refresh_token) == ("newer", "rt-new")
+async def test_refresh_async_rotates_tokens() -> None:
+ credential = OAuthCredential(
+ access_token="old",
+ refresh_token="rt-old",
+ expires_at=0.0,
+ client_id="c",
+ token_endpoint=METADATA.token_endpoint,
+ )
+ seen: list[httpx2.Request] = []
+
+ def rotating(request: httpx2.Request) -> httpx2.Response:
+ seen.append(request)
+ return httpx2.Response(200, json={"access_token": "new", "refresh_token": "rt-new", "expires_in": 60})
+
+ rotated = await refresh_async(credential, transport=transport_for(rotating))
+ assert form(seen[0]) == {"grant_type": "refresh_token", "refresh_token": "rt-old", "client_id": "c"}
+ assert (rotated.access_token, rotated.refresh_token) == ("new", "rt-new")
+
+
+def test_refresh_error_body_maps_to_session_expired() -> None:
+ credential = OAuthCredential(
+ access_token="old",
+ refresh_token="rt-old",
+ expires_at=0.0,
+ client_id="c",
+ token_endpoint=METADATA.token_endpoint,
+ )
+
+ def revoked(request: httpx2.Request) -> httpx2.Response:
+ return httpx2.Response(400, json={"error": "invalid_grant", "error_description": "revoked"})
+
+ with pytest.raises(AuthenticationError, match="discolike auth login") as info:
+ refresh(credential, transport=transport_for(revoked))
+ assert info.value.status_code == 400
+ assert isinstance(info.value.__cause__, OAuthError)
+ assert info.value.__cause__.error == "invalid_grant"
+
+
+def test_refresh_5xx_maps_to_session_expired() -> None:
+ credential = OAuthCredential(
+ access_token="old",
+ refresh_token="rt-old",
+ expires_at=0.0,
+ client_id="c",
+ token_endpoint=METADATA.token_endpoint,
+ )
+
+ def down(request: httpx2.Request) -> httpx2.Response:
+ return httpx2.Response(503, json={"detail": "maintenance"})
+
+ with pytest.raises(AuthenticationError, match="discolike auth login") as info:
+ refresh(credential, transport=transport_for(down))
+ assert info.value.status_code == 503
+
+
def test_credential_config_roundtrip_and_expiry() -> None:
credential = OAuthCredential(
access_token="a", refresh_token="r", expires_at=1000.0, client_id="c", token_endpoint="https://t"
diff --git a/packages/discolike/tests/test_package.py b/packages/discolike/tests/test_package.py
index cd97cc1..1564e7a 100644
--- a/packages/discolike/tests/test_package.py
+++ b/packages/discolike/tests/test_package.py
@@ -2,4 +2,4 @@
def test_version() -> None:
- assert discolike.__version__ == "0.3.0"
+ assert discolike.__version__ == "0.3.1"
diff --git a/uv.lock b/uv.lock
index 2de2feb..d5ab293 100644
--- a/uv.lock
+++ b/uv.lock
@@ -56,6 +56,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl", hash = "sha256:6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19", size = 43294, upload-time = "2026-08-06T04:53:20.246Z" },
]
+[[package]]
+name = "authlib"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "joserfc" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f1/51/bc1729d3cfdc214b4935f4e886e4dd443c3065fd8e1e66423fe84b490f81/authlib-1.8.0.tar.gz", hash = "sha256:f3ecd5f1da737262fb53bf1a4d95c4ea1ad9dd509316587a255c99ab1838a4f0", size = 177759, upload-time = "2026-08-30T12:12:34.833Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b8/c6/6f124bcfbbfb20fba22c939b4e43a06dccfc0e1ca20e5634ca573cb1e271/authlib-1.8.0-py2.py3-none-any.whl", hash = "sha256:88aebbd9af6757e14e912d5dc007ae1dc1f3e27e3b2152ce7c552ee2c3b3c121", size = 260804, upload-time = "2026-08-30T12:12:33.162Z" },
+]
+
[[package]]
name = "backports-asyncio-runner"
version = "1.2.0"
@@ -109,6 +122,116 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" },
]
+[[package]]
+name = "cffi"
+version = "2.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" },
+ { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" },
+ { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" },
+ { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" },
+ { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" },
+ { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" },
+ { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" },
+ { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" },
+ { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
+ { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
+ { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
+ { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
+ { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
+ { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
+ { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
+ { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
+ { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
+ { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
+ { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
+ { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
+ { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
+ { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
+ { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
+ { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
+ { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
+ { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
+ { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
+ { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
+ { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
+ { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
+ { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
+ { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
+ { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
+ { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
+ { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
+ { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
+ { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
+ { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
+ { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
+ { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
+]
+
[[package]]
name = "click"
version = "8.5.0"
@@ -127,6 +250,63 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
+[[package]]
+name = "cryptography"
+version = "50.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
+ { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
+ { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
+ { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
+ { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
+ { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
+ { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
+ { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
+ { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" },
+ { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" },
+ { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" },
+ { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" },
+ { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" },
+ { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
+ { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
+ { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
+ { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
+ { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" },
+ { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" },
+ { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" },
+ { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" },
+ { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" },
+]
+
[[package]]
name = "datamodel-code-generator"
version = "0.75.1"
@@ -151,6 +331,7 @@ wheels = [
name = "discolike"
source = { editable = "packages/discolike" }
dependencies = [
+ { name = "authlib" },
{ name = "httpx2" },
{ name = "pydantic" },
{ name = "typing-extensions" },
@@ -173,6 +354,7 @@ dev = [
[package.metadata]
requires-dist = [
+ { name = "authlib", specifier = ">=1.8.0" },
{ name = "discolike-cli", marker = "extra == 'cli'", editable = "packages/discolike-cli" },
{ name = "httpx2", specifier = ">=2.9" },
{ name = "pydantic", specifier = ">=2.7" },
@@ -192,7 +374,7 @@ dev = [
[[package]]
name = "discolike-cli"
-version = "0.3.0"
+version = "0.3.1"
source = { editable = "packages/discolike-cli" }
dependencies = [
{ name = "discolike" },
@@ -363,6 +545,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
+[[package]]
+name = "joserfc"
+version = "1.7.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/94/80fea1514b7c6d7d37804d3fe9ca81455f633347fc98731bd71ffe1faa17/joserfc-1.7.5.tar.gz", hash = "sha256:d5ff536e658e17664f8c1b1ab60dc4aa62aa973fcef1edd33cc44bda45d6f5ea", size = 234990, upload-time = "2026-08-29T13:05:42.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/c5/82addfd375e5ee6520644e0553e4aadde92d668c4fc99cc716d337fe7bb3/joserfc-1.7.5-py3-none-any.whl", hash = "sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159", size = 71269, upload-time = "2026-08-29T13:05:40.718Z" },
+]
+
[[package]]
name = "markdown-it-py"
version = "4.2.0"
@@ -523,6 +717,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
[[package]]
name = "pydantic"
version = "2.13.4"
From 47ff96f8a5995d73b401419c46eb64196fc539f0 Mon Sep 17 00:00:00 2001
From: Daniel Yudelevich
Date: Mon, 31 Aug 2026 12:56:07 -0700
Subject: [PATCH 02/13] feat(sdk): accept the subdomains append dataset
Regenerated request models from the platform spec: AppendParams.dataset
now allows "subdomains" (up to 300 known subdomains per domain).
---
CHANGELOG.md | 1 +
packages/discolike/src/discolike/_generated/requests.py | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e4e1923..75afcdb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## 0.3.1
+- SDK: `AppendParams.dataset` accepts the new `subdomains` dataset — appends the subdomains observed for each domain (up to 300, most popular first).
- SDK: OAuth token requests (PKCE authorization URL, code exchange, refresh) now go through [Authlib](https://authlib.org) 1.8.0's httpx2 client instead of hand-rolled request building; `authlib` is a new dependency. Bearer handling, proactive refresh, the single 401 replay, config persistence, and error types are unchanged. Refreshes use a dedicated token-endpoint client with a 30s timeout rather than the SDK's `http_client`, so proxies or transports configured on `http_client=` no longer apply to the token endpoint.
## 0.3.0 (2026-08-29)
diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py
index a0458f1..e101402 100644
--- a/packages/discolike/src/discolike/_generated/requests.py
+++ b/packages/discolike/src/discolike/_generated/requests.py
@@ -2576,7 +2576,7 @@ class AppendParams(DiscolikeRequest):
Field(description="Return results as CSV instead of JSON.", title="Csv"),
] = False
dataset: Annotated[
- list[Literal["bizdata", "redirects", "domain_status", "growth", "vendors"]],
+ list[Literal["bizdata", "redirects", "domain_status", "growth", "vendors", "subdomains"]],
Field(description="Datasets to append.", min_length=1, title="Dataset"),
]
query_id: Annotated[
From 8792788c0f5e8930459820a0656ce41d8676b447 Mon Sep 17 00:00:00 2001
From: Daniel Yudelevich
Date: Tue, 1 Sep 2026 12:36:07 -0700
Subject: [PATCH 03/13] feat(sdk): discolike.signup() for agent-opened accounts
Module-level rather than a client method because the client requires a
credential at construction and signup is the one call that has none.
Maps 409 to ValidationError since raise_for_status had no branch for
it and account-exists is a validation-style failure, not a generic error.
---
packages/discolike/src/discolike/__init__.py | 6 ++
.../discolike/src/discolike/_exceptions.py | 1 +
packages/discolike/src/discolike/signup.py | 81 +++++++++++++++++++
packages/discolike/tests/test_exceptions.py | 1 +
packages/discolike/tests/test_signup.py | 63 +++++++++++++++
5 files changed, 152 insertions(+)
create mode 100644 packages/discolike/src/discolike/signup.py
create mode 100644 packages/discolike/tests/test_signup.py
diff --git a/packages/discolike/src/discolike/__init__.py b/packages/discolike/src/discolike/__init__.py
index 48419cd..6b3cbc7 100644
--- a/packages/discolike/src/discolike/__init__.py
+++ b/packages/discolike/src/discolike/__init__.py
@@ -25,6 +25,9 @@
from discolike.resources.email import EnumerationMatch
from discolike.resources.email import EnumerationOutput
from discolike.resources.email import ValidationOutput
+from discolike.signup import SignupResult
+from discolike.signup import async_signup
+from discolike.signup import signup
__all__ = [
"APIConnectionError",
@@ -51,7 +54,10 @@
"PlanAccessError",
"RateLimitError",
"ServerError",
+ "SignupResult",
"ValidationError",
"ValidationOutput",
"__version__",
+ "async_signup",
+ "signup",
]
diff --git a/packages/discolike/src/discolike/_exceptions.py b/packages/discolike/src/discolike/_exceptions.py
index 23436b0..55f590a 100644
--- a/packages/discolike/src/discolike/_exceptions.py
+++ b/packages/discolike/src/discolike/_exceptions.py
@@ -62,6 +62,7 @@ def __init__(
402: PlanAccessError,
403: PlanAccessError,
404: NotFoundError,
+ 409: ValidationError,
422: ValidationError,
}
diff --git a/packages/discolike/src/discolike/signup.py b/packages/discolike/src/discolike/signup.py
new file mode 100644
index 0000000..3af0e62
--- /dev/null
+++ b/packages/discolike/src/discolike/signup.py
@@ -0,0 +1,81 @@
+"""Credential-free account creation, for agents opening an account on a person's behalf."""
+
+from __future__ import annotations
+
+import httpx2
+
+from discolike._client import DEFAULT_TIMEOUT_SECONDS
+from discolike._config import DEFAULT_BASE_URL
+from discolike._exceptions import APIConnectionError
+from discolike._exceptions import raise_for_status
+from discolike._models import DiscolikeModel
+from discolike._version import __version__
+
+SIGNUP_PATH = "/public/signup"
+DEFAULT_AGENT = f"discolike-python/{__version__}"
+
+
+class SignupResult(DiscolikeModel):
+ status: str
+ email: str
+ org_domain: str
+ org_status: str
+ next_step: str
+
+
+def _body(*, email: str, first_name: str, last_name: str, agent: str | None) -> dict[str, str]:
+ return {"email": email, "first_name": first_name, "last_name": last_name, "agent": agent or DEFAULT_AGENT}
+
+
+def signup(
+ *,
+ email: str,
+ first_name: str,
+ last_name: str,
+ agent: str | None = None,
+ base_url: str = DEFAULT_BASE_URL,
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
+ http_client: httpx2.Client | None = None,
+) -> SignupResult:
+ """Create a DiscoLike account for ``email``. No credential is returned; the person
+ confirms by email and logs in at https://app.discolike.com."""
+ client = http_client or httpx2.Client(base_url=base_url, timeout=timeout)
+ try:
+ response = client.post(
+ SIGNUP_PATH,
+ json=_body(email=email, first_name=first_name, last_name=last_name, agent=agent),
+ headers={"User-Agent": DEFAULT_AGENT},
+ )
+ except httpx2.TransportError as exc:
+ raise APIConnectionError(f"Connection to DiscoLike API failed: {exc}") from exc
+ finally:
+ if http_client is None:
+ client.close()
+ raise_for_status(response)
+ return SignupResult.model_validate(response.json())
+
+
+async def async_signup(
+ *,
+ email: str,
+ first_name: str,
+ last_name: str,
+ agent: str | None = None,
+ base_url: str = DEFAULT_BASE_URL,
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
+ http_client: httpx2.AsyncClient | None = None,
+) -> SignupResult:
+ client = http_client or httpx2.AsyncClient(base_url=base_url, timeout=timeout)
+ try:
+ response = await client.post(
+ SIGNUP_PATH,
+ json=_body(email=email, first_name=first_name, last_name=last_name, agent=agent),
+ headers={"User-Agent": DEFAULT_AGENT},
+ )
+ except httpx2.TransportError as exc:
+ raise APIConnectionError(f"Connection to DiscoLike API failed: {exc}") from exc
+ finally:
+ if http_client is None:
+ await client.aclose()
+ raise_for_status(response)
+ return SignupResult.model_validate(response.json())
diff --git a/packages/discolike/tests/test_exceptions.py b/packages/discolike/tests/test_exceptions.py
index 3faf913..4be2323 100644
--- a/packages/discolike/tests/test_exceptions.py
+++ b/packages/discolike/tests/test_exceptions.py
@@ -31,6 +31,7 @@ def _response(status: int, json_body: dict | None = None, headers: dict | None =
(402, PlanAccessError),
(403, PlanAccessError),
(404, NotFoundError),
+ (409, ValidationError),
(429, RateLimitError),
(500, ServerError),
(503, ServerError),
diff --git a/packages/discolike/tests/test_signup.py b/packages/discolike/tests/test_signup.py
new file mode 100644
index 0000000..880093e
--- /dev/null
+++ b/packages/discolike/tests/test_signup.py
@@ -0,0 +1,63 @@
+import httpx2
+import pytest
+
+from discolike import ValidationError
+from discolike import async_signup
+from discolike import signup
+from discolike.signup import SignupResult
+
+_RESPONSE = {
+ "status": "created",
+ "email": "jane@acme.com",
+ "org_domain": "acme.com",
+ "org_status": "created",
+ "next_step": "A confirmation email was sent to jane@acme.com.",
+}
+
+
+def test_signup_posts_without_credentials() -> None:
+ seen: dict[str, object] = {}
+
+ def handler(request: httpx2.Request) -> httpx2.Response:
+ seen["path"] = request.url.path
+ seen["auth"] = request.headers.get("Authorization")
+ seen["key"] = request.headers.get("x-discolike-key")
+ seen["body"] = request.read()
+ return httpx2.Response(201, json=_RESPONSE)
+
+ client = httpx2.Client(base_url="https://api.test/v1", transport=httpx2.MockTransport(handler))
+ result = signup(email="jane@acme.com", first_name="Jane", last_name="Doe", http_client=client)
+
+ assert isinstance(result, SignupResult)
+ assert result.org_status == "created"
+ assert seen["path"] == "/v1/public/signup"
+ assert seen["auth"] is None
+ assert seen["key"] is None
+ assert b'"agent": "discolike-python/' in seen["body"] or b'"agent":"discolike-python/' in seen["body"]
+
+
+def test_signup_agent_override() -> None:
+ def handler(request: httpx2.Request) -> httpx2.Response:
+ assert b"my-agent" in request.read()
+ return httpx2.Response(201, json=_RESPONSE)
+
+ client = httpx2.Client(base_url="https://api.test/v1", transport=httpx2.MockTransport(handler))
+ signup(email="jane@acme.com", first_name="Jane", last_name="Doe", agent="my-agent", http_client=client)
+
+
+def test_signup_conflict_raises() -> None:
+ def handler(request: httpx2.Request) -> httpx2.Response:
+ return httpx2.Response(409, json={"detail": "An account for this email already exists."})
+
+ client = httpx2.Client(base_url="https://api.test/v1", transport=httpx2.MockTransport(handler))
+ with pytest.raises(ValidationError, match="already exists"):
+ signup(email="jane@acme.com", first_name="Jane", last_name="Doe", http_client=client)
+
+
+async def test_async_signup() -> None:
+ def handler(request: httpx2.Request) -> httpx2.Response:
+ return httpx2.Response(201, json=_RESPONSE)
+
+ client = httpx2.AsyncClient(base_url="https://api.test/v1", transport=httpx2.MockTransport(handler))
+ result = await async_signup(email="jane@acme.com", first_name="Jane", last_name="Doe", http_client=client)
+ assert result.email == "jane@acme.com"
From afcf60db1513be1e897847fb7d5757a8e3d6f38f Mon Sep 17 00:00:00 2001
From: Daniel Yudelevich
Date: Tue, 1 Sep 2026 12:41:09 -0700
Subject: [PATCH 04/13] feat(cli): discolike signup
The only command that works before auth login, on purpose: it is how an
agent opens the account its human will later log into.
---
CHANGELOG.md | 2 +
README.md | 9 ++-
.../discolike-cli/src/discolike_cli/main.py | 2 +
.../discolike-cli/src/discolike_cli/signup.py | 30 ++++++++++
.../discolike-cli/tests/test_signup_cli.py | 59 +++++++++++++++++++
5 files changed, 101 insertions(+), 1 deletion(-)
create mode 100644 packages/discolike-cli/src/discolike_cli/signup.py
create mode 100644 packages/discolike-cli/tests/test_signup_cli.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75afcdb..83bac7c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
## 0.3.1
+- SDK: `discolike.signup()` / `discolike.async_signup()` create a DiscoLike account for a person from their work email and name, with no credential required. Returns `SignupResult` with the `next_step` text to relay.
+- CLI: `discolike signup --email --first-name --last-name` does the same from the terminal, without `discolike auth login`.
- SDK: `AppendParams.dataset` accepts the new `subdomains` dataset — appends the subdomains observed for each domain (up to 300, most popular first).
- SDK: OAuth token requests (PKCE authorization URL, code exchange, refresh) now go through [Authlib](https://authlib.org) 1.8.0's httpx2 client instead of hand-rolled request building; `authlib` is a new dependency. Bearer handling, proactive refresh, the single 401 replay, config persistence, and error types are unchanged. Refreshes use a dedicated token-endpoint client with a 30s timeout rather than the SDK's `http_client`, so proxies or transports configured on `http_client=` no longer apply to the token endpoint.
diff --git a/README.md b/README.md
index 5a5a14b..a44e894 100644
--- a/README.md
+++ b/README.md
@@ -67,6 +67,12 @@ Requires Python 3.10+.
## Authentication
+No account yet? An agent (or you) can open one without a browser; the account owner confirms by email and logs in:
+
+```bash
+discolike signup --email jane@acme.com --first-name Jane --last-name Doe
+```
+
Create an API key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys), then use any of:
```bash
@@ -175,9 +181,10 @@ discolike match --file companies.csv --name-column company_name --wait
discolike count --phrase-match "book a demo" --country US
discolike company data stripe.com
discolike extract https://stripe.com/enterprise
+discolike signup --email you@company.com --first-name You --last-name Person
```
-Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment` — plus `auth`, `company`, `contacts`, `discogen`, `queries`, `account`, `search-providers`, and `llm-providers` command groups.
+Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment`, `signup` — plus `auth`, `company`, `contacts`, `discogen`, `queries`, `account`, `search-providers`, and `llm-providers` command groups.
### CLI conventions
diff --git a/packages/discolike-cli/src/discolike_cli/main.py b/packages/discolike-cli/src/discolike_cli/main.py
index 92fe359..d655aca 100644
--- a/packages/discolike-cli/src/discolike_cli/main.py
+++ b/packages/discolike-cli/src/discolike_cli/main.py
@@ -18,6 +18,7 @@
from discolike_cli import match
from discolike_cli import providers
from discolike_cli import queries
+from discolike_cli import signup
app = typer.Typer(
name="discolike",
@@ -70,3 +71,4 @@ def get_client(ctx: typer.Context) -> Discolike:
app.command(name="validate-icp")(enrich.validate_icp_command)
app.command(name="append")(enrich.append_command)
app.command(name="segment")(enrich.segment_command)
+app.command(name="signup")(signup.signup_command)
diff --git a/packages/discolike-cli/src/discolike_cli/signup.py b/packages/discolike-cli/src/discolike_cli/signup.py
new file mode 100644
index 0000000..335f685
--- /dev/null
+++ b/packages/discolike-cli/src/discolike_cli/signup.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from importlib.metadata import version as package_version
+
+import typer
+
+from discolike._config import DEFAULT_BASE_URL
+from discolike.signup import signup
+from discolike_cli._output import emit
+from discolike_cli._output import handle_errors
+
+FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)."
+CLI_AGENT = f"discolike-cli/{package_version('discolike-cli')}"
+
+
+@handle_errors
+def signup_command(
+ ctx: typer.Context,
+ email: str = typer.Option(..., "--email", help="The person's work email. Becomes the login."),
+ first_name: str = typer.Option(..., "--first-name"),
+ last_name: str = typer.Option(..., "--last-name"),
+ agent: str | None = typer.Option(None, "--agent", help="Agent or framework name to record with the signup."),
+ fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
+) -> None:
+ """Create a DiscoLike account for a person. No login needed; they confirm by email."""
+ base_url = ctx.obj.get("base_url") or DEFAULT_BASE_URL
+ emit(
+ signup(email=email, first_name=first_name, last_name=last_name, agent=agent or CLI_AGENT, base_url=base_url),
+ fmt=fmt,
+ )
diff --git a/packages/discolike-cli/tests/test_signup_cli.py b/packages/discolike-cli/tests/test_signup_cli.py
new file mode 100644
index 0000000..ec5ce4d
--- /dev/null
+++ b/packages/discolike-cli/tests/test_signup_cli.py
@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+import json
+from unittest.mock import patch
+
+from typer.testing import CliRunner
+
+from discolike.signup import SignupResult
+from discolike_cli.main import app
+
+runner = CliRunner()
+
+_RESULT = SignupResult(
+ status="created",
+ email="jane@acme.com",
+ org_domain="acme.com",
+ org_status="created",
+ next_step="A confirmation email was sent to jane@acme.com.",
+)
+
+
+def test_signup_calls_sdk_without_client() -> None:
+ with patch("discolike_cli.signup.signup", autospec=True, return_value=_RESULT) as sdk_signup:
+ result = runner.invoke(
+ app, ["signup", "--email", "jane@acme.com", "--first-name", "Jane", "--last-name", "Doe"]
+ )
+ assert result.exit_code == 0, result.output
+ sdk_signup.assert_called_once()
+ kwargs = sdk_signup.call_args.kwargs
+ assert kwargs["email"] == "jane@acme.com"
+ assert kwargs["agent"].startswith("discolike-cli/")
+ assert json.loads(result.stdout)["next_step"] == _RESULT.next_step
+
+
+def test_signup_honours_base_url_option() -> None:
+ with patch("discolike_cli.signup.signup", autospec=True, return_value=_RESULT) as sdk_signup:
+ result = runner.invoke(
+ app,
+ [
+ "--base-url",
+ "https://api.dev.test/v1",
+ "signup",
+ "--email",
+ "j@acme.com",
+ "--first-name",
+ "J",
+ "--last-name",
+ "D",
+ ],
+ )
+ assert result.exit_code == 0, result.output
+ assert sdk_signup.call_args.kwargs["base_url"] == "https://api.dev.test/v1"
+
+
+def test_signup_does_not_require_credentials(monkeypatch) -> None:
+ monkeypatch.delenv("DISCOLIKE_API_KEY", raising=False)
+ with patch("discolike_cli.signup.signup", autospec=True, return_value=_RESULT):
+ result = runner.invoke(app, ["signup", "--email", "j@acme.com", "--first-name", "J", "--last-name", "D"])
+ assert result.exit_code == 0, result.output
From 914733e5052312e7673e86706e42de8157d8c37c Mon Sep 17 00:00:00 2001
From: Daniel Yudelevich
Date: Tue, 1 Sep 2026 12:49:36 -0700
Subject: [PATCH 05/13] feat(cli,sdk): remember the signup email and confirm
before switching
Without this, an agent (or a bug in one) could loop discolike signup /
discolike.signup() over arbitrary emails from one machine with nothing
in the way. Remembering the last signed-up email and requiring an
explicit override before switching adds friction to that specific
failure mode without touching normal single-account use.
---
CHANGELOG.md | 2 +-
README.md | 2 +
.../discolike-cli/src/discolike_cli/signup.py | 33 ++++++++++-
.../discolike-cli/tests/test_signup_cli.py | 38 +++++++++++++
packages/discolike/src/discolike/_config.py | 10 ++++
packages/discolike/src/discolike/signup.py | 27 +++++++++
packages/discolike/tests/test_signup.py | 55 +++++++++++++++++++
7 files changed, 165 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83bac7c..2f9e210 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,7 @@
## 0.3.1
- SDK: `discolike.signup()` / `discolike.async_signup()` create a DiscoLike account for a person from their work email and name, with no credential required. Returns `SignupResult` with the `next_step` text to relay.
-- CLI: `discolike signup --email --first-name --last-name` does the same from the terminal, without `discolike auth login`.
+- CLI: `discolike signup --email --first-name --last-name` does the same from the terminal, without `discolike auth login`. The CLI remembers the last email signed up from this machine and asks before signing up a different one (`--yes` skips the prompt).
- SDK: `AppendParams.dataset` accepts the new `subdomains` dataset — appends the subdomains observed for each domain (up to 300, most popular first).
- SDK: OAuth token requests (PKCE authorization URL, code exchange, refresh) now go through [Authlib](https://authlib.org) 1.8.0's httpx2 client instead of hand-rolled request building; `authlib` is a new dependency. Bearer handling, proactive refresh, the single 401 replay, config persistence, and error types are unchanged. Refreshes use a dedicated token-endpoint client with a 30s timeout rather than the SDK's `http_client`, so proxies or transports configured on `http_client=` no longer apply to the token endpoint.
diff --git a/README.md b/README.md
index a44e894..81ff321 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,8 @@ No account yet? An agent (or you) can open one without a browser; the account ow
discolike signup --email jane@acme.com --first-name Jane --last-name Doe
```
+The CLI remembers the last email signed up from this machine and asks before signing up a different one (`--yes` skips the prompt).
+
Create an API key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys), then use any of:
```bash
diff --git a/packages/discolike-cli/src/discolike_cli/signup.py b/packages/discolike-cli/src/discolike_cli/signup.py
index 335f685..1ab3a42 100644
--- a/packages/discolike-cli/src/discolike_cli/signup.py
+++ b/packages/discolike-cli/src/discolike_cli/signup.py
@@ -1,18 +1,35 @@
from __future__ import annotations
+import sys
from importlib.metadata import version as package_version
import typer
from discolike._config import DEFAULT_BASE_URL
+from discolike._config import load_signup_email
from discolike.signup import signup
from discolike_cli._output import emit
from discolike_cli._output import handle_errors
FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)."
+YES_HELP = "Skip the confirmation when signing up a different email than last time."
CLI_AGENT = f"discolike-cli/{package_version('discolike-cli')}"
+def _is_interactive() -> bool:
+ return sys.stdin.isatty()
+
+
+def _confirm_email_change(previous: str, email: str, *, yes: bool) -> bool:
+ if yes:
+ return True
+ prompt = f"This machine already signed up {previous}. Sign up {email} as well?"
+ if _is_interactive():
+ return typer.confirm(prompt, default=False)
+ typer.echo(f"{prompt} re-run with --yes", err=True)
+ return False
+
+
@handle_errors
def signup_command(
ctx: typer.Context,
@@ -20,11 +37,25 @@ def signup_command(
first_name: str = typer.Option(..., "--first-name"),
last_name: str = typer.Option(..., "--last-name"),
agent: str | None = typer.Option(None, "--agent", help="Agent or framework name to record with the signup."),
+ yes: bool = typer.Option(False, "--yes", "-y", help=YES_HELP),
fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
) -> None:
"""Create a DiscoLike account for a person. No login needed; they confirm by email."""
base_url = ctx.obj.get("base_url") or DEFAULT_BASE_URL
+ previous = load_signup_email()
+ allow_new_email = False
+ if previous is not None and previous.lower() != email.lower():
+ if not _confirm_email_change(previous, email, yes=yes):
+ raise typer.Exit(code=1)
+ allow_new_email = True
emit(
- signup(email=email, first_name=first_name, last_name=last_name, agent=agent or CLI_AGENT, base_url=base_url),
+ signup(
+ email=email,
+ first_name=first_name,
+ last_name=last_name,
+ agent=agent or CLI_AGENT,
+ base_url=base_url,
+ allow_new_email=allow_new_email,
+ ),
fmt=fmt,
)
diff --git a/packages/discolike-cli/tests/test_signup_cli.py b/packages/discolike-cli/tests/test_signup_cli.py
index ec5ce4d..f318480 100644
--- a/packages/discolike-cli/tests/test_signup_cli.py
+++ b/packages/discolike-cli/tests/test_signup_cli.py
@@ -5,6 +5,7 @@
from typer.testing import CliRunner
+from discolike._config import save_signup_email
from discolike.signup import SignupResult
from discolike_cli.main import app
@@ -57,3 +58,40 @@ def test_signup_does_not_require_credentials(monkeypatch) -> None:
with patch("discolike_cli.signup.signup", autospec=True, return_value=_RESULT):
result = runner.invoke(app, ["signup", "--email", "j@acme.com", "--first-name", "J", "--last-name", "D"])
assert result.exit_code == 0, result.output
+
+
+def test_signup_different_email_non_tty_exits_without_calling_sdk() -> None:
+ save_signup_email("jane@acme.com")
+ with patch("discolike_cli.signup.signup", autospec=True, return_value=_RESULT) as sdk_signup:
+ result = runner.invoke(
+ app, ["signup", "--email", "other@acme.com", "--first-name", "Other", "--last-name", "Person"]
+ )
+ assert result.exit_code == 1
+ sdk_signup.assert_not_called()
+ assert "re-run with --yes" in result.output
+
+
+def test_signup_different_email_with_yes_allows_new_email() -> None:
+ save_signup_email("jane@acme.com")
+ other_result = SignupResult(**{**_RESULT.to_dict(), "email": "other@acme.com"})
+ with patch("discolike_cli.signup.signup", autospec=True, return_value=other_result) as sdk_signup:
+ result = runner.invoke(
+ app,
+ ["signup", "--email", "other@acme.com", "--first-name", "Other", "--last-name", "Person", "--yes"],
+ )
+ assert result.exit_code == 0, result.output
+ assert sdk_signup.call_args.kwargs["allow_new_email"] is True
+
+
+def test_signup_different_email_tty_confirm_proceeds(monkeypatch) -> None:
+ save_signup_email("jane@acme.com")
+ monkeypatch.setattr("discolike_cli.signup._is_interactive", lambda: True)
+ other_result = SignupResult(**{**_RESULT.to_dict(), "email": "other@acme.com"})
+ with patch("discolike_cli.signup.signup", autospec=True, return_value=other_result) as sdk_signup:
+ result = runner.invoke(
+ app,
+ ["signup", "--email", "other@acme.com", "--first-name", "Other", "--last-name", "Person"],
+ input="y\n",
+ )
+ assert result.exit_code == 0, result.output
+ assert sdk_signup.call_args.kwargs["allow_new_email"] is True
diff --git a/packages/discolike/src/discolike/_config.py b/packages/discolike/src/discolike/_config.py
index 12924fe..c64403e 100644
--- a/packages/discolike/src/discolike/_config.py
+++ b/packages/discolike/src/discolike/_config.py
@@ -18,6 +18,7 @@
AUTH_METHOD_API_KEY = "api_key"
AUTH_METHOD_OAUTH = "oauth"
OAUTH_CLIENT_KEY = "oauth_client"
+SIGNUP_EMAIL_KEY = "signup_email"
NO_CREDENTIAL_MESSAGE = (
"No API key found. Set the DISCOLIKE_API_KEY environment variable, pass api_key=..., "
@@ -103,6 +104,15 @@ def delete_oauth_client() -> None:
save_config(config)
+def load_signup_email() -> str | None:
+ email = load_config().get(SIGNUP_EMAIL_KEY)
+ return email if isinstance(email, str) else None
+
+
+def save_signup_email(email: str) -> None:
+ save_config({**load_config(), SIGNUP_EMAIL_KEY: email})
+
+
def resolve_credential(*, api_key: str | None = None, auth: Credential | None = None) -> Credential:
if auth is not None:
return auth
diff --git a/packages/discolike/src/discolike/signup.py b/packages/discolike/src/discolike/signup.py
index 3af0e62..5f762fe 100644
--- a/packages/discolike/src/discolike/signup.py
+++ b/packages/discolike/src/discolike/signup.py
@@ -2,11 +2,16 @@
from __future__ import annotations
+import contextlib
+
import httpx2
from discolike._client import DEFAULT_TIMEOUT_SECONDS
from discolike._config import DEFAULT_BASE_URL
+from discolike._config import load_signup_email
+from discolike._config import save_signup_email
from discolike._exceptions import APIConnectionError
+from discolike._exceptions import DiscolikeError
from discolike._exceptions import raise_for_status
from discolike._models import DiscolikeModel
from discolike._version import __version__
@@ -27,6 +32,22 @@ def _body(*, email: str, first_name: str, last_name: str, agent: str | None) ->
return {"email": email, "first_name": first_name, "last_name": last_name, "agent": agent or DEFAULT_AGENT}
+def _check_email_change(email: str, allow_new_email: bool) -> None:
+ try:
+ previous = load_signup_email()
+ except OSError:
+ return
+ if previous is not None and previous.lower() != email.lower() and not allow_new_email:
+ raise DiscolikeError(
+ f"This machine already signed up {previous}. Pass allow_new_email=True to sign up {email} as well."
+ )
+
+
+def _remember_email(email: str) -> None:
+ with contextlib.suppress(OSError):
+ save_signup_email(email)
+
+
def signup(
*,
email: str,
@@ -36,9 +57,11 @@ def signup(
base_url: str = DEFAULT_BASE_URL,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
http_client: httpx2.Client | None = None,
+ allow_new_email: bool = False,
) -> SignupResult:
"""Create a DiscoLike account for ``email``. No credential is returned; the person
confirms by email and logs in at https://app.discolike.com."""
+ _check_email_change(email, allow_new_email)
client = http_client or httpx2.Client(base_url=base_url, timeout=timeout)
try:
response = client.post(
@@ -52,6 +75,7 @@ def signup(
if http_client is None:
client.close()
raise_for_status(response)
+ _remember_email(email)
return SignupResult.model_validate(response.json())
@@ -64,7 +88,9 @@ async def async_signup(
base_url: str = DEFAULT_BASE_URL,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
http_client: httpx2.AsyncClient | None = None,
+ allow_new_email: bool = False,
) -> SignupResult:
+ _check_email_change(email, allow_new_email)
client = http_client or httpx2.AsyncClient(base_url=base_url, timeout=timeout)
try:
response = await client.post(
@@ -78,4 +104,5 @@ async def async_signup(
if http_client is None:
await client.aclose()
raise_for_status(response)
+ _remember_email(email)
return SignupResult.model_validate(response.json())
diff --git a/packages/discolike/tests/test_signup.py b/packages/discolike/tests/test_signup.py
index 880093e..91ce80a 100644
--- a/packages/discolike/tests/test_signup.py
+++ b/packages/discolike/tests/test_signup.py
@@ -1,9 +1,11 @@
import httpx2
import pytest
+from discolike import DiscolikeError
from discolike import ValidationError
from discolike import async_signup
from discolike import signup
+from discolike._config import load_signup_email
from discolike.signup import SignupResult
_RESPONSE = {
@@ -61,3 +63,56 @@ def handler(request: httpx2.Request) -> httpx2.Response:
client = httpx2.AsyncClient(base_url="https://api.test/v1", transport=httpx2.MockTransport(handler))
result = await async_signup(email="jane@acme.com", first_name="Jane", last_name="Doe", http_client=client)
assert result.email == "jane@acme.com"
+
+
+def test_signup_remembers_email() -> None:
+ client = httpx2.Client(
+ base_url="https://api.test/v1",
+ transport=httpx2.MockTransport(lambda request: httpx2.Response(201, json=_RESPONSE)),
+ )
+ signup(email="jane@acme.com", first_name="Jane", last_name="Doe", http_client=client)
+ assert load_signup_email() == "jane@acme.com"
+
+
+def test_signup_different_email_rejected_without_http_call() -> None:
+ client = httpx2.Client(
+ base_url="https://api.test/v1",
+ transport=httpx2.MockTransport(lambda request: httpx2.Response(201, json=_RESPONSE)),
+ )
+ signup(email="jane@acme.com", first_name="Jane", last_name="Doe", http_client=client)
+
+ def unexpected_call(request: httpx2.Request) -> httpx2.Response:
+ raise AssertionError("HTTP request should not have been made")
+
+ blocked_client = httpx2.Client(base_url="https://api.test/v1", transport=httpx2.MockTransport(unexpected_call))
+ with pytest.raises(DiscolikeError, match=r"jane@acme\.com"):
+ signup(email="other@acme.com", first_name="Other", last_name="Person", http_client=blocked_client)
+
+
+def test_signup_same_email_again_succeeds() -> None:
+ client = httpx2.Client(
+ base_url="https://api.test/v1",
+ transport=httpx2.MockTransport(lambda request: httpx2.Response(201, json=_RESPONSE)),
+ )
+ signup(email="jane@acme.com", first_name="Jane", last_name="Doe", http_client=client)
+ result = signup(email="Jane@Acme.com", first_name="Jane", last_name="Doe", http_client=client)
+ assert result.email == "jane@acme.com"
+
+
+def test_signup_allow_new_email_updates_stored_email() -> None:
+ client = httpx2.Client(
+ base_url="https://api.test/v1",
+ transport=httpx2.MockTransport(lambda request: httpx2.Response(201, json=_RESPONSE)),
+ )
+ signup(email="jane@acme.com", first_name="Jane", last_name="Doe", http_client=client)
+
+ other_response = {**_RESPONSE, "email": "other@acme.com"}
+ other_client = httpx2.Client(
+ base_url="https://api.test/v1",
+ transport=httpx2.MockTransport(lambda request: httpx2.Response(201, json=other_response)),
+ )
+ result = signup(
+ email="other@acme.com", first_name="Other", last_name="Person", http_client=other_client, allow_new_email=True
+ )
+ assert result.email == "other@acme.com"
+ assert load_signup_email() == "other@acme.com"
From 2e0392313e25d616c16a646f6f8f6c226eaf562c Mon Sep 17 00:00:00 2001
From: Daniel Yudelevich
Date: Tue, 1 Sep 2026 12:55:30 -0700
Subject: [PATCH 06/13] feat(cli): auth login offers signup when there is no
account yet
A person with no account was bounced straight into a browser OAuth
flow that has nothing to authenticate against. Asking up front and
routing "no" into the same signup path discolike signup already uses
avoids that dead end without touching the existing login flow for
everyone who already has a key or credential.
---
CHANGELOG.md | 2 +-
README.md | 2 +-
.../discolike-cli/src/discolike_cli/auth.py | 33 +++++++++++++
.../discolike-cli/src/discolike_cli/signup.py | 37 ++++++++++-----
.../discolike-cli/tests/test_auth_oauth.py | 47 ++++++++++++++++++-
5 files changed, 107 insertions(+), 14 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f9e210..f0ef033 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,7 @@
## 0.3.1
- SDK: `discolike.signup()` / `discolike.async_signup()` create a DiscoLike account for a person from their work email and name, with no credential required. Returns `SignupResult` with the `next_step` text to relay.
-- CLI: `discolike signup --email --first-name --last-name` does the same from the terminal, without `discolike auth login`. The CLI remembers the last email signed up from this machine and asks before signing up a different one (`--yes` skips the prompt).
+- CLI: `discolike signup --email --first-name --last-name` does the same from the terminal, without `discolike auth login`. The CLI remembers the last email signed up from this machine and asks before signing up a different one (`--yes` skips the prompt). `discolike auth login` asks first whether you already have an account and offers signup if not.
- SDK: `AppendParams.dataset` accepts the new `subdomains` dataset — appends the subdomains observed for each domain (up to 300, most popular first).
- SDK: OAuth token requests (PKCE authorization URL, code exchange, refresh) now go through [Authlib](https://authlib.org) 1.8.0's httpx2 client instead of hand-rolled request building; `authlib` is a new dependency. Bearer handling, proactive refresh, the single 401 replay, config persistence, and error types are unchanged. Refreshes use a dedicated token-endpoint client with a 30s timeout rather than the SDK's `http_client`, so proxies or transports configured on `http_client=` no longer apply to the token endpoint.
diff --git a/README.md b/README.md
index 81ff321..aa4b8b5 100644
--- a/README.md
+++ b/README.md
@@ -73,7 +73,7 @@ No account yet? An agent (or you) can open one without a browser; the account ow
discolike signup --email jane@acme.com --first-name Jane --last-name Doe
```
-The CLI remembers the last email signed up from this machine and asks before signing up a different one (`--yes` skips the prompt).
+The CLI remembers the last email signed up from this machine and asks before signing up a different one (`--yes` skips the prompt). `discolike auth login` asks first whether you already have an account and offers signup if not.
Create an API key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys), then use any of:
diff --git a/packages/discolike-cli/src/discolike_cli/auth.py b/packages/discolike-cli/src/discolike_cli/auth.py
index 7a22238..0f077d9 100644
--- a/packages/discolike-cli/src/discolike_cli/auth.py
+++ b/packages/discolike-cli/src/discolike_cli/auth.py
@@ -37,6 +37,7 @@
from discolike_cli._loopback import CallbackServer
from discolike_cli._output import emit
from discolike_cli._output import handle_errors
+from discolike_cli.signup import run_signup
app = typer.Typer(help="Manage credentials: log in (browser or API key), check status, log out.")
@@ -54,6 +55,10 @@
DEAD_CLIENT_ERRORS = frozenset({"invalid_client", "unauthorized_client"})
+HAS_ACCOUNT_PROMPT = "Do you already have a DiscoLike account?"
+SIGNUP_FOLLOWUP_MESSAGE = "Confirm the email, then run `discolike auth login` again to sign in."
+
+
class _DeadClientError(Exception):
"""The authorization server no longer recognises the registered client_id."""
@@ -62,6 +67,27 @@ def _mask(key: str) -> str:
return "…" + key[-MASKED_VISIBLE_CHARS:]
+def _is_interactive() -> bool:
+ return sys.stdin.isatty()
+
+
+def _was_passed_on_command_line(ctx: typer.Context, name: str) -> bool:
+ source = ctx.find_root().get_parameter_source(name)
+ return source is not None and source.name == "COMMANDLINE"
+
+
+def _offer_signup(ctx: typer.Context) -> None:
+ email = typer.prompt("Work email")
+ first_name = typer.prompt("First name")
+ last_name = typer.prompt("Last name")
+ base_url = str(ctx.obj.get("base_url") or DEFAULT_BASE_URL).rstrip("/")
+ run_signup(
+ email=email, first_name=first_name, last_name=last_name, agent=None, base_url=base_url, yes=False, fmt=None
+ )
+ typer.echo(SIGNUP_FOLLOWUP_MESSAGE)
+ raise typer.Exit(code=0)
+
+
def _iso(epoch_seconds: float) -> str:
return datetime.fromtimestamp(epoch_seconds, tz=timezone.utc).isoformat()
@@ -223,6 +249,13 @@ def login(
),
) -> None:
"""Log in via the browser (OAuth) or with an API key, verify, and save to the local config file."""
+ if (
+ _is_interactive()
+ and api_key is None
+ and not _was_passed_on_command_line(ctx, "method")
+ and not typer.confirm(HAS_ACCOUNT_PROMPT, default=True)
+ ):
+ _offer_signup(ctx)
if method not in LOGIN_METHODS:
raise typer.BadParameter(f"must be one of {', '.join(LOGIN_METHODS)}", param_hint="--method")
global_key_passed = ctx.obj.get("api_key") is not None and _global_key_source(ctx) == SOURCE_OPTION
diff --git a/packages/discolike-cli/src/discolike_cli/signup.py b/packages/discolike-cli/src/discolike_cli/signup.py
index 1ab3a42..8b2e525 100644
--- a/packages/discolike-cli/src/discolike_cli/signup.py
+++ b/packages/discolike-cli/src/discolike_cli/signup.py
@@ -30,18 +30,16 @@ def _confirm_email_change(previous: str, email: str, *, yes: bool) -> bool:
return False
-@handle_errors
-def signup_command(
- ctx: typer.Context,
- email: str = typer.Option(..., "--email", help="The person's work email. Becomes the login."),
- first_name: str = typer.Option(..., "--first-name"),
- last_name: str = typer.Option(..., "--last-name"),
- agent: str | None = typer.Option(None, "--agent", help="Agent or framework name to record with the signup."),
- yes: bool = typer.Option(False, "--yes", "-y", help=YES_HELP),
- fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
+def run_signup(
+ *,
+ email: str,
+ first_name: str,
+ last_name: str,
+ agent: str | None,
+ base_url: str,
+ yes: bool,
+ fmt: str | None,
) -> None:
- """Create a DiscoLike account for a person. No login needed; they confirm by email."""
- base_url = ctx.obj.get("base_url") or DEFAULT_BASE_URL
previous = load_signup_email()
allow_new_email = False
if previous is not None and previous.lower() != email.lower():
@@ -59,3 +57,20 @@ def signup_command(
),
fmt=fmt,
)
+
+
+@handle_errors
+def signup_command(
+ ctx: typer.Context,
+ email: str = typer.Option(..., "--email", help="The person's work email. Becomes the login."),
+ first_name: str = typer.Option(..., "--first-name"),
+ last_name: str = typer.Option(..., "--last-name"),
+ agent: str | None = typer.Option(None, "--agent", help="Agent or framework name to record with the signup."),
+ yes: bool = typer.Option(False, "--yes", "-y", help=YES_HELP),
+ fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
+) -> None:
+ """Create a DiscoLike account for a person. No login needed; they confirm by email."""
+ base_url = ctx.obj.get("base_url") or DEFAULT_BASE_URL
+ run_signup(
+ email=email, first_name=first_name, last_name=last_name, agent=agent, base_url=base_url, yes=yes, fmt=fmt
+ )
diff --git a/packages/discolike-cli/tests/test_auth_oauth.py b/packages/discolike-cli/tests/test_auth_oauth.py
index d2e0347..5981540 100644
--- a/packages/discolike-cli/tests/test_auth_oauth.py
+++ b/packages/discolike-cli/tests/test_auth_oauth.py
@@ -7,6 +7,7 @@
import time
from collections.abc import Callable
from typing import Any
+from unittest.mock import patch
from urllib.parse import parse_qs
from urllib.parse import urlparse
from urllib.request import urlopen
@@ -410,4 +411,48 @@ def test_login_forged_error_callback_cannot_evict_stored_client(
assert result.exit_code == 1
assert "state mismatch" in json.loads(result.stderr.splitlines()[-1])["message"]
assert provider.register_calls == []
- assert load_oauth_client() == registration
+
+
+def test_login_tty_confirms_existing_account_runs_oauth(
+ provider: FakeProvider,
+ install_build_client: Callable[[Handler], None],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ install_build_client(_usage_ok)
+ monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
+ with patch("discolike_cli.auth.run_signup", autospec=True) as run_signup_mock:
+ result = runner.invoke(app, ["auth", "login"], input="y\n")
+ assert result.exit_code == 0, result.output
+ assert provider.discover_calls == [DEFAULT_BASE_URL]
+ run_signup_mock.assert_not_called()
+
+
+def test_login_tty_declines_account_runs_signup(
+ provider: FakeProvider,
+ install_build_client: Callable[[Handler], None],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ install_build_client(_usage_ok)
+ monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
+ with patch("discolike_cli.auth.run_signup", autospec=True, return_value=None) as run_signup_mock:
+ result = runner.invoke(app, ["auth", "login"], input="n\njane@acme.com\nJane\nDoe\n")
+ assert result.exit_code == 0, result.output
+ run_signup_mock.assert_called_once_with(
+ email="jane@acme.com",
+ first_name="Jane",
+ last_name="Doe",
+ agent=None,
+ base_url=DEFAULT_BASE_URL,
+ yes=False,
+ fmt=None,
+ )
+ assert "run `discolike auth login` again" in result.output
+ assert provider.discover_calls == []
+
+
+def test_login_with_api_key_skips_account_question(install_build_client: Callable[[Handler], None]) -> None:
+ install_build_client(_usage_ok)
+ with patch("discolike_cli.auth.typer.confirm", autospec=True) as confirm_mock:
+ result = runner.invoke(app, ["auth", "login", "--api-key", "dk-1"])
+ assert result.exit_code == 0, result.output
+ confirm_mock.assert_not_called()
From 7330e7c38bd8334b7ea34f29cd710690b574c940 Mon Sep 17 00:00:00 2001
From: Daniel Yudelevich
Date: Tue, 1 Sep 2026 13:02:28 -0700
Subject: [PATCH 07/13] feat(sdk): validate signup names locally
A bad name currently only fails after a round trip to the API. Mirroring
PropelAuth's real rule client-side (probed and pinned in the API task)
means an agent gets the rejection immediately, with the same wording,
instead of waiting on a network call to learn the same thing.
---
.../discolike-cli/tests/test_signup_cli.py | 7 ++
packages/discolike/src/discolike/signup.py | 23 +++++
packages/discolike/tests/test_signup.py | 93 +++++++++++++++++++
3 files changed, 123 insertions(+)
diff --git a/packages/discolike-cli/tests/test_signup_cli.py b/packages/discolike-cli/tests/test_signup_cli.py
index f318480..b1739f7 100644
--- a/packages/discolike-cli/tests/test_signup_cli.py
+++ b/packages/discolike-cli/tests/test_signup_cli.py
@@ -95,3 +95,10 @@ def test_signup_different_email_tty_confirm_proceeds(monkeypatch) -> None:
)
assert result.exit_code == 0, result.output
assert sdk_signup.call_args.kwargs["allow_new_email"] is True
+
+
+def test_signup_rejected_name_exits_nonzero_with_message_on_stderr() -> None:
+ result = runner.invoke(app, ["signup", "--email", "jane@acme.com", "--first-name", "Jane2", "--last-name", "Doe"])
+ assert result.exit_code != 0
+ payload = json.loads(result.stderr)
+ assert "letters, spaces, hyphens, apostrophes and periods only" in payload["message"]
diff --git a/packages/discolike/src/discolike/signup.py b/packages/discolike/src/discolike/signup.py
index 5f762fe..56c7401 100644
--- a/packages/discolike/src/discolike/signup.py
+++ b/packages/discolike/src/discolike/signup.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import contextlib
+import unicodedata
import httpx2
@@ -12,12 +13,17 @@
from discolike._config import save_signup_email
from discolike._exceptions import APIConnectionError
from discolike._exceptions import DiscolikeError
+from discolike._exceptions import ValidationError
from discolike._exceptions import raise_for_status
from discolike._models import DiscolikeModel
from discolike._version import __version__
SIGNUP_PATH = "/public/signup"
DEFAULT_AGENT = f"discolike-python/{__version__}"
+# PropelAuth's own cap, probed against the dev tenant: anything longer is a 400 there.
+MAX_NAME_LENGTH = 40
+NAME_PUNCTUATION = frozenset(" -.'\u2019")
+NAME_RULE_MESSAGE = "{field} may contain letters, spaces, hyphens, apostrophes and periods only"
class SignupResult(DiscolikeModel):
@@ -32,6 +38,19 @@ def _body(*, email: str, first_name: str, last_name: str, agent: str | None) ->
return {"email": email, "first_name": first_name, "last_name": last_name, "agent": agent or DEFAULT_AGENT}
+def _is_name_character(char: str) -> bool:
+ return char.isalpha() or char in NAME_PUNCTUATION or unicodedata.category(char).startswith("M")
+
+
+def validate_name(value: str, *, field: str) -> str:
+ name = unicodedata.normalize("NFC", value).strip()
+ if not name or len(name) > MAX_NAME_LENGTH:
+ raise ValidationError(f"{field} must be between 1 and {MAX_NAME_LENGTH} characters")
+ if not all(_is_name_character(char) for char in name) or not any(char.isalpha() for char in name):
+ raise ValidationError(NAME_RULE_MESSAGE.format(field=field))
+ return name
+
+
def _check_email_change(email: str, allow_new_email: bool) -> None:
try:
previous = load_signup_email()
@@ -61,6 +80,8 @@ def signup(
) -> SignupResult:
"""Create a DiscoLike account for ``email``. No credential is returned; the person
confirms by email and logs in at https://app.discolike.com."""
+ first_name = validate_name(first_name, field="first_name")
+ last_name = validate_name(last_name, field="last_name")
_check_email_change(email, allow_new_email)
client = http_client or httpx2.Client(base_url=base_url, timeout=timeout)
try:
@@ -90,6 +111,8 @@ async def async_signup(
http_client: httpx2.AsyncClient | None = None,
allow_new_email: bool = False,
) -> SignupResult:
+ first_name = validate_name(first_name, field="first_name")
+ last_name = validate_name(last_name, field="last_name")
_check_email_change(email, allow_new_email)
client = http_client or httpx2.AsyncClient(base_url=base_url, timeout=timeout)
try:
diff --git a/packages/discolike/tests/test_signup.py b/packages/discolike/tests/test_signup.py
index 91ce80a..eff85fb 100644
--- a/packages/discolike/tests/test_signup.py
+++ b/packages/discolike/tests/test_signup.py
@@ -6,6 +6,7 @@
from discolike import async_signup
from discolike import signup
from discolike._config import load_signup_email
+from discolike.signup import MAX_NAME_LENGTH
from discolike.signup import SignupResult
_RESPONSE = {
@@ -116,3 +117,95 @@ def test_signup_allow_new_email_updates_stored_email() -> None:
)
assert result.email == "other@acme.com"
assert load_signup_email() == "other@acme.com"
+
+
+@pytest.mark.parametrize(
+ "name",
+ [
+ "Jane",
+ "Mary-Jane",
+ "O'Brien",
+ "O\u2019Brien",
+ "St. John",
+ "José",
+ "Zoë",
+ "李",
+ "Jean Luc",
+ "J",
+ "x" * MAX_NAME_LENGTH,
+ ],
+)
+def test_accepted_names(name: str) -> None:
+ client = httpx2.Client(
+ base_url="https://api.test/v1",
+ transport=httpx2.MockTransport(lambda request: httpx2.Response(201, json=_RESPONSE)),
+ )
+ result = signup(email="jane@acme.com", first_name=name, last_name=name, http_client=client)
+ assert isinstance(result, SignupResult)
+
+
+@pytest.mark.parametrize(
+ "name",
+ ["Jane2", "Jane", "\U0001f600", "jane@acme.com", "Jane_Doe", "-", "", " ", " ", "x" * (MAX_NAME_LENGTH + 1)],
+)
+def test_rejected_names(name: str) -> None:
+ def unexpected_call(request: httpx2.Request) -> httpx2.Response:
+ raise AssertionError("HTTP request should not have been made")
+
+ client = httpx2.Client(base_url="https://api.test/v1", transport=httpx2.MockTransport(unexpected_call))
+ with pytest.raises(ValidationError):
+ signup(email="jane@acme.com", first_name=name, last_name="Doe", http_client=client)
+
+
+def test_charset_rejection_names_the_rule() -> None:
+ client = httpx2.Client(
+ base_url="https://api.test/v1", transport=httpx2.MockTransport(lambda r: httpx2.Response(201))
+ )
+ with pytest.raises(ValidationError, match="letters, spaces, hyphens, apostrophes and periods only"):
+ signup(email="jane@acme.com", first_name="Jane2", last_name="Doe", http_client=client)
+
+
+def test_over_length_rejection_names_the_limit() -> None:
+ client = httpx2.Client(
+ base_url="https://api.test/v1", transport=httpx2.MockTransport(lambda r: httpx2.Response(201))
+ )
+ with pytest.raises(ValidationError, match=f"between 1 and {MAX_NAME_LENGTH} characters"):
+ signup(email="jane@acme.com", first_name="x" * (MAX_NAME_LENGTH + 1), last_name="Doe", http_client=client)
+
+
+def test_surrounding_whitespace_is_trimmed_before_request() -> None:
+ seen: dict[str, object] = {}
+
+ def handler(request: httpx2.Request) -> httpx2.Response:
+ seen["body"] = request.read()
+ return httpx2.Response(201, json=_RESPONSE)
+
+ client = httpx2.Client(base_url="https://api.test/v1", transport=httpx2.MockTransport(handler))
+ signup(email="jane@acme.com", first_name=" Jane ", last_name="Doe", http_client=client)
+ assert b'"first_name": "Jane"' in seen["body"] or b'"first_name":"Jane"' in seen["body"]
+
+
+def test_decomposed_name_is_normalized_to_nfc_before_request() -> None:
+ seen: dict[str, object] = {}
+
+ def handler(request: httpx2.Request) -> httpx2.Response:
+ seen["body"] = request.read()
+ return httpx2.Response(201, json=_RESPONSE)
+
+ client = httpx2.Client(base_url="https://api.test/v1", transport=httpx2.MockTransport(handler))
+ decomposed = "José"
+ signup(email="jane@acme.com", first_name=decomposed, last_name="Doe", http_client=client)
+ body = seen["body"]
+ assert isinstance(body, bytes)
+ decoded = body.decode()
+ assert '"first_name": "José"' in decoded or '"first_name":"José"' in decoded
+
+
+def test_name_with_non_composing_mark_is_accepted() -> None:
+ client = httpx2.Client(
+ base_url="https://api.test/v1",
+ transport=httpx2.MockTransport(lambda request: httpx2.Response(201, json=_RESPONSE)),
+ )
+ name = "अनुज"
+ result = signup(email="jane@acme.com", first_name=name, last_name="Doe", http_client=client)
+ assert isinstance(result, SignupResult)
From 98e663d79adc4957b345464025df6737930422ae Mon Sep 17 00:00:00 2001
From: Daniel Yudelevich
Date: Tue, 1 Sep 2026 13:05:49 -0700
Subject: [PATCH 08/13] fix(cli): gate the signup offer on the global API key
too
The interactive account question only checked the login subcommand's
own --api-key, so `discolike --api-key X auth login` still got asked
and, on "no", discarded a key that was already good. It needed the
same option-source check the API-key login path itself already uses.
Also dedupes _is_interactive, which had drifted into two copies.
---
packages/discolike-cli/src/discolike_cli/auth.py | 8 +++-----
packages/discolike-cli/tests/test_auth_oauth.py | 13 +++++++++++++
2 files changed, 16 insertions(+), 5 deletions(-)
diff --git a/packages/discolike-cli/src/discolike_cli/auth.py b/packages/discolike-cli/src/discolike_cli/auth.py
index 0f077d9..134b89c 100644
--- a/packages/discolike-cli/src/discolike_cli/auth.py
+++ b/packages/discolike-cli/src/discolike_cli/auth.py
@@ -37,6 +37,7 @@
from discolike_cli._loopback import CallbackServer
from discolike_cli._output import emit
from discolike_cli._output import handle_errors
+from discolike_cli.signup import _is_interactive
from discolike_cli.signup import run_signup
app = typer.Typer(help="Manage credentials: log in (browser or API key), check status, log out.")
@@ -67,10 +68,6 @@ def _mask(key: str) -> str:
return "…" + key[-MASKED_VISIBLE_CHARS:]
-def _is_interactive() -> bool:
- return sys.stdin.isatty()
-
-
def _was_passed_on_command_line(ctx: typer.Context, name: str) -> bool:
source = ctx.find_root().get_parameter_source(name)
return source is not None and source.name == "COMMANDLINE"
@@ -249,16 +246,17 @@ def login(
),
) -> None:
"""Log in via the browser (OAuth) or with an API key, verify, and save to the local config file."""
+ global_key_passed = ctx.obj.get("api_key") is not None and _global_key_source(ctx) == SOURCE_OPTION
if (
_is_interactive()
and api_key is None
+ and not global_key_passed
and not _was_passed_on_command_line(ctx, "method")
and not typer.confirm(HAS_ACCOUNT_PROMPT, default=True)
):
_offer_signup(ctx)
if method not in LOGIN_METHODS:
raise typer.BadParameter(f"must be one of {', '.join(LOGIN_METHODS)}", param_hint="--method")
- global_key_passed = ctx.obj.get("api_key") is not None and _global_key_source(ctx) == SOURCE_OPTION
if api_key or global_key_passed or method == AUTH_METHOD_API_KEY:
_api_key_login(ctx, api_key=api_key)
return
diff --git a/packages/discolike-cli/tests/test_auth_oauth.py b/packages/discolike-cli/tests/test_auth_oauth.py
index 5981540..8472ea6 100644
--- a/packages/discolike-cli/tests/test_auth_oauth.py
+++ b/packages/discolike-cli/tests/test_auth_oauth.py
@@ -456,3 +456,16 @@ def test_login_with_api_key_skips_account_question(install_build_client: Callabl
result = runner.invoke(app, ["auth", "login", "--api-key", "dk-1"])
assert result.exit_code == 0, result.output
confirm_mock.assert_not_called()
+
+
+def test_login_with_global_api_key_skips_account_question(
+ install_build_client: Callable[[Handler], None],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ install_build_client(_usage_ok)
+ monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
+ with patch("discolike_cli.auth.typer.confirm", autospec=True) as confirm_mock:
+ result = runner.invoke(app, ["--api-key", "dk-global", "auth", "login"])
+ assert result.exit_code == 0, result.output
+ confirm_mock.assert_not_called()
+ assert json.loads(config_path().read_text())["api_key"] == "dk-global"
From 42d0c6355f23336aea2c48514d29b13aa8e7be16 Mon Sep 17 00:00:00 2001
From: Daniel Yudelevich
Date: Tue, 1 Sep 2026 13:17:12 -0700
Subject: [PATCH 09/13] fix(cli): respect --method on the login subcommand, not
the root context
get_parameter_source for "method" was read off the root Click context,
which only tracks root-level options, so a --method passed to the login
subcommand was never detected as explicit and the account question still
fired for `auth login --method api_key`. Read it off the subcommand's own
context instead.
Also validate --method before offering signup, so an invalid method
errors immediately instead of asking the account question first.
Restores an assertion dropped from
test_login_forged_error_callback_cannot_evict_stored_client during an
earlier commit on this branch.
---
.../discolike-cli/src/discolike_cli/auth.py | 6 ++---
.../discolike-cli/tests/test_auth_oauth.py | 26 +++++++++++++++++++
2 files changed, 29 insertions(+), 3 deletions(-)
diff --git a/packages/discolike-cli/src/discolike_cli/auth.py b/packages/discolike-cli/src/discolike_cli/auth.py
index 134b89c..9126a53 100644
--- a/packages/discolike-cli/src/discolike_cli/auth.py
+++ b/packages/discolike-cli/src/discolike_cli/auth.py
@@ -69,7 +69,7 @@ def _mask(key: str) -> str:
def _was_passed_on_command_line(ctx: typer.Context, name: str) -> bool:
- source = ctx.find_root().get_parameter_source(name)
+ source = ctx.get_parameter_source(name)
return source is not None and source.name == "COMMANDLINE"
@@ -246,6 +246,8 @@ def login(
),
) -> None:
"""Log in via the browser (OAuth) or with an API key, verify, and save to the local config file."""
+ if method not in LOGIN_METHODS:
+ raise typer.BadParameter(f"must be one of {', '.join(LOGIN_METHODS)}", param_hint="--method")
global_key_passed = ctx.obj.get("api_key") is not None and _global_key_source(ctx) == SOURCE_OPTION
if (
_is_interactive()
@@ -255,8 +257,6 @@ def login(
and not typer.confirm(HAS_ACCOUNT_PROMPT, default=True)
):
_offer_signup(ctx)
- if method not in LOGIN_METHODS:
- raise typer.BadParameter(f"must be one of {', '.join(LOGIN_METHODS)}", param_hint="--method")
if api_key or global_key_passed or method == AUTH_METHOD_API_KEY:
_api_key_login(ctx, api_key=api_key)
return
diff --git a/packages/discolike-cli/tests/test_auth_oauth.py b/packages/discolike-cli/tests/test_auth_oauth.py
index 8472ea6..0ec8fdf 100644
--- a/packages/discolike-cli/tests/test_auth_oauth.py
+++ b/packages/discolike-cli/tests/test_auth_oauth.py
@@ -411,6 +411,7 @@ def test_login_forged_error_callback_cannot_evict_stored_client(
assert result.exit_code == 1
assert "state mismatch" in json.loads(result.stderr.splitlines()[-1])["message"]
assert provider.register_calls == []
+ assert load_oauth_client() == registration
def test_login_tty_confirms_existing_account_runs_oauth(
@@ -469,3 +470,28 @@ def test_login_with_global_api_key_skips_account_question(
assert result.exit_code == 0, result.output
confirm_mock.assert_not_called()
assert json.loads(config_path().read_text())["api_key"] == "dk-global"
+
+
+def test_login_with_explicit_api_key_method_skips_account_question(
+ install_build_client: Callable[[Handler], None],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ install_build_client(_usage_ok)
+ monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
+ with patch("discolike_cli.auth.typer.confirm", autospec=True) as confirm_mock:
+ result = runner.invoke(app, ["auth", "login", "--method", "api_key", "--api-key", "dk-x"])
+ assert result.exit_code == 0, result.output
+ confirm_mock.assert_not_called()
+ assert json.loads(config_path().read_text())["api_key"] == "dk-x"
+
+
+def test_login_rejects_unknown_method_on_a_tty_without_asking(
+ provider: FakeProvider,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
+ with patch("discolike_cli.auth.typer.confirm", autospec=True) as confirm_mock:
+ result = runner.invoke(app, ["auth", "login", "--method", "bogus"])
+ assert result.exit_code == 2
+ confirm_mock.assert_not_called()
+ assert provider.discover_calls == []
From dcfe3017f9777e6933b9263828cb899727686774 Mon Sep 17 00:00:00 2001
From: Daniel Yudelevich
Date: Tue, 1 Sep 2026 14:07:43 -0700
Subject: [PATCH 10/13] feat(sdk): name rule follows PropelAuth, adds only
safety checks
The punctuation whitelist rejected real names PropelAuth itself
accepts (digits, parentheses, underscores, an email-shaped name).
Its only actual requirement is length; ours now adds just the two
things worth blocking client-side before a round trip: no
angle-bracket/control-character injection, and at least one letter.
---
.../discolike-cli/tests/test_signup_cli.py | 4 ++--
packages/discolike/src/discolike/signup.py | 10 ++++----
packages/discolike/tests/test_signup.py | 23 ++++++++++++++++---
3 files changed, 27 insertions(+), 10 deletions(-)
diff --git a/packages/discolike-cli/tests/test_signup_cli.py b/packages/discolike-cli/tests/test_signup_cli.py
index b1739f7..845090e 100644
--- a/packages/discolike-cli/tests/test_signup_cli.py
+++ b/packages/discolike-cli/tests/test_signup_cli.py
@@ -98,7 +98,7 @@ def test_signup_different_email_tty_confirm_proceeds(monkeypatch) -> None:
def test_signup_rejected_name_exits_nonzero_with_message_on_stderr() -> None:
- result = runner.invoke(app, ["signup", "--email", "jane@acme.com", "--first-name", "Jane2", "--last-name", "Doe"])
+ result = runner.invoke(app, ["signup", "--email", "jane@acme.com", "--first-name", "Jane", "--last-name", "Doe"])
assert result.exit_code != 0
payload = json.loads(result.stderr)
- assert "letters, spaces, hyphens, apostrophes and periods only" in payload["message"]
+ assert "must contain a letter and no angle brackets or control characters" in payload["message"]
diff --git a/packages/discolike/src/discolike/signup.py b/packages/discolike/src/discolike/signup.py
index 56c7401..c84cf80 100644
--- a/packages/discolike/src/discolike/signup.py
+++ b/packages/discolike/src/discolike/signup.py
@@ -22,8 +22,8 @@
DEFAULT_AGENT = f"discolike-python/{__version__}"
# PropelAuth's own cap, probed against the dev tenant: anything longer is a 400 there.
MAX_NAME_LENGTH = 40
-NAME_PUNCTUATION = frozenset(" -.'\u2019")
-NAME_RULE_MESSAGE = "{field} may contain letters, spaces, hyphens, apostrophes and periods only"
+NAME_DISALLOWED_CHARS = frozenset("<>")
+NAME_RULE_MESSAGE = "{field} must contain a letter and no angle brackets or control characters"
class SignupResult(DiscolikeModel):
@@ -38,15 +38,15 @@ def _body(*, email: str, first_name: str, last_name: str, agent: str | None) ->
return {"email": email, "first_name": first_name, "last_name": last_name, "agent": agent or DEFAULT_AGENT}
-def _is_name_character(char: str) -> bool:
- return char.isalpha() or char in NAME_PUNCTUATION or unicodedata.category(char).startswith("M")
+def _is_disallowed_name_character(char: str) -> bool:
+ return char in NAME_DISALLOWED_CHARS or unicodedata.category(char).startswith("C")
def validate_name(value: str, *, field: str) -> str:
name = unicodedata.normalize("NFC", value).strip()
if not name or len(name) > MAX_NAME_LENGTH:
raise ValidationError(f"{field} must be between 1 and {MAX_NAME_LENGTH} characters")
- if not all(_is_name_character(char) for char in name) or not any(char.isalpha() for char in name):
+ if any(_is_disallowed_name_character(char) for char in name) or not any(char.isalpha() for char in name):
raise ValidationError(NAME_RULE_MESSAGE.format(field=field))
return name
diff --git a/packages/discolike/tests/test_signup.py b/packages/discolike/tests/test_signup.py
index eff85fb..fcebdf9 100644
--- a/packages/discolike/tests/test_signup.py
+++ b/packages/discolike/tests/test_signup.py
@@ -133,6 +133,12 @@ def test_signup_allow_new_email_updates_stored_email() -> None:
"Jean Luc",
"J",
"x" * MAX_NAME_LENGTH,
+ "Jane2",
+ "Anne (AM)",
+ "J.R., Jr",
+ "Jane_Doe",
+ "jane@acme.com",
+ "Björn 2nd",
],
)
def test_accepted_names(name: str) -> None:
@@ -146,7 +152,18 @@ def test_accepted_names(name: str) -> None:
@pytest.mark.parametrize(
"name",
- ["Jane2", "Jane", "\U0001f600", "jane@acme.com", "Jane_Doe", "-", "", " ", " ", "x" * (MAX_NAME_LENGTH + 1)],
+ [
+ "Jane",
+ "\U0001f600",
+ "-",
+ "123",
+ "Jane",
+ "Jane\x07",
+ "",
+ " ",
+ " ",
+ "x" * (MAX_NAME_LENGTH + 1),
+ ],
)
def test_rejected_names(name: str) -> None:
def unexpected_call(request: httpx2.Request) -> httpx2.Response:
@@ -161,8 +178,8 @@ def test_charset_rejection_names_the_rule() -> None:
client = httpx2.Client(
base_url="https://api.test/v1", transport=httpx2.MockTransport(lambda r: httpx2.Response(201))
)
- with pytest.raises(ValidationError, match="letters, spaces, hyphens, apostrophes and periods only"):
- signup(email="jane@acme.com", first_name="Jane2", last_name="Doe", http_client=client)
+ with pytest.raises(ValidationError, match="must contain a letter and no angle brackets or control characters"):
+ signup(email="jane@acme.com", first_name="Jane", last_name="Doe", http_client=client)
def test_over_length_rejection_names_the_limit() -> None:
From 9e9b28799a966f173cdbd62cce1ecaed26be930c Mon Sep 17 00:00:00 2001
From: Daniel Yudelevich
Date: Tue, 1 Sep 2026 14:10:38 -0700
Subject: [PATCH 11/13] fix(sdk): type the captured request body in signup
tests
CI type-checks the tests directories.
---
packages/discolike/tests/test_signup.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/packages/discolike/tests/test_signup.py b/packages/discolike/tests/test_signup.py
index fcebdf9..04e9768 100644
--- a/packages/discolike/tests/test_signup.py
+++ b/packages/discolike/tests/test_signup.py
@@ -36,7 +36,9 @@ def handler(request: httpx2.Request) -> httpx2.Response:
assert seen["path"] == "/v1/public/signup"
assert seen["auth"] is None
assert seen["key"] is None
- assert b'"agent": "discolike-python/' in seen["body"] or b'"agent":"discolike-python/' in seen["body"]
+ body = seen["body"]
+ assert isinstance(body, bytes)
+ assert b'"agent": "discolike-python/' in body or b'"agent":"discolike-python/' in body
def test_signup_agent_override() -> None:
@@ -199,7 +201,9 @@ def handler(request: httpx2.Request) -> httpx2.Response:
client = httpx2.Client(base_url="https://api.test/v1", transport=httpx2.MockTransport(handler))
signup(email="jane@acme.com", first_name=" Jane ", last_name="Doe", http_client=client)
- assert b'"first_name": "Jane"' in seen["body"] or b'"first_name":"Jane"' in seen["body"]
+ body = seen["body"]
+ assert isinstance(body, bytes)
+ assert b'"first_name": "Jane"' in body or b'"first_name":"Jane"' in body
def test_decomposed_name_is_normalized_to_nfc_before_request() -> None:
From 1122459c65d34d981957129c865744e9333af2c6 Mon Sep 17 00:00:00 2001
From: Daniel Yudelevich
Date: Wed, 2 Sep 2026 12:03:41 -0700
Subject: [PATCH 12/13] docs: date the 0.3.1 changelog entry
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f0ef033..a521087 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
# Changelog
-## 0.3.1
+## 0.3.1 (2026-09-02)
- SDK: `discolike.signup()` / `discolike.async_signup()` create a DiscoLike account for a person from their work email and name, with no credential required. Returns `SignupResult` with the `next_step` text to relay.
- CLI: `discolike signup --email --first-name --last-name` does the same from the terminal, without `discolike auth login`. The CLI remembers the last email signed up from this machine and asks before signing up a different one (`--yes` skips the prompt). `discolike auth login` asks first whether you already have an account and offers signup if not.
From 3434bbc354a3942e4f526eee09dc1ce6ed9f85c7 Mon Sep 17 00:00:00 2001
From: Daniel Yudelevich
Date: Wed, 2 Sep 2026 12:09:29 -0700
Subject: [PATCH 13/13] fix(sdk): signup always posts to base_url
An injected http_client is transport only. A bare one had no base URL at
all, and one pointed elsewhere would have taken the signup body with it.
---
CHANGELOG.md | 1 +
packages/discolike/src/discolike/signup.py | 12 ++++---
packages/discolike/tests/test_signup.py | 39 ++++++++++++++++++++++
3 files changed, 48 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a521087..5f07f56 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
- SDK: `discolike.signup()` / `discolike.async_signup()` create a DiscoLike account for a person from their work email and name, with no credential required. Returns `SignupResult` with the `next_step` text to relay.
- CLI: `discolike signup --email --first-name --last-name` does the same from the terminal, without `discolike auth login`. The CLI remembers the last email signed up from this machine and asks before signing up a different one (`--yes` skips the prompt). `discolike auth login` asks first whether you already have an account and offers signup if not.
+- SDK: `signup()` / `async_signup()` always post to `base_url` (the DiscoLike API by default); an injected `http_client=` is used as transport only, and its own base URL never redirects the signup request.
- SDK: `AppendParams.dataset` accepts the new `subdomains` dataset — appends the subdomains observed for each domain (up to 300, most popular first).
- SDK: OAuth token requests (PKCE authorization URL, code exchange, refresh) now go through [Authlib](https://authlib.org) 1.8.0's httpx2 client instead of hand-rolled request building; `authlib` is a new dependency. Bearer handling, proactive refresh, the single 401 replay, config persistence, and error types are unchanged. Refreshes use a dedicated token-endpoint client with a 30s timeout rather than the SDK's `http_client`, so proxies or transports configured on `http_client=` no longer apply to the token endpoint.
diff --git a/packages/discolike/src/discolike/signup.py b/packages/discolike/src/discolike/signup.py
index c84cf80..d15b15d 100644
--- a/packages/discolike/src/discolike/signup.py
+++ b/packages/discolike/src/discolike/signup.py
@@ -34,6 +34,10 @@ class SignupResult(DiscolikeModel):
next_step: str
+def _signup_url(base_url: str) -> str:
+ return f"{base_url.rstrip('/')}{SIGNUP_PATH}"
+
+
def _body(*, email: str, first_name: str, last_name: str, agent: str | None) -> dict[str, str]:
return {"email": email, "first_name": first_name, "last_name": last_name, "agent": agent or DEFAULT_AGENT}
@@ -83,10 +87,10 @@ def signup(
first_name = validate_name(first_name, field="first_name")
last_name = validate_name(last_name, field="last_name")
_check_email_change(email, allow_new_email)
- client = http_client or httpx2.Client(base_url=base_url, timeout=timeout)
+ client = http_client or httpx2.Client(timeout=timeout)
try:
response = client.post(
- SIGNUP_PATH,
+ _signup_url(base_url),
json=_body(email=email, first_name=first_name, last_name=last_name, agent=agent),
headers={"User-Agent": DEFAULT_AGENT},
)
@@ -114,10 +118,10 @@ async def async_signup(
first_name = validate_name(first_name, field="first_name")
last_name = validate_name(last_name, field="last_name")
_check_email_change(email, allow_new_email)
- client = http_client or httpx2.AsyncClient(base_url=base_url, timeout=timeout)
+ client = http_client or httpx2.AsyncClient(timeout=timeout)
try:
response = await client.post(
- SIGNUP_PATH,
+ _signup_url(base_url),
json=_body(email=email, first_name=first_name, last_name=last_name, agent=agent),
headers={"User-Agent": DEFAULT_AGENT},
)
diff --git a/packages/discolike/tests/test_signup.py b/packages/discolike/tests/test_signup.py
index 04e9768..d2482bb 100644
--- a/packages/discolike/tests/test_signup.py
+++ b/packages/discolike/tests/test_signup.py
@@ -230,3 +230,42 @@ def test_name_with_non_composing_mark_is_accepted() -> None:
name = "अनुज"
result = signup(email="jane@acme.com", first_name=name, last_name="Doe", http_client=client)
assert isinstance(result, SignupResult)
+
+
+def test_signup_posts_to_base_url_not_the_injected_client_base_url() -> None:
+ seen: dict[str, str] = {}
+
+ def handler(request: httpx2.Request) -> httpx2.Response:
+ seen["url"] = str(request.url)
+ return httpx2.Response(201, json=_RESPONSE)
+
+ client = httpx2.Client(base_url="https://evil.test", transport=httpx2.MockTransport(handler))
+ signup(
+ email="jane@acme.com",
+ first_name="Jane",
+ last_name="Doe",
+ base_url="https://api.test/v1",
+ http_client=client,
+ )
+
+ assert seen["url"] == "https://api.test/v1/public/signup"
+
+
+@pytest.mark.anyio
+async def test_async_signup_posts_to_base_url_not_the_injected_client_base_url() -> None:
+ seen: dict[str, str] = {}
+
+ def handler(request: httpx2.Request) -> httpx2.Response:
+ seen["url"] = str(request.url)
+ return httpx2.Response(201, json=_RESPONSE)
+
+ client = httpx2.AsyncClient(base_url="https://evil.test", transport=httpx2.MockTransport(handler))
+ await async_signup(
+ email="jane@acme.com",
+ first_name="Jane",
+ last_name="Doe",
+ base_url="https://api.test/v1",
+ http_client=client,
+ )
+
+ assert seen["url"] == "https://api.test/v1/public/signup"