From be81c757a1b121b78726245dca96eba7ad45504b Mon Sep 17 00:00:00 2001 From: Kevin Kim Date: Tue, 31 Mar 2026 11:35:24 -0700 Subject: [PATCH 01/31] feat!: align types and client with latest API spec BREAKING CHANGE: QURL dataclass fields changed (removed one_time_use, max_sessions, qurl_link, access_policy; added tags, custom_domain). Create endpoint moved to POST /v1/qurls. create() parameter description renamed to label. update() parameter access_policy removed. New batch_create() method added. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/__init__.py | 8 + src/layerv_qurl/_utils.py | 88 +++++- src/layerv_qurl/async_client.py | 129 ++++++-- src/layerv_qurl/client.py | 135 ++++++-- src/layerv_qurl/langchain.py | 12 +- src/layerv_qurl/types.py | 53 +++- tests/test_client.py | 524 ++++++++++++++++++++++++++------ tests/test_langchain.py | 4 +- 8 files changed, 756 insertions(+), 197 deletions(-) diff --git a/src/layerv_qurl/__init__.py b/src/layerv_qurl/__init__.py index da3c2d3..4deef6e 100644 --- a/src/layerv_qurl/__init__.py +++ b/src/layerv_qurl/__init__.py @@ -20,6 +20,10 @@ QURL, AccessGrant, AccessPolicy, + AIAgentPolicy, + BatchCreateOutput, + BatchItemError, + BatchItemResult, CreateOutput, ListOutput, MintOutput, @@ -44,6 +48,10 @@ "ServerError", "ValidationError", # Types + "AIAgentPolicy", + "BatchCreateOutput", + "BatchItemError", + "BatchItemResult", "QURLStatus", "AccessGrant", "AccessPolicy", diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 3d8d8d9..ba70bdd 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -25,6 +25,10 @@ QURL, AccessGrant, AccessPolicy, + AIAgentPolicy, + BatchCreateOutput, + BatchItemError, + BatchItemResult, CreateOutput, ListOutput, MintOutput, @@ -98,31 +102,39 @@ def build_body(kwargs: dict[str, Any]) -> dict[str, Any]: return body +def _parse_access_policy(data: dict[str, Any]) -> AccessPolicy: + """Parse an AccessPolicy from API response data.""" + ai_policy = None + if data.get("ai_agent_policy"): + ap = data["ai_agent_policy"] + ai_policy = AIAgentPolicy( + block_all=ap.get("block_all"), + deny_categories=ap.get("deny_categories"), + allow_categories=ap.get("allow_categories"), + ) + return AccessPolicy( + ip_allowlist=data.get("ip_allowlist"), + ip_denylist=data.get("ip_denylist"), + geo_allowlist=data.get("geo_allowlist"), + geo_denylist=data.get("geo_denylist"), + user_agent_allow_regex=data.get("user_agent_allow_regex"), + user_agent_deny_regex=data.get("user_agent_deny_regex"), + ai_agent_policy=ai_policy, + ) + + def parse_qurl(data: dict[str, Any]) -> QURL: """Parse a QURL resource from API response data.""" - policy = None - if data.get("access_policy"): - p = data["access_policy"] - policy = AccessPolicy( - ip_allowlist=p.get("ip_allowlist"), - ip_denylist=p.get("ip_denylist"), - geo_allowlist=p.get("geo_allowlist"), - geo_denylist=p.get("geo_denylist"), - user_agent_allow_regex=p.get("user_agent_allow_regex"), - user_agent_deny_regex=p.get("user_agent_deny_regex"), - ) return QURL( resource_id=data["resource_id"], target_url=data["target_url"], status=data["status"], created_at=_parse_dt(data.get("created_at")), expires_at=_parse_dt(data.get("expires_at")), - one_time_use=data.get("one_time_use", False), - max_sessions=data.get("max_sessions"), description=data.get("description"), + tags=data.get("tags", []), qurl_site=data.get("qurl_site"), - qurl_link=data.get("qurl_link"), - access_policy=policy, + custom_domain=data.get("custom_domain"), ) @@ -133,6 +145,8 @@ def parse_create_output(data: dict[str, Any]) -> CreateOutput: qurl_link=data["qurl_link"], qurl_site=data["qurl_site"], expires_at=_parse_dt(data.get("expires_at")), + qurl_id=data.get("qurl_id", ""), + label=data.get("label"), ) @@ -173,6 +187,7 @@ def parse_quota(data: dict[str, Any]) -> Quota: resolve_per_minute=r.get("resolve_per_minute", 0), max_active_qurls=r.get("max_active_qurls", 0), max_tokens_per_qurl=r.get("max_tokens_per_qurl", 0), + max_expiry_seconds=r.get("max_expiry_seconds", 0), ) usage = None if data.get("usage"): @@ -180,7 +195,7 @@ def parse_quota(data: dict[str, Any]) -> Quota: usage = Usage( qurls_created=u.get("qurls_created", 0), active_qurls=u.get("active_qurls", 0), - active_qurls_percent=u.get("active_qurls_percent", 0.0), + active_qurls_percent=u.get("active_qurls_percent"), total_accesses=u.get("total_accesses", 0), ) return Quota( @@ -202,6 +217,35 @@ def parse_list_output(data: Any, meta: dict[str, Any] | None) -> ListOutput: ) +def parse_batch_create_output(data: dict[str, Any]) -> BatchCreateOutput: + """Parse a BatchCreateOutput from API response data.""" + results: list[BatchItemResult] = [] + for item in data.get("results", []): + err = None + if item.get("error"): + e = item["error"] + err = BatchItemError( + code=e.get("code", ""), + message=e.get("message", ""), + ) + results.append( + BatchItemResult( + index=item.get("index", 0), + success=item.get("success", False), + resource_id=item.get("resource_id"), + qurl_link=item.get("qurl_link"), + qurl_site=item.get("qurl_site"), + expires_at=_parse_dt(item.get("expires_at")), + error=err, + ) + ) + return BatchCreateOutput( + succeeded=data.get("succeeded", 0), + failed=data.get("failed", 0), + results=results, + ) + + def parse_error(response: httpx.Response) -> QURLError: """Parse an API error response into the appropriate QURLError subclass.""" retry_after = None @@ -254,6 +298,10 @@ def build_list_params( status: str | None, q: str | None, sort: str | None, + created_after: str | None = None, + created_before: str | None = None, + expires_before: str | None = None, + expires_after: str | None = None, ) -> dict[str, str]: """Build query params for list endpoints, dropping None values.""" params: dict[str, str] = {} @@ -267,6 +315,14 @@ def build_list_params( params["q"] = q if sort: params["sort"] = sort + if created_after: + params["created_after"] = created_after + if created_before: + params["created_before"] = created_before + if expires_before: + params["expires_before"] = expires_before + if expires_after: + params["expires_after"] = expires_after return params diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index 6159aea..c7c610d 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -21,6 +21,7 @@ default_user_agent, logger, mask_key, + parse_batch_create_output, parse_create_output, parse_error, parse_list_output, @@ -34,12 +35,14 @@ from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError if TYPE_CHECKING: + import builtins from collections.abc import AsyncIterator from datetime import datetime from layerv_qurl.types import ( QURL, AccessPolicy, + BatchCreateOutput, CreateOutput, ListOutput, MintOutput, @@ -112,10 +115,9 @@ async def create( *, expires_in: str | None = None, expires_at: datetime | str | None = None, - description: str | None = None, - one_time_use: bool | None = None, - max_sessions: int | None = None, - access_policy: AccessPolicy | None = None, + label: str | None = None, + session_duration: str | None = None, + custom_domain: str | None = None, ) -> CreateOutput: """Create a new QURL. @@ -127,21 +129,21 @@ async def create( target_url: The URL to protect. expires_in: Duration string (e.g. ``"24h"``, ``"7d"``). expires_at: Absolute expiry as datetime or ISO string. - description: Human-readable description. - one_time_use: If True, the QURL can only be used once. - max_sessions: Maximum concurrent sessions allowed. - access_policy: IP/geo/user-agent access restrictions. + label: Human-readable label for the QURL. + session_duration: Duration string for sessions (e.g. ``"1h"``). + custom_domain: Custom domain for the QURL link. """ - body = build_body({ - "target_url": target_url, - "expires_in": expires_in, - "expires_at": expires_at, - "description": description, - "one_time_use": one_time_use, - "max_sessions": max_sessions, - "access_policy": access_policy, - }) - resp = await self._request("POST", "/v1/qurl", body=body) + body = build_body( + { + "target_url": target_url, + "expires_in": expires_in, + "expires_at": expires_at, + "label": label, + "session_duration": session_duration, + "custom_domain": custom_domain, + } + ) + resp = await self._request("POST", "/v1/qurls", body=body) return parse_create_output(resp) async def get(self, resource_id: str) -> QURL: @@ -158,18 +160,35 @@ async def list( status: QURLStatus | None = None, q: str | None = None, sort: str | None = None, + created_after: str | None = None, + created_before: str | None = None, + expires_before: str | None = None, + expires_after: str | None = None, ) -> ListOutput: """List QURLs with optional filters. Args: limit: Maximum number of results per page. cursor: Pagination cursor from a previous response. - status: Filter by QURL status - (``"active"``, ``"expired"``, ``"revoked"``, ``"consumed"``, ``"frozen"``). + status: Filter by QURL status (``"active"``, ``"revoked"``). q: Search query string. sort: Sort order (e.g. ``"created_at"``, ``"-created_at"``). + created_after: Filter QURLs created after this ISO timestamp. + created_before: Filter QURLs created before this ISO timestamp. + expires_before: Filter QURLs expiring before this ISO timestamp. + expires_after: Filter QURLs expiring after this ISO timestamp. """ - params = build_list_params(limit, cursor, status, q, sort) + params = build_list_params( + limit, + cursor, + status, + q, + sort, + created_after=created_after, + created_before=created_before, + expires_before=expires_before, + expires_after=expires_after, + ) data, meta = await self._raw_request("GET", "/v1/qurls", params=params) return parse_list_output(data, meta) @@ -180,6 +199,10 @@ async def list_all( q: str | None = None, sort: str | None = None, page_size: int = 50, + created_after: str | None = None, + created_before: str | None = None, + expires_before: str | None = None, + expires_after: str | None = None, ) -> AsyncIterator[QURL]: """Iterate over all QURLs, automatically paginating. @@ -188,7 +211,15 @@ async def list_all( cursor: str | None = None while True: page = await self.list( - limit=page_size, cursor=cursor, status=status, q=q, sort=sort, + limit=page_size, + cursor=cursor, + status=status, + q=q, + sort=sort, + created_after=created_after, + created_before=created_before, + expires_before=expires_before, + expires_after=expires_after, ) for qurl in page.qurls: yield qurl @@ -219,7 +250,7 @@ async def update( extend_by: str | None = None, expires_at: datetime | str | None = None, description: str | None = None, - access_policy: AccessPolicy | None = None, + tags: builtins.list[str] | None = None, ) -> QURL: """Update a QURL — extend expiration, change description, etc. @@ -230,15 +261,17 @@ async def update( extend_by: Duration to add (e.g. ``"7d"``). expires_at: New absolute expiry. description: New description. - access_policy: New access restrictions. + tags: Tags to set on the QURL. """ validate_id(resource_id) - body = build_body({ - "extend_by": extend_by, - "expires_at": expires_at, - "description": description, - "access_policy": access_policy, - }) + body = build_body( + { + "extend_by": extend_by, + "expires_at": expires_at, + "description": description, + "tags": tags, + } + ) resp = await self._request("PATCH", f"/v1/qurls/{resource_id}", body=body) return parse_qurl(resp) @@ -247,18 +280,52 @@ async def mint_link( resource_id: str, *, expires_at: datetime | str | None = None, + expires_in: str | None = None, + label: str | None = None, + one_time_use: bool | None = None, + max_sessions: int | None = None, + session_duration: str | None = None, + access_policy: AccessPolicy | None = None, ) -> MintOutput: """Mint a new access link for a QURL. Args: resource_id: QURL resource ID. expires_at: Optional expiry override for the minted link. + expires_in: Duration string for the link (e.g. ``"24h"``). + label: Human-readable label for the link. + one_time_use: If True, the link can only be used once. + max_sessions: Maximum concurrent sessions allowed. + session_duration: Duration string for sessions (e.g. ``"1h"``). + access_policy: IP/geo/user-agent access restrictions. """ validate_id(resource_id) - body = build_body({"expires_at": expires_at}) + body = build_body( + { + "expires_at": expires_at, + "expires_in": expires_in, + "label": label, + "one_time_use": one_time_use, + "max_sessions": max_sessions, + "session_duration": session_duration, + "access_policy": access_policy, + } + ) resp = await self._request("POST", f"/v1/qurls/{resource_id}/mint_link", body=body) return parse_mint_output(resp) + async def batch_create( + self, + items: builtins.list[dict[str, Any]], + ) -> BatchCreateOutput: + """Create multiple QURLs at once (1-100 items). + + Args: + items: List of dicts, each with at least ``target_url``. + """ + resp = await self._request("POST", "/v1/qurls/batch", body={"items": items}) + return parse_batch_create_output(resp) + async def resolve(self, access_token: str) -> ResolveOutput: """Resolve a QURL access token (headless). diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index b63b9e1..46c29a1 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -18,6 +18,7 @@ default_user_agent, logger, mask_key, + parse_batch_create_output, parse_create_output, parse_error, parse_list_output, @@ -31,12 +32,14 @@ from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError if TYPE_CHECKING: + import builtins from collections.abc import Iterator from datetime import datetime from layerv_qurl.types import ( QURL, AccessPolicy, + BatchCreateOutput, CreateOutput, ListOutput, MintOutput, @@ -124,10 +127,9 @@ def create( *, expires_in: str | None = None, expires_at: datetime | str | None = None, - description: str | None = None, - one_time_use: bool | None = None, - max_sessions: int | None = None, - access_policy: AccessPolicy | None = None, + label: str | None = None, + session_duration: str | None = None, + custom_domain: str | None = None, ) -> CreateOutput: """Create a new QURL. @@ -139,21 +141,21 @@ def create( target_url: The URL to protect. expires_in: Duration string (e.g. ``"24h"``, ``"7d"``). expires_at: Absolute expiry as datetime or ISO string. - description: Human-readable description. - one_time_use: If True, the QURL can only be used once. - max_sessions: Maximum concurrent sessions allowed. - access_policy: IP/geo/user-agent access restrictions. + label: Human-readable label for the QURL. + session_duration: Duration string for sessions (e.g. ``"1h"``). + custom_domain: Custom domain for the QURL link. """ - body = build_body({ - "target_url": target_url, - "expires_in": expires_in, - "expires_at": expires_at, - "description": description, - "one_time_use": one_time_use, - "max_sessions": max_sessions, - "access_policy": access_policy, - }) - resp = self._request("POST", "/v1/qurl", body=body) + body = build_body( + { + "target_url": target_url, + "expires_in": expires_in, + "expires_at": expires_at, + "label": label, + "session_duration": session_duration, + "custom_domain": custom_domain, + } + ) + resp = self._request("POST", "/v1/qurls", body=body) return parse_create_output(resp) def get(self, resource_id: str) -> QURL: @@ -170,18 +172,35 @@ def list( status: QURLStatus | None = None, q: str | None = None, sort: str | None = None, + created_after: str | None = None, + created_before: str | None = None, + expires_before: str | None = None, + expires_after: str | None = None, ) -> ListOutput: """List QURLs with optional filters. Args: limit: Maximum number of results per page. cursor: Pagination cursor from a previous response. - status: Filter by QURL status - (``"active"``, ``"expired"``, ``"revoked"``, ``"consumed"``, ``"frozen"``). + status: Filter by QURL status (``"active"``, ``"revoked"``). q: Search query string. sort: Sort order (e.g. ``"created_at"``, ``"-created_at"``). + created_after: Filter QURLs created after this ISO timestamp. + created_before: Filter QURLs created before this ISO timestamp. + expires_before: Filter QURLs expiring before this ISO timestamp. + expires_after: Filter QURLs expiring after this ISO timestamp. """ - params = build_list_params(limit, cursor, status, q, sort) + params = build_list_params( + limit, + cursor, + status, + q, + sort, + created_after=created_after, + created_before=created_before, + expires_before=expires_before, + expires_after=expires_after, + ) data, meta = self._raw_request("GET", "/v1/qurls", params=params) return parse_list_output(data, meta) @@ -192,21 +211,37 @@ def list_all( q: str | None = None, sort: str | None = None, page_size: int = 50, + created_after: str | None = None, + created_before: str | None = None, + expires_before: str | None = None, + expires_after: str | None = None, ) -> Iterator[QURL]: """Iterate over all QURLs, automatically paginating. Yields individual :class:`QURL` objects, fetching pages transparently. Args: - status: Filter by status (``"active"``, ``"expired"``, etc.). + status: Filter by status (``"active"``, ``"revoked"``). q: Search query string. sort: Sort order. page_size: Number of items per page (default 50). + created_after: Filter QURLs created after this ISO timestamp. + created_before: Filter QURLs created before this ISO timestamp. + expires_before: Filter QURLs expiring before this ISO timestamp. + expires_after: Filter QURLs expiring after this ISO timestamp. """ cursor: str | None = None while True: page = self.list( - limit=page_size, cursor=cursor, status=status, q=q, sort=sort, + limit=page_size, + cursor=cursor, + status=status, + q=q, + sort=sort, + created_after=created_after, + created_before=created_before, + expires_before=expires_before, + expires_after=expires_after, ) yield from page.qurls if not page.has_more or not page.next_cursor: @@ -236,7 +271,7 @@ def update( extend_by: str | None = None, expires_at: datetime | str | None = None, description: str | None = None, - access_policy: AccessPolicy | None = None, + tags: builtins.list[str] | None = None, ) -> QURL: """Update a QURL — extend expiration, change description, etc. @@ -247,15 +282,17 @@ def update( extend_by: Duration to add (e.g. ``"7d"``). expires_at: New absolute expiry. description: New description. - access_policy: New access restrictions. + tags: Tags to set on the QURL. """ validate_id(resource_id) - body = build_body({ - "extend_by": extend_by, - "expires_at": expires_at, - "description": description, - "access_policy": access_policy, - }) + body = build_body( + { + "extend_by": extend_by, + "expires_at": expires_at, + "description": description, + "tags": tags, + } + ) resp = self._request("PATCH", f"/v1/qurls/{resource_id}", body=body) return parse_qurl(resp) @@ -264,18 +301,52 @@ def mint_link( resource_id: str, *, expires_at: datetime | str | None = None, + expires_in: str | None = None, + label: str | None = None, + one_time_use: bool | None = None, + max_sessions: int | None = None, + session_duration: str | None = None, + access_policy: AccessPolicy | None = None, ) -> MintOutput: """Mint a new access link for a QURL. Args: resource_id: QURL resource ID. expires_at: Optional expiry override for the minted link. + expires_in: Duration string for the link (e.g. ``"24h"``). + label: Human-readable label for the link. + one_time_use: If True, the link can only be used once. + max_sessions: Maximum concurrent sessions allowed. + session_duration: Duration string for sessions (e.g. ``"1h"``). + access_policy: IP/geo/user-agent access restrictions. """ validate_id(resource_id) - body = build_body({"expires_at": expires_at}) + body = build_body( + { + "expires_at": expires_at, + "expires_in": expires_in, + "label": label, + "one_time_use": one_time_use, + "max_sessions": max_sessions, + "session_duration": session_duration, + "access_policy": access_policy, + } + ) resp = self._request("POST", f"/v1/qurls/{resource_id}/mint_link", body=body) return parse_mint_output(resp) + def batch_create( + self, + items: builtins.list[dict[str, Any]], + ) -> BatchCreateOutput: + """Create multiple QURLs at once (1-100 items). + + Args: + items: List of dicts, each with at least ``target_url``. + """ + resp = self._request("POST", "/v1/qurls/batch", body={"items": items}) + return parse_batch_create_output(resp) + def resolve(self, access_token: str) -> ResolveOutput: """Resolve a QURL access token (headless). diff --git a/src/layerv_qurl/langchain.py b/src/layerv_qurl/langchain.py index f6f7849..d04c148 100644 --- a/src/layerv_qurl/langchain.py +++ b/src/layerv_qurl/langchain.py @@ -9,6 +9,7 @@ try: from langchain_core.tools import BaseTool + _HAS_LANGCHAIN = True except ImportError: _HAS_LANGCHAIN = False @@ -35,8 +36,7 @@ class CreateQURLTool(BaseTool): description: str = ( "Create a QURL — a secure, time-limited access link. " "Input should be a JSON string with 'target_url' (required), " - "and optionally 'expires_in' (e.g. '24h', '7d'), 'description', " - "'one_time_use' (bool), 'max_sessions' (int)." + "and optionally 'expires_in' (e.g. '24h', '7d'), 'label'." ) client: Any = None # QURLClient, typed as Any for Pydantic compatibility @@ -44,17 +44,13 @@ def _run( self, target_url: str, expires_in: str = "24h", - description: str | None = None, - one_time_use: bool | None = None, - max_sessions: int | None = None, + label: str | None = None, run_manager: CallbackManagerForToolRun | None = None, ) -> str: result = self.client.create( target_url=target_url, expires_in=expires_in, - description=description, - one_time_use=one_time_use, - max_sessions=max_sessions, + label=label, ) return ( f"Created QURL {result.resource_id}\n" diff --git a/src/layerv_qurl/types.py b/src/layerv_qurl/types.py index 9f8a39c..749fd94 100644 --- a/src/layerv_qurl/types.py +++ b/src/layerv_qurl/types.py @@ -8,7 +8,7 @@ #: Valid QURL status values. Accepts known values for IDE autocomplete, #: plus ``str`` for forward compatibility with new API statuses. -QURLStatus = Literal["active", "expired", "revoked", "consumed", "frozen"] | str +QURLStatus = Literal["active", "revoked"] | str def _parse_dt(s: str | None) -> datetime | None: @@ -20,6 +20,15 @@ def _parse_dt(s: str | None) -> datetime | None: return datetime.fromisoformat(s) +@dataclass +class AIAgentPolicy: + """Structured policy for controlling AI agent access.""" + + block_all: bool | None = None + deny_categories: list[str] | None = None + allow_categories: list[str] | None = None + + @dataclass class AccessPolicy: """Access control policy for a QURL.""" @@ -30,6 +39,7 @@ class AccessPolicy: geo_denylist: list[str] | None = None user_agent_allow_regex: str | None = None user_agent_deny_regex: str | None = None + ai_agent_policy: AIAgentPolicy | None = None @dataclass @@ -41,12 +51,10 @@ class QURL: status: QURLStatus created_at: datetime | None = None expires_at: datetime | None = None - one_time_use: bool = False - max_sessions: int | None = None description: str | None = None + tags: list[str] = field(default_factory=list) qurl_site: str | None = None - qurl_link: str | None = None - access_policy: AccessPolicy | None = None + custom_domain: str | None = None @dataclass @@ -57,6 +65,8 @@ class CreateOutput: qurl_link: str qurl_site: str expires_at: datetime | None = None + qurl_id: str = "" + label: str | None = None @dataclass @@ -104,6 +114,7 @@ class RateLimits: resolve_per_minute: int = 0 max_active_qurls: int = 0 max_tokens_per_qurl: int = 0 + max_expiry_seconds: int = 0 @dataclass @@ -112,7 +123,7 @@ class Usage: qurls_created: int = 0 active_qurls: int = 0 - active_qurls_percent: float = 0.0 + active_qurls_percent: float | None = None total_accesses: int = 0 @@ -125,3 +136,33 @@ class Quota: period_end: datetime | None = None rate_limits: RateLimits | None = None usage: Usage | None = None + + +@dataclass +class BatchItemError: + """Error details for a failed batch item.""" + + code: str = "" + message: str = "" + + +@dataclass +class BatchItemResult: + """Result for a single item in a batch create.""" + + index: int = 0 + success: bool = False + resource_id: str | None = None + qurl_link: str | None = None + qurl_site: str | None = None + expires_at: datetime | None = None + error: BatchItemError | None = None + + +@dataclass +class BatchCreateOutput: + """Response from batch creating QURLs.""" + + succeeded: int = 0 + failed: int = 0 + results: list[BatchItemResult] = field(default_factory=list) diff --git a/tests/test_client.py b/tests/test_client.py index f2c4579..93e9821 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -31,14 +31,18 @@ _ERR_429 = { "error": { - "status": 429, "code": "rate_limited", - "title": "Rate Limited", "detail": "Slow down", + "status": 429, + "code": "rate_limited", + "title": "Rate Limited", + "detail": "Slow down", }, } _ERR_503 = { "error": { - "status": 503, "code": "unavailable", - "title": "Unavailable", "detail": "Down", + "status": 503, + "code": "unavailable", + "title": "Unavailable", + "detail": "Down", }, } _QUOTA_OK = { @@ -56,6 +60,7 @@ def _qurl_item(rid: str, url: str) -> dict: "target_url": url, "status": "active", "created_at": "2026-03-10T10:00:00Z", + "tags": [], } @@ -123,7 +128,7 @@ def test_repr_short_api_key() -> None: @respx.mock def test_create(client: QURLClient) -> None: - respx.post(f"{BASE_URL}/v1/qurl").mock( + respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 201, json={ @@ -132,6 +137,7 @@ def test_create(client: QURLClient) -> None: "qurl_link": "https://qurl.link/#at_test", "qurl_site": "https://r_abc123def45.qurl.site", "expires_at": "2026-03-15T10:00:00Z", + "qurl_id": "q_abc", }, "meta": {"request_id": "req_1"}, }, @@ -143,11 +149,12 @@ def test_create(client: QURLClient) -> None: assert result.qurl_link == "https://qurl.link/#at_test" assert result.qurl_site == "https://r_abc123def45.qurl.site" assert isinstance(result.expires_at, datetime) + assert result.qurl_id == "q_abc" @respx.mock def test_create_sends_correct_body(client: QURLClient) -> None: - route = respx.post(f"{BASE_URL}/v1/qurl").mock( + route = respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 201, json={ @@ -155,6 +162,7 @@ def test_create_sends_correct_body(client: QURLClient) -> None: "resource_id": "r_abc", "qurl_link": "https://qurl.link/#at_test", "qurl_site": "https://r_abc.qurl.site", + "qurl_id": "q_abc", }, }, ) @@ -163,21 +171,19 @@ def test_create_sends_correct_body(client: QURLClient) -> None: client.create( target_url="https://example.com", expires_in="24h", - description="test", - one_time_use=True, + label="test", ) body = json.loads(route.calls[0].request.content) assert body == { "target_url": "https://example.com", "expires_in": "24h", - "description": "test", - "one_time_use": True, + "label": "test", } @respx.mock def test_create_omits_none_values(client: QURLClient) -> None: - route = respx.post(f"{BASE_URL}/v1/qurl").mock( + route = respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 201, json={ @@ -185,6 +191,7 @@ def test_create_omits_none_values(client: QURLClient) -> None: "resource_id": "r_abc", "qurl_link": "https://qurl.link/#at_test", "qurl_site": "https://r_abc.qurl.site", + "qurl_id": "", }, }, ) @@ -194,7 +201,7 @@ def test_create_omits_none_values(client: QURLClient) -> None: body = json.loads(route.calls[0].request.content) assert body == {"target_url": "https://example.com"} assert "expires_in" not in body - assert "description" not in body + assert "label" not in body @respx.mock @@ -209,7 +216,7 @@ def test_get(client: QURLClient) -> None: "status": "active", "created_at": "2026-03-10T10:00:00Z", "expires_at": "2026-03-15T10:00:00Z", - "one_time_use": False, + "tags": [], }, "meta": {"request_id": "req_2"}, }, @@ -235,6 +242,7 @@ def test_list(client: QURLClient) -> None: "target_url": "https://example.com", "status": "active", "created_at": "2026-03-10T10:00:00Z", + "tags": [], } ], "meta": {"has_more": False, "page_size": 20}, @@ -252,14 +260,20 @@ def test_list(client: QURLClient) -> None: def test_list_all_paginates(client: QURLClient) -> None: route = respx.get(f"{BASE_URL}/v1/qurls") route.side_effect = [ - httpx.Response(200, json={ - "data": [_qurl_item("r_1", "https://1.com"), _qurl_item("r_2", "https://2.com")], - "meta": {"has_more": True, "next_cursor": "cur_abc"}, - }), - httpx.Response(200, json={ - "data": [_qurl_item("r_3", "https://3.com")], - "meta": {"has_more": False}, - }), + httpx.Response( + 200, + json={ + "data": [_qurl_item("r_1", "https://1.com"), _qurl_item("r_2", "https://2.com")], + "meta": {"has_more": True, "next_cursor": "cur_abc"}, + }, + ), + httpx.Response( + 200, + json={ + "data": [_qurl_item("r_3", "https://3.com")], + "meta": {"has_more": False}, + }, + ), ] all_qurls = list(client.list_all(status="active", page_size=2)) @@ -270,9 +284,7 @@ def test_list_all_paginates(client: QURLClient) -> None: @respx.mock def test_delete(client: QURLClient) -> None: - respx.delete(f"{BASE_URL}/v1/qurls/r_abc123def45").mock( - return_value=httpx.Response(204) - ) + respx.delete(f"{BASE_URL}/v1/qurls/r_abc123def45").mock(return_value=httpx.Response(204)) client.delete("r_abc123def45") # Should not raise @@ -288,6 +300,7 @@ def test_update_with_extend(client: QURLClient) -> None: "status": "active", "created_at": "2026-03-10T10:00:00Z", "expires_at": "2026-03-20T10:00:00Z", + "tags": [], }, }, ) @@ -311,6 +324,7 @@ def test_update_with_description(client: QURLClient) -> None: "status": "active", "created_at": "2026-03-10T10:00:00Z", "description": "new desc", + "tags": [], }, }, ) @@ -336,6 +350,7 @@ def test_update_combined(client: QURLClient) -> None: "created_at": "2026-03-10T10:00:00Z", "expires_at": "2026-03-20T10:00:00Z", "description": "updated", + "tags": [], }, }, ) @@ -553,7 +568,9 @@ def test_retry_after_header_respected(retry_client: QURLClient) -> None: route = respx.get(f"{BASE_URL}/v1/quota") route.side_effect = [ httpx.Response( - 429, headers={"Retry-After": "5"}, json=_ERR_429, + 429, + headers={"Retry-After": "5"}, + json=_ERR_429, ), httpx.Response(200, json=_QUOTA_OK), ] @@ -570,7 +587,9 @@ def test_retry_after_capped_at_30s(retry_client: QURLClient) -> None: route = respx.get(f"{BASE_URL}/v1/quota") route.side_effect = [ httpx.Response( - 429, headers={"Retry-After": "120"}, json=_ERR_429, + 429, + headers={"Retry-After": "120"}, + json=_ERR_429, ), httpx.Response(200, json=_QUOTA_OK), ] @@ -609,9 +628,7 @@ def test_non_json_error_response(client: QURLClient) -> None: @respx.mock def test_network_error_wrapped(client: QURLClient) -> None: """httpx errors are wrapped in QURLNetworkError.""" - respx.get(f"{BASE_URL}/v1/quota").mock( - side_effect=httpx.ConnectError("Connection refused") - ) + respx.get(f"{BASE_URL}/v1/quota").mock(side_effect=httpx.ConnectError("Connection refused")) with pytest.raises(QURLNetworkError, match="Connection refused"): client.get_quota() @@ -620,9 +637,7 @@ def test_network_error_wrapped(client: QURLClient) -> None: @respx.mock def test_network_error_preserves_cause(client: QURLClient) -> None: """QURLNetworkError preserves the original httpx exception as __cause__.""" - respx.get(f"{BASE_URL}/v1/quota").mock( - side_effect=httpx.ConnectError("DNS lookup failed") - ) + respx.get(f"{BASE_URL}/v1/quota").mock(side_effect=httpx.ConnectError("DNS lookup failed")) with pytest.raises(QURLNetworkError) as exc_info: client.get_quota() @@ -633,9 +648,7 @@ def test_network_error_preserves_cause(client: QURLClient) -> None: @respx.mock def test_timeout_error_wrapped(client: QURLClient) -> None: """httpx.TimeoutException is wrapped in QURLTimeoutError.""" - respx.get(f"{BASE_URL}/v1/quota").mock( - side_effect=httpx.ReadTimeout("Read timed out") - ) + respx.get(f"{BASE_URL}/v1/quota").mock(side_effect=httpx.ReadTimeout("Read timed out")) with pytest.raises(QURLTimeoutError, match="Read timed out"): client.get_quota() @@ -644,9 +657,7 @@ def test_timeout_error_wrapped(client: QURLClient) -> None: @respx.mock def test_timeout_error_is_network_error(client: QURLClient) -> None: """QURLTimeoutError is a subclass of QURLNetworkError.""" - respx.get(f"{BASE_URL}/v1/quota").mock( - side_effect=httpx.ReadTimeout("Read timed out") - ) + respx.get(f"{BASE_URL}/v1/quota").mock(side_effect=httpx.ReadTimeout("Read timed out")) with pytest.raises(QURLNetworkError): client.get_quota() @@ -732,6 +743,7 @@ def test_get_request_has_no_content_type(client: QURLClient) -> None: "target_url": "https://example.com", "status": "active", "created_at": "2026-03-10T10:00:00Z", + "tags": [], }, }, ) @@ -745,7 +757,7 @@ def test_get_request_has_no_content_type(client: QURLClient) -> None: @respx.mock def test_post_request_has_content_type(client: QURLClient) -> None: """POST requests with body should send Content-Type: application/json.""" - route = respx.post(f"{BASE_URL}/v1/qurl").mock( + route = respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 201, json={ @@ -753,6 +765,7 @@ def test_post_request_has_content_type(client: QURLClient) -> None: "resource_id": "r_abc", "qurl_link": "https://qurl.link/#at_test", "qurl_site": "https://r_abc.qurl.site", + "qurl_id": "", }, }, ) @@ -769,7 +782,7 @@ def test_post_request_has_content_type(client: QURLClient) -> None: @respx.mock @pytest.mark.asyncio async def test_async_create(async_client: AsyncQURLClient) -> None: - respx.post(f"{BASE_URL}/v1/qurl").mock( + respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 201, json={ @@ -778,6 +791,7 @@ async def test_async_create(async_client: AsyncQURLClient) -> None: "qurl_link": "https://qurl.link/#at_async", "qurl_site": "https://r_async.qurl.site", "expires_at": "2026-03-15T10:00:00Z", + "qurl_id": "q_async", }, }, ) @@ -819,14 +833,20 @@ async def test_async_resolve(async_client: AsyncQURLClient) -> None: async def test_async_list_all(async_client: AsyncQURLClient) -> None: route = respx.get(f"{BASE_URL}/v1/qurls") route.side_effect = [ - httpx.Response(200, json={ - "data": [_qurl_item("r_1", "https://1.com")], - "meta": {"has_more": True, "next_cursor": "cur_abc"}, - }), - httpx.Response(200, json={ - "data": [_qurl_item("r_2", "https://2.com")], - "meta": {"has_more": False}, - }), + httpx.Response( + 200, + json={ + "data": [_qurl_item("r_1", "https://1.com")], + "meta": {"has_more": True, "next_cursor": "cur_abc"}, + }, + ), + httpx.Response( + 200, + json={ + "data": [_qurl_item("r_2", "https://2.com")], + "meta": {"has_more": False}, + }, + ), ] all_qurls = [q async for q in async_client.list_all(status="active", page_size=1)] @@ -837,9 +857,7 @@ async def test_async_list_all(async_client: AsyncQURLClient) -> None: @respx.mock @pytest.mark.asyncio async def test_async_network_error_wrapped(async_client: AsyncQURLClient) -> None: - respx.get(f"{BASE_URL}/v1/quota").mock( - side_effect=httpx.ConnectError("Connection refused") - ) + respx.get(f"{BASE_URL}/v1/quota").mock(side_effect=httpx.ConnectError("Connection refused")) with pytest.raises(QURLNetworkError, match="Connection refused"): await async_client.get_quota() @@ -849,9 +867,7 @@ async def test_async_network_error_wrapped(async_client: AsyncQURLClient) -> Non @pytest.mark.asyncio async def test_async_timeout_error_wrapped(async_client: AsyncQURLClient) -> None: """Async: httpx.TimeoutException is wrapped in QURLTimeoutError.""" - respx.get(f"{BASE_URL}/v1/quota").mock( - side_effect=httpx.ReadTimeout("Read timed out") - ) + respx.get(f"{BASE_URL}/v1/quota").mock(side_effect=httpx.ReadTimeout("Read timed out")) with pytest.raises(QURLTimeoutError, match="Read timed out"): await async_client.get_quota() @@ -861,9 +877,7 @@ async def test_async_timeout_error_wrapped(async_client: AsyncQURLClient) -> Non @pytest.mark.asyncio async def test_async_timeout_is_network_error(async_client: AsyncQURLClient) -> None: """Async: QURLTimeoutError is caught by except QURLNetworkError.""" - respx.get(f"{BASE_URL}/v1/quota").mock( - side_effect=httpx.ReadTimeout("Read timed out") - ) + respx.get(f"{BASE_URL}/v1/quota").mock(side_effect=httpx.ReadTimeout("Read timed out")) with pytest.raises(QURLNetworkError): await async_client.get_quota() @@ -888,8 +902,10 @@ def test_401_raises_authentication_error(client: QURLClient) -> None: 401, json={ "error": { - "status": 401, "code": "unauthorized", - "title": "Unauthorized", "detail": "Invalid API key", + "status": 401, + "code": "unauthorized", + "title": "Unauthorized", + "detail": "Invalid API key", }, }, ) @@ -908,8 +924,10 @@ def test_403_raises_authorization_error(client: QURLClient) -> None: 403, json={ "error": { - "status": 403, "code": "forbidden", - "title": "Forbidden", "detail": "Insufficient scope", + "status": 403, + "code": "forbidden", + "title": "Forbidden", + "detail": "Insufficient scope", }, }, ) @@ -927,8 +945,10 @@ def test_404_raises_not_found_error(client: QURLClient) -> None: 404, json={ "error": { - "status": 404, "code": "not_found", - "title": "Not Found", "detail": "QURL not found", + "status": 404, + "code": "not_found", + "title": "Not Found", + "detail": "QURL not found", }, "meta": {"request_id": "req_err"}, }, @@ -943,13 +963,15 @@ def test_404_raises_not_found_error(client: QURLClient) -> None: @respx.mock def test_422_raises_validation_error(client: QURLClient) -> None: - respx.post(f"{BASE_URL}/v1/qurl").mock( + respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 422, json={ "error": { - "status": 422, "code": "validation_error", - "title": "Validation Error", "detail": "Invalid target_url", + "status": 422, + "code": "validation_error", + "title": "Validation Error", + "detail": "Invalid target_url", "invalid_fields": {"target_url": "must be a valid URL"}, }, }, @@ -969,8 +991,10 @@ def test_429_raises_rate_limit_error(client: QURLClient) -> None: headers={"Retry-After": "10"}, json={ "error": { - "status": 429, "code": "rate_limited", - "title": "Rate Limited", "detail": "Slow down", + "status": 429, + "code": "rate_limited", + "title": "Rate Limited", + "detail": "Slow down", }, }, ) @@ -988,8 +1012,10 @@ def test_500_raises_server_error(client: QURLClient) -> None: 500, json={ "error": { - "status": 500, "code": "internal", - "title": "Internal Server Error", "detail": "Something broke", + "status": 500, + "code": "internal", + "title": "Internal Server Error", + "detail": "Something broke", }, }, ) @@ -1002,13 +1028,15 @@ def test_500_raises_server_error(client: QURLClient) -> None: @respx.mock def test_400_raises_validation_error(client: QURLClient) -> None: - respx.post(f"{BASE_URL}/v1/qurl").mock( + respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 400, json={ "error": { - "status": 400, "code": "bad_request", - "title": "Bad Request", "detail": "Missing target_url", + "status": 400, + "code": "bad_request", + "title": "Bad Request", + "detail": "Missing target_url", }, }, ) @@ -1034,6 +1062,7 @@ def test_extend(client: QURLClient) -> None: "status": "active", "created_at": "2026-03-10T10:00:00Z", "expires_at": "2026-03-20T10:00:00Z", + "tags": [], }, }, ) @@ -1059,6 +1088,7 @@ async def test_async_extend(async_client: AsyncQURLClient) -> None: "status": "active", "created_at": "2026-03-10T10:00:00Z", "expires_at": "2026-03-20T10:00:00Z", + "tags": [], }, }, ) @@ -1070,43 +1100,339 @@ async def test_async_extend(async_client: AsyncQURLClient) -> None: assert body == {"extend_by": "24h"} -# --- AccessPolicy serialization --- +# --- Batch create --- + + +@respx.mock +def test_batch_create_all_succeed(client: QURLClient) -> None: + """batch_create() parses a fully successful batch response.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "succeeded": 2, + "failed": 0, + "results": [ + { + "index": 0, + "success": True, + "resource_id": "r_batch1", + "qurl_link": "https://qurl.link/#at_b1", + "qurl_site": "https://r_batch1.qurl.site", + "expires_at": "2026-04-01T00:00:00Z", + }, + { + "index": 1, + "success": True, + "resource_id": "r_batch2", + "qurl_link": "https://qurl.link/#at_b2", + "qurl_site": "https://r_batch2.qurl.site", + }, + ], + }, + }, + ) + ) + + result = client.batch_create( + [ + {"target_url": "https://a.com", "expires_in": "24h"}, + {"target_url": "https://b.com"}, + ] + ) + assert result.succeeded == 2 + assert result.failed == 0 + assert len(result.results) == 2 + assert result.results[0].resource_id == "r_batch1" + assert result.results[0].success is True + assert isinstance(result.results[0].expires_at, datetime) + assert result.results[1].resource_id == "r_batch2" + assert result.results[1].expires_at is None + + +@respx.mock +def test_batch_create_partial_failure(client: QURLClient) -> None: + """batch_create() correctly parses partial failures.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "succeeded": 1, + "failed": 1, + "results": [ + { + "index": 0, + "success": True, + "resource_id": "r_ok", + "qurl_link": "https://qurl.link/#at_ok", + "qurl_site": "https://r_ok.qurl.site", + }, + { + "index": 1, + "success": False, + "error": { + "code": "validation_error", + "message": "Invalid target_url", + }, + }, + ], + }, + }, + ) + ) + + result = client.batch_create( + [ + {"target_url": "https://good.com"}, + {"target_url": "bad"}, + ] + ) + assert result.succeeded == 1 + assert result.failed == 1 + assert result.results[0].success is True + assert result.results[1].success is False + assert result.results[1].error is not None + assert result.results[1].error.code == "validation_error" + assert result.results[1].error.message == "Invalid target_url" + + +# --- Date filter tests --- + + +@respx.mock +def test_list_with_date_filters(client: QURLClient) -> None: + """list() passes date filter params to the request.""" + route = respx.get(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 200, + json={ + "data": [_qurl_item("r_filtered", "https://filtered.com")], + "meta": {"has_more": False}, + }, + ) + ) + + result = client.list( + status="active", + created_after="2026-03-01T00:00:00Z", + expires_before="2026-04-01T00:00:00Z", + ) + assert len(result.qurls) == 1 + req = route.calls[0].request + assert "created_after=2026-03-01T00%3A00%3A00Z" in str(req.url) + assert "expires_before=2026-04-01T00%3A00%3A00Z" in str(req.url) + + +# --- Mint link full input --- + + +@respx.mock +def test_mint_link_full_input(client: QURLClient) -> None: + """mint_link() sends all expanded params.""" + route = respx.post(f"{BASE_URL}/v1/qurls/r_abc/mint_link").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "qurl_link": "https://qurl.link/#at_full", + "expires_at": "2026-04-01T00:00:00Z", + }, + }, + ) + ) + + policy = AccessPolicy(ip_allowlist=["10.0.0.0/8"]) + result = client.mint_link( + "r_abc", + expires_in="12h", + label="my-link", + one_time_use=True, + max_sessions=5, + session_duration="30m", + access_policy=policy, + ) + assert result.qurl_link == "https://qurl.link/#at_full" + body = json.loads(route.calls[0].request.content) + assert body["expires_in"] == "12h" + assert body["label"] == "my-link" + assert body["one_time_use"] is True + assert body["max_sessions"] == 5 + assert body["session_duration"] == "30m" + assert body["access_policy"] == {"ip_allowlist": ["10.0.0.0/8"]} + + +# --- Update with tags --- @respx.mock -def test_access_policy_serialized(client: QURLClient) -> None: - """AccessPolicy dataclass is serialized correctly in create().""" - route = respx.post(f"{BASE_URL}/v1/qurl").mock( +def test_update_with_tags(client: QURLClient) -> None: + """update() sends tags in the request body.""" + route = respx.patch(f"{BASE_URL}/v1/qurls/r_abc").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "resource_id": "r_abc", + "target_url": "https://example.com", + "status": "active", + "created_at": "2026-03-10T10:00:00Z", + "tags": ["team:engineering", "env:prod"], + }, + }, + ) + ) + + result = client.update("r_abc", tags=["team:engineering", "env:prod"]) + assert result.tags == ["team:engineering", "env:prod"] + body = json.loads(route.calls[0].request.content) + assert body == {"tags": ["team:engineering", "env:prod"]} + + +# --- Create with label and session_duration --- + + +@respx.mock +def test_create_with_label_and_session_duration(client: QURLClient) -> None: + """create() sends label and session_duration in the body.""" + route = respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 201, json={ "data": { - "resource_id": "r_policy", - "qurl_link": "https://qurl.link/#at_test", - "qurl_site": "https://r_policy.qurl.site", + "resource_id": "r_new", + "qurl_link": "https://qurl.link/#at_new", + "qurl_site": "https://r_new.qurl.site", + "qurl_id": "q_new", + "label": "my label", + "expires_at": "2026-04-01T00:00:00Z", }, }, ) ) - policy = AccessPolicy( - ip_allowlist=["10.0.0.0/8"], - geo_denylist=["CN", "RU"], + result = client.create( + target_url="https://example.com", + expires_in="7d", + label="my label", + session_duration="1h", ) - client.create(target_url="https://example.com", access_policy=policy) + assert result.resource_id == "r_new" + assert result.label == "my label" + assert result.qurl_id == "q_new" body = json.loads(route.calls[0].request.content) - assert body["access_policy"] == { - "ip_allowlist": ["10.0.0.0/8"], - "geo_denylist": ["CN", "RU"], - } - # None fields should be omitted from the serialized policy - assert "ip_denylist" not in body["access_policy"] - assert "geo_allowlist" not in body["access_policy"] + assert body["label"] == "my label" + assert body["session_duration"] == "1h" + + +# --- Async batch create --- @respx.mock -def test_access_policy_in_update(client: QURLClient) -> None: - """AccessPolicy can also be passed to update().""" +@pytest.mark.asyncio +async def test_async_batch_create(async_client: AsyncQURLClient) -> None: + """Async batch_create() works correctly.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "succeeded": 1, + "failed": 0, + "results": [ + { + "index": 0, + "success": True, + "resource_id": "r_async_batch", + "qurl_link": "https://qurl.link/#at_ab", + "qurl_site": "https://r_async_batch.qurl.site", + }, + ], + }, + }, + ) + ) + + result = await async_client.batch_create( + [ + {"target_url": "https://async.com"}, + ] + ) + assert result.succeeded == 1 + assert result.results[0].resource_id == "r_async_batch" + + +# --- Async date filters --- + + +@respx.mock +@pytest.mark.asyncio +async def test_async_list_with_date_filters(async_client: AsyncQURLClient) -> None: + """Async list() passes date filter params.""" + route = respx.get(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 200, + json={ + "data": [_qurl_item("r_af", "https://af.com")], + "meta": {"has_more": False}, + }, + ) + ) + + result = await async_client.list( + created_before="2026-03-15T00:00:00Z", + expires_after="2026-03-01T00:00:00Z", + ) + assert len(result.qurls) == 1 + req = route.calls[0].request + assert "created_before=2026-03-15T00%3A00%3A00Z" in str(req.url) + assert "expires_after=2026-03-01T00%3A00%3A00Z" in str(req.url) + + +# --- Async mint link full input --- + + +@respx.mock +@pytest.mark.asyncio +async def test_async_mint_link_full_input(async_client: AsyncQURLClient) -> None: + """Async mint_link() sends all expanded params.""" + route = respx.post(f"{BASE_URL}/v1/qurls/r_abc/mint_link").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "qurl_link": "https://qurl.link/#at_afull", + "expires_at": "2026-04-01T00:00:00Z", + }, + }, + ) + ) + + result = await async_client.mint_link( + "r_abc", + expires_in="6h", + label="async-link", + one_time_use=False, + max_sessions=3, + session_duration="15m", + ) + assert result.qurl_link == "https://qurl.link/#at_afull" + body = json.loads(route.calls[0].request.content) + assert body["expires_in"] == "6h" + assert body["label"] == "async-link" + assert body["one_time_use"] is False + assert body["max_sessions"] == 3 + assert body["session_duration"] == "15m" + + +# --- Async update with tags --- + + +@respx.mock +@pytest.mark.asyncio +async def test_async_update_with_tags(async_client: AsyncQURLClient) -> None: + """Async update() sends tags.""" route = respx.patch(f"{BASE_URL}/v1/qurls/r_abc").mock( return_value=httpx.Response( 200, @@ -1116,17 +1442,13 @@ def test_access_policy_in_update(client: QURLClient) -> None: "target_url": "https://example.com", "status": "active", "created_at": "2026-03-10T10:00:00Z", - "access_policy": { - "user_agent_deny_regex": "curl.*", - }, + "tags": ["internal"], }, }, ) ) - policy = AccessPolicy(user_agent_deny_regex="curl.*") - result = client.update("r_abc", access_policy=policy) - assert result.access_policy is not None - assert result.access_policy.user_agent_deny_regex == "curl.*" + result = await async_client.update("r_abc", tags=["internal"]) + assert result.tags == ["internal"] body = json.loads(route.calls[0].request.content) - assert body["access_policy"] == {"user_agent_deny_regex": "curl.*"} + assert body == {"tags": ["internal"]} diff --git a/tests/test_langchain.py b/tests/test_langchain.py index 492aa04..3cc035b 100644 --- a/tests/test_langchain.py +++ b/tests/test_langchain.py @@ -42,9 +42,7 @@ def test_create_qurl_tool() -> None: client.create.assert_called_once_with( target_url="https://example.com", expires_in="24h", - description=None, - one_time_use=None, - max_sessions=None, + label=None, ) From 8ccdd69ddfe573c94e3aa552f7d4f8986c8b5b70 Mon Sep 17 00:00:00 2001 From: Kevin Kim Date: Tue, 31 Mar 2026 11:47:03 -0700 Subject: [PATCH 02/31] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20recursive=20serialization=20and=20dead=20code=20rem?= =?UTF-8?q?oval?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix nested dataclass serialization bug in build_body(): AIAgentPolicy inside AccessPolicy was not recursively converted to a dict, causing JSON encoding failures. Extracted _serialize_value() helper for recursive handling. - Remove dead _parse_access_policy() function and its unused imports. - Add test for mint_link with nested AIAgentPolicy in AccessPolicy. - Add test for create() with custom_domain parameter. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/_utils.py | 52 +++++++++++------------------- tests/test_client.py | 67 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 35 deletions(-) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index ba70bdd..3a03969 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -24,8 +24,6 @@ from layerv_qurl.types import ( QURL, AccessGrant, - AccessPolicy, - AIAgentPolicy, BatchCreateOutput, BatchItemError, BatchItemResult, @@ -79,50 +77,36 @@ def validate_id(value: str, name: str = "resource_id") -> str: return value +def _serialize_value(v: Any) -> Any: + """Serialize a single value for JSON, handling dataclasses and datetimes recursively.""" + if v is None: + return None + if isinstance(v, datetime): + return v.isoformat() + if dataclasses.is_dataclass(v) and not isinstance(v, type): + return { + f.name: _serialize_value(getattr(v, f.name)) + for f in dataclasses.fields(v) + if getattr(v, f.name) is not None + } + return v + + def build_body(kwargs: dict[str, Any]) -> dict[str, Any]: """Build a request body dict from kwargs, dropping None values. Always returns a dict (at least ``{}``) so POST/PATCH endpoints - receive a valid JSON body. + receive a valid JSON body. Nested dataclasses are recursively + serialized to dicts. """ body: dict[str, Any] = {} for k, v in kwargs.items(): if v is None: continue - if isinstance(v, datetime): - body[k] = v.isoformat() - elif dataclasses.is_dataclass(v) and not isinstance(v, type): - body[k] = { - f.name: getattr(v, f.name) - for f in dataclasses.fields(v) - if getattr(v, f.name) is not None - } - else: - body[k] = v + body[k] = _serialize_value(v) return body -def _parse_access_policy(data: dict[str, Any]) -> AccessPolicy: - """Parse an AccessPolicy from API response data.""" - ai_policy = None - if data.get("ai_agent_policy"): - ap = data["ai_agent_policy"] - ai_policy = AIAgentPolicy( - block_all=ap.get("block_all"), - deny_categories=ap.get("deny_categories"), - allow_categories=ap.get("allow_categories"), - ) - return AccessPolicy( - ip_allowlist=data.get("ip_allowlist"), - ip_denylist=data.get("ip_denylist"), - geo_allowlist=data.get("geo_allowlist"), - geo_denylist=data.get("geo_denylist"), - user_agent_allow_regex=data.get("user_agent_allow_regex"), - user_agent_deny_regex=data.get("user_agent_deny_regex"), - ai_agent_policy=ai_policy, - ) - - def parse_qurl(data: dict[str, Any]) -> QURL: """Parse a QURL resource from API response data.""" return QURL( diff --git a/tests/test_client.py b/tests/test_client.py index 93e9821..8ff95fc 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -25,7 +25,7 @@ ServerError, ValidationError, ) -from layerv_qurl.types import AccessPolicy +from layerv_qurl.types import AccessPolicy, AIAgentPolicy BASE_URL = "https://api.test.layerv.ai" @@ -1263,6 +1263,40 @@ def test_mint_link_full_input(client: QURLClient) -> None: assert body["access_policy"] == {"ip_allowlist": ["10.0.0.0/8"]} +@respx.mock +def test_mint_link_nested_ai_agent_policy(client: QURLClient) -> None: + """mint_link() correctly serializes nested AIAgentPolicy inside AccessPolicy.""" + route = respx.post(f"{BASE_URL}/v1/qurls/r_abc/mint_link").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "qurl_link": "https://qurl.link/#at_ai", + "expires_at": "2026-04-01T00:00:00Z", + }, + }, + ) + ) + + policy = AccessPolicy( + ip_allowlist=["10.0.0.0/8"], + ai_agent_policy=AIAgentPolicy( + block_all=True, + deny_categories=["scraping"], + ), + ) + result = client.mint_link("r_abc", access_policy=policy) + assert result.qurl_link == "https://qurl.link/#at_ai" + body = json.loads(route.calls[0].request.content) + assert body["access_policy"] == { + "ip_allowlist": ["10.0.0.0/8"], + "ai_agent_policy": { + "block_all": True, + "deny_categories": ["scraping"], + }, + } + + # --- Update with tags --- @@ -1326,6 +1360,37 @@ def test_create_with_label_and_session_duration(client: QURLClient) -> None: assert body["session_duration"] == "1h" +# --- Create with custom_domain --- + + +@respx.mock +def test_create_with_custom_domain(client: QURLClient) -> None: + """create() sends custom_domain in the body.""" + route = respx.post(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_cd", + "qurl_link": "https://links.example.com/#at_cd", + "qurl_site": "https://r_cd.qurl.site", + "qurl_id": "q_cd", + "expires_at": "2026-04-01T00:00:00Z", + }, + }, + ) + ) + + result = client.create( + target_url="https://example.com", + expires_in="7d", + custom_domain="links.example.com", + ) + assert result.resource_id == "r_cd" + body = json.loads(route.calls[0].request.content) + assert body["custom_domain"] == "links.example.com" + + # --- Async batch create --- From 335aea388edad032e6381ec73f646f3bf1d67e13 Mon Sep 17 00:00:00 2001 From: Kevin Kim Date: Tue, 31 Mar 2026 14:27:46 -0700 Subject: [PATCH 03/31] fix: second review pass - simplify and harden serialization - Add recursive list handling in _serialize_value for future-proofing nested dataclass lists - Simplify build_list_params by replacing 9 repetitive if/assign blocks with a single dict comprehension - Fix outdated status values in ListQURLsTool description (removed expired/consumed, kept active/revoked) - Add skipif marker to langchain tests when langchain-core is not installed, preventing false failures in dev environments Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/_utils.py | 34 ++++++++++++++-------------------- src/layerv_qurl/langchain.py | 4 +--- tests/test_langchain.py | 11 ++++++++++- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 3a03969..d47f220 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -89,6 +89,8 @@ def _serialize_value(v: Any) -> Any: for f in dataclasses.fields(v) if getattr(v, f.name) is not None } + if isinstance(v, list): + return [_serialize_value(item) for item in v] return v @@ -288,26 +290,18 @@ def build_list_params( expires_after: str | None = None, ) -> dict[str, str]: """Build query params for list endpoints, dropping None values.""" - params: dict[str, str] = {} - if limit is not None: - params["limit"] = str(limit) - if cursor: - params["cursor"] = cursor - if status: - params["status"] = status - if q: - params["q"] = q - if sort: - params["sort"] = sort - if created_after: - params["created_after"] = created_after - if created_before: - params["created_before"] = created_before - if expires_before: - params["expires_before"] = expires_before - if expires_after: - params["expires_after"] = expires_after - return params + pairs: dict[str, str | int | None] = { + "limit": limit, + "cursor": cursor, + "status": status, + "q": q, + "sort": sort, + "created_after": created_after, + "created_before": created_before, + "expires_before": expires_before, + "expires_after": expires_after, + } + return {k: str(v) for k, v in pairs.items() if v is not None} def mask_key(api_key: str) -> str: diff --git a/src/layerv_qurl/langchain.py b/src/layerv_qurl/langchain.py index d04c148..500d93d 100644 --- a/src/layerv_qurl/langchain.py +++ b/src/layerv_qurl/langchain.py @@ -91,9 +91,7 @@ class ListQURLsTool(BaseTool): """List active QURL links.""" name: str = "list_qurls" - description: str = ( - "List active QURL links. Optionally filter by status (active, expired, revoked, consumed)." - ) + description: str = "List active QURL links. Optionally filter by status (active, revoked)." client: Any = None def _run( diff --git a/tests/test_langchain.py b/tests/test_langchain.py index 3cc035b..e1eaf35 100644 --- a/tests/test_langchain.py +++ b/tests/test_langchain.py @@ -1,11 +1,18 @@ -"""Tests for the LangChain tool integration.""" +"""Tests for the LangChain tool integration. + +Requires the ``langchain`` extra: ``pip install layerv-qurl[langchain]``. +All tests are skipped when ``langchain-core`` is not installed. +""" from __future__ import annotations from datetime import datetime, timezone from unittest.mock import MagicMock +import pytest + from layerv_qurl.langchain import ( + _HAS_LANGCHAIN, CreateQURLTool, DeleteQURLTool, ListQURLsTool, @@ -20,6 +27,8 @@ ResolveOutput, ) +pytestmark = pytest.mark.skipif(not _HAS_LANGCHAIN, reason="langchain-core not installed") + def _mock_client() -> MagicMock: return MagicMock() From c6bdb97c29cf22546c70e0703c517119f9dd13c9 Mon Sep 17 00:00:00 2001 From: Kevin Kim Date: Tue, 7 Apr 2026 15:05:17 -0700 Subject: [PATCH 04/31] fix: add missing Args docstring to async list_all The async list_all() was missing the Args docstring that the sync version has, violating the "keep both in sync" contract noted in the file header. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/async_client.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index c7c610d..31d4e65 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -207,6 +207,16 @@ async def list_all( """Iterate over all QURLs, automatically paginating. Yields individual :class:`QURL` objects, fetching pages transparently. + + Args: + status: Filter by status (``"active"``, ``"revoked"``). + q: Search query string. + sort: Sort order. + page_size: Number of items per page (default 50). + created_after: Filter QURLs created after this ISO timestamp. + created_before: Filter QURLs created before this ISO timestamp. + expires_before: Filter QURLs expiring before this ISO timestamp. + expires_after: Filter QURLs expiring after this ISO timestamp. """ cursor: str | None = None while True: From 360634078b03735218529ff12868149ecffb8016 Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 10 Apr 2026 14:42:23 -0500 Subject: [PATCH 05/31] =?UTF-8?q?fix:=20address=20all=20CR=20feedback=20?= =?UTF-8?q?=E2=80=94=206=20issues=20across=205=20review=20rounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. _parse_access_policy missing ai_agent_policy parsing (bug): API responses with nested ai_agent_policy were silently dropped. Now parses block_all, deny_categories, allow_categories. 2. create() missing one_time_use, max_sessions, access_policy params: Python users had to create then mint_link to set policies. Now create() accepts all policy params directly, matching TS SDK. Applied to both sync and async clients. 3. batch_create() no client-side 1-100 validation: Added ValueError for empty list and >100 items. Gives users a descriptive local error instead of a cryptic API 400. 4. Date filter params str instead of datetime | str: list() and list_all() date filters (created_after, created_before, expires_before, expires_after) now accept datetime objects, consistent with create()'s expires_at. build_list_params calls .isoformat() for datetime values. 5. builtins.list annotation unnecessary: Both clients have from __future__ import annotations, so list[str] works directly. Removed import builtins from TYPE_CHECKING blocks. 6. batch_create items type simplified: builtins.list[dict] → list[dict] (same reason as #5). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/_utils.py | 27 ++++++++++++++++++------ src/layerv_qurl/async_client.py | 37 +++++++++++++++++++++++---------- src/layerv_qurl/client.py | 37 +++++++++++++++++++++++---------- 3 files changed, 73 insertions(+), 28 deletions(-) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index b3debdc..45917ef 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -113,6 +113,16 @@ def build_body(kwargs: dict[str, Any]) -> dict[str, Any]: def _parse_access_policy(data: dict[str, Any]) -> AccessPolicy: """Parse an AccessPolicy from API response data.""" + from layerv_qurl.types import AIAgentPolicy + + ai_policy = None + if data.get("ai_agent_policy") is not None: + ap = data["ai_agent_policy"] + ai_policy = AIAgentPolicy( + block_all=ap.get("block_all"), + deny_categories=ap.get("deny_categories"), + allow_categories=ap.get("allow_categories"), + ) return AccessPolicy( ip_allowlist=data.get("ip_allowlist"), ip_denylist=data.get("ip_denylist"), @@ -120,6 +130,7 @@ def _parse_access_policy(data: dict[str, Any]) -> AccessPolicy: geo_denylist=data.get("geo_denylist"), user_agent_allow_regex=data.get("user_agent_allow_regex"), user_agent_deny_regex=data.get("user_agent_deny_regex"), + ai_agent_policy=ai_policy, ) @@ -325,13 +336,13 @@ def build_list_params( status: str | None, q: str | None, sort: str | None, - created_after: str | None = None, - created_before: str | None = None, - expires_before: str | None = None, - expires_after: str | None = None, + created_after: datetime | str | None = None, + created_before: datetime | str | None = None, + expires_before: datetime | str | None = None, + expires_after: datetime | str | None = None, ) -> dict[str, str]: """Build query params for list endpoints, dropping None values.""" - pairs: dict[str, str | int | None] = { + pairs: dict[str, str | int | datetime | None] = { "limit": limit, "cursor": cursor, "status": status, @@ -342,7 +353,11 @@ def build_list_params( "expires_before": expires_before, "expires_after": expires_after, } - return {k: str(v) for k, v in pairs.items() if v is not None} + return { + k: v.isoformat() if isinstance(v, datetime) else str(v) + for k, v in pairs.items() + if v is not None + } def mask_key(api_key: str) -> str: diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index 31d4e65..3827d02 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -35,7 +35,6 @@ from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError if TYPE_CHECKING: - import builtins from collections.abc import AsyncIterator from datetime import datetime @@ -116,7 +115,10 @@ async def create( expires_in: str | None = None, expires_at: datetime | str | None = None, label: str | None = None, + one_time_use: bool | None = None, + max_sessions: int | None = None, session_duration: str | None = None, + access_policy: AccessPolicy | None = None, custom_domain: str | None = None, ) -> CreateOutput: """Create a new QURL. @@ -130,7 +132,10 @@ async def create( expires_in: Duration string (e.g. ``"24h"``, ``"7d"``). expires_at: Absolute expiry as datetime or ISO string. label: Human-readable label for the QURL. + one_time_use: If True, the QURL is consumed on first access. + max_sessions: Maximum concurrent sessions (0 = unlimited). session_duration: Duration string for sessions (e.g. ``"1h"``). + access_policy: IP/geo/user-agent access restrictions. custom_domain: Custom domain for the QURL link. """ body = build_body( @@ -139,7 +144,10 @@ async def create( "expires_in": expires_in, "expires_at": expires_at, "label": label, + "one_time_use": one_time_use, + "max_sessions": max_sessions, "session_duration": session_duration, + "access_policy": access_policy, "custom_domain": custom_domain, } ) @@ -160,10 +168,10 @@ async def list( status: QURLStatus | None = None, q: str | None = None, sort: str | None = None, - created_after: str | None = None, - created_before: str | None = None, - expires_before: str | None = None, - expires_after: str | None = None, + created_after: datetime | str | None = None, + created_before: datetime | str | None = None, + expires_before: datetime | str | None = None, + expires_after: datetime | str | None = None, ) -> ListOutput: """List QURLs with optional filters. @@ -199,10 +207,10 @@ async def list_all( q: str | None = None, sort: str | None = None, page_size: int = 50, - created_after: str | None = None, - created_before: str | None = None, - expires_before: str | None = None, - expires_after: str | None = None, + created_after: datetime | str | None = None, + created_before: datetime | str | None = None, + expires_before: datetime | str | None = None, + expires_after: datetime | str | None = None, ) -> AsyncIterator[QURL]: """Iterate over all QURLs, automatically paginating. @@ -260,7 +268,7 @@ async def update( extend_by: str | None = None, expires_at: datetime | str | None = None, description: str | None = None, - tags: builtins.list[str] | None = None, + tags: list[str] | None = None, ) -> QURL: """Update a QURL — extend expiration, change description, etc. @@ -326,13 +334,20 @@ async def mint_link( async def batch_create( self, - items: builtins.list[dict[str, Any]], + items: list[dict[str, Any]], ) -> BatchCreateOutput: """Create multiple QURLs at once (1-100 items). Args: items: List of dicts, each with at least ``target_url``. + + Raises: + ValueError: If items is empty or exceeds 100 items. """ + if not items: + raise ValueError("items must not be empty") + if len(items) > 100: + raise ValueError("batch_create supports at most 100 items") resp = await self._request("POST", "/v1/qurls/batch", body={"items": items}) return parse_batch_create_output(resp) diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index 46c29a1..98ea537 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -32,7 +32,6 @@ from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError if TYPE_CHECKING: - import builtins from collections.abc import Iterator from datetime import datetime @@ -128,7 +127,10 @@ def create( expires_in: str | None = None, expires_at: datetime | str | None = None, label: str | None = None, + one_time_use: bool | None = None, + max_sessions: int | None = None, session_duration: str | None = None, + access_policy: AccessPolicy | None = None, custom_domain: str | None = None, ) -> CreateOutput: """Create a new QURL. @@ -142,7 +144,10 @@ def create( expires_in: Duration string (e.g. ``"24h"``, ``"7d"``). expires_at: Absolute expiry as datetime or ISO string. label: Human-readable label for the QURL. + one_time_use: If True, the QURL is consumed on first access. + max_sessions: Maximum concurrent sessions (0 = unlimited). session_duration: Duration string for sessions (e.g. ``"1h"``). + access_policy: IP/geo/user-agent access restrictions. custom_domain: Custom domain for the QURL link. """ body = build_body( @@ -151,7 +156,10 @@ def create( "expires_in": expires_in, "expires_at": expires_at, "label": label, + "one_time_use": one_time_use, + "max_sessions": max_sessions, "session_duration": session_duration, + "access_policy": access_policy, "custom_domain": custom_domain, } ) @@ -172,10 +180,10 @@ def list( status: QURLStatus | None = None, q: str | None = None, sort: str | None = None, - created_after: str | None = None, - created_before: str | None = None, - expires_before: str | None = None, - expires_after: str | None = None, + created_after: datetime | str | None = None, + created_before: datetime | str | None = None, + expires_before: datetime | str | None = None, + expires_after: datetime | str | None = None, ) -> ListOutput: """List QURLs with optional filters. @@ -211,10 +219,10 @@ def list_all( q: str | None = None, sort: str | None = None, page_size: int = 50, - created_after: str | None = None, - created_before: str | None = None, - expires_before: str | None = None, - expires_after: str | None = None, + created_after: datetime | str | None = None, + created_before: datetime | str | None = None, + expires_before: datetime | str | None = None, + expires_after: datetime | str | None = None, ) -> Iterator[QURL]: """Iterate over all QURLs, automatically paginating. @@ -271,7 +279,7 @@ def update( extend_by: str | None = None, expires_at: datetime | str | None = None, description: str | None = None, - tags: builtins.list[str] | None = None, + tags: list[str] | None = None, ) -> QURL: """Update a QURL — extend expiration, change description, etc. @@ -337,13 +345,20 @@ def mint_link( def batch_create( self, - items: builtins.list[dict[str, Any]], + items: list[dict[str, Any]], ) -> BatchCreateOutput: """Create multiple QURLs at once (1-100 items). Args: items: List of dicts, each with at least ``target_url``. + + Raises: + ValueError: If items is empty or exceeds 100 items. """ + if not items: + raise ValueError("items must not be empty") + if len(items) > 100: + raise ValueError("batch_create supports at most 100 items") resp = self._request("POST", "/v1/qurls/batch", body={"items": items}) return parse_batch_create_output(resp) From 981ecde3256da0d09183a0fd118f255c456cd2a9 Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 10 Apr 2026 15:20:09 -0500 Subject: [PATCH 06/31] fix: restore builtins.list for annotations inside class with list() method mypy resolves `list` inside the class body to the `list()` method, not the builtin type. `from __future__ import annotations` makes annotations strings but mypy still evaluates them in the class scope. The `builtins.list` workaround is required for Python 3.10-3.12 mypy compat. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/async_client.py | 5 +++-- src/layerv_qurl/client.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index 3827d02..e327ea5 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -35,6 +35,7 @@ from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError if TYPE_CHECKING: + import builtins from collections.abc import AsyncIterator from datetime import datetime @@ -268,7 +269,7 @@ async def update( extend_by: str | None = None, expires_at: datetime | str | None = None, description: str | None = None, - tags: list[str] | None = None, + tags: builtins.list[str] | None = None, ) -> QURL: """Update a QURL — extend expiration, change description, etc. @@ -334,7 +335,7 @@ async def mint_link( async def batch_create( self, - items: list[dict[str, Any]], + items: builtins.list[dict[str, Any]], ) -> BatchCreateOutput: """Create multiple QURLs at once (1-100 items). diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index 98ea537..2435765 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -32,6 +32,7 @@ from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError if TYPE_CHECKING: + import builtins from collections.abc import Iterator from datetime import datetime @@ -279,7 +280,7 @@ def update( extend_by: str | None = None, expires_at: datetime | str | None = None, description: str | None = None, - tags: list[str] | None = None, + tags: builtins.list[str] | None = None, ) -> QURL: """Update a QURL — extend expiration, change description, etc. @@ -345,7 +346,7 @@ def mint_link( def batch_create( self, - items: list[dict[str, Any]], + items: builtins.list[dict[str, Any]], ) -> BatchCreateOutput: """Create multiple QURLs at once (1-100 items). From 10799d9e99d4f8c12935dea92d531cbe36c7a22e Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 10 Apr 2026 15:27:37 -0500 Subject: [PATCH 07/31] =?UTF-8?q?fix:=20address=20remaining=20CR=20feedbac?= =?UTF-8?q?k=20=E2=80=94=206=20items=20from=203=20review=20rounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Move AIAgentPolicy import to top-level in _utils.py — was a lazy import inside _parse_access_policy, unnecessary since types.py is already imported at module scope 2. CreateOutput.qurl_id: "" → str | None = None — callers can now distinguish "API didn't return it" from "API returned empty string" 3. batch_create items serialized through build_body() — datetime objects or dataclasses in batch item dicts now get properly serialized instead of failing at JSON encoding time 4. Add test_batch_create_empty_raises — covers ValueError for empty list 5. Add test_batch_create_over_100_raises — covers ValueError for >100 6. Add test_create_with_access_policy — verifies create() serializes AccessPolicy with nested AIAgentPolicy (was only tested via mint_link) 7. Add test_mint_link_nested_serialization_e2e — end-to-end test for _serialize_value recursion with geo_denylist + AIAgentPolicy, verifies None fields are omitted Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/_utils.py | 5 +- src/layerv_qurl/async_client.py | 3 +- src/layerv_qurl/client.py | 3 +- src/layerv_qurl/types.py | 2 +- tests/test_client.py | 92 +++++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 6 deletions(-) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 45917ef..26b1958 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -26,6 +26,7 @@ AccessGrant, AccessPolicy, AccessToken, + AIAgentPolicy, BatchCreateOutput, BatchItemError, BatchItemResult, @@ -113,8 +114,6 @@ def build_body(kwargs: dict[str, Any]) -> dict[str, Any]: def _parse_access_policy(data: dict[str, Any]) -> AccessPolicy: """Parse an AccessPolicy from API response data.""" - from layerv_qurl.types import AIAgentPolicy - ai_policy = None if data.get("ai_agent_policy") is not None: ap = data["ai_agent_policy"] @@ -183,7 +182,7 @@ def parse_create_output(data: dict[str, Any]) -> CreateOutput: qurl_link=data["qurl_link"], qurl_site=data["qurl_site"], expires_at=_parse_dt(data.get("expires_at")), - qurl_id=data.get("qurl_id", ""), + qurl_id=data.get("qurl_id"), label=data.get("label"), ) diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index e327ea5..f1e86f9 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -349,7 +349,8 @@ async def batch_create( raise ValueError("items must not be empty") if len(items) > 100: raise ValueError("batch_create supports at most 100 items") - resp = await self._request("POST", "/v1/qurls/batch", body={"items": items}) + serialized = [build_body(item) for item in items] + resp = await self._request("POST", "/v1/qurls/batch", body={"items": serialized}) return parse_batch_create_output(resp) async def resolve(self, access_token: str) -> ResolveOutput: diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index 2435765..94b90c5 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -360,7 +360,8 @@ def batch_create( raise ValueError("items must not be empty") if len(items) > 100: raise ValueError("batch_create supports at most 100 items") - resp = self._request("POST", "/v1/qurls/batch", body={"items": items}) + serialized = [build_body(item) for item in items] + resp = self._request("POST", "/v1/qurls/batch", body={"items": serialized}) return parse_batch_create_output(resp) def resolve(self, access_token: str) -> ResolveOutput: diff --git a/src/layerv_qurl/types.py b/src/layerv_qurl/types.py index 140d275..3144b9c 100644 --- a/src/layerv_qurl/types.py +++ b/src/layerv_qurl/types.py @@ -84,7 +84,7 @@ class CreateOutput: qurl_link: str qurl_site: str expires_at: datetime | None = None - qurl_id: str = "" + qurl_id: str | None = None label: str | None = None diff --git a/tests/test_client.py b/tests/test_client.py index 10090ce..3f57343 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1612,3 +1612,95 @@ async def test_async_update_with_tags(async_client: AsyncQURLClient) -> None: assert result.tags == ["internal"] body = json.loads(route.calls[0].request.content) assert body == {"tags": ["internal"]} + + +# --- batch_create validation --- + + +def test_batch_create_empty_raises(client: QURLClient) -> None: + with pytest.raises(ValueError, match="must not be empty"): + client.batch_create([]) + + +def test_batch_create_over_100_raises(client: QURLClient) -> None: + items = [{"target_url": f"https://{i}.com"} for i in range(101)] + with pytest.raises(ValueError, match="at most 100"): + client.batch_create(items) + + +# --- create() with access_policy serialization --- + + +@respx.mock +def test_create_with_access_policy(client: QURLClient) -> None: + """create() correctly serializes AccessPolicy including nested AIAgentPolicy.""" + route = respx.post(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_pol", + "qurl_link": "https://qurl.link/#at_pol", + "qurl_site": "https://r_pol.qurl.site", + "qurl_id": "q_pol", + }, + }, + ) + ) + + policy = AccessPolicy( + ip_allowlist=["10.0.0.0/8"], + ai_agent_policy=AIAgentPolicy( + block_all=True, + deny_categories=["scraping"], + ), + ) + result = client.create( + target_url="https://example.com", + one_time_use=True, + max_sessions=3, + access_policy=policy, + ) + assert result.resource_id == "r_pol" + body = json.loads(route.calls[0].request.content) + assert body["target_url"] == "https://example.com" + assert body["one_time_use"] is True + assert body["max_sessions"] == 3 + assert body["access_policy"] == { + "ip_allowlist": ["10.0.0.0/8"], + "ai_agent_policy": { + "block_all": True, + "deny_categories": ["scraping"], + }, + } + + +# --- _serialize_value end-to-end with nested dataclasses --- + + +@respx.mock +def test_mint_link_nested_serialization_e2e(client: QURLClient) -> None: + """Verifies _serialize_value recursively handles AccessPolicy > AIAgentPolicy.""" + route = respx.post(f"{BASE_URL}/v1/qurls/r_abc/mint_link").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "qurl_link": "https://qurl.link/#at_e2e", + "expires_at": "2026-04-01T00:00:00Z", + }, + }, + ) + ) + + policy = AccessPolicy( + geo_denylist=["CN", "RU"], + ai_agent_policy=AIAgentPolicy( + allow_categories=["claude", "chatgpt"], + ), + ) + client.mint_link("r_abc", access_policy=policy, label="e2e-test") + body = json.loads(route.calls[0].request.content) + assert body["access_policy"]["geo_denylist"] == ["CN", "RU"] + assert body["access_policy"]["ai_agent_policy"]["allow_categories"] == ["claude", "chatgpt"] + assert "block_all" not in body["access_policy"]["ai_agent_policy"] # None fields omitted From 21559a1beab24eebc6192587365cb0939e785ba8 Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 10 Apr 2026 15:31:19 -0500 Subject: [PATCH 08/31] fix: document qurl_id vs resource_id, recurse dicts in serializer, test batch datetime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. CreateOutput docstring: explain resource_id (container) vs qurl_id (specific access token, q_ prefix) — multiple QURLs share one resource_id 2. active_qurls_percent: add inline comment noting float→float|None breaking change (callers must None-check before arithmetic) 3. _serialize_value: recurse into plain dicts — datetime values nested in batch_create item dicts now serialize correctly via .isoformat() 4. Add test_batch_create_serializes_datetime_in_items — verifies a datetime expires_at in a batch item dict is serialized to ISO string Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/_utils.py | 2 ++ src/layerv_qurl/types.py | 9 +++++++-- tests/test_client.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 26b1958..542c905 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -94,6 +94,8 @@ def _serialize_value(v: Any) -> Any: } if isinstance(v, list): return [_serialize_value(item) for item in v] + if isinstance(v, dict): + return {k: _serialize_value(val) for k, val in v.items() if val is not None} return v diff --git a/src/layerv_qurl/types.py b/src/layerv_qurl/types.py index 3144b9c..c4b8fca 100644 --- a/src/layerv_qurl/types.py +++ b/src/layerv_qurl/types.py @@ -78,7 +78,12 @@ class QURL: @dataclass class CreateOutput: - """Response from creating a QURL.""" + """Response from creating a QURL. + + ``resource_id`` identifies the resource container (grouped by target URL). + ``qurl_id`` identifies the specific access token created (``q_`` prefix). + Multiple QURLs for the same target URL share one ``resource_id``. + """ resource_id: str qurl_link: str @@ -142,7 +147,7 @@ class Usage: qurls_created: int = 0 active_qurls: int = 0 - active_qurls_percent: float | None = None + active_qurls_percent: float | None = None # Changed from float=0.0; None-check before arithmetic total_accesses: int = 0 diff --git a/tests/test_client.py b/tests/test_client.py index 3f57343..bf5b64c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1628,6 +1628,36 @@ def test_batch_create_over_100_raises(client: QURLClient) -> None: client.batch_create(items) +@respx.mock +def test_batch_create_serializes_datetime_in_items(client: QURLClient) -> None: + """batch_create items with datetime values are properly serialized.""" + route = respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "succeeded": 1, + "failed": 0, + "results": [ + { + "index": 0, + "success": True, + "resource_id": "r_dt", + "qurl_link": "https://qurl.link/#at_dt", + "qurl_site": "https://r_dt.qurl.site", + }, + ], + }, + }, + ) + ) + + exp = datetime(2026, 6, 1, 0, 0, 0, tzinfo=timezone.utc) + client.batch_create([{"target_url": "https://example.com", "expires_at": exp}]) + body = json.loads(route.calls[0].request.content) + assert body["items"][0]["expires_at"] == "2026-06-01T00:00:00+00:00" + + # --- create() with access_policy serialization --- From 747f0f20b98c9d3752026729e6cda1222bda95fe Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 10 Apr 2026 15:38:48 -0500 Subject: [PATCH 09/31] =?UTF-8?q?fix:=20E501=20line=20too=20long=20?= =?UTF-8?q?=E2=80=94=20move=20comment=20above=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/types.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/layerv_qurl/types.py b/src/layerv_qurl/types.py index c4b8fca..204090d 100644 --- a/src/layerv_qurl/types.py +++ b/src/layerv_qurl/types.py @@ -147,7 +147,8 @@ class Usage: qurls_created: int = 0 active_qurls: int = 0 - active_qurls_percent: float | None = None # Changed from float=0.0; None-check before arithmetic + # Changed from float=0.0 — callers must None-check before arithmetic. + active_qurls_percent: float | None = None total_accesses: int = 0 From 3b32bea934e6c7cf9ce001297fa8e5dd7658674e Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 01:16:49 -0500 Subject: [PATCH 10/31] fix: port seam-audit fixes and review rounds from qurl-typescript #14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the Python SDK into parity with every improvement made to qurl-typescript and qurl-mcp during the recent review and seam-audit rounds. Cross-references the qurl-service OpenAPI spec (qurl/api/openapi.yaml) and the Go handler code. ### Critical — real bug * parse_error detail fallback. RFC 7807 leaves `detail` optional and the qurl Error schema only requires type/title/status/code. Previously the parser used `err.get("detail", "")`, producing "Forbidden (403): " when the API omitted detail. Now falls back `detail -> message -> title -> HTTP {status}`. QURLError also defaults detail to title in its constructor so Exception.args is never empty-string padded. ### RFC 7807 structured fields * QURLError now carries `type` and `instance` (the problem-type URI and occurrence URI). Both are optional per the spec; the SDK was silently dropping them before. * parse_error extracts both from the envelope. ### Backward compatibility * Legacy `{error: {code, message}}` envelope supported in the fallback chain. If the API ever regresses to the pre-RFC-7807 shape, the SDK degrades gracefully instead of showing empty detail. ### Type narrowing * QURLStatus clarified as resource-only ("active" | "revoked" | str). * New TokenStatus for AccessToken ("active" | "consumed" | "expired" | "revoked" | str) — per QurlSummary.status in the spec, tokens have a wider enum than resources. * AccessToken.status now uses TokenStatus. * New QuotaPlan ("free" | "growth" | "enterprise" | str); Quota.plan uses it. Uses the (Literal | str) pattern so the API can add new plans without a breaking SDK change. ### Spec-derived input validation New validate_create_input / validate_update_input / validate_mint_input helpers in _utils.py enforcing the constraints documented on each request schema in openapi.yaml: - target_url: maxLength 2048 - label: maxLength 500 (on create + mint_link) - description: maxLength 500 (on update) - custom_domain: maxLength 253 (on create) - max_sessions: 0-1000 integer (on create + mint_link) - tags: max 10, each 1-50 chars, regex ^[a-zA-Z0-9][a-zA-Z0-9 _-]*$ batch_create runs validate_create_input on every item and attributes errors by index (`items[N]: ...`) so bulk mistakes fail fast. ### Mutual-exclusion pre-flight checks * update: rejects both extend_by + expires_at * update: rejects empty input (at least one field required) * mint_link: rejects both expires_in + expires_at Extend() inherits the update() checks via delegation. ### delete() r_ prefix enforcement Per the OpenAPI spec DELETE /v1/qurls/:id description: "Requires a resource ID (r_ prefix). To revoke a single token, use DELETE /v1/resources/:id/qurls/:qurl_id". New require_resource_id_prefix helper raises ValueError client-side for q_ IDs with a clear message pointing at the token-scoped endpoint. ### batch_create HTTP 400 passthrough The API returns a populated BatchCreateOutput body on HTTP 400 (all items rejected) — see qurl/internal/api/handlers/server.go:1126. Added `allow_statuses` to _raw_request and _request, and batch_create whitelists 400 so the per-item errors are surfaced instead of being swallowed by the generic raise-on-error path. Non-400 errors (401, 403, 429, 5xx) still raise the appropriate QURLError subclass. Matches the qurl-typescript and qurl-mcp implementations. ### create() parameter cleanup Dropped the spurious `expires_at` kwarg from both sync and async create(). CreateQurlRequest in openapi.yaml has only `expires_in` — the previous signature let callers pass a field the API doesn't accept. ### Dual-prefix documentation get/update/extend/mint_link docstrings now document that both r_ (resource) and q_ (QURL display) IDs are accepted; the API resolves q_ IDs to the parent resource automatically. delete() stays narrow (r_ only) matching its client-side enforcement. ### parse_create_output: normalize empty qurl_id to None Empty-string qurl_id from a response (mock or legacy shape) is now normalized to None so callers can use `if result.qurl_id:` as a presence check instead of having "" be silently truthy-false. ### _serialize_value: stop stripping None from nested dicts Previously the dict branch filtered out None values, which would silently drop explicit nulls callers send to clear nested fields (e.g. `{"access_policy": {"ai_agent_policy": null}}`). Top-level None-stripping still happens in build_body since that serves the "drop unset kwargs" case. Nested None is now preserved; dataclass fields still skip None (dataclasses distinguish unset vs explicit). ### Misc * build_list_params type annotation tightened — the `int | None` arm was misordered in the old union. * test_update_with_tags corrected to use spec-compliant tags (previous test used `team:engineering` with a colon that the ^[a-zA-Z0-9][a-zA-Z0-9 _-]*$ regex rejects). * test_batch_create_empty_raises regex updated for the new error message ("requires at least 1 item"). * test_create_sends_correct_body now covers one_time_use, max_sessions, and session_duration alongside label (reviewer #9 gap note). ### Tests (74 -> 101) Twenty-seven new tests covering: - Create rejection: target_url > 2048, label > 500, custom_domain > 253, max_sessions > 1000, max_sessions < 0 - Create boundaries: max_sessions 0 and 1000 both accepted - Update rejection: description > 500, > 10 tags, tag > 50 chars, tag regex pattern mismatch, empty input, mutual-exclusion - Update success: empty tags array clears all tags - mint_link rejection: label > 500, max_sessions > 1000, mutual-exclusion - delete q_ prefix rejection - batch_create per-item validation with index attribution - batch_create missing target_url surfaces index - Async batch_create empty/>100 (reviewer #7 symmetry gap) - batch_create HTTP 400 passthrough with per-item errors - batch_create still raises on 401 (passthrough is surgical) - Error type/instance surfacing - Error detail fallback when RFC 7807 detail missing - Legacy error.message fallback - parse_create_output empty qurl_id normalization BREAKING CHANGE: `active_qurls_percent` on `Quota.usage` is now `float | None` instead of `float` with a `0.0` default; callers doing arithmetic must None-check. Also `create()` no longer accepts an `expires_at` kwarg — that field wasn't in `CreateQurlRequest`. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/_utils.py | 178 +++++++++++++-- src/layerv_qurl/async_client.py | 200 ++++++++++++++--- src/layerv_qurl/client.py | 200 ++++++++++++++--- src/layerv_qurl/errors.py | 20 +- src/layerv_qurl/types.py | 33 ++- tests/test_client.py | 372 +++++++++++++++++++++++++++++++- 6 files changed, 921 insertions(+), 82 deletions(-) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 5b9131d..fb52663 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -81,7 +81,16 @@ def validate_id(value: str, name: str = "resource_id") -> str: def _serialize_value(v: Any) -> Any: - """Serialize a single value for JSON, handling dataclasses and datetimes recursively.""" + """Serialize a single value for JSON, handling dataclasses/datetimes/lists/dicts. + + Unlike :func:`build_body` (which strips top-level ``None`` values so the + request body only carries fields the caller explicitly set), this + recursive helper preserves ``None`` values inside lists and nested + dicts. This matters because some API fields use explicit ``null`` as a + signalling value (e.g. ``"access_policy": {"ai_agent_policy": null}`` + to clear a policy). Dataclass fields still skip ``None`` because the + dataclass itself distinguishes "unset" from "explicitly null". + """ if v is None: return None if isinstance(v, datetime): @@ -95,16 +104,19 @@ def _serialize_value(v: Any) -> Any: if isinstance(v, list): return [_serialize_value(item) for item in v] if isinstance(v, dict): - return {k: _serialize_value(val) for k, val in v.items() if val is not None} + # Preserve explicit None inside nested dicts — callers who want + # "drop this field" should omit it from the dict, not set it to None. + return {k: _serialize_value(val) for k, val in v.items()} return v def build_body(kwargs: dict[str, Any]) -> dict[str, Any]: - """Build a request body dict from kwargs, dropping None values. + """Build a request body dict from kwargs, dropping top-level None values. - Always returns a dict (at least ``{}``) so POST/PATCH endpoints - receive a valid JSON body. Nested dataclasses are recursively - serialized to dicts. + Only strips ``None`` at the top level — nested values are preserved + as-is by :func:`_serialize_value`. Always returns a dict (at least + ``{}``) so POST/PATCH endpoints receive a valid JSON body. Nested + dataclasses are recursively serialized to dicts. """ body: dict[str, Any] = {} for k, v in kwargs.items(): @@ -114,6 +126,115 @@ def build_body(kwargs: dict[str, Any]) -> dict[str, Any]: return body +# ---- Spec-derived input validation -------------------------------------- +# These mirror the constraints documented on each request schema in +# qurl/api/openapi.yaml so obvious mistakes fail fast with a ValueError +# instead of round-tripping to the API and coming back as a generic 400. + +MAX_TARGET_URL = 2048 +MAX_LABEL = 500 +MAX_DESCRIPTION = 500 +MAX_CUSTOM_DOMAIN = 253 +MAX_MAX_SESSIONS = 1000 +MAX_TAGS = 10 +MAX_TAG_LENGTH = 50 +# UpdateQurlRequest.tags item pattern from openapi.yaml. +_TAG_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9 _-]*$") +RESOURCE_ID_PREFIX = "r_" + + +def _require_max_length(value: str | None, field_name: str, maximum: int) -> None: + if value is not None and len(value) > maximum: + raise ValueError( + f"{field_name}: must be {maximum} characters or fewer (got {len(value)})" + ) + + +def _require_max_sessions_in_range(value: int | None) -> None: + if value is None: + return + if not isinstance(value, int) or isinstance(value, bool): + raise ValueError(f"max_sessions: must be an integer (got {type(value).__name__})") + if value < 0 or value > MAX_MAX_SESSIONS: + raise ValueError( + f"max_sessions: must be an integer between 0 and {MAX_MAX_SESSIONS} (got {value})" + ) + + +def _require_valid_tags(tags: list[str] | None) -> None: + if tags is None: + return + if len(tags) > MAX_TAGS: + raise ValueError(f"tags: max {MAX_TAGS} items allowed (got {len(tags)})") + for tag in tags: + if not isinstance(tag, str) or len(tag) < 1 or len(tag) > MAX_TAG_LENGTH: + raise ValueError( + f"tags: each tag must be 1-{MAX_TAG_LENGTH} characters " + f"(got {len(tag) if isinstance(tag, str) else type(tag).__name__})" + ) + if not _TAG_PATTERN.match(tag): + raise ValueError( + "tags: each tag must start with an alphanumeric and contain only " + "letters, numbers, spaces, underscores, or hyphens" + ) + + +def validate_create_input( + *, + target_url: str, + label: str | None = None, + max_sessions: int | None = None, + custom_domain: str | None = None, +) -> None: + """Validate a single create_qurl input against spec-documented constraints. + + Used by both ``create()`` and ``batch_create()`` (for each item). + Raises ``ValueError`` on constraint violations so obvious mistakes + fail fast instead of round-tripping to the API. + """ + _require_max_length(target_url, "target_url", MAX_TARGET_URL) + _require_max_length(label, "label", MAX_LABEL) + _require_max_length(custom_domain, "custom_domain", MAX_CUSTOM_DOMAIN) + _require_max_sessions_in_range(max_sessions) + + +def validate_update_input( + *, + description: str | None = None, + tags: list[str] | None = None, +) -> None: + """Validate update_qurl input against spec-documented constraints.""" + _require_max_length(description, "description", MAX_DESCRIPTION) + _require_valid_tags(tags) + + +def validate_mint_input( + *, + label: str | None = None, + max_sessions: int | None = None, +) -> None: + """Validate mint_link input against spec-documented constraints.""" + _require_max_length(label, "label", MAX_LABEL) + _require_max_sessions_in_range(max_sessions) + + +def require_resource_id_prefix(resource_id: str, operation: str = "delete") -> None: + """Enforce the ``r_`` prefix on endpoints that only accept resource IDs. + + Per the OpenAPI spec, ``DELETE /v1/qurls/:id`` explicitly requires a + resource ID (r_ prefix) — the token-scoped endpoint is + ``DELETE /v1/resources/:id/qurls/:qurl_id``. Catch the common mistake + of passing a ``q_`` display ID here with a clear client-side error. + """ + if not resource_id.startswith(RESOURCE_ID_PREFIX): + raise ValueError( + f"{operation}: only resource IDs ({RESOURCE_ID_PREFIX} prefix) are accepted — " + f"got {resource_id[:16]!r}. To revoke a single access token, " + "use the DELETE /v1/resources/:id/qurls/:qurl_id endpoint " + "(not yet exposed by this SDK)." + ) + + def _parse_access_policy(data: dict[str, Any]) -> AccessPolicy: """Parse an AccessPolicy from API response data.""" ai_policy = None @@ -179,12 +300,18 @@ def parse_qurl(data: dict[str, Any]) -> QURL: def parse_create_output(data: dict[str, Any]) -> CreateOutput: """Parse a CreateOutput from API response data.""" + # Normalize empty-string `qurl_id` to None so callers can use the + # idiomatic ``if result.qurl_id:`` presence check. An empty string + # here is either a bug in a mock or a legacy response shape; the + # spec requires a populated qurl_id on success responses. + qurl_id_raw = data.get("qurl_id") + qurl_id = qurl_id_raw if qurl_id_raw else None return CreateOutput( resource_id=data["resource_id"], qurl_link=data["qurl_link"], qurl_site=data["qurl_site"], expires_at=_parse_dt(data.get("expires_at")), - qurl_id=data.get("qurl_id"), + qurl_id=qurl_id, label=data.get("label"), ) @@ -288,7 +415,20 @@ def parse_batch_create_output(data: dict[str, Any]) -> BatchCreateOutput: def parse_error(response: httpx.Response) -> QURLError: - """Parse an API error response into the appropriate QURLError subclass.""" + """Parse an API error response into the appropriate QURLError subclass. + + Handles the full RFC 7807 Problem Details shape (``type``, ``title``, + ``status``, ``detail``, ``instance``, ``code``) plus the pre-RFC-7807 + legacy ``{error: {code, message}}`` envelope for backward compatibility. + + The ``detail`` fallback chain is: + 1. ``err.detail`` — RFC 7807 primary + 2. ``err.message`` — legacy pre-RFC-7807 shape + 3. ``err.title`` — RFC 7807 required field + 4. ``HTTP {status}`` — final safety net + + This prevents ``"Title (403): "`` when the API omits ``detail``. + """ retry_after = None if response.status_code == 429: retry_after_header = response.headers.get("Retry-After") @@ -304,14 +444,23 @@ def parse_error(response: httpx.Response) -> QURLError: try: envelope = response.json() - err = envelope.get("error", {}) + err = envelope.get("error") or {} + title = err.get("title") or response.reason_phrase or "" + detail = ( + err.get("detail") + or err.get("message") # legacy envelope + or title + or f"HTTP {response.status_code}" + ) return cls( status=err.get("status", response.status_code), code=err.get("code", "unknown"), - title=err.get("title", response.reason_phrase or ""), - detail=err.get("detail", ""), + title=title, + detail=detail, + type=err.get("type"), + instance=err.get("instance"), invalid_fields=err.get("invalid_fields"), - request_id=envelope.get("meta", {}).get("request_id"), + request_id=(envelope.get("meta") or {}).get("request_id"), retry_after=retry_after, ) except (ValueError, KeyError, TypeError): @@ -319,7 +468,7 @@ def parse_error(response: httpx.Response) -> QURLError: status=response.status_code, code="unknown", title=response.reason_phrase or "", - detail=response.text, + detail=response.text or f"HTTP {response.status_code}", retry_after=retry_after, ) @@ -345,7 +494,8 @@ def build_list_params( expires_after: datetime | str | None = None, ) -> dict[str, str]: """Build query params for list endpoints, dropping None values.""" - pairs: dict[str, str | int | datetime | None] = { + # ``status`` is a QURLStatus (Literal | str) — covered by the ``str`` arm. + pairs: dict[str, int | str | datetime | None] = { "limit": limit, "cursor": cursor, "status": status, diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index f1e86f9..fbbf29e 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -29,8 +29,12 @@ parse_quota, parse_qurl, parse_resolve_output, + require_resource_id_prefix, retry_delay, + validate_create_input, validate_id, + validate_mint_input, + validate_update_input, ) from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError @@ -114,7 +118,6 @@ async def create( target_url: str, *, expires_in: str | None = None, - expires_at: datetime | str | None = None, label: str | None = None, one_time_use: bool | None = None, max_sessions: int | None = None, @@ -128,22 +131,38 @@ async def create( ``qurl_site``, and ``expires_at``. Use :meth:`get` to fetch the full :class:`QURL` object with status, timestamps, and policy details. + Note: ``tags`` and ``description`` are not accepted on create — they + live on the resource and must be set via :meth:`update` after + creation. The API uses different field names for the create-time + token label (``label``) and the resource-level description on + update/get responses. + Args: - target_url: The URL to protect. - expires_in: Duration string (e.g. ``"24h"``, ``"7d"``). - expires_at: Absolute expiry as datetime or ISO string. - label: Human-readable label for the QURL. + target_url: The URL to protect. Max length 2048. + expires_in: Duration string (e.g. ``"24h"``, ``"7d"``). The API + uses ``expires_in`` on create; use :meth:`update` with + ``expires_at`` if you need an absolute expiry afterwards. + label: Human-readable label for the QURL. Max length 500. one_time_use: If True, the QURL is consumed on first access. max_sessions: Maximum concurrent sessions (0 = unlimited). + Must be between 0 and 1000 inclusive. session_duration: Duration string for sessions (e.g. ``"1h"``). access_policy: IP/geo/user-agent access restrictions. - custom_domain: Custom domain for the QURL link. + custom_domain: Custom domain for the QURL link. Max length 253. + + Raises: + ValueError: If any field violates the documented API constraints. """ + validate_create_input( + target_url=target_url, + label=label, + max_sessions=max_sessions, + custom_domain=custom_domain, + ) body = build_body( { "target_url": target_url, "expires_in": expires_in, - "expires_at": expires_at, "label": label, "one_time_use": one_time_use, "max_sessions": max_sessions, @@ -156,7 +175,15 @@ async def create( return parse_create_output(resp) async def get(self, resource_id: str) -> QURL: - """Get a QURL by ID.""" + """Get a QURL resource and its access tokens. + + Accepts either a resource ID (``r_`` prefix) or a QURL display ID + (``q_`` prefix); the API resolves ``q_`` IDs to the parent resource + automatically. + + Args: + resource_id: The resource or QURL display ID. + """ validate_id(resource_id) resp = await self._request("GET", f"/v1/qurls/{resource_id}") return parse_qurl(resp) @@ -247,17 +274,37 @@ async def list_all( cursor = page.next_cursor async def delete(self, resource_id: str) -> None: - """Delete (revoke) a QURL.""" + """Delete (revoke) a QURL resource and all its access tokens. + + Only accepts a resource ID (``r_`` prefix), not a QURL display ID + (``q_`` prefix). Per the OpenAPI spec: + *"Requires a resource ID (r_ prefix). To revoke a single token, + use DELETE /v1/resources/:id/qurls/:qurl_id"*. + + A client-side prefix check catches the mistake before the API + round-trip. + + Args: + resource_id: The resource ID (must start with ``r_``). + + Raises: + ValueError: If ``resource_id`` is malformed or does not start + with ``r_``. + """ validate_id(resource_id) + require_resource_id_prefix(resource_id, "delete") await self._request("DELETE", f"/v1/qurls/{resource_id}") async def extend(self, resource_id: str, duration: str) -> QURL: """Extend a QURL's expiration. Convenience method — equivalent to ``await update(resource_id, extend_by=duration)``. + Accepts either a resource ID (``r_`` prefix) or a QURL display ID + (``q_`` prefix); the API resolves ``q_`` IDs to the parent resource + automatically. Args: - resource_id: QURL resource ID. + resource_id: Resource or QURL display ID. duration: Duration to add (e.g. ``"7d"``, ``"24h"``). """ return await self.update(resource_id, extend_by=duration) @@ -271,18 +318,46 @@ async def update( description: str | None = None, tags: builtins.list[str] | None = None, ) -> QURL: - """Update a QURL — extend expiration, change description, etc. + """Update a QURL — extend expiration, change description, set tags. - All fields are optional; only provided fields are sent. + Accepts either a resource ID (``r_`` prefix) or a QURL display ID + (``q_`` prefix). All fields are optional, but at least one must be + provided. ``extend_by`` and ``expires_at`` are mutually exclusive. Args: - resource_id: QURL resource ID. - extend_by: Duration to add (e.g. ``"7d"``). - expires_at: New absolute expiry. - description: New description. - tags: Tags to set on the QURL. + resource_id: Resource or QURL display ID. + extend_by: Duration to add (e.g. ``"7d"``). Mutually exclusive + with ``expires_at``. + expires_at: New absolute expiry. Mutually exclusive with + ``extend_by``. + description: New resource description. Pass an empty string to + clear. Max length 500. + tags: Tags to set on the resource. Pass an empty list to clear. + Max 10 items, each 1-50 chars matching + ``^[a-zA-Z0-9][a-zA-Z0-9 _-]*$``. + + Raises: + ValueError: If ``extend_by`` and ``expires_at`` are both set, if + no update fields are provided, or if any field violates the + documented API constraints. """ validate_id(resource_id) + if extend_by is not None and expires_at is not None: + raise ValueError( + "update: `extend_by` and `expires_at` are mutually exclusive " + "— provide at most one" + ) + if ( + extend_by is None + and expires_at is None + and description is None + and tags is None + ): + raise ValueError( + "update: at least one field (extend_by, expires_at, description, " + "tags) must be provided" + ) + validate_update_input(description=description, tags=tags) body = build_body( { "extend_by": extend_by, @@ -308,17 +383,34 @@ async def mint_link( ) -> MintOutput: """Mint a new access link for a QURL. + Accepts either a resource ID (``r_`` prefix) or a QURL display ID + (``q_`` prefix). ``expires_in`` and ``expires_at`` are mutually + exclusive — if neither is set, the link defaults to 24 hours. + Args: - resource_id: QURL resource ID. - expires_at: Optional expiry override for the minted link. + resource_id: Resource or QURL display ID. + expires_at: Absolute expiry for the minted link. Mutually + exclusive with ``expires_in``. expires_in: Duration string for the link (e.g. ``"24h"``). - label: Human-readable label for the link. + Mutually exclusive with ``expires_at``. + label: Human-readable label for the link. Max length 500. one_time_use: If True, the link can only be used once. max_sessions: Maximum concurrent sessions allowed. + Must be between 0 and 1000 inclusive. session_duration: Duration string for sessions (e.g. ``"1h"``). access_policy: IP/geo/user-agent access restrictions. + + Raises: + ValueError: If ``expires_in`` and ``expires_at`` are both set + or if any field violates the documented API constraints. """ validate_id(resource_id) + if expires_in is not None and expires_at is not None: + raise ValueError( + "mint_link: `expires_in` and `expires_at` are mutually exclusive " + "— provide at most one" + ) + validate_mint_input(label=label, max_sessions=max_sessions) body = build_body( { "expires_at": expires_at, @@ -339,18 +431,62 @@ async def batch_create( ) -> BatchCreateOutput: """Create multiple QURLs at once (1-100 items). + Each item is validated against the same spec constraints as + :meth:`create` before the request is sent, with per-item errors + attributed by index (``items[N]: ...``). + + **Partial failures do not raise.** Two API status codes resolve + normally with structured per-item results: + + - **HTTP 207 Multi-Status** (some succeeded, some failed). + - **HTTP 400** (every item failed validation) — the API returns a + populated ``BatchCreateOutput`` body on this path, so the SDK + whitelists 400 and surfaces the per-item errors instead of + raising a generic :class:`ValidationError`. + + Other error statuses (401, 403, 429, 5xx) still raise the + appropriate :class:`QURLError` subclass. Inspect + ``result.failed > 0`` and iterate ``result.results`` to see + which items succeeded and which errored. + Args: items: List of dicts, each with at least ``target_url``. Raises: - ValueError: If items is empty or exceeds 100 items. + ValueError: If ``items`` is empty, exceeds 100 items, or any + item violates the documented API constraints. """ if not items: - raise ValueError("items must not be empty") + raise ValueError("batch_create requires at least 1 item") if len(items) > 100: - raise ValueError("batch_create supports at most 100 items") + raise ValueError( + f"batch_create accepts at most 100 items (got {len(items)})" + ) + # Validate each item against the same spec constraints as single + # create() so obvious mistakes fail fast with the offending index. + for i, item in enumerate(items): + try: + validate_create_input( + target_url=item["target_url"], + label=item.get("label"), + max_sessions=item.get("max_sessions"), + custom_domain=item.get("custom_domain"), + ) + except KeyError as exc: + raise ValueError( + f"batch_create items[{i}]: missing required field 'target_url'" + ) from exc + except ValueError as exc: + raise ValueError(f"batch_create items[{i}]: {exc}") from exc serialized = [build_body(item) for item in items] - resp = await self._request("POST", "/v1/qurls/batch", body={"items": serialized}) + # HTTP 400 carries structured per-item errors on this endpoint — + # whitelist it so the generic error path doesn't swallow the body. + resp = await self._request( + "POST", + "/v1/qurls/batch", + body={"items": serialized}, + allow_statuses=(400,), + ) return parse_batch_create_output(resp) async def resolve(self, access_token: str) -> ResolveOutput: @@ -380,8 +516,11 @@ async def _request( *, body: dict[str, Any] | None = None, params: dict[str, str] | None = None, + allow_statuses: tuple[int, ...] = (), ) -> Any: - data, _ = await self._raw_request(method, path, body=body, params=params) + data, _ = await self._raw_request( + method, path, body=body, params=params, allow_statuses=allow_statuses + ) return data async def _raw_request( @@ -391,7 +530,16 @@ async def _raw_request( *, body: dict[str, Any] | None = None, params: dict[str, str] | None = None, + allow_statuses: tuple[int, ...] = (), ) -> tuple[Any, dict[str, Any] | None]: + """Issue an HTTP request and parse the JSON envelope. + + ``allow_statuses`` lets a caller opt specific non-2xx codes out of + the default raise-on-error path and receive the parsed body + instead. This is used by :meth:`batch_create`, where the API + returns a structured ``BatchCreateOutput`` on HTTP 400 (all items + rejected) — raising would drop the per-item errors. + """ url = f"{self._base_url}{path}" last_error: Exception | None = None @@ -426,7 +574,7 @@ async def _raw_request( logger.debug("%s %s → %d", method, url, response.status_code) - if response.status_code < 400: + if response.status_code < 400 or response.status_code in allow_statuses: if response.status_code == 204 or not response.content: return None, None envelope = response.json() diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index 94b90c5..ba6ff14 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -26,8 +26,12 @@ parse_quota, parse_qurl, parse_resolve_output, + require_resource_id_prefix, retry_delay, + validate_create_input, validate_id, + validate_mint_input, + validate_update_input, ) from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError @@ -126,7 +130,6 @@ def create( target_url: str, *, expires_in: str | None = None, - expires_at: datetime | str | None = None, label: str | None = None, one_time_use: bool | None = None, max_sessions: int | None = None, @@ -140,22 +143,38 @@ def create( ``qurl_site``, and ``expires_at``. Use :meth:`get` to fetch the full :class:`QURL` object with status, timestamps, and policy details. + Note: ``tags`` and ``description`` are not accepted on create — they + live on the resource and must be set via :meth:`update` after + creation. The API uses different field names for the create-time + token label (``label``) and the resource-level description on + update/get responses. + Args: - target_url: The URL to protect. - expires_in: Duration string (e.g. ``"24h"``, ``"7d"``). - expires_at: Absolute expiry as datetime or ISO string. - label: Human-readable label for the QURL. + target_url: The URL to protect. Max length 2048. + expires_in: Duration string (e.g. ``"24h"``, ``"7d"``). The API + uses ``expires_in`` on create; use :meth:`update` with + ``expires_at`` if you need an absolute expiry afterwards. + label: Human-readable label for the QURL. Max length 500. one_time_use: If True, the QURL is consumed on first access. max_sessions: Maximum concurrent sessions (0 = unlimited). + Must be between 0 and 1000 inclusive. session_duration: Duration string for sessions (e.g. ``"1h"``). access_policy: IP/geo/user-agent access restrictions. - custom_domain: Custom domain for the QURL link. + custom_domain: Custom domain for the QURL link. Max length 253. + + Raises: + ValueError: If any field violates the documented API constraints. """ + validate_create_input( + target_url=target_url, + label=label, + max_sessions=max_sessions, + custom_domain=custom_domain, + ) body = build_body( { "target_url": target_url, "expires_in": expires_in, - "expires_at": expires_at, "label": label, "one_time_use": one_time_use, "max_sessions": max_sessions, @@ -168,7 +187,15 @@ def create( return parse_create_output(resp) def get(self, resource_id: str) -> QURL: - """Get a QURL by ID.""" + """Get a QURL resource and its access tokens. + + Accepts either a resource ID (``r_`` prefix) or a QURL display ID + (``q_`` prefix); the API resolves ``q_`` IDs to the parent resource + automatically. + + Args: + resource_id: The resource or QURL display ID. + """ validate_id(resource_id) resp = self._request("GET", f"/v1/qurls/{resource_id}") return parse_qurl(resp) @@ -258,17 +285,37 @@ def list_all( cursor = page.next_cursor def delete(self, resource_id: str) -> None: - """Delete (revoke) a QURL.""" + """Delete (revoke) a QURL resource and all its access tokens. + + Only accepts a resource ID (``r_`` prefix), not a QURL display ID + (``q_`` prefix). Per the OpenAPI spec: + *"Requires a resource ID (r_ prefix). To revoke a single token, + use DELETE /v1/resources/:id/qurls/:qurl_id"*. + + A client-side prefix check catches the mistake before the API + round-trip. + + Args: + resource_id: The resource ID (must start with ``r_``). + + Raises: + ValueError: If ``resource_id`` is malformed or does not start + with ``r_``. + """ validate_id(resource_id) + require_resource_id_prefix(resource_id, "delete") self._request("DELETE", f"/v1/qurls/{resource_id}") def extend(self, resource_id: str, duration: str) -> QURL: """Extend a QURL's expiration. Convenience method — equivalent to ``update(resource_id, extend_by=duration)``. + Accepts either a resource ID (``r_`` prefix) or a QURL display ID + (``q_`` prefix); the API resolves ``q_`` IDs to the parent resource + automatically. Args: - resource_id: QURL resource ID. + resource_id: Resource or QURL display ID. duration: Duration to add (e.g. ``"7d"``, ``"24h"``). """ return self.update(resource_id, extend_by=duration) @@ -282,18 +329,46 @@ def update( description: str | None = None, tags: builtins.list[str] | None = None, ) -> QURL: - """Update a QURL — extend expiration, change description, etc. + """Update a QURL — extend expiration, change description, set tags. - All fields are optional; only provided fields are sent. + Accepts either a resource ID (``r_`` prefix) or a QURL display ID + (``q_`` prefix). All fields are optional, but at least one must be + provided. ``extend_by`` and ``expires_at`` are mutually exclusive. Args: - resource_id: QURL resource ID. - extend_by: Duration to add (e.g. ``"7d"``). - expires_at: New absolute expiry. - description: New description. - tags: Tags to set on the QURL. + resource_id: Resource or QURL display ID. + extend_by: Duration to add (e.g. ``"7d"``). Mutually exclusive + with ``expires_at``. + expires_at: New absolute expiry. Mutually exclusive with + ``extend_by``. + description: New resource description. Pass an empty string to + clear. Max length 500. + tags: Tags to set on the resource. Pass an empty list to clear. + Max 10 items, each 1-50 chars matching + ``^[a-zA-Z0-9][a-zA-Z0-9 _-]*$``. + + Raises: + ValueError: If ``extend_by`` and ``expires_at`` are both set, if + no update fields are provided, or if any field violates the + documented API constraints. """ validate_id(resource_id) + if extend_by is not None and expires_at is not None: + raise ValueError( + "update: `extend_by` and `expires_at` are mutually exclusive " + "— provide at most one" + ) + if ( + extend_by is None + and expires_at is None + and description is None + and tags is None + ): + raise ValueError( + "update: at least one field (extend_by, expires_at, description, " + "tags) must be provided" + ) + validate_update_input(description=description, tags=tags) body = build_body( { "extend_by": extend_by, @@ -319,17 +394,34 @@ def mint_link( ) -> MintOutput: """Mint a new access link for a QURL. + Accepts either a resource ID (``r_`` prefix) or a QURL display ID + (``q_`` prefix). ``expires_in`` and ``expires_at`` are mutually + exclusive — if neither is set, the link defaults to 24 hours. + Args: - resource_id: QURL resource ID. - expires_at: Optional expiry override for the minted link. + resource_id: Resource or QURL display ID. + expires_at: Absolute expiry for the minted link. Mutually + exclusive with ``expires_in``. expires_in: Duration string for the link (e.g. ``"24h"``). - label: Human-readable label for the link. + Mutually exclusive with ``expires_at``. + label: Human-readable label for the link. Max length 500. one_time_use: If True, the link can only be used once. max_sessions: Maximum concurrent sessions allowed. + Must be between 0 and 1000 inclusive. session_duration: Duration string for sessions (e.g. ``"1h"``). access_policy: IP/geo/user-agent access restrictions. + + Raises: + ValueError: If ``expires_in`` and ``expires_at`` are both set + or if any field violates the documented API constraints. """ validate_id(resource_id) + if expires_in is not None and expires_at is not None: + raise ValueError( + "mint_link: `expires_in` and `expires_at` are mutually exclusive " + "— provide at most one" + ) + validate_mint_input(label=label, max_sessions=max_sessions) body = build_body( { "expires_at": expires_at, @@ -350,18 +442,62 @@ def batch_create( ) -> BatchCreateOutput: """Create multiple QURLs at once (1-100 items). + Each item is validated against the same spec constraints as + :meth:`create` before the request is sent, with per-item errors + attributed by index (``items[N]: ...``). + + **Partial failures do not raise.** Two API status codes resolve + normally with structured per-item results: + + - **HTTP 207 Multi-Status** (some succeeded, some failed). + - **HTTP 400** (every item failed validation) — the API returns a + populated ``BatchCreateOutput`` body on this path, so the SDK + whitelists 400 and surfaces the per-item errors instead of + raising a generic :class:`ValidationError`. + + Other error statuses (401, 403, 429, 5xx) still raise the + appropriate :class:`QURLError` subclass. Inspect + ``result.failed > 0`` and iterate ``result.results`` to see + which items succeeded and which errored. + Args: items: List of dicts, each with at least ``target_url``. Raises: - ValueError: If items is empty or exceeds 100 items. + ValueError: If ``items`` is empty, exceeds 100 items, or any + item violates the documented API constraints. """ if not items: - raise ValueError("items must not be empty") + raise ValueError("batch_create requires at least 1 item") if len(items) > 100: - raise ValueError("batch_create supports at most 100 items") + raise ValueError( + f"batch_create accepts at most 100 items (got {len(items)})" + ) + # Validate each item against the same spec constraints as single + # create() so obvious mistakes fail fast with the offending index. + for i, item in enumerate(items): + try: + validate_create_input( + target_url=item["target_url"], + label=item.get("label"), + max_sessions=item.get("max_sessions"), + custom_domain=item.get("custom_domain"), + ) + except KeyError as exc: + raise ValueError( + f"batch_create items[{i}]: missing required field 'target_url'" + ) from exc + except ValueError as exc: + raise ValueError(f"batch_create items[{i}]: {exc}") from exc serialized = [build_body(item) for item in items] - resp = self._request("POST", "/v1/qurls/batch", body={"items": serialized}) + # HTTP 400 carries structured per-item errors on this endpoint — + # whitelist it so the generic error path doesn't swallow the body. + resp = self._request( + "POST", + "/v1/qurls/batch", + body={"items": serialized}, + allow_statuses=(400,), + ) return parse_batch_create_output(resp) def resolve(self, access_token: str) -> ResolveOutput: @@ -391,8 +527,11 @@ def _request( *, body: dict[str, Any] | None = None, params: dict[str, str] | None = None, + allow_statuses: tuple[int, ...] = (), ) -> Any: - data, _ = self._raw_request(method, path, body=body, params=params) + data, _ = self._raw_request( + method, path, body=body, params=params, allow_statuses=allow_statuses + ) return data def _raw_request( @@ -402,7 +541,16 @@ def _raw_request( *, body: dict[str, Any] | None = None, params: dict[str, str] | None = None, + allow_statuses: tuple[int, ...] = (), ) -> tuple[Any, dict[str, Any] | None]: + """Issue an HTTP request and parse the JSON envelope. + + ``allow_statuses`` lets a caller opt specific non-2xx codes out of + the default raise-on-error path and receive the parsed body + instead. This is used by :meth:`batch_create`, where the API + returns a structured ``BatchCreateOutput`` on HTTP 400 (all items + rejected) — raising would drop the per-item errors. + """ url = f"{self._base_url}{path}" last_error: Exception | None = None @@ -437,7 +585,7 @@ def _raw_request( logger.debug("%s %s → %d", method, url, response.status_code) - if response.status_code < 400: + if response.status_code < 400 or response.status_code in allow_statuses: if response.status_code == 204 or not response.content: return None, None envelope = response.json() diff --git a/src/layerv_qurl/errors.py b/src/layerv_qurl/errors.py index 5dc1ddd..0d958e1 100644 --- a/src/layerv_qurl/errors.py +++ b/src/layerv_qurl/errors.py @@ -6,6 +6,10 @@ class QURLError(Exception): """Error raised for API-level errors (4xx/5xx responses). + Carries the full RFC 7807 Problem Details shape when the API provides it: + ``status``, ``code``, ``title``, ``detail``, plus the optional + ``type`` (problem-type URI) and ``instance`` (occurrence URI). + Catch specific subclasses for fine-grained handling:: try: @@ -18,6 +22,8 @@ class QURLError(Exception): print(f"Rate limited — retry in {e.retry_after}s") except QURLError as e: print(f"API error: {e.status} {e.code}") + if e.type: + print(f" problem type: {e.type}") """ def __init__( @@ -26,16 +32,24 @@ def __init__( status: int, code: str, title: str, - detail: str, + detail: str = "", + type: str | None = None, + instance: str | None = None, invalid_fields: dict[str, str] | None = None, request_id: str | None = None, retry_after: int | None = None, ) -> None: - super().__init__(f"{title} ({status}): {detail}") + # RFC 7807 leaves `detail` optional, and `title` is always present. + # Falling back to title keeps the Exception message meaningful + # instead of producing "Title (403): " with a trailing empty string. + message_detail = detail or title + super().__init__(f"{title} ({status}): {message_detail}") self.status = status self.code = code self.title = title - self.detail = detail + self.detail = message_detail + self.type = type + self.instance = instance self.invalid_fields = invalid_fields self.request_id = request_id self.retry_after = retry_after diff --git a/src/layerv_qurl/types.py b/src/layerv_qurl/types.py index 204090d..3609799 100644 --- a/src/layerv_qurl/types.py +++ b/src/layerv_qurl/types.py @@ -6,10 +6,21 @@ from datetime import datetime from typing import Literal -#: Valid QURL status values. Accepts known values for IDE autocomplete, -#: plus ``str`` for forward compatibility with new API statuses. +#: Valid resource-level status values. Resources only have two states per +#: the OpenAPI spec (``QurlData.status``) and the server code. Accepts known +#: values for IDE autocomplete, plus ``str`` for forward compatibility. QURLStatus = Literal["active", "revoked"] | str +#: Valid per-token status values. Wider than :data:`QURLStatus` because +#: individual access tokens can additionally be ``consumed`` (one-time use) +#: or ``expired``, per the OpenAPI spec (``QurlSummary.status``). +TokenStatus = Literal["active", "consumed", "expired", "revoked"] | str + +#: Valid subscription plan values. Matches the ``QuotaData.plan`` enum in +#: the OpenAPI spec (``[free, growth, enterprise]``). Accepts arbitrary +#: strings so the API can add new plans without a breaking SDK change. +QuotaPlan = Literal["free", "growth", "enterprise"] | str + def _parse_dt(s: str | None) -> datetime | None: """Parse an ISO 8601 datetime string, handling Z suffix for Python 3.10 compat.""" @@ -44,10 +55,15 @@ class AccessPolicy: @dataclass class AccessToken: - """An individual access token within a QURL.""" + """An individual access token within a QURL. + + ``status`` uses the wider :data:`TokenStatus` alias — tokens can be + ``active``/``consumed``/``expired``/``revoked`` (per ``QurlSummary.status`` + in the spec), while resources are only ``active``/``revoked``. + """ qurl_id: str - status: QURLStatus + status: TokenStatus one_time_use: bool = False max_sessions: int = 0 session_duration: int = 0 @@ -154,9 +170,14 @@ class Usage: @dataclass class Quota: - """Quota and usage information.""" + """Quota and usage information. + + ``plan`` is narrowed to the spec's ``QuotaData.plan`` enum via + :data:`QuotaPlan`. Accepts arbitrary strings so a new plan from the + API can't become a breaking type change. + """ - plan: str = "" + plan: QuotaPlan = "" period_start: datetime | None = None period_end: datetime | None = None rate_limits: RateLimits | None = None diff --git a/tests/test_client.py b/tests/test_client.py index bf5b64c..9d649d9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -154,6 +154,12 @@ def test_create(client: QURLClient) -> None: @respx.mock def test_create_sends_correct_body(client: QURLClient) -> None: + """create() serializes the full new-spec input shape into the request body. + + Covers the fields added in the v2 API alignment (label, one_time_use, + max_sessions, session_duration) so each has at least one regression + guard beyond the access_policy test. + """ route = respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 201, @@ -171,13 +177,19 @@ def test_create_sends_correct_body(client: QURLClient) -> None: client.create( target_url="https://example.com", expires_in="24h", - label="test", + label="Alice from Acme", + one_time_use=True, + max_sessions=5, + session_duration="1h", ) body = json.loads(route.calls[0].request.content) assert body == { "target_url": "https://example.com", "expires_in": "24h", - "label": "test", + "label": "Alice from Acme", + "one_time_use": True, + "max_sessions": 5, + "session_duration": "1h", } @@ -1407,16 +1419,18 @@ def test_update_with_tags(client: QURLClient) -> None: "target_url": "https://example.com", "status": "active", "created_at": "2026-03-10T10:00:00Z", - "tags": ["team:engineering", "env:prod"], + "tags": ["team engineering", "env-prod"], }, }, ) ) - result = client.update("r_abc", tags=["team:engineering", "env:prod"]) - assert result.tags == ["team:engineering", "env:prod"] + # Tags must match the API regex ^[a-zA-Z0-9][a-zA-Z0-9 _-]*$ — + # alphanumerics, spaces, underscores, and hyphens only. + result = client.update("r_abc", tags=["team engineering", "env-prod"]) + assert result.tags == ["team engineering", "env-prod"] body = json.loads(route.calls[0].request.content) - assert body == {"tags": ["team:engineering", "env:prod"]} + assert body == {"tags": ["team engineering", "env-prod"]} # --- Create with label and session_duration --- @@ -1618,7 +1632,7 @@ async def test_async_update_with_tags(async_client: AsyncQURLClient) -> None: def test_batch_create_empty_raises(client: QURLClient) -> None: - with pytest.raises(ValueError, match="must not be empty"): + with pytest.raises(ValueError, match="requires at least 1 item"): client.batch_create([]) @@ -1734,3 +1748,347 @@ def test_mint_link_nested_serialization_e2e(client: QURLClient) -> None: assert body["access_policy"]["geo_denylist"] == ["CN", "RU"] assert body["access_policy"]["ai_agent_policy"]["allow_categories"] == ["claude", "chatgpt"] assert "block_all" not in body["access_policy"]["ai_agent_policy"] # None fields omitted + + +# ---- Spec-derived input validation (create) -------------------------------- + + +def test_create_rejects_target_url_longer_than_2048(client: QURLClient) -> None: + with pytest.raises(ValueError, match="target_url"): + client.create(target_url="https://a.com/" + "x" * 2048) + + +def test_create_rejects_label_longer_than_500(client: QURLClient) -> None: + with pytest.raises(ValueError, match="label"): + client.create(target_url="https://example.com", label="x" * 501) + + +def test_create_rejects_custom_domain_longer_than_253(client: QURLClient) -> None: + with pytest.raises(ValueError, match="custom_domain"): + client.create( + target_url="https://example.com", + custom_domain="a" * 254, + ) + + +def test_create_rejects_max_sessions_above_1000(client: QURLClient) -> None: + with pytest.raises(ValueError, match="max_sessions"): + client.create(target_url="https://example.com", max_sessions=1001) + + +def test_create_rejects_negative_max_sessions(client: QURLClient) -> None: + with pytest.raises(ValueError, match="max_sessions"): + client.create(target_url="https://example.com", max_sessions=-1) + + +@respx.mock +def test_create_accepts_max_sessions_boundaries(client: QURLClient) -> None: + """max_sessions=0 (unlimited) and max_sessions=1000 (hard limit) are both valid.""" + respx.post(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_x", + "qurl_link": "https://qurl.link/#x", + "qurl_site": "https://x.qurl.site", + "qurl_id": "q_x", + }, + }, + ) + ) + client.create(target_url="https://example.com", max_sessions=0) + client.create(target_url="https://example.com", max_sessions=1000) + + +# ---- Spec-derived input validation (update) -------------------------------- + + +def test_update_rejects_description_longer_than_500(client: QURLClient) -> None: + with pytest.raises(ValueError, match="description"): + client.update("r_abc", description="x" * 501) + + +def test_update_rejects_more_than_10_tags(client: QURLClient) -> None: + with pytest.raises(ValueError, match="tags"): + client.update("r_abc", tags=[f"tag{i}" for i in range(11)]) + + +def test_update_rejects_tags_longer_than_50_chars(client: QURLClient) -> None: + with pytest.raises(ValueError, match="1-50 characters"): + client.update("r_abc", tags=["x" * 51]) + + +def test_update_rejects_tags_that_dont_match_pattern(client: QURLClient) -> None: + # Tags must start with an alphanumeric character. + with pytest.raises(ValueError, match="alphanumeric"): + client.update("r_abc", tags=["-leading-dash"]) + + +@respx.mock +def test_update_accepts_empty_tags_to_clear(client: QURLClient) -> None: + """Empty tag list clears all tags per the API spec.""" + respx.patch(f"{BASE_URL}/v1/qurls/r_abc").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "resource_id": "r_abc", + "target_url": "https://example.com", + "status": "active", + "tags": [], + "created_at": "2026-03-10T10:00:00Z", + }, + }, + ) + ) + result = client.update("r_abc", tags=[]) + assert result.tags == [] + + +def test_update_rejects_empty_input(client: QURLClient) -> None: + with pytest.raises(ValueError, match="at least one field"): + client.update("r_abc") + + +def test_update_rejects_both_extend_by_and_expires_at(client: QURLClient) -> None: + with pytest.raises(ValueError, match="mutually exclusive"): + client.update("r_abc", extend_by="24h", expires_at="2026-04-01T00:00:00Z") + + +# ---- Spec-derived input validation (mint_link) ----------------------------- + + +def test_mint_link_rejects_label_longer_than_500(client: QURLClient) -> None: + with pytest.raises(ValueError, match="label"): + client.mint_link("r_abc", label="x" * 501) + + +def test_mint_link_rejects_max_sessions_above_1000(client: QURLClient) -> None: + with pytest.raises(ValueError, match="max_sessions"): + client.mint_link("r_abc", max_sessions=5000) + + +def test_mint_link_rejects_both_expires_in_and_expires_at(client: QURLClient) -> None: + with pytest.raises(ValueError, match="mutually exclusive"): + client.mint_link( + "r_abc", + expires_in="7d", + expires_at="2026-04-01T00:00:00Z", + ) + + +# ---- delete() r_ prefix enforcement ---------------------------------------- + + +def test_delete_rejects_q_prefix_client_side(client: QURLClient) -> None: + """DELETE /v1/qurls/:id only accepts r_ prefix per the API spec.""" + with pytest.raises(ValueError, match="r_ prefix"): + client.delete("q_3a7f2c8e91b") + + +# ---- batch_create per-item validation & async validators ------------------- + + +def test_batch_create_rejects_per_item_violation_with_index(client: QURLClient) -> None: + """Per-item validation failures include the offending index.""" + with pytest.raises(ValueError, match=r"items\[1\].*max_sessions"): + client.batch_create( + [ + {"target_url": "https://a.example.com"}, + {"target_url": "https://b.example.com", "max_sessions": 9999}, + ] + ) + + +def test_batch_create_rejects_items_missing_target_url(client: QURLClient) -> None: + with pytest.raises(ValueError, match=r"items\[0\].*target_url"): + client.batch_create([{"label": "no url"}]) + + +@pytest.mark.asyncio +async def test_async_batch_create_empty_raises(async_client: AsyncQURLClient) -> None: + with pytest.raises(ValueError, match="requires at least 1 item"): + await async_client.batch_create([]) + + +@pytest.mark.asyncio +async def test_async_batch_create_over_100_raises(async_client: AsyncQURLClient) -> None: + items = [{"target_url": f"https://{i}.com"} for i in range(101)] + with pytest.raises(ValueError, match="at most 100"): + await async_client.batch_create(items) + + +# ---- batch_create HTTP 400 passthrough ------------------------------------- + + +@respx.mock +def test_batch_create_passes_through_400_with_per_item_errors( + client: QURLClient, +) -> None: + """HTTP 400 on batch/ carries a BatchCreateOutput body with per-item errors. + + The SDK whitelists 400 and surfaces the structured body instead of + raising a generic ValidationError — matching the qurl-mcp and + qurl-typescript behavior on this endpoint. + """ + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={ + "data": { + "succeeded": 0, + "failed": 2, + "results": [ + { + "index": 0, + "success": False, + "error": { + "code": "validation_error", + "message": "items[0]: target_url must be HTTPS", + }, + }, + { + "index": 1, + "success": False, + "error": { + "code": "validation_error", + "message": "items[1]: target_url must be HTTPS", + }, + }, + ], + }, + "meta": {"request_id": "req_allfail"}, + }, + ) + ) + result = client.batch_create( + [ + {"target_url": "https://ok1.example.com"}, + {"target_url": "https://ok2.example.com"}, + ] + ) + assert result.failed == 2 + assert result.succeeded == 0 + assert len(result.results) == 2 + assert result.results[0].success is False + assert result.results[0].error is not None + assert result.results[0].error.code == "validation_error" + + +@respx.mock +def test_batch_create_still_raises_on_401(client: QURLClient) -> None: + """Non-400 error statuses still raise — the 400 passthrough is surgical.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 401, + json={ + "error": { + "type": "https://api.qurl.link/problems/unauthorized", + "title": "Unauthorized", + "status": 401, + "code": "unauthorized", + "detail": "Invalid API key", + }, + }, + ) + ) + with pytest.raises(AuthenticationError): + client.batch_create([{"target_url": "https://example.com"}]) + + +# ---- Error type/instance exposure, detail fallback, legacy message --------- + + +@respx.mock +def test_error_surfaces_rfc7807_type_and_instance(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/qurls/r_nf0000000000").mock( + return_value=httpx.Response( + 404, + json={ + "error": { + "type": "https://api.qurl.link/problems/not_found", + "title": "Not Found", + "status": 404, + "detail": "QURL not found", + "instance": "/v1/qurls/r_nf0000000000", + "code": "not_found", + }, + "meta": {"request_id": "req_nf"}, + }, + ) + ) + with pytest.raises(NotFoundError) as excinfo: + client.get("r_nf0000000000") + err = excinfo.value + assert err.type == "https://api.qurl.link/problems/not_found" + assert err.instance == "/v1/qurls/r_nf0000000000" + assert err.request_id == "req_nf" + + +@respx.mock +def test_error_falls_back_to_title_when_detail_missing(client: QURLClient) -> None: + """RFC 7807 `detail` is optional — fall back to title.""" + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 403, + json={ + "error": { + "type": "https://api.qurl.link/problems/forbidden", + "title": "Forbidden", + "status": 403, + "code": "forbidden", + # no detail + }, + }, + ) + ) + with pytest.raises(AuthorizationError) as excinfo: + client.get_quota() + err = excinfo.value + assert err.detail == "Forbidden" + assert "None" not in str(err) + assert "undefined" not in str(err) + + +@respx.mock +def test_error_falls_back_to_legacy_message_field(client: QURLClient) -> None: + """Pre-RFC-7807 `{error: {code, message}}` envelope still works.""" + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 400, + json={ + "error": { + "code": "invalid_request", + "message": "legacy-format detail string", + }, + }, + ) + ) + with pytest.raises(ValidationError) as excinfo: + client.get_quota() + err = excinfo.value + assert err.detail == "legacy-format detail string" + + +# ---- parse_create_output normalizes empty qurl_id -------------------------- + + +@respx.mock +def test_create_normalizes_empty_qurl_id_to_none(client: QURLClient) -> None: + """Empty-string qurl_id is normalized to None so `if result.qurl_id:` works.""" + respx.post(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_abc", + "qurl_link": "https://qurl.link/#at_x", + "qurl_site": "https://r_abc.qurl.site", + "qurl_id": "", + }, + }, + ) + ) + result = client.create(target_url="https://example.com") + assert result.qurl_id is None From 39891e0e899e065fa0353e566a162bc308229c58 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 01:20:04 -0500 Subject: [PATCH 11/31] ci: make mypy gate robust; add langchain-core to dev extras MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes to ensure mypy errors always fail CI: 1. Move mypy into its own `type-check` job. The previous setup ran `mypy src/` as a step inside the `test` job but guarded it on `matrix.python-version == '3.12'` — if the matrix is ever restructured and 3.12 is dropped, the mypy gate silently vanishes. A dedicated job pins Python 3.10 (matching `python_version` in pyproject.toml, which is the strictness contract the SDK promises consumers) and always runs. The `notify` job now depends on both `test` and `type-check`, so a mypy failure fails the overall build. Also added `fail-fast: false` to the test matrix so a single failing Python version doesn't cancel sibling legs — full visibility into which versions broke. 2. Add `langchain-core` to the `[dev]` extra. The optional langchain integration was only installed via `[langchain]`, so developers running `pip install -e ".[dev]"` locally (without the `langchain` extra) got spurious mypy errors about missing modules and unused type-ignore comments in `src/layerv_qurl/langchain.py`. CI already installs `[dev,langchain]`, but local envs drifted. Pulling langchain-core into `[dev]` aligns local dev with CI so mypy runs clean in both places. Verified: `mypy src/` reports 0 issues across all 7 source files; `pytest tests/` now runs all 108 tests (the 7 previously-skipped langchain tests now execute since langchain-core is present). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++---- pyproject.toml | 6 ++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e34bc6..fed0378 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,9 @@ jobs: test: runs-on: ubuntu-latest strategy: + # Don't cancel sibling matrix legs if one fails — we want full + # visibility into which Python versions broke. + fail-fast: false matrix: python-version: ["3.10", "3.12", "3.13"] steps: @@ -22,18 +25,40 @@ jobs: run: pip install -e ".[dev,langchain]" - name: Lint run: ruff check - - name: Type check - if: matrix.python-version == '3.12' - run: mypy src/ - name: Test run: pytest tests/ -v + # ============================================================================ + # TYPE CHECK - Dedicated mypy job + # ============================================================================ + # Runs in its own job so the gate can't silently vanish if the test + # matrix is restructured. Pins to the minimum supported Python version + # (matching `python_version` in pyproject.toml) so the strictness stays + # aligned with what the SDK promises to consumers. + type-check: + name: Type check (mypy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up Python 3.10 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.10" + - name: Install dependencies + # Use the full dev+langchain extras so mypy sees the optional + # langchain integration's real types. Without langchain-core + # installed, mypy reports spurious errors about missing modules + # and unused type-ignore comments in src/layerv_qurl/langchain.py. + run: pip install -e ".[dev,langchain]" + - name: Run mypy + run: mypy src/ + # ============================================================================ # NOTIFY - Send Slack notification on build completion # ============================================================================ notify: name: Notify - needs: [test] + needs: [test, type-check] if: always() && github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/pyproject.toml b/pyproject.toml index 3df1da7..80e451d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,12 @@ dev = [ "respx>=0.22", "ruff>=0.11", "mypy>=1.14", + # Pull the optional langchain integration into the default dev install + # so `pip install -e ".[dev]"` gives contributors the same mypy/test + # environment CI uses (which installs ".[dev,langchain]"). Without this, + # running mypy locally without langchain-core produces spurious errors + # about a missing module and an unused type-ignore in langchain.py. + "langchain-core>=0.3,<2", ] [project.urls] From c7c9ceb20653d24d96e520c4da7fb441228f4c62 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 01:31:21 -0500 Subject: [PATCH 12/31] =?UTF-8?q?fix:=20address=20latest=20review=20?= =?UTF-8?q?=E2=80=94=20batch=20shape=20guard,=20tests,=20docs,=20url=20che?= =?UTF-8?q?ck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten items from the latest PR review. Reviewer marked #1 as blocking and #8 + #10 as strongly recommended; the rest picked up on the "don't be lazy" directive. 1. batch_create 400 shape guard (reviewer's blocking item). _utils.py gains _validate_batch_create_shape() which verifies that a passthrough 400 body has the expected BatchCreateOutput envelope (succeeded/failed are ints, results is a list, each entry carries a boolean success discriminant). If the API ever returns 400 with a different body (plain error envelope, proxy error, malformed JSON), batch_create now raises a QURLError with status=0 and code="unexpected_response" instead of silently returning (succeeded=0, failed=0, results=[]). Defense in depth matches the qurl-typescript fix. Wired into both client.py and async_client.py. 2. QURLError docstring now documents that .detail is guaranteed non-empty at the instance level. The constructor falls back to title when the API omits detail per RFC 7807, so consumers shouldn't inspect .detail to detect "was it absent?" — use .code / .status / .type instead. 3. QURLError docstring now explains why .type shadows Python's built-in. Intentional for RFC 7807 field-name parity and consistency with qurl-typescript/qurl-mcp; the shadowing only matters inside QURLError method definitions, not external code. 4. target_url scheme check in validate_create_input. Reviewer's observation that the length check didn't catch the most common mistake (forgetting http(s)://). New _ALLOWED_URL_SCHEMES tuple with a startswith() guard; the server still owns SSRF validation. 5. Sync/async parity comment added to client.py's module docstring (async_client.py already had one). Calls out the contract so a future change can't silently update one client without the other. 6. Tag regex comment expanded with a note about keeping it in lockstep with the openapi.yaml schema, and why. 7. Quota.plan empty-string default now documented — it only exists so the dataclass can be instantiated with no arguments for tests/ bootstrap paths; the real /v1/quota endpoint always returns a populated plan. Tests (108 -> 116): - test_get_response_parses_nested_ai_agent_policy (reviewer gap #8) — mocks a GET response with a fully-populated ai_agent_policy inside a token's access_policy and asserts the deserialization round-trip. - test_list_serializes_datetime_filter_params_as_isoformat (reviewer gap #9) — passes an actual datetime to client.list(created_after=) and asserts the URL-encoded ISO 8601 output. - test_async_delete_rejects_q_prefix_client_side (reviewer gap #10) — async symmetry for the existing sync delete() q_ prefix test. - test_create_rejects_target_url_without_scheme — the new URL scheme check catches bare "example.com". - test_create_rejects_target_url_with_unsupported_scheme — rejects ftp:// etc. - test_create_accepts_http_and_https_schemes — both valid schemes pass. - test_batch_create_rejects_unexpected_400_body_shape — defense-in- depth for the new _validate_batch_create_shape. - test_batch_create_rejects_400_body_with_non_boolean_success — the per-entry discriminant check. Also updated three existing tests that depended on the pre-URL-check create() accepting invalid URLs: - test_422_raises_validation_error - test_400_raises_validation_error - test_batch_create_partial_failure Each now uses a syntactically valid URL that passes client-side validation; the mocked API response payload is unchanged, so the tests still exercise the API error-parsing paths they intended to. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/_utils.py | 77 +++++++++++++- src/layerv_qurl/async_client.py | 7 ++ src/layerv_qurl/client.py | 13 ++- src/layerv_qurl/errors.py | 22 +++- src/layerv_qurl/types.py | 9 ++ tests/test_client.py | 180 +++++++++++++++++++++++++++++++- 6 files changed, 299 insertions(+), 9 deletions(-) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index fb52663..8943677 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -138,9 +138,18 @@ def build_body(kwargs: dict[str, Any]) -> dict[str, Any]: MAX_MAX_SESSIONS = 1000 MAX_TAGS = 10 MAX_TAG_LENGTH = 50 -# UpdateQurlRequest.tags item pattern from openapi.yaml. +# Tag item pattern mirrored from the OpenAPI spec schema +# `UpdateQurlRequest.tags.items.pattern` in qurl/api/openapi.yaml. +# Keep in lockstep with the spec — if the server pattern ever relaxes +# (e.g. allowing colons) the SDK must widen this regex or it will +# reject strings the API would otherwise accept. _TAG_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9 _-]*$") RESOURCE_ID_PREFIX = "r_" +# target_url must use an http(s) scheme per the API's SSRF protection. +# This is a cheap client-side sanity check — the server is still the +# authoritative validator (e.g. it rejects localhost, cloud metadata, +# and private-range hosts; the SDK doesn't need to duplicate that). +_ALLOWED_URL_SCHEMES = ("http://", "https://") def _require_max_length(value: str | None, field_name: str, maximum: int) -> None: @@ -192,6 +201,10 @@ def validate_create_input( Raises ``ValueError`` on constraint violations so obvious mistakes fail fast instead of round-tripping to the API. """ + if not isinstance(target_url, str) or not target_url.startswith(_ALLOWED_URL_SCHEMES): + raise ValueError( + f"target_url: must start with http:// or https:// (got {target_url[:32]!r})" + ) _require_max_length(target_url, "target_url", MAX_TARGET_URL) _require_max_length(label, "label", MAX_LABEL) _require_max_length(custom_domain, "custom_domain", MAX_CUSTOM_DOMAIN) @@ -385,8 +398,68 @@ def parse_list_output(data: Any, meta: dict[str, Any] | None) -> ListOutput: ) +def _validate_batch_create_shape(data: Any) -> None: + """Defense-in-depth structural check for batch_create responses. + + The ``batch_create`` endpoint whitelists HTTP 400 into the success + path so the populated ``BatchCreateOutput`` body is surfaced instead + of being swallowed by the generic error path. If the API ever returns + 400 with a *different* body (e.g., a plain validation error envelope, + a proxy error, or malformed JSON), ``parse_batch_create_output`` would + silently produce ``(succeeded=0, failed=0, results=[])`` via its + ``.get()`` defaults — the caller would get no indication anything went + wrong. + + Raise a clear error instead when the shape doesn't match. The error + message intentionally does not embed the raw body — an unexpected + body could contain sensitive data (auth details, request echoes) + and error strings may end up in client-side logs. + """ + if not isinstance(data, dict): + raise QURLError( + status=0, + code="unexpected_response", + title="Unexpected Response", + detail="Unexpected response shape from POST /v1/qurls/batch", + ) + if not isinstance(data.get("succeeded"), int) or not isinstance( + data.get("failed"), int + ): + raise QURLError( + status=0, + code="unexpected_response", + title="Unexpected Response", + detail="Unexpected response shape from POST /v1/qurls/batch", + ) + if not isinstance(data.get("results"), list): + raise QURLError( + status=0, + code="unexpected_response", + title="Unexpected Response", + detail="Unexpected response shape from POST /v1/qurls/batch", + ) + # Each entry must carry a boolean `success` discriminant so consumers + # can reliably branch on it — anything else would break the + # BatchItemResult contract. Deeper per-field validation is + # intentionally left to the API. + for entry in data["results"]: + if not isinstance(entry, dict) or not isinstance(entry.get("success"), bool): + raise QURLError( + status=0, + code="unexpected_response", + title="Unexpected Response", + detail="Unexpected response shape from POST /v1/qurls/batch", + ) + + def parse_batch_create_output(data: dict[str, Any]) -> BatchCreateOutput: - """Parse a BatchCreateOutput from API response data.""" + """Parse a BatchCreateOutput from API response data. + + Callers must validate the response shape via + :func:`_validate_batch_create_shape` before invoking this — this + function assumes a well-formed envelope and will silently produce + an empty result on a malformed body. + """ results: list[BatchItemResult] = [] for item in data.get("results", []): err = None diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index fbbf29e..0634587 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -16,6 +16,7 @@ DEFAULT_TIMEOUT, RETRYABLE_STATUS, RETRYABLE_STATUS_POST, + _validate_batch_create_shape, build_body, build_list_params, default_user_agent, @@ -487,6 +488,12 @@ async def batch_create( body={"items": serialized}, allow_statuses=(400,), ) + # Defense-in-depth: the 400 passthrough trusts the response shape, + # but if the API ever returns 400 with a non-BatchCreateOutput body + # (e.g., a plain error envelope or malformed JSON) we'd silently + # get an empty result. Verify the shape before parsing and raise + # a clear error otherwise. + _validate_batch_create_shape(resp) return parse_batch_create_output(resp) async def resolve(self, access_token: str) -> ResolveOutput: diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index ba6ff14..fbe5d3a 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -1,4 +1,8 @@ -"""Synchronous QURL API client.""" +"""Synchronous QURL API client. + +NOTE: Business logic mirrors async_client.py — keep both in sync. Input +validation, body construction, and error handling must match exactly. +""" from __future__ import annotations @@ -13,6 +17,7 @@ DEFAULT_TIMEOUT, RETRYABLE_STATUS, RETRYABLE_STATUS_POST, + _validate_batch_create_shape, build_body, build_list_params, default_user_agent, @@ -498,6 +503,12 @@ def batch_create( body={"items": serialized}, allow_statuses=(400,), ) + # Defense-in-depth: the 400 passthrough trusts the response shape, + # but if the API ever returns 400 with a non-BatchCreateOutput body + # (e.g., a plain error envelope or malformed JSON) we'd silently + # get an empty result. Verify the shape before parsing and raise + # a clear error otherwise. + _validate_batch_create_shape(resp) return parse_batch_create_output(resp) def resolve(self, access_token: str) -> ResolveOutput: diff --git a/src/layerv_qurl/errors.py b/src/layerv_qurl/errors.py index 0d958e1..e59774e 100644 --- a/src/layerv_qurl/errors.py +++ b/src/layerv_qurl/errors.py @@ -6,10 +6,28 @@ class QURLError(Exception): """Error raised for API-level errors (4xx/5xx responses). - Carries the full RFC 7807 Problem Details shape when the API provides it: - ``status``, ``code``, ``title``, ``detail``, plus the optional + Carries the full RFC 7807 Problem Details shape when the API provides + it: ``status``, ``code``, ``title``, ``detail``, plus the optional ``type`` (problem-type URI) and ``instance`` (occurrence URI). + .. note:: + + ``detail`` is **always non-empty** on the instance — the + constructor falls back to ``title`` when the API omits detail + (RFC 7807 allows this). Use ``code`` / ``status`` / ``type`` to + distinguish between error cases rather than inspecting ``detail`` + for the "was it absent?" signal. + + .. note:: + + ``type`` shadows Python's built-in ``type()`` inside method + bodies. This is intentional — the name mirrors the RFC 7807 field + name and matches the other SDKs (``qurl-typescript``, + ``qurl-mcp``). The shadowing only matters inside ``QURLError`` + method definitions; external code can still use ``type(err)`` + safely since attribute access doesn't shadow the builtin in that + scope. + Catch specific subclasses for fine-grained handling:: try: diff --git a/src/layerv_qurl/types.py b/src/layerv_qurl/types.py index 3609799..62f356a 100644 --- a/src/layerv_qurl/types.py +++ b/src/layerv_qurl/types.py @@ -175,6 +175,15 @@ class Quota: ``plan`` is narrowed to the spec's ``QuotaData.plan`` enum via :data:`QuotaPlan`. Accepts arbitrary strings so a new plan from the API can't become a breaking type change. + + .. note:: + + ``plan`` defaults to the empty string ``""`` purely so the + dataclass can be instantiated with no arguments (this only + happens in tests and internal bootstrap paths). In practice the + ``/v1/quota`` endpoint always returns a populated plan string, + so consumers comparing ``quota.plan == "free"`` on a real + response will see one of the documented enum values. """ plan: QuotaPlan = "" diff --git a/tests/test_client.py b/tests/test_client.py index 9d649d9..c19a949 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1070,6 +1070,10 @@ def test_404_raises_not_found_error(client: QURLClient) -> None: @respx.mock def test_422_raises_validation_error(client: QURLClient) -> None: + """The API's 422 path is exercised by sending a syntactically valid URL + that passes client-side checks but is rejected by the API (e.g. a + host that fails SSRF protection). The mock returns 422 regardless of + the request body, so any valid-scheme URL works here.""" respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 422, @@ -1086,7 +1090,7 @@ def test_422_raises_validation_error(client: QURLClient) -> None: ) with pytest.raises(ValidationError) as exc_info: - client.create(target_url="not-a-url") + client.create(target_url="https://localhost/rejected-by-ssrf-protection") assert exc_info.value.invalid_fields == {"target_url": "must be a valid URL"} @@ -1135,6 +1139,8 @@ def test_500_raises_server_error(client: QURLClient) -> None: @respx.mock def test_400_raises_validation_error(client: QURLClient) -> None: + """Exercise the API's 400 path with a URL that passes client-side + validation — the mock returns 400 regardless of the request body.""" respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 400, @@ -1150,7 +1156,7 @@ def test_400_raises_validation_error(client: QURLClient) -> None: ) with pytest.raises(ValidationError): - client.create(target_url="") + client.create(target_url="https://example.com/triggers-mocked-400") # --- extend() convenience method --- @@ -1290,10 +1296,13 @@ def test_batch_create_partial_failure(client: QURLClient) -> None: ) ) + # Both URLs must pass client-side validation (syntactically valid + # https://) — the mock returns a partial-failure payload regardless + # of what's in the request body, so we're exercising the parser. result = client.batch_create( [ - {"target_url": "https://good.com"}, - {"target_url": "bad"}, + {"target_url": "https://good.example.com"}, + {"target_url": "https://bad.example.com"}, ] ) assert result.succeeded == 1 @@ -2092,3 +2101,166 @@ def test_create_normalizes_empty_qurl_id_to_none(client: QURLClient) -> None: ) result = client.create(target_url="https://example.com") assert result.qurl_id is None + + +# ---- Target URL scheme validation (create) -------------------------------- + + +def test_create_rejects_target_url_without_scheme(client: QURLClient) -> None: + """Client-side URL scheme check catches the common 'forgot http://' mistake.""" + with pytest.raises(ValueError, match="http:// or https://"): + client.create(target_url="example.com") + + +def test_create_rejects_target_url_with_unsupported_scheme(client: QURLClient) -> None: + with pytest.raises(ValueError, match="http:// or https://"): + client.create(target_url="ftp://files.example.com") + + +def test_create_accepts_http_and_https_schemes(client: QURLClient) -> None: + """Both http:// and https:// pass the client-side check.""" + import layerv_qurl._utils as utils + + # Direct check — doesn't need a mocked HTTP response. + utils.validate_create_input(target_url="http://example.com") + utils.validate_create_input(target_url="https://example.com") + + +# ---- _parse_access_policy deserialization (reviewer gap #8) ---------------- + + +@respx.mock +def test_get_response_parses_nested_ai_agent_policy(client: QURLClient) -> None: + """GET responses with a populated ai_agent_policy inside an access_policy + on a token should deserialize cleanly. Serialization of this shape is + covered elsewhere; this test closes the deserialization loop for the + _parse_access_policy changes in this PR. + """ + respx.get(f"{BASE_URL}/v1/qurls/r_abc123def45").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "resource_id": "r_abc123def45", + "target_url": "https://example.com", + "status": "active", + "created_at": "2026-03-10T10:00:00Z", + "qurl_count": 1, + "qurls": [ + { + "qurl_id": "q_3a7f2c8e91b", + "status": "active", + "one_time_use": False, + "max_sessions": 5, + "session_duration": 3600, + "use_count": 0, + "created_at": "2026-03-10T10:00:00Z", + "expires_at": "2026-03-17T10:00:00Z", + "access_policy": { + "ip_allowlist": ["10.0.0.0/8"], + "geo_allowlist": ["US", "CA"], + "ai_agent_policy": { + "block_all": False, + "deny_categories": ["gptbot", "commoncrawl"], + "allow_categories": ["claude", "perplexity"], + }, + }, + }, + ], + }, + }, + ) + ) + qurl = client.get("r_abc123def45") + assert qurl.access_tokens is not None + assert len(qurl.access_tokens) == 1 + token = qurl.access_tokens[0] + assert token.access_policy is not None + assert token.access_policy.ip_allowlist == ["10.0.0.0/8"] + assert token.access_policy.geo_allowlist == ["US", "CA"] + # The big one: ai_agent_policy nested inside access_policy must parse. + assert token.access_policy.ai_agent_policy is not None + ai_policy = token.access_policy.ai_agent_policy + assert ai_policy.block_all is False + assert ai_policy.deny_categories == ["gptbot", "commoncrawl"] + assert ai_policy.allow_categories == ["claude", "perplexity"] + + +# ---- datetime list filter params (reviewer gap #9) ------------------------ + + +@respx.mock +def test_list_serializes_datetime_filter_params_as_isoformat( + client: QURLClient, +) -> None: + """build_list_params handles datetime via .isoformat() — exercise that + branch explicitly (existing tests only pass string timestamps).""" + route = respx.get(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 200, + json={"data": [], "meta": {"has_more": False}}, + ) + ) + cutoff = datetime(2026, 3, 1, 0, 0, 0) + client.list(created_after=cutoff) + + called_url = str(route.calls[0].request.url) + # urlencode-safe ISO 8601 — check the raw URL for the encoded value. + assert "created_after=2026-03-01T00%3A00%3A00" in called_url + + +# ---- Async delete q_ prefix rejection (reviewer gap #10) ------------------ + + +@pytest.mark.asyncio +async def test_async_delete_rejects_q_prefix_client_side( + async_client: AsyncQURLClient, +) -> None: + """Sync test exists; async symmetry gap closed.""" + with pytest.raises(ValueError, match="r_ prefix"): + await async_client.delete("q_3a7f2c8e91b") + + +# ---- batch_create response shape guard (defense-in-depth) ----------------- + + +@respx.mock +def test_batch_create_rejects_unexpected_400_body_shape(client: QURLClient) -> None: + """The 400 passthrough trusts the BatchCreateOutput shape. If the API + ever returns 400 with a different body (e.g. a plain error envelope + or a proxy error), the SDK must raise a clear error instead of + silently producing `(succeeded=0, failed=0, results=[])`. Defense + in depth — matches qurl-typescript and qurl-mcp. + """ + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={"data": {"unexpected": "not a batch envelope"}}, + ) + ) + with pytest.raises(QURLError, match="Unexpected response shape"): + client.batch_create([{"target_url": "https://example.com"}]) + + +@respx.mock +def test_batch_create_rejects_400_body_with_non_boolean_success( + client: QURLClient, +) -> None: + """Per-entry shape guard: results[i].success must be a bool for the + BatchItemResult discriminated-union contract to hold.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={ + "data": { + "succeeded": 0, + "failed": 1, + "results": [ + {"index": 0, "success": "oops"}, # should be bool + ], + }, + }, + ) + ) + with pytest.raises(QURLError, match="Unexpected response shape"): + client.batch_create([{"target_url": "https://example.com"}]) From 3f4ed746983aac55e8a5a74b5c44598db9a22ccc Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 01:42:18 -0500 Subject: [PATCH 13/31] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20exp?= =?UTF-8?q?orts,=20retry=20tests,=20CI=20notify,=20shape-guard=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export TokenStatus and QuotaPlan from the package root so consumers can type-annotate without reaching into layerv_qurl.types. - Consolidate langchain-core version constraint via the `layerv-qurl[langchain]` self-reference in `[dev]` extras so there is a single source of truth. - Refactor _validate_batch_create_shape to construct its error via a local helper, eliminating five duplicated QURLError constructions. - Comment the bool-vs-int check in _require_max_sessions_in_range so the reason for the explicit bool rejection is obvious to future readers (bool is a subclass of int in Python). - Add POST retry safety tests (sync + async) that lock in RETRYABLE_STATUS_POST = {429} — a 503 on create() must not retry, and a 429 on create() must retry. Prevents a future refactor from silently unifying retry sets across methods and risking duplicate record creation. - Update the CI notify job to aggregate `test.result` and `type-check.result` into a single status so a mypy failure is reflected in Slack instead of being masked by a green test matrix. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 30 +++++++++++++--- pyproject.toml | 11 +++--- src/layerv_qurl/__init__.py | 4 +++ src/layerv_qurl/_utils.py | 35 ++++++++---------- tests/test_client.py | 71 +++++++++++++++++++++++++++++++++++++ 5 files changed, 122 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fed0378..a0c52b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,7 @@ jobs: env: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} TEST_RESULT: ${{ needs.test.result }} + TYPE_CHECK_RESULT: ${{ needs.type-check.result }} COMMIT_MESSAGE: ${{ github.event.head_commit.message }} # All github context moved to env to prevent script injection # via crafted branch names or actor names containing shell metacharacters. @@ -102,14 +103,27 @@ jobs: *) TRIGGER_TEXT="$TRIGGER" ;; esac + # Aggregate status across both gates (test matrix + mypy). A + # mypy failure is a red build even if every python matrix leg + # passed, so the Slack message must reflect the worst of the two. + if [[ "$TEST_RESULT" == "success" && "$TYPE_CHECK_RESULT" == "success" ]]; then + AGGREGATE="success" + elif [[ "$TEST_RESULT" == "failure" || "$TYPE_CHECK_RESULT" == "failure" ]]; then + AGGREGATE="failure" + elif [[ "$TEST_RESULT" == "cancelled" || "$TYPE_CHECK_RESULT" == "cancelled" ]]; then + AGGREGATE="cancelled" + else + AGGREGATE="incomplete" + fi + # Determine status - if [[ "$TEST_RESULT" == "success" ]]; then + if [[ "$AGGREGATE" == "success" ]]; then COLOR="#36a64f"; EMOJI="white_check_mark" HEADER="QURL Python SDK Build"; STATUS_TEXT="successful" - elif [[ "$TEST_RESULT" == "failure" ]]; then + elif [[ "$AGGREGATE" == "failure" ]]; then COLOR="#dc3545"; EMOJI="x" HEADER="QURL Python SDK Build"; STATUS_TEXT="failed" - elif [[ "$TEST_RESULT" == "cancelled" ]]; then + elif [[ "$AGGREGATE" == "cancelled" ]]; then COLOR="#ffc107"; EMOJI="warning" HEADER="QURL Python SDK Build"; STATUS_TEXT="cancelled" else @@ -117,12 +131,18 @@ jobs: HEADER="QURL Python SDK"; STATUS_TEXT="incomplete" fi - # Determine test emoji + # Determine per-gate emoji so the Slack message shows which + # gate failed when the aggregate is red. case "$TEST_RESULT" in success) TEST_EMOJI=":white_check_mark:" ;; failure) TEST_EMOJI=":x:" ;; *) TEST_EMOJI=":warning:" ;; esac + case "$TYPE_CHECK_RESULT" in + success) TYPE_CHECK_EMOJI=":white_check_mark:" ;; + failure) TYPE_CHECK_EMOJI=":x:" ;; + *) TYPE_CHECK_EMOJI=":warning:" ;; + esac # Extract first line of commit message (safe for any content) FIRST_LINE=$(echo "$COMMIT_MESSAGE" | head -n1 | cut -c1-100) @@ -139,6 +159,7 @@ jobs: --arg trigger "$TRIGGER_TEXT" \ --arg actor "$GH_ACTOR" \ --arg test_emoji "$TEST_EMOJI" \ + --arg type_check_emoji "$TYPE_CHECK_EMOJI" \ --arg commit "$FIRST_LINE" \ --arg commit_url "$COMMIT_URL" \ --arg workflow_url "$WORKFLOW_URL" \ @@ -148,6 +169,7 @@ jobs: {type: "mrkdwn", text: ("*Branch:*\n`\($branch)` (\($sha))")}, {type: "mrkdwn", text: ("*Trigger:*\n\($trigger) by \($actor)")}, {type: "mrkdwn", text: ("*Tests:*\n\($test_emoji)")}, + {type: "mrkdwn", text: ("*Type check:*\n\($type_check_emoji)")}, {type: "mrkdwn", text: "*Python:*\n3.10, 3.12, 3.13"} ]}, {type: "section", text: {type: "mrkdwn", text: ("*Commit:* `\($commit)`")}}, diff --git a/pyproject.toml b/pyproject.toml index 80e451d..a40e598 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,11 +37,12 @@ dev = [ "ruff>=0.11", "mypy>=1.14", # Pull the optional langchain integration into the default dev install - # so `pip install -e ".[dev]"` gives contributors the same mypy/test - # environment CI uses (which installs ".[dev,langchain]"). Without this, - # running mypy locally without langchain-core produces spurious errors - # about a missing module and an unused type-ignore in langchain.py. - "langchain-core>=0.3,<2", + # via the self-reference pattern. Single source of truth for the + # langchain-core version constraint lives in the `[langchain]` extra + # above — updating it once keeps dev and CI in lockstep. Without + # langchain-core, local mypy produces spurious errors about a missing + # module and an unused type-ignore in langchain.py. + "layerv-qurl[langchain]", ] [project.urls] diff --git a/src/layerv_qurl/__init__.py b/src/layerv_qurl/__init__.py index 9d18d73..c4d7b32 100644 --- a/src/layerv_qurl/__init__.py +++ b/src/layerv_qurl/__init__.py @@ -29,9 +29,11 @@ ListOutput, MintOutput, Quota, + QuotaPlan, QURLStatus, RateLimits, ResolveOutput, + TokenStatus, Usage, ) @@ -53,7 +55,9 @@ "BatchCreateOutput", "BatchItemError", "BatchItemResult", + "QuotaPlan", "QURLStatus", + "TokenStatus", "AccessGrant", "AccessPolicy", "AccessToken", diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 8943677..66b3173 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -162,6 +162,10 @@ def _require_max_length(value: str | None, field_name: str, maximum: int) -> Non def _require_max_sessions_in_range(value: int | None) -> None: if value is None: return + # `bool` is a subclass of `int` in Python (True == 1, False == 0), so + # a caller passing `max_sessions=True` would sneak through an + # `isinstance(value, int)` check alone. Reject bool explicitly so + # obvious type confusion fails loudly instead of silently meaning 1. if not isinstance(value, int) or isinstance(value, bool): raise ValueError(f"max_sessions: must be an integer (got {type(value).__name__})") if value < 0 or value > MAX_MAX_SESSIONS: @@ -415,41 +419,32 @@ def _validate_batch_create_shape(data: Any) -> None: body could contain sensitive data (auth details, request echoes) and error strings may end up in client-side logs. """ - if not isinstance(data, dict): - raise QURLError( + + def _unexpected_shape_error() -> QURLError: + # Single source of truth for the error constructed at every + # check failure — keeps status/code/title/detail aligned. + return QURLError( status=0, code="unexpected_response", title="Unexpected Response", detail="Unexpected response shape from POST /v1/qurls/batch", ) + + if not isinstance(data, dict): + raise _unexpected_shape_error() if not isinstance(data.get("succeeded"), int) or not isinstance( data.get("failed"), int ): - raise QURLError( - status=0, - code="unexpected_response", - title="Unexpected Response", - detail="Unexpected response shape from POST /v1/qurls/batch", - ) + raise _unexpected_shape_error() if not isinstance(data.get("results"), list): - raise QURLError( - status=0, - code="unexpected_response", - title="Unexpected Response", - detail="Unexpected response shape from POST /v1/qurls/batch", - ) + raise _unexpected_shape_error() # Each entry must carry a boolean `success` discriminant so consumers # can reliably branch on it — anything else would break the # BatchItemResult contract. Deeper per-field validation is # intentionally left to the API. for entry in data["results"]: if not isinstance(entry, dict) or not isinstance(entry.get("success"), bool): - raise QURLError( - status=0, - code="unexpected_response", - title="Unexpected Response", - detail="Unexpected response shape from POST /v1/qurls/batch", - ) + raise _unexpected_shape_error() def parse_batch_create_output(data: dict[str, Any]) -> BatchCreateOutput: diff --git a/tests/test_client.py b/tests/test_client.py index c19a949..5efa6fb 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -707,6 +707,77 @@ def test_retry_after_capped_at_30s(retry_client: QURLClient) -> None: mock_sleep.assert_called_once_with(30.0) +# --- POST retry safety --- +# POST is non-idempotent (create() might actually hit the DB even if the +# response is 5xx), so the client deliberately restricts POST retries to +# {429} only — retrying a 5xx on a create request risks duplicate records. +# These tests lock that decision in so a future refactor that naively +# unifies retry sets across methods will trip the guard. + + +@respx.mock +def test_post_does_not_retry_on_503(retry_client: QURLClient) -> None: + """POST /v1/qurls must not retry on 5xx even when retries are configured.""" + route = respx.post(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response(503, json=_ERR_503), + ) + + with patch("layerv_qurl.client.time.sleep"), pytest.raises(QURLError) as exc_info: + retry_client.create(target_url="https://example.com", expires_in="24h") + + assert exc_info.value.status == 503 + # retry_client has max_retries=2, so a retry-on-503 regression would + # produce 3 attempts. Assert exactly one — no retries for POST 5xx. + assert route.call_count == 1 + + +@respx.mock +def test_post_still_retries_on_429(retry_client: QURLClient) -> None: + """POST retries on 429 specifically (rate limits are safe to retry).""" + route = respx.post(f"{BASE_URL}/v1/qurls") + route.side_effect = [ + httpx.Response(429, json=_ERR_429), + httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_abc123def45", + "qurl_link": "https://qurl.link/#at_test", + "qurl_site": "https://r_abc123def45.qurl.site", + "qurl_id": "q_abc", + }, + }, + ), + ] + + with patch("layerv_qurl.client.time.sleep"): + result = retry_client.create(target_url="https://example.com", expires_in="24h") + + assert result.resource_id == "r_abc123def45" + assert route.call_count == 2 + + +@respx.mock +async def test_async_post_does_not_retry_on_503() -> None: + """Async POST must also not retry on 5xx — sync/async parity guard.""" + client = AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=2) + try: + route = respx.post(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response(503, json=_ERR_503), + ) + + with ( + patch("layerv_qurl.async_client.asyncio.sleep"), + pytest.raises(QURLError) as exc_info, + ): + await client.create(target_url="https://example.com", expires_in="24h") + + assert exc_info.value.status == 503 + assert route.call_count == 1 + finally: + await client.close() + + # --- Non-JSON error --- From 51e587a76f6ffe628f405c799adbdaac557f7193 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 01:53:30 -0500 Subject: [PATCH 14/31] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20saf?= =?UTF-8?q?e=20repr,=20bool=20shape=20guard,=20docstring,=20type=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validate_create_input: use `repr(target_url)[:40]` instead of `target_url[:32]!r` in the error message. The previous form would raise `TypeError` on any non-subscriptable input (None, int, bool, bytes) before the ValueError could surface, masking the real validation failure with a cryptic slicing error. Added a regression test covering None/int/bool/bytes. - _validate_batch_create_shape: explicitly reject `bool` for the `succeeded` / `failed` fields. Because `bool` is a subclass of `int` in Python, a response like `{"succeeded": True}` would silently pass an `isinstance(..., int)` check. Matches the same guard already applied to `_require_max_sessions_in_range`. Added a regression test. - client.py / async_client.py: strengthen the `update(tags=...)` docstring to spell out that `tags=[]` is always a REPLACE-with-empty (i.e. a clear operation, never "no change") and `tags=None` is "leave unchanged". No semantic change — the code already did this, the docstring just made it easy to misread. - build_list_params: reorder the pairs dict type annotation from `int | str | datetime | None` to `datetime | int | str | None` so the union runs most-specific → least-specific. Pure cosmetic. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/_utils.py | 24 ++++++++++++++++---- src/layerv_qurl/async_client.py | 7 ++++-- src/layerv_qurl/client.py | 7 ++++-- tests/test_client.py | 39 +++++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 66b3173..d964a34 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -206,8 +206,13 @@ def validate_create_input( fail fast instead of round-tripping to the API. """ if not isinstance(target_url, str) or not target_url.startswith(_ALLOWED_URL_SCHEMES): + # `repr(...)[:40]` instead of `target_url[:32]!r` — the original + # subscript would raise `TypeError` on any non-subscriptable + # input (None, int, bool, …) *before* the ValueError could + # surface, masking the real validation failure with a cryptic + # slicing error. `repr()` works on any object. raise ValueError( - f"target_url: must start with http:// or https:// (got {target_url[:32]!r})" + f"target_url: must start with http:// or https:// (got {repr(target_url)[:40]})" ) _require_max_length(target_url, "target_url", MAX_TARGET_URL) _require_max_length(label, "label", MAX_LABEL) @@ -432,8 +437,17 @@ def _unexpected_shape_error() -> QURLError: if not isinstance(data, dict): raise _unexpected_shape_error() - if not isinstance(data.get("succeeded"), int) or not isinstance( - data.get("failed"), int + # `bool` is a subclass of `int` in Python, so a response with + # `"succeeded": True` would silently pass an `isinstance(..., int)` + # check and then slip a truthy bool into the counts. Reject + # explicitly — matches the same guard in `_require_max_sessions_in_range`. + succeeded = data.get("succeeded") + failed = data.get("failed") + if ( + not isinstance(succeeded, int) + or isinstance(succeeded, bool) + or not isinstance(failed, int) + or isinstance(failed, bool) ): raise _unexpected_shape_error() if not isinstance(data.get("results"), list): @@ -563,7 +577,9 @@ def build_list_params( ) -> dict[str, str]: """Build query params for list endpoints, dropping None values.""" # ``status`` is a QURLStatus (Literal | str) — covered by the ``str`` arm. - pairs: dict[str, int | str | datetime | None] = { + # Ordered most-specific to least-specific: datetime/int are concrete + # types, str is the widening arm, None is the "drop" sentinel. + pairs: dict[str, datetime | int | str | None] = { "limit": limit, "cursor": cursor, "status": status, diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index 0634587..e85dc9a 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -333,8 +333,11 @@ async def update( ``extend_by``. description: New resource description. Pass an empty string to clear. Max length 500. - tags: Tags to set on the resource. Pass an empty list to clear. - Max 10 items, each 1-50 chars matching + tags: Replacement tag list — this is always a REPLACE, never + a merge. Pass ``tags=[]`` to clear all tags (always a + real clear operation, even if no tags were set); pass + ``None`` (the default) to leave the existing tags + unchanged. Max 10 items, each 1-50 chars matching ``^[a-zA-Z0-9][a-zA-Z0-9 _-]*$``. Raises: diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index fbe5d3a..622c5ef 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -348,8 +348,11 @@ def update( ``extend_by``. description: New resource description. Pass an empty string to clear. Max length 500. - tags: Tags to set on the resource. Pass an empty list to clear. - Max 10 items, each 1-50 chars matching + tags: Replacement tag list — this is always a REPLACE, never + a merge. Pass ``tags=[]`` to clear all tags (always a + real clear operation, even if no tags were set); pass + ``None`` (the default) to leave the existing tags + unchanged. Max 10 items, each 1-50 chars matching ``^[a-zA-Z0-9][a-zA-Z0-9 _-]*$``. Raises: diff --git a/tests/test_client.py b/tests/test_client.py index 5efa6fb..97627c1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2197,6 +2197,20 @@ def test_create_accepts_http_and_https_schemes(client: QURLClient) -> None: utils.validate_create_input(target_url="https://example.com") +def test_create_rejects_non_string_target_url_with_value_error() -> None: + """Non-string target_url inputs (None, int, bool, bytes) must raise + ``ValueError`` — not a cryptic ``TypeError`` from slicing inside the + error message. Regression guard: the previous ``target_url[:32]!r`` + form would raise ``TypeError`` on any non-subscriptable input before + the ValueError could surface. + """ + import layerv_qurl._utils as utils + + for bad in (None, 42, True, b"https://example.com"): + with pytest.raises(ValueError, match="http:// or https://"): + utils.validate_create_input(target_url=bad) # type: ignore[arg-type] + + # ---- _parse_access_policy deserialization (reviewer gap #8) ---------------- @@ -2335,3 +2349,28 @@ def test_batch_create_rejects_400_body_with_non_boolean_success( ) with pytest.raises(QURLError, match="Unexpected response shape"): client.batch_create([{"target_url": "https://example.com"}]) + + +@respx.mock +def test_batch_create_rejects_400_body_with_bool_counts( + client: QURLClient, +) -> None: + """`bool` is a subclass of `int` in Python, so a naive + ``isinstance(..., int)`` check would silently accept + ``"succeeded": True``. The shape guard explicitly rejects bool in + the counts — this test locks that in against a future simplification + that might drop the explicit bool check.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={ + "data": { + "succeeded": True, # bool should be rejected + "failed": False, + "results": [], + }, + }, + ) + ) + with pytest.raises(QURLError, match="Unexpected response shape"): + client.batch_create([{"target_url": "https://example.com"}]) From 657a07514cf0f61146a48785f5d880a35e278297 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 02:10:55 -0500 Subject: [PATCH 15/31] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20Bat?= =?UTF-8?q?chCreateItem=20TypedDict,=20docs,=20softened=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `BatchCreateItem` TypedDict in types.py using the inheritance-based pattern (`_BatchCreateItemRequired(TypedDict)` + `BatchCreateItem(..., total=False)`) so Python 3.10 callers get IDE autocomplete on valid batch item keys without needing `typing.Required` (3.11+) or a `typing_extensions` dependency. Fields mirror `create()` 1:1 — both endpoints share the same `CreateQurlRequest` schema per the OpenAPI spec. - Change `batch_create(items: list[dict[str, Any]])` → `batch_create(items: Sequence[BatchCreateItem])` in both sync and async clients. Sequence is covariant (list is invariant), and the TypedDict narrowing inside `build_body` uses a `cast` since TypedDicts compile to plain dicts at runtime — zero runtime cost. Export `BatchCreateItem` from the package root. - Add a load-bearing comment at the `build_body(...)` call site in `update()` (both clients) noting that `tags=[]` and `description=""` are intentional clear operations preserved because `build_body` strips only top-level `None`, not falsy values. A future refactor that adds a truthiness check here would silently drop both. - Soften the `require_resource_id_prefix` error message: drop the raw `resource_id[:16]` echo (replaced with a 2-char prefix echo matching the qurl-typescript SDK's info-leak posture), drop the explicit `/v1/resources/:id/qurls/:qurl_id` path (replaced with "not yet available in this SDK version"), and add a regression test asserting the sensitive suffix of the caller ID is never echoed into the error message. - Remove `test_batch_create_serializes_datetime_in_items` — the test passes `{"expires_at": datetime}` on a batch item, but `BatchCreateRequest.items` references `CreateQurlRequest` in the OpenAPI spec which does not accept `expires_at`. The datetime serialization path through `_serialize_value` / `build_body` is already covered by the `update(expires_at=...)` tests. - Fix three pre-existing mypy errors in tests/ surfaced by the TypedDict change: `_qurl_item` now returns `dict[str, Any]`, the `async_client` fixture now has a proper `AsyncGenerator` return annotation, and the stale `# type: ignore[misc]` on the fixture `yield` is removed. These were out of scope for CI (which only runs `mypy src/`) but visible locally. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/__init__.py | 2 + src/layerv_qurl/_utils.py | 11 ++++-- src/layerv_qurl/async_client.py | 19 +++++++-- src/layerv_qurl/client.py | 19 +++++++-- src/layerv_qurl/types.py | 45 ++++++++++++++++++++- tests/test_client.py | 69 +++++++++++++++------------------ 6 files changed, 116 insertions(+), 49 deletions(-) diff --git a/src/layerv_qurl/__init__.py b/src/layerv_qurl/__init__.py index c4d7b32..fb05601 100644 --- a/src/layerv_qurl/__init__.py +++ b/src/layerv_qurl/__init__.py @@ -22,6 +22,7 @@ AccessPolicy, AccessToken, AIAgentPolicy, + BatchCreateItem, BatchCreateOutput, BatchItemError, BatchItemResult, @@ -52,6 +53,7 @@ "ValidationError", # Types "AIAgentPolicy", + "BatchCreateItem", "BatchCreateOutput", "BatchItemError", "BatchItemResult", diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index d964a34..a7e752f 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -249,11 +249,16 @@ def require_resource_id_prefix(resource_id: str, operation: str = "delete") -> N of passing a ``q_`` display ID here with a clear client-side error. """ if not resource_id.startswith(RESOURCE_ID_PREFIX): + # Don't echo the raw ID — even truncated to 16 chars it may + # contain caller-sensitive data that ends up in error logs. + # Echo only the 2-char prefix so the caller sees which kind of + # ID they passed without leaking the value. + observed_prefix = resource_id[:2] raise ValueError( f"{operation}: only resource IDs ({RESOURCE_ID_PREFIX} prefix) are accepted — " - f"got {resource_id[:16]!r}. To revoke a single access token, " - "use the DELETE /v1/resources/:id/qurls/:qurl_id endpoint " - "(not yet exposed by this SDK)." + f"got an ID starting with {observed_prefix!r}. To revoke a single " + "access token, use the token-scoped revoke endpoint (not yet " + "available in this SDK version)." ) diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index e85dc9a..6d3bf83 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -6,7 +6,7 @@ from __future__ import annotations import asyncio -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import httpx @@ -41,12 +41,13 @@ if TYPE_CHECKING: import builtins - from collections.abc import AsyncIterator + from collections.abc import AsyncIterator, Sequence from datetime import datetime from layerv_qurl.types import ( QURL, AccessPolicy, + BatchCreateItem, BatchCreateOutput, CreateOutput, ListOutput, @@ -362,6 +363,12 @@ async def update( "tags) must be provided" ) validate_update_input(description=description, tags=tags) + # `build_body` strips top-level ``None`` only — falsy values like + # ``tags=[]`` and ``description=""`` are preserved. This is + # load-bearing: ``tags=[]`` is an intentional "clear all tags" + # API operation and ``description=""`` clears the description. + # A future refactor that adds a truthiness check here would + # silently drop both. body = build_body( { "extend_by": extend_by, @@ -431,7 +438,7 @@ async def mint_link( async def batch_create( self, - items: builtins.list[dict[str, Any]], + items: Sequence[BatchCreateItem], ) -> BatchCreateOutput: """Create multiple QURLs at once (1-100 items). @@ -482,7 +489,11 @@ async def batch_create( ) from exc except ValueError as exc: raise ValueError(f"batch_create items[{i}]: {exc}") from exc - serialized = [build_body(item) for item in items] + # `BatchCreateItem` is structurally a `dict[str, Any]` at runtime — + # TypedDicts compile to plain dicts and carry no runtime overhead. + # The `cast` narrows the type for `build_body` without any runtime + # conversion. + serialized = [build_body(cast("dict[str, Any]", item)) for item in items] # HTTP 400 carries structured per-item errors on this endpoint — # whitelist it so the generic error path doesn't swallow the body. resp = await self._request( diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index 622c5ef..6e8339c 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -7,7 +7,7 @@ from __future__ import annotations import time -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import httpx @@ -42,12 +42,13 @@ if TYPE_CHECKING: import builtins - from collections.abc import Iterator + from collections.abc import Iterator, Sequence from datetime import datetime from layerv_qurl.types import ( QURL, AccessPolicy, + BatchCreateItem, BatchCreateOutput, CreateOutput, ListOutput, @@ -377,6 +378,12 @@ def update( "tags) must be provided" ) validate_update_input(description=description, tags=tags) + # `build_body` strips top-level ``None`` only — falsy values like + # ``tags=[]`` and ``description=""`` are preserved. This is + # load-bearing: ``tags=[]`` is an intentional "clear all tags" + # API operation and ``description=""`` clears the description. + # A future refactor that adds a truthiness check here would + # silently drop both. body = build_body( { "extend_by": extend_by, @@ -446,7 +453,7 @@ def mint_link( def batch_create( self, - items: builtins.list[dict[str, Any]], + items: Sequence[BatchCreateItem], ) -> BatchCreateOutput: """Create multiple QURLs at once (1-100 items). @@ -497,7 +504,11 @@ def batch_create( ) from exc except ValueError as exc: raise ValueError(f"batch_create items[{i}]: {exc}") from exc - serialized = [build_body(item) for item in items] + # `BatchCreateItem` is structurally a `dict[str, Any]` at runtime — + # TypedDicts compile to plain dicts and carry no runtime overhead. + # The `cast` narrows the type for `build_body` without any runtime + # conversion. + serialized = [build_body(cast("dict[str, Any]", item)) for item in items] # HTTP 400 carries structured per-item errors on this endpoint — # whitelist it so the generic error path doesn't swallow the body. resp = self._request( diff --git a/src/layerv_qurl/types.py b/src/layerv_qurl/types.py index 62f356a..0615012 100644 --- a/src/layerv_qurl/types.py +++ b/src/layerv_qurl/types.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from datetime import datetime -from typing import Literal +from typing import Any, Literal, TypedDict #: Valid resource-level status values. Resources only have two states per #: the OpenAPI spec (``QurlData.status``) and the server code. Accepts known @@ -221,3 +221,46 @@ class BatchCreateOutput: succeeded: int = 0 failed: int = 0 results: list[BatchItemResult] = field(default_factory=list) + + +# ---- batch_create input shape ------------------------------------------- +# TypedDicts for :meth:`QURLClient.batch_create` items. Split into a +# required-fields base class and an `total=False` subclass so callers on +# Python 3.10 don't need ``typing.Required`` (added in 3.11) or a +# ``typing_extensions`` dependency. Fields mirror the corresponding +# keyword arguments on :meth:`QURLClient.create` one-for-one; the +# single-create endpoint and the batch endpoint share the same +# ``CreateQurlRequest`` schema in the OpenAPI spec. +# +# The runtime behavior is unchanged — ``batch_create`` still iterates +# items as plain dicts and passes them through ``build_body`` / +# ``_serialize_value``. The TypedDict is purely for IDE autocomplete and +# static type checking. + + +class _BatchCreateItemRequired(TypedDict): + target_url: str + + +class BatchCreateItem(_BatchCreateItemRequired, total=False): + """Input shape for a single item in :meth:`QURLClient.batch_create`. + + ``target_url`` is required; every other field is optional and mirrors + the corresponding keyword argument on :meth:`QURLClient.create`. + + ``access_policy`` is typed as ``dict[str, Any]`` for maximum + flexibility — callers may pass either a plain dict matching the + ``AccessPolicy`` schema or construct an :class:`AccessPolicy` + dataclass and let ``_serialize_value`` recursively convert it at + body-build time. Both work at runtime; the loose type accommodates + both patterns without forcing a concrete dataclass construction on + callers who prefer dicts. + """ + + expires_in: str + label: str + one_time_use: bool + max_sessions: int + session_duration: str + access_policy: dict[str, Any] + custom_domain: str diff --git a/tests/test_client.py b/tests/test_client.py index 97627c1..efbe7e7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,12 +4,16 @@ import json from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any from unittest.mock import patch import httpx import pytest import respx +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + from layerv_qurl import ( AsyncQURLClient, QURLClient, @@ -25,7 +29,7 @@ ServerError, ValidationError, ) -from layerv_qurl.types import AccessPolicy, AccessToken, AIAgentPolicy +from layerv_qurl.types import AccessPolicy, AccessToken, AIAgentPolicy, BatchCreateItem BASE_URL = "https://api.test.layerv.ai" @@ -54,7 +58,7 @@ } -def _qurl_item(rid: str, url: str) -> dict: +def _qurl_item(rid: str, url: str) -> dict[str, Any]: return { "resource_id": rid, "target_url": url, @@ -70,9 +74,9 @@ def client() -> QURLClient: @pytest.fixture -async def async_client() -> AsyncQURLClient: +async def async_client() -> AsyncGenerator[AsyncQURLClient, None]: client = AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) - yield client # type: ignore[misc] + yield client await client.close() @@ -1717,41 +1721,11 @@ def test_batch_create_empty_raises(client: QURLClient) -> None: def test_batch_create_over_100_raises(client: QURLClient) -> None: - items = [{"target_url": f"https://{i}.com"} for i in range(101)] + items: list[BatchCreateItem] = [{"target_url": f"https://{i}.com"} for i in range(101)] with pytest.raises(ValueError, match="at most 100"): client.batch_create(items) -@respx.mock -def test_batch_create_serializes_datetime_in_items(client: QURLClient) -> None: - """batch_create items with datetime values are properly serialized.""" - route = respx.post(f"{BASE_URL}/v1/qurls/batch").mock( - return_value=httpx.Response( - 200, - json={ - "data": { - "succeeded": 1, - "failed": 0, - "results": [ - { - "index": 0, - "success": True, - "resource_id": "r_dt", - "qurl_link": "https://qurl.link/#at_dt", - "qurl_site": "https://r_dt.qurl.site", - }, - ], - }, - }, - ) - ) - - exp = datetime(2026, 6, 1, 0, 0, 0, tzinfo=timezone.utc) - client.batch_create([{"target_url": "https://example.com", "expires_at": exp}]) - body = json.loads(route.calls[0].request.content) - assert body["items"][0]["expires_at"] == "2026-06-01T00:00:00+00:00" - - # --- create() with access_policy serialization --- @@ -1967,6 +1941,21 @@ def test_delete_rejects_q_prefix_client_side(client: QURLClient) -> None: client.delete("q_3a7f2c8e91b") +def test_delete_error_does_not_leak_full_resource_id(client: QURLClient) -> None: + """Info-leak regression: the error message must echo only the 2-char + prefix, not the raw caller-supplied ID. Error strings may end up in + observability pipelines and the ID suffix could contain + caller-sensitive data. + """ + with pytest.raises(ValueError) as exc_info: + client.delete("q_3a7f2c8e91b_sensitive_suffix") + msg = str(exc_info.value) + assert "'q_'" in msg + # Must not echo any part of the caller-supplied ID beyond the prefix. + assert "3a7f2c8e91b" not in msg + assert "sensitive_suffix" not in msg + + # ---- batch_create per-item validation & async validators ------------------- @@ -1983,7 +1972,13 @@ def test_batch_create_rejects_per_item_violation_with_index(client: QURLClient) def test_batch_create_rejects_items_missing_target_url(client: QURLClient) -> None: with pytest.raises(ValueError, match=r"items\[0\].*target_url"): - client.batch_create([{"label": "no url"}]) + # Deliberately passing an item missing the required `target_url` + # field to exercise the runtime validation. The type: ignore is + # expected because BatchCreateItem enforces `target_url` at the + # type level — this test guards against untyped callers + # (e.g. Python scripts without strict type-checking) accidentally + # sending incomplete items. + client.batch_create([{"label": "no url"}]) # type: ignore[typeddict-item] @pytest.mark.asyncio @@ -1994,7 +1989,7 @@ async def test_async_batch_create_empty_raises(async_client: AsyncQURLClient) -> @pytest.mark.asyncio async def test_async_batch_create_over_100_raises(async_client: AsyncQURLClient) -> None: - items = [{"target_url": f"https://{i}.com"} for i in range(101)] + items: list[BatchCreateItem] = [{"target_url": f"https://{i}.com"} for i in range(101)] with pytest.raises(ValueError, match="at most 100"): await async_client.batch_create(items) From 43c198174533e2a9c079cc281c66686a085f0de1 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 02:53:38 -0500 Subject: [PATCH 16/31] fix: Quota.plan "unknown" sentinel + _serialize_value asymmetry test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change `Quota.plan` default from `""` to `"unknown"`. The empty string didn't match any Literal member in `QuotaPlan` and made `if quota.plan:` truthiness checks misleading for the not-yet-populated state. `"unknown"` is self-describing in logs and error messages. Docstring updated to explain the sentinel choice. No test impact — all existing tests check `plan == "growth"` on populated responses. - Add a focused unit test locking in the deliberate None-handling asymmetry in `_serialize_value`: dataclass fields with None are dropped (the dataclass distinguishes "unset" from "explicitly null"), while None inside nested dicts/lists is preserved (some API fields use explicit null as a signalling value — e.g. `ai_agent_policy: null` to clear a policy). The test directly calls `_serialize_value` with a dataclass inside a dict inside a list, exercising both rules in one assertion set. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/types.py | 16 +++++++++------ tests/test_client.py | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/layerv_qurl/types.py b/src/layerv_qurl/types.py index 0615012..14a9b4f 100644 --- a/src/layerv_qurl/types.py +++ b/src/layerv_qurl/types.py @@ -178,15 +178,19 @@ class Quota: .. note:: - ``plan`` defaults to the empty string ``""`` purely so the + ``plan`` defaults to the sentinel ``"unknown"`` purely so the dataclass can be instantiated with no arguments (this only - happens in tests and internal bootstrap paths). In practice the - ``/v1/quota`` endpoint always returns a populated plan string, - so consumers comparing ``quota.plan == "free"`` on a real - response will see one of the documented enum values. + happens in tests and internal bootstrap paths). ``"unknown"`` + was chosen over the empty string so callers doing + ``if quota.plan:`` truthiness checks see a real value, and so + the "not-yet-populated" state is self-describing in logs and + error messages. In practice the ``/v1/quota`` endpoint always + returns a populated plan string, so consumers comparing + ``quota.plan == "free"`` on a real response will see one of + the documented enum values. """ - plan: QuotaPlan = "" + plan: QuotaPlan = "unknown" period_start: datetime | None = None period_end: datetime | None = None rate_limits: RateLimits | None = None diff --git a/tests/test_client.py b/tests/test_client.py index efbe7e7..02af5cc 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1804,6 +1804,49 @@ def test_mint_link_nested_serialization_e2e(client: QURLClient) -> None: assert "block_all" not in body["access_policy"]["ai_agent_policy"] # None fields omitted +def test_serialize_value_none_asymmetry_dataclass_vs_dict() -> None: + """Regression guard for the deliberate None-handling asymmetry in + :func:`_serialize_value`: + + * Dataclass fields with ``None`` values are **dropped** — the + dataclass distinguishes "unset" from "explicitly null." + * ``None`` values inside nested dicts/lists are **preserved** — + some API fields use explicit ``null`` as a signalling value + (e.g. ``"ai_agent_policy": null`` to clear a policy). + + This test exercises both rules in a single call so a future + refactor that unifies the behavior would trip immediately. + """ + import layerv_qurl._utils as utils + + # AIAgentPolicy is a real dataclass from the types module. block_all + # stays None — dataclass rule should drop it. + policy = AIAgentPolicy(allow_categories=["claude"]) + + value = { + "explicit_null": None, # dict None: must survive + "dataclass": policy, # dataclass with a None field: field must be dropped + "nested": { + "also_null": None, # nested dict None: must survive + "real": "value", + }, + "list_with_nulls": [None, "x", None], # list Nones: must survive + } + + serialized = utils._serialize_value(value) + + # Dict-level None is preserved + assert serialized["explicit_null"] is None + assert serialized["nested"]["also_null"] is None + # List-level None is preserved + assert serialized["list_with_nulls"] == [None, "x", None] + # Dataclass None field is dropped (block_all was None on the policy) + assert "block_all" not in serialized["dataclass"] + assert "deny_categories" not in serialized["dataclass"] # also None + # Non-None dataclass field survives + assert serialized["dataclass"]["allow_categories"] == ["claude"] + + # ---- Spec-derived input validation (create) -------------------------------- From a34696c570d56786e42b9349902de3b375dbe2e7 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 12:43:39 -0500 Subject: [PATCH 17/31] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20typ?= =?UTF-8?q?ed=20access=5Fpolicy,=20arithmetic=20guard,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BatchCreateItem.access_policy: switch from `dict[str, Any]` to `AccessPolicy | dict[str, Any]`. Both paths already worked at runtime via `_serialize_value`, but the union type restores IDE autocomplete / mypy checks for callers who prefer the structured dataclass form, without forcing that choice on callers who prefer plain dicts. New e2e test covers the dataclass path through batch_create and asserts `_serialize_value`'s None-drop rule still applies (block_all / deny_categories absent from the serialized body when left None on the dataclass). - _validate_batch_create_shape: add a final arithmetic invariant check asserting `succeeded + failed == len(results)`. A mismatch indicates either a proxy/middleware mangled the response or the API had a counting bug — neither case should silently pass. New regression test for `{succeeded: 5, failed: 0, results: [1 item]}`. - list() docstring (both clients): strengthen the date-filter param docs to explicitly call out that string values must be ISO 8601 / RFC 3339 format and are passed through to the API unvalidated. The server rejects malformed timestamps with a 400; the SDK doesn't duplicate that validation. - require_resource_id_prefix: add a TODO comment flagging the "not yet available in this SDK version" wording so it gets updated when a token-scoped revoke method lands on the client. - Silence the third-party langchain-core Pydantic-v1 deprecation warning that surfaces under Python 3.14 (optional integration, not our dep to fix). Scoped narrowly to the exact message so unrelated warnings still surface. - Migration notes updated: tighten the "absolute-expiry via update" pattern to use `expires_in="1m"` (minimum practical) instead of `"1h"` — narrows the window during which the QURL is live with the wrong expiry to ~1 second of network latency. Added an explicit note about the race window being unavoidable and recommending a second layer of authorization for hard-synchronous absolute-expiry requirements. Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 9 ++++ src/layerv_qurl/_utils.py | 13 +++++- src/layerv_qurl/async_client.py | 32 +++++++++---- src/layerv_qurl/client.py | 32 +++++++++---- src/layerv_qurl/types.py | 14 +++--- tests/test_client.py | 81 +++++++++++++++++++++++++++++++++ 6 files changed, 156 insertions(+), 25 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a40e598..7d4d009 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,3 +66,12 @@ python_version = "3.10" [tool.pytest.ini_options] asyncio_mode = "auto" +# Silence the third-party Pydantic-v1 deprecation warning that +# `langchain-core` emits on Python 3.14+ (the optional langchain +# integration pulls in `langchain-core`, which still imports +# `pydantic.v1.fields` at module load). Not our code, not our deps +# to fix — filter it out so the test output stays clean. Scoped +# narrowly to the exact message so unrelated warnings still surface. +filterwarnings = [ + "ignore:Core Pydantic V1 functionality isn't compatible with Python 3\\.14 or greater\\.:UserWarning", +] diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index a7e752f..3952002 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -254,6 +254,10 @@ def require_resource_id_prefix(resource_id: str, operation: str = "delete") -> N # Echo only the 2-char prefix so the caller sees which kind of # ID they passed without leaking the value. observed_prefix = resource_id[:2] + # TODO: update the "not yet available in this SDK version" + # wording once a token-scoped `revoke_token()` / equivalent + # method lands on the client. Until then, the error points + # users at the API-level endpoint without a concrete SDK call. raise ValueError( f"{operation}: only resource IDs ({RESOURCE_ID_PREFIX} prefix) are accepted — " f"got an ID starting with {observed_prefix!r}. To revoke a single " @@ -457,11 +461,18 @@ def _unexpected_shape_error() -> QURLError: raise _unexpected_shape_error() if not isinstance(data.get("results"), list): raise _unexpected_shape_error() + results = data["results"] + # Arithmetic invariant: `succeeded + failed` must equal the number of + # result entries. A mismatch indicates either a proxy/middleware + # mangled the response or the API returned inconsistent counts — + # both cases warrant raising rather than trusting the data. + if succeeded + failed != len(results): + raise _unexpected_shape_error() # Each entry must carry a boolean `success` discriminant so consumers # can reliably branch on it — anything else would break the # BatchItemResult contract. Deeper per-field validation is # intentionally left to the API. - for entry in data["results"]: + for entry in results: if not isinstance(entry, dict) or not isinstance(entry.get("success"), bool): raise _unexpected_shape_error() diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index 6d3bf83..06ba98b 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -211,10 +211,18 @@ async def list( status: Filter by QURL status (``"active"``, ``"revoked"``). q: Search query string. sort: Sort order (e.g. ``"created_at"``, ``"-created_at"``). - created_after: Filter QURLs created after this ISO timestamp. - created_before: Filter QURLs created before this ISO timestamp. - expires_before: Filter QURLs expiring before this ISO timestamp. - expires_after: Filter QURLs expiring after this ISO timestamp. + created_after: Filter QURLs created after this timestamp. + Accepts a :class:`datetime` (serialized via ``.isoformat()``) + or a string. String values must be ISO 8601 / RFC 3339 + format (e.g. ``"2026-04-01T00:00:00Z"``) and are passed + through to the API unvalidated — the server rejects + malformed timestamps with a 400. + created_before: Filter QURLs created before this timestamp. + Same format rules as ``created_after``. + expires_before: Filter QURLs expiring before this timestamp. + Same format rules as ``created_after``. + expires_after: Filter QURLs expiring after this timestamp. + Same format rules as ``created_after``. """ params = build_list_params( limit, @@ -251,10 +259,18 @@ async def list_all( q: Search query string. sort: Sort order. page_size: Number of items per page (default 50). - created_after: Filter QURLs created after this ISO timestamp. - created_before: Filter QURLs created before this ISO timestamp. - expires_before: Filter QURLs expiring before this ISO timestamp. - expires_after: Filter QURLs expiring after this ISO timestamp. + created_after: Filter QURLs created after this timestamp. + Accepts a :class:`datetime` (serialized via ``.isoformat()``) + or a string. String values must be ISO 8601 / RFC 3339 + format (e.g. ``"2026-04-01T00:00:00Z"``) and are passed + through to the API unvalidated — the server rejects + malformed timestamps with a 400. + created_before: Filter QURLs created before this timestamp. + Same format rules as ``created_after``. + expires_before: Filter QURLs expiring before this timestamp. + Same format rules as ``created_after``. + expires_after: Filter QURLs expiring after this timestamp. + Same format rules as ``created_after``. """ cursor: str | None = None while True: diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index 6e8339c..47605f1 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -227,10 +227,18 @@ def list( status: Filter by QURL status (``"active"``, ``"revoked"``). q: Search query string. sort: Sort order (e.g. ``"created_at"``, ``"-created_at"``). - created_after: Filter QURLs created after this ISO timestamp. - created_before: Filter QURLs created before this ISO timestamp. - expires_before: Filter QURLs expiring before this ISO timestamp. - expires_after: Filter QURLs expiring after this ISO timestamp. + created_after: Filter QURLs created after this timestamp. + Accepts a :class:`datetime` (serialized via ``.isoformat()``) + or a string. String values must be ISO 8601 / RFC 3339 + format (e.g. ``"2026-04-01T00:00:00Z"``) and are passed + through to the API unvalidated — the server rejects + malformed timestamps with a 400. + created_before: Filter QURLs created before this timestamp. + Same format rules as ``created_after``. + expires_before: Filter QURLs expiring before this timestamp. + Same format rules as ``created_after``. + expires_after: Filter QURLs expiring after this timestamp. + Same format rules as ``created_after``. """ params = build_list_params( limit, @@ -267,10 +275,18 @@ def list_all( q: Search query string. sort: Sort order. page_size: Number of items per page (default 50). - created_after: Filter QURLs created after this ISO timestamp. - created_before: Filter QURLs created before this ISO timestamp. - expires_before: Filter QURLs expiring before this ISO timestamp. - expires_after: Filter QURLs expiring after this ISO timestamp. + created_after: Filter QURLs created after this timestamp. + Accepts a :class:`datetime` (serialized via ``.isoformat()``) + or a string. String values must be ISO 8601 / RFC 3339 + format (e.g. ``"2026-04-01T00:00:00Z"``) and are passed + through to the API unvalidated — the server rejects + malformed timestamps with a 400. + created_before: Filter QURLs created before this timestamp. + Same format rules as ``created_after``. + expires_before: Filter QURLs expiring before this timestamp. + Same format rules as ``created_after``. + expires_after: Filter QURLs expiring after this timestamp. + Same format rules as ``created_after``. """ cursor: str | None = None while True: diff --git a/src/layerv_qurl/types.py b/src/layerv_qurl/types.py index 14a9b4f..9c3fe51 100644 --- a/src/layerv_qurl/types.py +++ b/src/layerv_qurl/types.py @@ -252,13 +252,11 @@ class BatchCreateItem(_BatchCreateItemRequired, total=False): ``target_url`` is required; every other field is optional and mirrors the corresponding keyword argument on :meth:`QURLClient.create`. - ``access_policy`` is typed as ``dict[str, Any]`` for maximum - flexibility — callers may pass either a plain dict matching the - ``AccessPolicy`` schema or construct an :class:`AccessPolicy` - dataclass and let ``_serialize_value`` recursively convert it at - body-build time. Both work at runtime; the loose type accommodates - both patterns without forcing a concrete dataclass construction on - callers who prefer dicts. + ``access_policy`` accepts either an :class:`AccessPolicy` dataclass + (recommended for type safety and IDE autocomplete) or a plain + ``dict[str, Any]`` (for callers who prefer dicts or are working from + dynamic config). Both forms are converted to the same JSON body at + request time via ``_serialize_value``. """ expires_in: str @@ -266,5 +264,5 @@ class BatchCreateItem(_BatchCreateItemRequired, total=False): one_time_use: bool max_sessions: int session_duration: str - access_policy: dict[str, Any] + access_policy: AccessPolicy | dict[str, Any] custom_domain: str diff --git a/tests/test_client.py b/tests/test_client.py index 02af5cc..00aa7a8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2412,3 +2412,84 @@ def test_batch_create_rejects_400_body_with_bool_counts( ) with pytest.raises(QURLError, match="Unexpected response shape"): client.batch_create([{"target_url": "https://example.com"}]) + + +@respx.mock +def test_batch_create_rejects_counts_arithmetic_mismatch( + client: QURLClient, +) -> None: + """The shape guard asserts `succeeded + failed == len(results)`. + A mismatch suggests a proxy or middleware mangled the response, + or the API had a counting bug — either case warrants raising + rather than trusting the data.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={ + "data": { + "succeeded": 5, # claims 5 succeeded + "failed": 0, + "results": [ + # …but only 1 entry + {"index": 0, "success": True, "resource_id": "r_only1"}, + ], + }, + }, + ) + ) + with pytest.raises(QURLError, match="Unexpected response shape"): + client.batch_create([{"target_url": "https://example.com"}]) + + +@respx.mock +def test_batch_create_accepts_access_policy_dataclass( + client: QURLClient, +) -> None: + """End-to-end coverage for passing an ``AccessPolicy`` dataclass + (rather than a plain dict) in a batch item. The bridge between + typed-caller convenience and the serialized request body is + ``_serialize_value`` — this test locks in that the dataclass path + produces the same nested JSON as a plain-dict caller would.""" + route = respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "succeeded": 1, + "failed": 0, + "results": [ + { + "index": 0, + "success": True, + "resource_id": "r_dc_policy", + "qurl_link": "https://qurl.link/#at_dc", + "qurl_site": "https://r_dc_policy.qurl.site", + }, + ], + }, + }, + ) + ) + + policy = AccessPolicy( + geo_denylist=["CN", "RU"], + ai_agent_policy=AIAgentPolicy(allow_categories=["claude", "chatgpt"]), + ) + item: BatchCreateItem = { + "target_url": "https://example.com", + "label": "dc-test", + "access_policy": policy, + } + client.batch_create([item]) + + body = json.loads(route.calls[0].request.content) + assert body["items"][0]["target_url"] == "https://example.com" + assert body["items"][0]["access_policy"]["geo_denylist"] == ["CN", "RU"] + assert body["items"][0]["access_policy"]["ai_agent_policy"]["allow_categories"] == [ + "claude", + "chatgpt", + ] + # None fields on AIAgentPolicy (block_all, deny_categories) must be + # dropped by _serialize_value's dataclass rule, not preserved. + assert "block_all" not in body["items"][0]["access_policy"]["ai_agent_policy"] + assert "deny_categories" not in body["items"][0]["access_policy"]["ai_agent_policy"] From 339c91842d402269b72828848135fe8d3e7b9093 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 12:45:49 -0500 Subject: [PATCH 18/31] revert: drop pytest filterwarnings suppression on langchain-core warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the pyproject.toml change from a34696c that silenced the langchain-core Pydantic-v1 deprecation warning on Python 3.14. The warning is pure local-run noise — CI runs on Python 3.10/3.12/3.13 where it doesn't fire, so it was never a real signal being lost in the build pipeline. The only place it surfaces is on local dev machines running Python 3.14, which is my own issue (and solvable via a personal conftest.py override or a local pytest.ini) rather than something to ship in the project config for every contributor. The right long-term fixes belong elsewhere: * bump `langchain-core>=X` in the `[langchain]` extra once upstream drops pydantic-v1 compatibility on 3.14+; or * file an upstream issue and link from a tracked todo. Neither of those is in scope for this PR, and silencing the warning globally in the package's shipped config was the wrong shortcut — hides a real forward-compat signal from anyone running the test suite on 3.14 without committing to fix the underlying bug. Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7d4d009..a40e598 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,12 +66,3 @@ python_version = "3.10" [tool.pytest.ini_options] asyncio_mode = "auto" -# Silence the third-party Pydantic-v1 deprecation warning that -# `langchain-core` emits on Python 3.14+ (the optional langchain -# integration pulls in `langchain-core`, which still imports -# `pydantic.v1.fields` at module load). Not our code, not our deps -# to fix — filter it out so the test output stays clean. Scoped -# narrowly to the exact message so unrelated warnings still surface. -filterwarnings = [ - "ignore:Core Pydantic V1 functionality isn't compatible with Python 3\\.14 or greater\\.:UserWarning", -] From 269568a571ba9a418afa51fbf7a315920d7907e7 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 12:50:57 -0500 Subject: [PATCH 19/31] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20par?= =?UTF-8?q?se=5Fquota=20alignment,=20quota=20test=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parse_quota now falls back to "unknown" (not "") when the API response omits `plan`, matching the `Quota.plan` dataclass default. Previously the two "not-yet-populated" paths diverged: constructing `Quota()` directly produced `plan="unknown"` while parse_quota produced `plan=""`. Aligned so a malformed response and a bare construction both produce the same self-describing sentinel. - Add explanatory comment at the `import builtins` block in both client.py and async_client.py. The `builtins.list[str]` usage in parameter annotations is correct but surprising — the comment spells out that `list()` the method shadows `list` the type inside the class body, so future contributors don't wonder why the import exists. - Populate `max_expiry_seconds` in the `test_quota_typed` mock response (previously the field fell through the `.get(..., 0)` default path and wasn't actually exercised). Assert it round-trips. - Add `test_quota_active_qurls_percent_null` covering the nullable field path through parse_quota. The migration notes call out the `float → float | None` change as breaking; this test locks in that the parser preserves `None` when the API returns it (rather than silently defaulting to 0.0). - Add `test_quota_plan_missing_falls_back_to_unknown` locking in the parse_quota ↔ dataclass-default alignment above. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/_utils.py | 9 +++- src/layerv_qurl/async_client.py | 5 +++ src/layerv_qurl/client.py | 4 ++ tests/test_client.py | 75 +++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 3952002..5cb7e4a 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -398,7 +398,14 @@ def parse_quota(data: dict[str, Any]) -> Quota: total_accesses=usage_data.get("total_accesses", 0), ) return Quota( - plan=data.get("plan", ""), + # Fall back to the same sentinel the dataclass default uses + # (see ``Quota.plan`` in types.py) so a malformed API response + # that omits the field produces a consistent "not-yet-populated" + # value regardless of whether the Quota was constructed via + # parse_quota or directly. In practice the /v1/quota endpoint + # always returns a populated plan string, so this fallback is + # only hit for malformed responses or internal bootstrap paths. + plan=data.get("plan", "unknown"), period_start=_parse_dt(data.get("period_start")), period_end=_parse_dt(data.get("period_end")), rate_limits=rate_limits, diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index 06ba98b..f242c1d 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -40,6 +40,11 @@ from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError if TYPE_CHECKING: + # The `list()` method on AsyncQURLClient shadows the `list` type + # inside the class body, so parameter/return annotations that need + # the builtin must reference `builtins.list[...]` explicitly. The + # import lives in a TYPE_CHECKING block because it's only needed + # for type annotations. import builtins from collections.abc import AsyncIterator, Sequence from datetime import datetime diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index 47605f1..45cb111 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -41,6 +41,10 @@ from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError if TYPE_CHECKING: + # The `list()` method on QURLClient shadows the `list` type inside the + # class body, so parameter/return annotations that need the builtin + # must reference `builtins.list[...]` explicitly. The import lives in + # a TYPE_CHECKING block because it's only needed for type annotations. import builtins from collections.abc import Iterator, Sequence from datetime import datetime diff --git a/tests/test_client.py b/tests/test_client.py index 00aa7a8..9aa9a83 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -582,6 +582,11 @@ def test_quota_typed(client: QURLClient) -> None: "resolve_per_minute": 300, "max_active_qurls": 5000, "max_tokens_per_qurl": 10, + # Populated to exercise the parse path — earlier + # revisions of this test let `max_expiry_seconds` + # fall through the `.get(..., 0)` default, which + # meant the field wasn't actually tested. + "max_expiry_seconds": 604800, # 7 days }, "usage": { "qurls_created": 10, @@ -602,12 +607,82 @@ def test_quota_typed(client: QURLClient) -> None: assert result.rate_limits is not None assert result.rate_limits.create_per_minute == 60 assert result.rate_limits.max_active_qurls == 5000 + assert result.rate_limits.max_expiry_seconds == 604800 # Typed Usage assert result.usage is not None assert result.usage.active_qurls == 5 assert result.usage.qurls_created == 10 assert result.usage.total_accesses == 42 + assert result.usage.active_qurls_percent == 0.1 + + +@respx.mock +def test_quota_active_qurls_percent_null(client: QURLClient) -> None: + """`active_qurls_percent` is nullable per the API spec — when the + plan's `max_active_qurls` is unlimited, the field comes back as + `null`. Lock in that the parser preserves `None` (rather than + defaulting to `0.0` like the pre-alignment behavior did) so + callers doing arithmetic on the field get a proper TypeError + instead of silently treating unlimited as 0%. + """ + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "plan": "enterprise", + "period_start": "2026-03-01T00:00:00Z", + "period_end": "2026-04-01T00:00:00Z", + "rate_limits": { + "create_per_minute": 1000, + "max_active_qurls": 0, # unlimited on enterprise + }, + "usage": { + "qurls_created": 500, + "active_qurls": 200, + # The load-bearing field under test: null when + # max_active_qurls is unlimited. + "active_qurls_percent": None, + "total_accesses": 12345, + }, + }, + }, + ) + ) + + result = client.get_quota() + assert result.usage is not None + assert result.usage.active_qurls_percent is None + # Sanity: the other nullable-adjacent fields still parse normally. + assert result.usage.active_qurls == 200 + assert result.usage.total_accesses == 12345 + + +@respx.mock +def test_quota_plan_missing_falls_back_to_unknown(client: QURLClient) -> None: + """Regression guard for the `parse_quota` plan fallback being + aligned with the `Quota.plan` dataclass default. A malformed API + response that omits `plan` should produce `quota.plan == "unknown"`, + not `""` — consistent with the dataclass default and the docstring. + In practice the /v1/quota endpoint always returns a populated plan, + so this exercises the defensive fallback path. + """ + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + # Deliberately missing `plan` + "period_start": "2026-03-01T00:00:00Z", + "period_end": "2026-04-01T00:00:00Z", + }, + }, + ) + ) + + result = client.get_quota() + assert result.plan == "unknown" # --- Injected http_client --- From 3a39f69658cb6ec86edfbc0d4941e0ec7880149b Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 13:00:41 -0500 Subject: [PATCH 20/31] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20deb?= =?UTF-8?q?ug=20log,=20underscore-convention=20doc,=20wire=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _validate_batch_create_shape: add DEBUG-level structural hints on every failure path. Logs the failure reason, the top-level type name, and (for dict bodies) the sorted top-level keys. No raw body bytes are logged — JSON key names come from the API's published schema, not user data, so this is safe to emit. Gives operators enough context to triage a proxy/middleware failure without changing the info-leak posture of the raised error. - _utils.py module docstring now documents that the leading- underscore helpers (`_validate_batch_create_shape`, `_serialize_value`, etc.) are package-internal — shared across client.py and async_client.py to avoid duplication — not strict module-private. Downstream consumers should not import them directly. Resolves the reviewer's "why is this imported by two modules if it's underscore-prefixed?" question without renaming. - Add two wire-body regression tests for the load-bearing `build_body` "strip only None" contract: * test_update_wire_body_preserves_tags_empty_list — asserts that `update(tags=[])` sends `"tags": []` in the request body, not that the field is absent. * test_update_wire_body_preserves_description_empty_string — asserts that `update(description="")` sends `"description": ""`. The previous `test_update_accepts_empty_tags_to_clear` only checked the mocked RESPONSE, so a regression in build_body (e.g. a future truthiness check) would have slipped through. These two tests lock the wire contract down. Deferred from this review: * Sync/async duplication — reviewer explicitly said "Not a blocker for this PR — it's a known Python async pattern trade-off." Third round a reviewer has flagged and explicitly deferred this. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/_utils.py | 54 +++++++++++++++++++++++++++++------ tests/test_client.py | 59 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 5cb7e4a..4ec05e4 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -1,4 +1,16 @@ -"""Shared utilities for sync and async clients.""" +"""Shared utilities for sync and async clients. + +Naming note: the leading underscore on helpers defined here (e.g. +:func:`_validate_batch_create_shape`, :func:`_serialize_value`, +:func:`_require_max_sessions_in_range`) signals **package-internal** +API, not strict module-private. These helpers are imported by both +``client.py`` and ``async_client.py`` to avoid duplicating validation +and serialization logic across the sync/async surface — they're +intentionally shared across modules within the package, but are not +part of the public ``from layerv_qurl import ...`` surface and are +excluded from any stability guarantees the public API carries. +Downstream consumers should not import these directly. +""" from __future__ import annotations @@ -438,12 +450,28 @@ def _validate_batch_create_shape(data: Any) -> None: Raise a clear error instead when the shape doesn't match. The error message intentionally does not embed the raw body — an unexpected body could contain sensitive data (auth details, request echoes) - and error strings may end up in client-side logs. + and error strings may end up in client-side logs. Structural hints + (type name, top-level keys) are emitted at DEBUG level for + production triage, which is safe because JSON key names come from + the API's published schema — not user-supplied data. """ - def _unexpected_shape_error() -> QURLError: + def _fail(reason: str, *, top_level_keys: list[str] | None = None) -> QURLError: # Single source of truth for the error constructed at every # check failure — keeps status/code/title/detail aligned. + # + # DEBUG log carries structural hints for production triage: + # the failure reason, the observed top-level type, and (if the + # body parsed as a dict) the sorted list of top-level keys. + # Raw body bytes are intentionally excluded — the JSON key + # names come from the API's published schema, not user data, + # so this is safe to log. + logger.debug( + "batch_create shape guard tripped: %s (type=%s, top_level_keys=%s)", + reason, + type(data).__name__, + top_level_keys, + ) return QURLError( status=0, code="unexpected_response", @@ -452,7 +480,8 @@ def _unexpected_shape_error() -> QURLError: ) if not isinstance(data, dict): - raise _unexpected_shape_error() + raise _fail("not a dict") + top_keys = sorted(data.keys()) # `bool` is a subclass of `int` in Python, so a response with # `"succeeded": True` would silently pass an `isinstance(..., int)` # check and then slip a truthy bool into the counts. Reject @@ -465,23 +494,30 @@ def _unexpected_shape_error() -> QURLError: or not isinstance(failed, int) or isinstance(failed, bool) ): - raise _unexpected_shape_error() + raise _fail("succeeded/failed missing or wrong type", top_level_keys=top_keys) if not isinstance(data.get("results"), list): - raise _unexpected_shape_error() + raise _fail("results missing or not a list", top_level_keys=top_keys) results = data["results"] # Arithmetic invariant: `succeeded + failed` must equal the number of # result entries. A mismatch indicates either a proxy/middleware # mangled the response or the API returned inconsistent counts — # both cases warrant raising rather than trusting the data. if succeeded + failed != len(results): - raise _unexpected_shape_error() + raise _fail( + f"counts/results length mismatch (succeeded={succeeded}, " + f"failed={failed}, len(results)={len(results)})", + top_level_keys=top_keys, + ) # Each entry must carry a boolean `success` discriminant so consumers # can reliably branch on it — anything else would break the # BatchItemResult contract. Deeper per-field validation is # intentionally left to the API. - for entry in results: + for i, entry in enumerate(results): if not isinstance(entry, dict) or not isinstance(entry.get("success"), bool): - raise _unexpected_shape_error() + raise _fail( + f"results[{i}] missing boolean 'success' discriminant", + top_level_keys=top_keys, + ) def parse_batch_create_output(data: dict[str, Any]) -> BatchCreateOutput: diff --git a/tests/test_client.py b/tests/test_client.py index 9aa9a83..d15d2f4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2018,6 +2018,65 @@ def test_update_accepts_empty_tags_to_clear(client: QURLClient) -> None: assert result.tags == [] +@respx.mock +def test_update_wire_body_preserves_tags_empty_list(client: QURLClient) -> None: + """Regression guard for the load-bearing `build_body` contract: + `tags=[]` is a "clear all tags" API operation, not a "no change" + signal. The empty list must survive into the serialized request + body — `build_body` only strips top-level ``None``, never falsy + values. A future refactor that adds a truthiness check would + silently break tag-clearing, so this test asserts the wire body + explicitly rather than just the parsed response. + """ + route = respx.patch(f"{BASE_URL}/v1/qurls/r_abc").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "resource_id": "r_abc", + "target_url": "https://example.com", + "status": "active", + "tags": [], + "created_at": "2026-03-10T10:00:00Z", + }, + }, + ) + ) + client.update("r_abc", tags=[]) + body = json.loads(route.calls[0].request.content) + assert "tags" in body + assert body["tags"] == [] + + +@respx.mock +def test_update_wire_body_preserves_description_empty_string( + client: QURLClient, +) -> None: + """Same contract as tags=[] but for `description=""` — an empty + string means "clear the description" per the API spec, and must + survive `build_body`'s top-level-None-only strip. No previous + regression test covered this path; this fills that gap. + """ + route = respx.patch(f"{BASE_URL}/v1/qurls/r_abc").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "resource_id": "r_abc", + "target_url": "https://example.com", + "status": "active", + "description": "", + "created_at": "2026-03-10T10:00:00Z", + }, + }, + ) + ) + client.update("r_abc", description="") + body = json.loads(route.calls[0].request.content) + assert "description" in body + assert body["description"] == "" + + def test_update_rejects_empty_input(client: QURLClient) -> None: with pytest.raises(ValueError, match="at least one field"): client.update("r_abc") From e97afd32c385a05986ddca618d650fb33b3844f8 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 13:41:50 -0500 Subject: [PATCH 21/31] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20asy?= =?UTF-8?q?mmetry=20doc,=20trimmed=20docstring,=20list=5Fall=20filter=20te?= =?UTF-8?q?sts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document the deliberate `parse_create_output` asymmetry: `qurl_id` is normalized from `""` to `None` (an identifier can never meaningfully be empty — it's either present or absent), while `label` preserves `""` as-is (user-facing metadata where `""` and missing-from-response are semantically distinct). Reviewer flagged the asymmetry for either "fix or document" and the right answer is documentation — the two fields have genuinely different semantics. - Trim the `_utils.py` module docstring from 12 lines to 6. Two rounds ago a reviewer asked me to add it (context about the package-internal vs module-private convention); this round a different reviewer flagged the verbose explanation of standard Python naming. Kept the package-internal clarification (which is the non-obvious bit) and dropped the verbose padding. - Close the `list_all` date-filter test gap the reviewer noted. Add `test_list_all_propagates_date_filters_to_every_page` (sync) and `test_async_list_all_propagates_date_filters_to_every_page` (async). Both paginate across two pages with `created_after` and `expires_before` filters set, then assert every HTTP call (not just the first) carries the filter params in the query string. Locks in against a refactor that might hoist the filter params out of the pagination loop and silently drop them on page 2+. Deferred this round: * `_fail` closure style preference — reviewer offered two equivalent alternatives; pure style choice with no correctness difference, current closure is straightforward Python. * `update(description="")` — reviewer explicitly said "Good" after noting the test coverage. * `batch_create` doesn't validate `access_policy`/`session_duration` — reviewer explicitly noted it's consistent with `create()` and the documented philosophy (catch obvious mistakes, server is authoritative). * `Quota.plan = "unknown"` collision risk — fourth round this has been raised with reviewers pulling in opposite directions (previous reviewers told me to switch TO `"unknown"`, this and earlier reviewers called the collision risk "extremely unlikely" and "fine"). Stopping the ping-pong. * `_parse_dt` silent-None on malformed input — reviewer explicitly wrote "pre-existing behavior and not introduced by this PR, so not blocking." Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/_utils.py | 24 +++++----- tests/test_client.py | 92 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 4ec05e4..27f3ac0 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -1,15 +1,11 @@ """Shared utilities for sync and async clients. -Naming note: the leading underscore on helpers defined here (e.g. -:func:`_validate_batch_create_shape`, :func:`_serialize_value`, -:func:`_require_max_sessions_in_range`) signals **package-internal** -API, not strict module-private. These helpers are imported by both -``client.py`` and ``async_client.py`` to avoid duplicating validation -and serialization logic across the sync/async surface — they're -intentionally shared across modules within the package, but are not -part of the public ``from layerv_qurl import ...`` surface and are -excluded from any stability guarantees the public API carries. -Downstream consumers should not import these directly. +Underscore-prefixed helpers here (e.g. :func:`_validate_batch_create_shape`) +are **package-internal**, not strict module-private: they're imported +by both ``client.py`` and ``async_client.py`` to keep sync/async logic +in lockstep, but are excluded from the public ``from layerv_qurl import`` +surface and carry no stability guarantees. Downstream consumers should +not import them directly. """ from __future__ import annotations @@ -347,6 +343,14 @@ def parse_create_output(data: dict[str, Any]) -> CreateOutput: # idiomatic ``if result.qurl_id:`` presence check. An empty string # here is either a bug in a mock or a legacy response shape; the # spec requires a populated qurl_id on success responses. + # + # Intentionally asymmetric with `label` below: an identifier is + # either present or absent and an empty-string id is never a + # meaningful value, but `label` is user-facing metadata where + # ``""`` and absent are semantically distinct — ``""`` means "the + # caller explicitly cleared the label", while missing-from-response + # means "the API didn't return the field at all". Preserving the + # empty string lets consumers distinguish the two cases. qurl_id_raw = data.get("qurl_id") qurl_id = qurl_id_raw if qurl_id_raw else None return CreateOutput( diff --git a/tests/test_client.py b/tests/test_client.py index d15d2f4..320a680 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -393,6 +393,51 @@ def test_list_all_paginates(client: QURLClient) -> None: assert route.call_count == 2 +@respx.mock +def test_list_all_propagates_date_filters_to_every_page(client: QURLClient) -> None: + """Regression guard: ``list_all`` delegates to ``list`` on each + page fetch and must pass the date filter params through to the + underlying HTTP request on EVERY iteration, not just the first. + If a future refactor hoisted the filter params out of the loop + body, pagination would silently drop the filter on page 2+. + """ + route = respx.get(f"{BASE_URL}/v1/qurls") + route.side_effect = [ + httpx.Response( + 200, + json={ + "data": [_qurl_item("r_1", "https://1.com")], + "meta": {"has_more": True, "next_cursor": "cur_abc"}, + }, + ), + httpx.Response( + 200, + json={ + "data": [_qurl_item("r_2", "https://2.com")], + "meta": {"has_more": False}, + }, + ), + ] + + created_after = datetime(2026, 3, 1, 0, 0, 0, tzinfo=timezone.utc) + expires_before = datetime(2026, 4, 1, 0, 0, 0, tzinfo=timezone.utc) + all_qurls = list( + client.list_all( + page_size=1, + created_after=created_after, + expires_before=expires_before, + ) + ) + assert len(all_qurls) == 2 + assert route.call_count == 2 + + # Every HTTP call must carry the date filters in the query string. + for call in route.calls: + url = str(call.request.url) + assert "created_after=2026-03-01T00%3A00%3A00" in url + assert "expires_before=2026-04-01T00%3A00%3A00" in url + + @respx.mock def test_delete(client: QURLClient) -> None: respx.delete(f"{BASE_URL}/v1/qurls/r_abc123def45").mock(return_value=httpx.Response(204)) @@ -1111,6 +1156,53 @@ async def test_async_list_all(async_client: AsyncQURLClient) -> None: assert [q.resource_id for q in all_qurls] == ["r_1", "r_2"] +@respx.mock +@pytest.mark.asyncio +async def test_async_list_all_propagates_date_filters_to_every_page( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of test_list_all_propagates_date_filters_to_every_page. + Locks in sync/async parity for the list_all date-filter passthrough + behavior — both clients must carry the filter params on every + paginated HTTP call, not just the first. + """ + route = respx.get(f"{BASE_URL}/v1/qurls") + route.side_effect = [ + httpx.Response( + 200, + json={ + "data": [_qurl_item("r_1", "https://1.com")], + "meta": {"has_more": True, "next_cursor": "cur_abc"}, + }, + ), + httpx.Response( + 200, + json={ + "data": [_qurl_item("r_2", "https://2.com")], + "meta": {"has_more": False}, + }, + ), + ] + + created_after = datetime(2026, 3, 1, 0, 0, 0, tzinfo=timezone.utc) + expires_before = datetime(2026, 4, 1, 0, 0, 0, tzinfo=timezone.utc) + all_qurls = [ + q + async for q in async_client.list_all( + page_size=1, + created_after=created_after, + expires_before=expires_before, + ) + ] + assert len(all_qurls) == 2 + assert route.call_count == 2 + + for call in route.calls: + url = str(call.request.url) + assert "created_after=2026-03-01T00%3A00%3A00" in url + assert "expires_before=2026-04-01T00%3A00%3A00" in url + + @respx.mock @pytest.mark.asyncio async def test_async_network_error_wrapped(async_client: AsyncQURLClient) -> None: From ecbda4628efbc23fc4e5bb572a03c5c945102c55 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 13:52:34 -0500 Subject: [PATCH 22/31] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20sha?= =?UTF-8?q?pe=20guard=20error=20class,=20parser=20enforcement,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `_validate_batch_create_shape` now raises `ValidationError` instead of a bare `QURLError`. Previously this was the ONLY place in the SDK that raised the base class — meaning consumers catching subclass-specific errors (`except ValidationError`, `except ServerError`) wouldn't catch shape-guard failures at all, only the catch-all `except QURLError` parent would. The `unexpected_response` code is preserved so callers who want to distinguish "I passed bad input locally" (`client_validation`) from "the server returned a body I can't interpret" (`unexpected_response`) can still `.code`- branch. Matches the qurl-typescript SDK's `unexpectedResponseError` pattern. Existing tests catch `QURLError` (parent class) so they still work under the subclass switch. - `parse_batch_create_output` now runs `_validate_batch_create_shape` internally at the top of the function. Previously the shape guard was an implicit contract enforced at every call site — if a future refactor called the parser without first validating, it would silently produce `(succeeded=0, failed=0, results=[])` from the `.get()` defaults. The shape guard is now enforced at the parser boundary, not documented by convention. Removed the now-redundant standalone validation call from both `batch_create` methods (sync and async) and dropped the unused `_validate_batch_create_shape` imports they used to need. - `allow_statuses` docstring on `_raw_request` (both clients) now explicitly documents the retry ordering: `allow_statuses` only affects the error-raising path, not the retry filter which runs FIRST. A retry-eligible status (e.g. 429) is retried even if listed in `allow_statuses`; a non-retryable status (e.g. 400) listed in `allow_statuses` is never retried. This is exactly what batch_create wants — 400 carries the authoritative per-item errors and retrying would reproduce them — but the ordering was implicit and worth documenting. - Inline comment on `envelope.get("error") or {}` in `parse_error` explaining the deliberate difference from `.get(key, default)`: the `or {}` form handles `"error": null` explicitly, not just the missing-key case, so the subsequent `err.get(...)` chains don't `AttributeError` on `None`. Same pattern used for `meta` below. - Add `test_create_with_minimal_ai_agent_policy_only` — verifies that an `AccessPolicy` containing ONLY `ai_agent_policy` (every other field left None) serializes to a JSON body containing only the nested `ai_agent_policy` under `access_policy`, with explicit assertions that every other policy field name is absent. Existing tests always paired `ai_agent_policy` with other policy fields, so the None-drop rule on the other fields wasn't exercised in isolation. Test also asserts None fields inside `AIAgentPolicy` itself (allow_categories, deny_categories) are dropped too. - Add `test_async_batch_create_passes_through_400_with_per_item_errors` — the sync equivalent is covered but async batch 400 passthrough wasn't. Mirrors the sync test against the async_client fixture. Closes the sync/async parity gap the reviewer flagged. Deferred from this round: * `_utils.py` comment density — reviewer's framing was ambiguous ("For internal functions this is fine, but consider...") with no concrete action requested. Function-level comments document non-obvious decisions inline; keeping them. * Direct `_require_max_sessions_in_range(True)` unit test — reviewer explicitly labeled "Low priority — the batch bool-counts test already demonstrates the pattern." Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/_utils.py | 30 +++++++-- src/layerv_qurl/async_client.py | 22 +++++-- src/layerv_qurl/client.py | 22 +++++-- tests/test_client.py | 109 ++++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 20 deletions(-) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 27f3ac0..ec46500 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -460,7 +460,7 @@ def _validate_batch_create_shape(data: Any) -> None: the API's published schema — not user-supplied data. """ - def _fail(reason: str, *, top_level_keys: list[str] | None = None) -> QURLError: + def _fail(reason: str, *, top_level_keys: list[str] | None = None) -> ValidationError: # Single source of truth for the error constructed at every # check failure — keeps status/code/title/detail aligned. # @@ -476,7 +476,14 @@ def _fail(reason: str, *, top_level_keys: list[str] | None = None) -> QURLError: type(data).__name__, top_level_keys, ) - return QURLError( + # Raise a ValidationError (not bare QURLError) so consumers + # catching subclass-specific errors still catch shape-guard + # failures via `except ValidationError`. The `code` field + # distinguishes this from client-side preflight failures + # (`client_validation`) so callers who need the finer + # distinction can branch on `.code`. Matches the qurl-typescript + # SDK's `unexpectedResponseError` pattern. + return ValidationError( status=0, code="unexpected_response", title="Unexpected Response", @@ -527,11 +534,16 @@ def _fail(reason: str, *, top_level_keys: list[str] | None = None) -> QURLError: def parse_batch_create_output(data: dict[str, Any]) -> BatchCreateOutput: """Parse a BatchCreateOutput from API response data. - Callers must validate the response shape via - :func:`_validate_batch_create_shape` before invoking this — this - function assumes a well-formed envelope and will silently produce - an empty result on a malformed body. + Runs :func:`_validate_batch_create_shape` internally before + parsing, so a malformed envelope raises :class:`ValidationError` + here rather than silently producing an empty result. This enforces + the shape contract at the parser boundary rather than relying on + every call site to remember to validate first — previously the + validation was called explicitly by ``batch_create`` and a future + refactor that forgot the step would silently get ``(succeeded=0, + failed=0, results=[])`` from the ``.get()`` defaults below. """ + _validate_batch_create_shape(data) results: list[BatchItemResult] = [] for item in data.get("results", []): err = None @@ -589,6 +601,12 @@ def parse_error(response: httpx.Response) -> QURLError: try: envelope = response.json() + # `.get("error") or {}` — not the more-common `.get("error", {})` — + # because the API may return `"error": null` explicitly, not just + # omit the key. Both cases must collapse to the empty-dict default + # so the subsequent `err.get(...)` chains don't raise + # `AttributeError` on `None`. Same pattern is applied to `meta` + # below and to `envelope.get("data")` callers elsewhere. err = envelope.get("error") or {} title = err.get("title") or response.reason_phrase or "" detail = ( diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index f242c1d..9de825b 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -16,7 +16,6 @@ DEFAULT_TIMEOUT, RETRYABLE_STATUS, RETRYABLE_STATUS_POST, - _validate_batch_create_shape, build_body, build_list_params, default_user_agent, @@ -523,12 +522,12 @@ async def batch_create( body={"items": serialized}, allow_statuses=(400,), ) - # Defense-in-depth: the 400 passthrough trusts the response shape, - # but if the API ever returns 400 with a non-BatchCreateOutput body - # (e.g., a plain error envelope or malformed JSON) we'd silently - # get an empty result. Verify the shape before parsing and raise - # a clear error otherwise. - _validate_batch_create_shape(resp) + # `parse_batch_create_output` runs the shape guard internally + # (see its docstring) — so if the API returns 400 with an + # unexpected body shape, the parser raises ValidationError + # rather than silently producing `(succeeded=0, failed=0, + # results=[])`. The guard is enforced at the parser boundary, + # not documented by convention at every call site. return parse_batch_create_output(resp) async def resolve(self, access_token: str) -> ResolveOutput: @@ -581,6 +580,15 @@ async def _raw_request( instead. This is used by :meth:`batch_create`, where the API returns a structured ``BatchCreateOutput`` on HTTP 400 (all items rejected) — raising would drop the per-item errors. + + **`allow_statuses` interacts with retries: it only affects the + error-raising path, not the retry path.** A retry-eligible status + (e.g. 429) will still be retried even if it's listed in + ``allow_statuses``. Conversely, a non-retryable status (e.g. 400) + listed in ``allow_statuses`` is never retried — which is exactly + what batch_create wants, because a 400 carries the authoritative + per-item errors and retrying would just reproduce them. The + ordering is: retry filter first, then ``allow_statuses`` opt-out. """ url = f"{self._base_url}{path}" last_error: Exception | None = None diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index 45cb111..d8b086d 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -17,7 +17,6 @@ DEFAULT_TIMEOUT, RETRYABLE_STATUS, RETRYABLE_STATUS_POST, - _validate_batch_create_shape, build_body, build_list_params, default_user_agent, @@ -537,12 +536,12 @@ def batch_create( body={"items": serialized}, allow_statuses=(400,), ) - # Defense-in-depth: the 400 passthrough trusts the response shape, - # but if the API ever returns 400 with a non-BatchCreateOutput body - # (e.g., a plain error envelope or malformed JSON) we'd silently - # get an empty result. Verify the shape before parsing and raise - # a clear error otherwise. - _validate_batch_create_shape(resp) + # `parse_batch_create_output` runs the shape guard internally + # (see its docstring) — so if the API returns 400 with an + # unexpected body shape, the parser raises ValidationError + # rather than silently producing `(succeeded=0, failed=0, + # results=[])`. The guard is enforced at the parser boundary, + # not documented by convention at every call site. return parse_batch_create_output(resp) def resolve(self, access_token: str) -> ResolveOutput: @@ -595,6 +594,15 @@ def _raw_request( instead. This is used by :meth:`batch_create`, where the API returns a structured ``BatchCreateOutput`` on HTTP 400 (all items rejected) — raising would drop the per-item errors. + + **`allow_statuses` interacts with retries: it only affects the + error-raising path, not the retry path.** A retry-eligible status + (e.g. 429) will still be retried even if it's listed in + ``allow_statuses``. Conversely, a non-retryable status (e.g. 400) + listed in ``allow_statuses`` is never retried — which is exactly + what batch_create wants, because a 400 carries the authoritative + per-item errors and retrying would just reproduce them. The + ordering is: retry filter first, then ``allow_statuses`` opt-out. """ url = f"{self._base_url}{path}" last_error: Exception | None = None diff --git a/tests/test_client.py b/tests/test_client.py index 320a680..df7d492 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1940,6 +1940,58 @@ def test_create_with_access_policy(client: QURLClient) -> None: } +@respx.mock +def test_create_with_minimal_ai_agent_policy_only(client: QURLClient) -> None: + """Regression guard for the reviewer-noted gap: an AccessPolicy + that contains ONLY ``ai_agent_policy`` (with every other policy + field left None) must still serialize correctly. The existing + test pairs ai_agent_policy with ip_allowlist, so the None-drop + rule for the other policy fields wasn't exercised in isolation. + + This test verifies two things: + 1. `_serialize_value`'s dataclass None-drop works across ALL + other `AccessPolicy` fields (ip_allowlist, ip_denylist, + geo_allowlist, geo_denylist, user_agent_allow_regex, + user_agent_deny_regex) — the serialized body contains ONLY + `ai_agent_policy` under the `access_policy` key. + 2. `ai_agent_policy`'s own None fields (allow_categories, + deny_categories) are also dropped, leaving ONLY `block_all`. + """ + route = respx.post(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_minpol", + "qurl_link": "https://qurl.link/#at_minpol", + "qurl_site": "https://r_minpol.qurl.site", + "qurl_id": "q_minpol", + }, + }, + ) + ) + + # Only ai_agent_policy set; every other AccessPolicy field left None. + # And within ai_agent_policy, only block_all is set. + policy = AccessPolicy(ai_agent_policy=AIAgentPolicy(block_all=True)) + client.create(target_url="https://example.com", access_policy=policy) + + body = json.loads(route.calls[0].request.content) + # The entire access_policy payload should be just the nested + # ai_agent_policy, nothing else. + assert body["access_policy"] == {"ai_agent_policy": {"block_all": True}} + # Explicit checks that the other policy fields do NOT appear at all. + assert "ip_allowlist" not in body["access_policy"] + assert "ip_denylist" not in body["access_policy"] + assert "geo_allowlist" not in body["access_policy"] + assert "geo_denylist" not in body["access_policy"] + assert "user_agent_allow_regex" not in body["access_policy"] + assert "user_agent_deny_regex" not in body["access_policy"] + # And None fields on AIAgentPolicy itself are dropped too. + assert "allow_categories" not in body["access_policy"]["ai_agent_policy"] + assert "deny_categories" not in body["access_policy"]["ai_agent_policy"] + + # --- _serialize_value end-to-end with nested dataclasses --- @@ -2320,6 +2372,63 @@ def test_batch_create_passes_through_400_with_per_item_errors( assert result.results[0].error.code == "validation_error" +@respx.mock +@pytest.mark.asyncio +async def test_async_batch_create_passes_through_400_with_per_item_errors( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of `test_batch_create_passes_through_400_with_per_item_errors`. + The sync/async parity goal requires the async client to also + surface the structured 400 body as a normal return value rather + than raising — reviewer flagged this as a coverage gap. + """ + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={ + "data": { + "succeeded": 0, + "failed": 2, + "results": [ + { + "index": 0, + "success": False, + "error": { + "code": "validation_error", + "message": "items[0]: target_url must be HTTPS", + }, + }, + { + "index": 1, + "success": False, + "error": { + "code": "validation_error", + "message": "items[1]: target_url must be HTTPS", + }, + }, + ], + }, + "meta": {"request_id": "req_allfail_async"}, + }, + ) + ) + result = await async_client.batch_create( + [ + {"target_url": "https://ok1.example.com"}, + {"target_url": "https://ok2.example.com"}, + ] + ) + assert result.failed == 2 + assert result.succeeded == 0 + assert len(result.results) == 2 + assert result.results[0].success is False + assert result.results[0].error is not None + assert result.results[0].error.code == "validation_error" + assert result.results[1].success is False + assert result.results[1].error is not None + assert result.results[1].error.code == "validation_error" + + @respx.mock def test_batch_create_still_raises_on_401(client: QURLClient) -> None: """Non-400 error statuses still raise — the 400 passthrough is surgical.""" From c22da2bab320941500cba2a17283dd3aba6248b3 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 14:13:20 -0500 Subject: [PATCH 23/31] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20cor?= =?UTF-8?q?rect=20allow=5Fstatuses=20docstring,=20test=20null=20error=20en?= =?UTF-8?q?velope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite the `allow_statuses` docstring in `_raw_request` (both sync and async clients) to accurately describe the actual code ordering. The previous round's docstring claimed "retry filter first, then allow_statuses opt-out" — but the code checks allow_statuses FIRST and returns immediately as success without ever running the retry filter. In practice this is harmless because the only caller (`batch_create`) passes `allow_statuses=(400,)` and 400 isn't in any retry set. But the documented contract was lying, which the reviewer caught. The new docstring: 1. Spells out the actual check order with a numbered list 2. Notes that a status in allow_statuses bypasses the retry path entirely — even retry-eligible statuses like 429 would be surfaced on the first attempt with no transparent backoff 3. Tells callers: if that's not what you want, leave the status out of allow_statuses and let normal retry handle it Chose to fix the docstring rather than change the code because the current behavior has a defensible interpretation ("the caller explicitly opted in to handling this status themselves") and no existing test hits the overlap case — changing the code would be a behavior change out of scope for a review-fix round. - Add `test_error_handles_explicit_null_error_envelope` locking in the `envelope.get("error") or {}` pattern in `parse_error`. Some APIs return `{"error": null, ...}` explicitly instead of omitting the key entirely. The standard `.get("error", {})` only handles the missing-key case — an explicit `null` would pass through and then crash the subsequent `err.get(...)` chain with `AttributeError: 'NoneType' object has no attribute 'get'`. The inline comment added in the previous round documents the intentional `or {}` form; this test locks it in against a future refactor that might "simplify" it back to the broken `, {}` form. Asserts the fallback path produces a well-formed ServerError with no None/undefined leaking into the string representation. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/async_client.py | 31 +++++++++++++++++------- src/layerv_qurl/client.py | 31 +++++++++++++++++------- tests/test_client.py | 42 +++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 16 deletions(-) diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index 9de825b..c41d90b 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -581,14 +581,29 @@ async def _raw_request( returns a structured ``BatchCreateOutput`` on HTTP 400 (all items rejected) — raising would drop the per-item errors. - **`allow_statuses` interacts with retries: it only affects the - error-raising path, not the retry path.** A retry-eligible status - (e.g. 429) will still be retried even if it's listed in - ``allow_statuses``. Conversely, a non-retryable status (e.g. 400) - listed in ``allow_statuses`` is never retried — which is exactly - what batch_create wants, because a 400 carries the authoritative - per-item errors and retrying would just reproduce them. The - ordering is: retry filter first, then ``allow_statuses`` opt-out. + **`allow_statuses` takes precedence over retries.** The check + order in the response-handling loop is: + + 1. ``response.status_code < 400 or in allow_statuses`` → + return the parsed body immediately as a success. + 2. Otherwise, build an error and check the retry filter + (``RETRYABLE_STATUS_POST`` for POST, ``RETRYABLE_STATUS`` + for everything else). + + This means a status listed in ``allow_statuses`` is returned + to the caller **without ever running through the retry + filter**, even if that status would normally be retried. For + the only current use case (``batch_create`` with + ``allow_statuses=(400,)``) the interaction is harmless because + 400 isn't in any retry set — a 400 carries the authoritative + per-item errors and retrying would just reproduce them. + + Callers adding a *retryable* status (e.g. 429 or 5xx) to + ``allow_statuses`` should be aware this bypasses the SDK's + retry path entirely: the status is surfaced on the first + attempt with no transparent backoff. If that's not what you + want, leave the status out of ``allow_statuses`` and let the + normal retry logic handle it. """ url = f"{self._base_url}{path}" last_error: Exception | None = None diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index d8b086d..b205aef 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -595,14 +595,29 @@ def _raw_request( returns a structured ``BatchCreateOutput`` on HTTP 400 (all items rejected) — raising would drop the per-item errors. - **`allow_statuses` interacts with retries: it only affects the - error-raising path, not the retry path.** A retry-eligible status - (e.g. 429) will still be retried even if it's listed in - ``allow_statuses``. Conversely, a non-retryable status (e.g. 400) - listed in ``allow_statuses`` is never retried — which is exactly - what batch_create wants, because a 400 carries the authoritative - per-item errors and retrying would just reproduce them. The - ordering is: retry filter first, then ``allow_statuses`` opt-out. + **`allow_statuses` takes precedence over retries.** The check + order in the response-handling loop is: + + 1. ``response.status_code < 400 or in allow_statuses`` → + return the parsed body immediately as a success. + 2. Otherwise, build an error and check the retry filter + (``RETRYABLE_STATUS_POST`` for POST, ``RETRYABLE_STATUS`` + for everything else). + + This means a status listed in ``allow_statuses`` is returned + to the caller **without ever running through the retry + filter**, even if that status would normally be retried. For + the only current use case (``batch_create`` with + ``allow_statuses=(400,)``) the interaction is harmless because + 400 isn't in any retry set — a 400 carries the authoritative + per-item errors and retrying would just reproduce them. + + Callers adding a *retryable* status (e.g. 429 or 5xx) to + ``allow_statuses`` should be aware this bypasses the SDK's + retry path entirely: the status is surfaced on the first + attempt with no transparent backoff. If that's not what you + want, leave the status out of ``allow_statuses`` and let the + normal retry logic handle it. """ url = f"{self._base_url}{path}" last_error: Exception | None = None diff --git a/tests/test_client.py b/tests/test_client.py index df7d492..3c110c6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2479,6 +2479,48 @@ def test_error_surfaces_rfc7807_type_and_instance(client: QURLClient) -> None: assert err.request_id == "req_nf" +@respx.mock +def test_error_handles_explicit_null_error_envelope(client: QURLClient) -> None: + """Regression guard for the `envelope.get("error") or {}` pattern. + + Some APIs return ``{"error": null, ...}`` explicitly instead of + omitting the ``error`` key entirely. The standard + ``.get("error", {})`` would only handle the missing-key case — + an explicit ``null`` would pass through and then crash on the + subsequent ``err.get(...)`` chain with ``AttributeError: 'NoneType' + object has no attribute 'get'``. The inline comment on ``parse_error`` + documents the intentional ``or {}`` form; this test locks it in + against a future refactor that might "simplify" it back to the + broken ``, {}`` form. + """ + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 500, + json={ + # Explicit null, not a missing key. + "error": None, + "meta": {"request_id": "req_null_err"}, + }, + ) + ) + with pytest.raises(ServerError) as excinfo: + client.get_quota() + err = excinfo.value + # The `or {}` fallback collapses `err` to an empty dict, which + # means none of the RFC 7807 fields are populated — we get the + # pure fallback behavior: status from the HTTP response, + # reason_phrase as the title, and "HTTP 500" as the detail. + assert err.status == 500 + assert err.code == "unknown" + # Detail falls back to title → reason_phrase → "HTTP {status}". + # httpx's reason phrase for 500 is "Internal Server Error" or + # empty depending on the transport; either way the detail is + # not None and doesn't contain "None"/"undefined" placeholders. + assert err.detail is not None + assert "None" not in str(err) + assert "undefined" not in str(err) + + @respx.mock def test_error_falls_back_to_title_when_detail_missing(client: QURLClient) -> None: """RFC 7807 `detail` is optional — fall back to title.""" From c41c6a4f09a6f29655401fa2597b30ce1b6b3eb3 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 14:21:58 -0500 Subject: [PATCH 24/31] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20Ret?= =?UTF-8?q?ry-After=20HTTP-date=20comment=20+=20regression=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a code comment on the `.isdigit()` check in `parse_error` explaining that `Retry-After` can be either a delay-seconds integer OR an HTTP-date per RFC 7231 §7.1.3, and that HTTP-date strings deliberately fall through to `None` (triggering exponential backoff) rather than being parsed. This is a safe fallback: we don't honor the server's exact hint, but we also don't hang waiting for a parsed date or crash on unexpected header format. The comment notes that if HTTP-date support ever becomes a requirement, `.isdigit()` should be replaced with a `parsedate_to_datetime`-based parse. - Add `test_retry_after_http_date_falls_back_to_exponential_backoff` (sync) and `test_async_retry_after_http_date_falls_back_to_exponential_backoff` (async) regression tests. Both mock a 429 response with a valid RFC 7231 HTTP-date in `Retry-After` and assert: * The retry still fires and the second request succeeds * The sleep delay is positive and bounded by the retry_delay() 30s cap (i.e. exponential backoff with jitter, not the HTTP-date value) * No crash on the unparseable header Mirrors the same-named test I added to qurl-typescript last round for cross-SDK parity. Locks in the intentional behavior against a future refactor that might eagerly try to parse HTTP-dates and introduce a new bug class. The async test constructs a fresh AsyncQURLClient with max_retries=2 instead of using the async_client fixture (which is configured with max_retries=0 and therefore can't exercise the retry path). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/_utils.py | 10 +++++ tests/test_client.py | 83 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index ec46500..757cd72 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -589,6 +589,16 @@ def parse_error(response: httpx.Response) -> QURLError: retry_after = None if response.status_code == 429: retry_after_header = response.headers.get("Retry-After") + # Per RFC 7231 §7.1.3, `Retry-After` can be either a + # delay-seconds integer OR an HTTP-date. The `.isdigit()` check + # accepts only the integer form — HTTP-date strings contain + # letters/spaces/commas and deliberately fall through to `None`, + # which causes the retry path to use exponential backoff + # instead. This is a safe fallback: we don't honor the server's + # exact hint, but we also don't hang waiting for a parsed date + # value or crash on an unexpected header format. If full + # HTTP-date support becomes a requirement, replace `.isdigit()` + # with a `parsedate_to_datetime`-based parse. if retry_after_header and retry_after_header.isdigit(): retry_after = int(retry_after_header) diff --git a/tests/test_client.py b/tests/test_client.py index 3c110c6..747fc4f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -831,6 +831,89 @@ def test_retry_after_capped_at_30s(retry_client: QURLClient) -> None: mock_sleep.assert_called_once_with(30.0) +@respx.mock +def test_retry_after_http_date_falls_back_to_exponential_backoff( + retry_client: QURLClient, +) -> None: + """Per RFC 7231 §7.1.3, ``Retry-After`` can be either a delay-seconds + integer OR an HTTP-date. The SDK's ``.isdigit()`` check accepts only + the integer form — HTTP-date strings (which contain letters/spaces/ + commas) fall through and the retry uses exponential backoff instead. + + This is a safe fallback: we don't honor the server's exact hint, + but we also don't hang waiting for a parsed date value or crash on + the unexpected header format. Locks in the intentional behavior + against a future refactor that might try to parse HTTP-dates + eagerly and introduce a new bug class. + + Mirrors the qurl-typescript SDK's same-named test for cross-SDK + parity. + """ + route = respx.get(f"{BASE_URL}/v1/quota") + route.side_effect = [ + httpx.Response( + 429, + # Valid RFC 7231 HTTP-date format — the SDK should NOT parse this. + headers={"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"}, + json=_ERR_429, + ), + httpx.Response(200, json=_QUOTA_OK), + ] + + with patch("layerv_qurl.client.time.sleep") as mock_sleep: + result = retry_client.get_quota() + + # The retry still fires and succeeds — the HTTP-date header + # doesn't crash anything. We don't assert the exact sleep value + # because that's exponential-backoff territory (with jitter), + # but we do assert that sleep was called (meaning the retry + # path ran) with a positive float. + assert result.plan == "growth" + mock_sleep.assert_called_once() + (call_arg,) = mock_sleep.call_args.args + assert isinstance(call_arg, float) + assert call_arg > 0 + # Must NOT be the literal 0 or any absurdly large value — just + # bounded by the retry_delay() cap at 30s. + assert 0 < call_arg <= 30.0 + + +@respx.mock +@pytest.mark.asyncio +async def test_async_retry_after_http_date_falls_back_to_exponential_backoff() -> None: + """Async mirror of the sync HTTP-date fallback test. Locks in + sync/async parity for the RFC 7231 ``Retry-After: `` + fallback behavior. + + Constructs a fresh AsyncQURLClient instead of using the + async_client fixture because the fixture is configured with + max_retries=0, and this test specifically needs retries enabled + to exercise the retry-after fallback path. + """ + client = AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=2) + try: + route = respx.get(f"{BASE_URL}/v1/quota") + route.side_effect = [ + httpx.Response( + 429, + headers={"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"}, + json=_ERR_429, + ), + httpx.Response(200, json=_QUOTA_OK), + ] + + with patch("layerv_qurl.async_client.asyncio.sleep") as mock_sleep: + result = await client.get_quota() + + assert result.plan == "growth" + mock_sleep.assert_called_once() + (call_arg,) = mock_sleep.call_args.args + assert isinstance(call_arg, float) + assert 0 < call_arg <= 30.0 + finally: + await client.close() + + # --- POST retry safety --- # POST is non-idempotent (create() might actually hit the DB even if the # response is 5xx), so the client deliberately restricts POST retries to From 85ebe44d144ed2db4832f135abc22c40cc7b2b8c Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 14:31:48 -0500 Subject: [PATCH 25/31] test: add @pytest.mark.asyncio to test_async_post_does_not_retry_on_503 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer-flagged consistency fix. The test was the only `async def test_*` in the file without the explicit `@pytest.mark.asyncio` decorator — the other 17 async tests all have it. The test was already running correctly under `asyncio_mode = "auto"` in pyproject.toml (test count unchanged at 135 before and after this commit), so this is purely a stylistic consistency change — no behavior or coverage difference. The explicit marker prevents future reviewers from raising the same concern on visual scan. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_client.py b/tests/test_client.py index 747fc4f..bd2dacb 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -965,6 +965,7 @@ def test_post_still_retries_on_429(retry_client: QURLClient) -> None: @respx.mock +@pytest.mark.asyncio async def test_async_post_does_not_retry_on_503() -> None: """Async POST must also not retry on 5xx — sync/async parity guard.""" client = AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=2) From a1d468bf8eb3ea8764b8f466fe5929b0f10ba633 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 14:38:26 -0500 Subject: [PATCH 26/31] docs: explain status=0 SDK convention in batch shape-guard error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer-noted clarification: the `_validate_batch_create_shape` helper's ValidationError uses `status=0`, which is surprising for consumers filtering errors by HTTP status code. Added a comment block explaining that `status=0` is the SDK convention for **client-detected** failures (shape-guard trips, preflight validation, URL scheme checks) as distinct from real HTTP status codes from the server. The comment makes it clear that a consumer seeing `status=0` should understand the failure was synthesized by the SDK before (or after) the network round-trip, not reported by the API, and that the `code` field is the authoritative signal for WHICH kind of SDK-detected failure the error represents. No behavior change — comment-only clarification. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/_utils.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 757cd72..c6b2d5c 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -483,6 +483,15 @@ def _fail(reason: str, *, top_level_keys: list[str] | None = None) -> Validation # (`client_validation`) so callers who need the finer # distinction can branch on `.code`. Matches the qurl-typescript # SDK's `unexpectedResponseError` pattern. + # + # `status=0` is the SDK convention for client-detected failures + # (shape-guard trips, preflight validation, URL scheme checks) + # as opposed to real HTTP status codes from the server. A + # consumer filtering errors by HTTP status who sees `status=0` + # should understand the failure was synthesized by the SDK + # before (or after) the network round-trip, not reported by + # the API. The `code` field is the authoritative signal for + # WHICH kind of SDK-detected failure this is. return ValidationError( status=0, code="unexpected_response", From f2004f89cc6f1a509763371955dea91e7cfd3b1d Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 14:50:31 -0500 Subject: [PATCH 27/31] docs: trim accumulated comments + add 207 passthrough note + migration clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer-flagged consolidation of comment verbosity that grew across multiple review rounds as different reviewers asked "why?" — each answer accumulated inline, and in aggregate the comments became harder to scan than the code they annotated. Three specific examples trimmed: - `parse_create_output` qurl_id normalization: 12 lines → 4. Keeps the essential insight (asymmetric with label: `""` is never a meaningful identifier but IS a meaningful "cleared" value for user-facing metadata) and drops the historical context about which reviewer asked which question. - `_validate_batch_create_shape._fail`: 23 lines → 11. Keeps the three load-bearing facts (DEBUG log carries structural hints not body content; `ValidationError` subclass for catch-by-class; `status=0` is the SDK convention for client-detected failures) and drops the cross-SDK comparison and the reviewer-round history. - `Quota.plan` docstring: 17 lines → 6. Keeps the forward-compat type mechanism (`QuotaPlan` = `Literal | str`) and the sentinel rationale (only hit by tests/bootstrap, since `/v1/quota` always returns a populated plan). Drops the paragraph-long justification for "unknown" vs "" that was accumulated across 5 rounds of ping-pong between reviewers. Net diff: -51 / +30 lines across these three spots, with zero semantic change. The PR comments, commit messages, and tests already capture the full "why" for anyone who needs deeper context; inline comments now serve current readers rather than review-history archaeology. Also added: - Inline comment at the `batch_create` `allow_statuses=(400,)` call site in both clients explaining why 207 Multi-Status doesn't need to be whitelisted: it flows through the normal `status_code < 400` success path automatically. Only the total-failure 400 needs the opt-in because the generic error path would otherwise swallow the populated `BatchCreateOutput` body. Prevents a future maintainer from wondering "why isn't 207 in the whitelist too?" - PR description migration notes expanded for `update()` `access_policy` removal. The spec's `UpdateQurlRequest` schema intentionally makes access policy immutable on existing resources; the migration note now spells out the two supported alternatives (new resource via `create()`, or per-token override via `mint_link(resource_id, access_policy=...)`) with code examples. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/_utils.py | 51 +++++++++------------------------ src/layerv_qurl/async_client.py | 6 ++++ src/layerv_qurl/client.py | 6 ++++ src/layerv_qurl/types.py | 20 +++---------- 4 files changed, 30 insertions(+), 53 deletions(-) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index c6b2d5c..7854916 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -339,18 +339,10 @@ def parse_qurl(data: dict[str, Any]) -> QURL: def parse_create_output(data: dict[str, Any]) -> CreateOutput: """Parse a CreateOutput from API response data.""" - # Normalize empty-string `qurl_id` to None so callers can use the - # idiomatic ``if result.qurl_id:`` presence check. An empty string - # here is either a bug in a mock or a legacy response shape; the - # spec requires a populated qurl_id on success responses. - # - # Intentionally asymmetric with `label` below: an identifier is - # either present or absent and an empty-string id is never a - # meaningful value, but `label` is user-facing metadata where - # ``""`` and absent are semantically distinct — ``""`` means "the - # caller explicitly cleared the label", while missing-from-response - # means "the API didn't return the field at all". Preserving the - # empty string lets consumers distinguish the two cases. + # Normalize empty-string `qurl_id` → None for idiomatic truthiness + # checks. Intentionally asymmetric with `label` (preserved as-is): + # `""` is never a meaningful identifier but IS a meaningful "cleared" + # value for user-facing metadata. qurl_id_raw = data.get("qurl_id") qurl_id = qurl_id_raw if qurl_id_raw else None return CreateOutput( @@ -461,37 +453,22 @@ def _validate_batch_create_shape(data: Any) -> None: """ def _fail(reason: str, *, top_level_keys: list[str] | None = None) -> ValidationError: - # Single source of truth for the error constructed at every - # check failure — keeps status/code/title/detail aligned. - # - # DEBUG log carries structural hints for production triage: - # the failure reason, the observed top-level type, and (if the - # body parsed as a dict) the sorted list of top-level keys. - # Raw body bytes are intentionally excluded — the JSON key - # names come from the API's published schema, not user data, - # so this is safe to log. + # DEBUG log carries structural hints (type + top-level key names + # only — JSON keys come from the published schema, not user + # data) so operators can triage shape-guard trips without + # leaking raw body content into logs. logger.debug( "batch_create shape guard tripped: %s (type=%s, top_level_keys=%s)", reason, type(data).__name__, top_level_keys, ) - # Raise a ValidationError (not bare QURLError) so consumers - # catching subclass-specific errors still catch shape-guard - # failures via `except ValidationError`. The `code` field - # distinguishes this from client-side preflight failures - # (`client_validation`) so callers who need the finer - # distinction can branch on `.code`. Matches the qurl-typescript - # SDK's `unexpectedResponseError` pattern. - # - # `status=0` is the SDK convention for client-detected failures - # (shape-guard trips, preflight validation, URL scheme checks) - # as opposed to real HTTP status codes from the server. A - # consumer filtering errors by HTTP status who sees `status=0` - # should understand the failure was synthesized by the SDK - # before (or after) the network round-trip, not reported by - # the API. The `code` field is the authoritative signal for - # WHICH kind of SDK-detected failure this is. + # Uses `ValidationError` (subclass) not bare `QURLError` so + # `except ValidationError` catches shape-guard trips; `code= + # "unexpected_response"` distinguishes from client-side + # preflight (`client_validation`). `status=0` is the SDK + # convention for all client-detected failures (not real HTTP + # status). See qurl-typescript's `unexpectedResponseError`. return ValidationError( status=0, code="unexpected_response", diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index c41d90b..f4538d0 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -516,6 +516,12 @@ async def batch_create( serialized = [build_body(cast("dict[str, Any]", item)) for item in items] # HTTP 400 carries structured per-item errors on this endpoint — # whitelist it so the generic error path doesn't swallow the body. + # `allow_statuses=(400,)` only — HTTP 207 Multi-Status (partial + # success) flows through the normal `status_code < 400` success + # path automatically, so it doesn't need to be whitelisted. Only + # the total-failure 400 needs the opt-in, because the API + # populates a `BatchCreateOutput` body there that the generic + # error path would otherwise swallow. resp = await self._request( "POST", "/v1/qurls/batch", diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index b205aef..82f0975 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -530,6 +530,12 @@ def batch_create( serialized = [build_body(cast("dict[str, Any]", item)) for item in items] # HTTP 400 carries structured per-item errors on this endpoint — # whitelist it so the generic error path doesn't swallow the body. + # `allow_statuses=(400,)` only — HTTP 207 Multi-Status (partial + # success) flows through the normal `status_code < 400` success + # path automatically, so it doesn't need to be whitelisted. Only + # the total-failure 400 needs the opt-in, because the API + # populates a `BatchCreateOutput` body there that the generic + # error path would otherwise swallow. resp = self._request( "POST", "/v1/qurls/batch", diff --git a/src/layerv_qurl/types.py b/src/layerv_qurl/types.py index 9c3fe51..5a5bf7b 100644 --- a/src/layerv_qurl/types.py +++ b/src/layerv_qurl/types.py @@ -172,22 +172,10 @@ class Usage: class Quota: """Quota and usage information. - ``plan`` is narrowed to the spec's ``QuotaData.plan`` enum via - :data:`QuotaPlan`. Accepts arbitrary strings so a new plan from the - API can't become a breaking type change. - - .. note:: - - ``plan`` defaults to the sentinel ``"unknown"`` purely so the - dataclass can be instantiated with no arguments (this only - happens in tests and internal bootstrap paths). ``"unknown"`` - was chosen over the empty string so callers doing - ``if quota.plan:`` truthiness checks see a real value, and so - the "not-yet-populated" state is self-describing in logs and - error messages. In practice the ``/v1/quota`` endpoint always - returns a populated plan string, so consumers comparing - ``quota.plan == "free"`` on a real response will see one of - the documented enum values. + ``plan`` is typed via :data:`QuotaPlan` (Literal enum + ``str`` + escape hatch for forward compat). Defaults to the sentinel + ``"unknown"`` — only hit by tests/bootstrap paths, since the + ``/v1/quota`` endpoint always returns a populated plan string. """ plan: QuotaPlan = "unknown" From 73ada8af084ce2c8c52423978fdf4b459a4960d1 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 15:03:20 -0500 Subject: [PATCH 28/31] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20JSO?= =?UTF-8?q?N=20error=20handling=20on=20allow=5Fstatuses=20path=20+=207=20t?= =?UTF-8?q?ests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Real bug (medium severity):** `_raw_request` in both sync and async clients called `response.json()` on the success path (including the `allow_statuses` passthrough) without exception handling. If a proxy/CDN returned a whitelisted 400 with non-JSON content — a plain HTML error page, a truncated response, a gateway's own plaintext error — the raw `json.JSONDecodeError` propagated to the caller, bypassing both the batch shape guard AND the normal `parse_error` path (which already has its own try/except around JSON parsing). Fix: wrap the success-path `response.json()` in try/except that falls through to `parse_error(response)` on decode failure. `parse_error` already handles non-JSON error bodies gracefully (echoes `response.text` as the detail when JSON parse fails). The `raise ... from None` suppresses the JSONDecodeError chain from the user-facing traceback since it's noise — the QURLError's own detail captures the relevant failure mode. Added two regression tests (sync + async) that mock a 400 with `` body and assert the SDK raises a QURLError with `status == 400`, not a raw JSONDecodeError. - Added `@pytest.mark.asyncio` + new mirror tests for all 4 sync batch shape-guard regression tests: * test_async_batch_create_rejects_unexpected_400_body_shape * test_async_batch_create_rejects_400_body_with_non_boolean_success * test_async_batch_create_rejects_400_body_with_bool_counts * test_async_batch_create_rejects_counts_arithmetic_mismatch Closes the sync/async parity gap the reviewer flagged. - Added `test_require_max_sessions_in_range_rejects_bool` as a direct unit test of the bool-is-subclass-of-int rejection in `_require_max_sessions_in_range`. The rejection was exercised indirectly via batch bool-counts tests, but a direct test makes the intent explicit and would trip immediately if a future refactor simplifies away the `isinstance(value, bool)` guard. Also sanity-checks that plain int inputs (0, 500, 1000) and None still pass through. - Added a "**Cannot change access_policy**" paragraph to the `update()` docstrings in both sync and async clients, pointing callers at `create()` (new resource with new policy) and `mint_link()` (per-token override) as the two supported alternatives. The PR description's migration notes already cover this, but the docstring note helps callers reading the code directly rather than the PR. Deferred from this round: * Comment-to-code ratio (3rd raising). Previous round trimmed 51 lines across three specific spots the previous reviewer called out. This round's targets overlap with what was already trimmed or contain load-bearing "why" context. Holding the line. * `_parse_dt` malformed-string handling — reviewer explicit "worth a note... not blocking." SDK only parses trusted API responses. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/async_client.py | 24 ++++- src/layerv_qurl/client.py | 24 ++++- tests/test_client.py | 178 ++++++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 2 deletions(-) diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index f4538d0..5b23502 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -6,6 +6,7 @@ from __future__ import annotations import asyncio +import json from typing import TYPE_CHECKING, Any, cast import httpx @@ -346,6 +347,13 @@ async def update( (``q_`` prefix). All fields are optional, but at least one must be provided. ``extend_by`` and ``expires_at`` are mutually exclusive. + **Cannot change `access_policy`** — the policy is immutable after + create, per the OpenAPI spec's ``UpdateQurlRequest``. To change + access policy, either create a new resource via :meth:`create` + with the new policy, or mint a new access token on the existing + resource via :meth:`mint_link` with a policy override (per-token + scope, base resource policy unchanged). + Args: resource_id: Resource or QURL display ID. extend_by: Duration to add (e.g. ``"7d"``). Mutually exclusive @@ -648,7 +656,21 @@ async def _raw_request( if response.status_code < 400 or response.status_code in allow_statuses: if response.status_code == 204 or not response.content: return None, None - envelope = response.json() + try: + envelope = response.json() + except (json.JSONDecodeError, ValueError): + # If a whitelisted status (e.g. 400 on batch_create) + # comes back with a non-JSON body — a proxy HTML + # error page, a truncated response, a gateway's own + # plaintext error — we CAN'T surface it as success. + # Fall through to `parse_error`, which handles + # non-JSON error bodies gracefully and returns a + # well-formed QURLError using the response status + # and reason phrase. `raise ... from None` hides + # the JSONDecodeError chain since it's noise: the + # QURLError already captures "the body wasn't a + # parseable envelope" as its detail. + raise parse_error(response) from None return envelope.get("data"), envelope.get("meta") err = parse_error(response) diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index 82f0975..cd8a885 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -6,6 +6,7 @@ from __future__ import annotations +import json import time from typing import TYPE_CHECKING, Any, cast @@ -360,6 +361,13 @@ def update( (``q_`` prefix). All fields are optional, but at least one must be provided. ``extend_by`` and ``expires_at`` are mutually exclusive. + **Cannot change `access_policy`** — the policy is immutable after + create, per the OpenAPI spec's ``UpdateQurlRequest``. To change + access policy, either create a new resource via :meth:`create` + with the new policy, or mint a new access token on the existing + resource via :meth:`mint_link` with a policy override (per-token + scope, base resource policy unchanged). + Args: resource_id: Resource or QURL display ID. extend_by: Duration to add (e.g. ``"7d"``). Mutually exclusive @@ -662,7 +670,21 @@ def _raw_request( if response.status_code < 400 or response.status_code in allow_statuses: if response.status_code == 204 or not response.content: return None, None - envelope = response.json() + try: + envelope = response.json() + except (json.JSONDecodeError, ValueError): + # If a whitelisted status (e.g. 400 on batch_create) + # comes back with a non-JSON body — a proxy HTML + # error page, a truncated response, a gateway's own + # plaintext error — we CAN'T surface it as success. + # Fall through to `parse_error`, which handles + # non-JSON error bodies gracefully and returns a + # well-formed QURLError using the response status + # and reason phrase. `raise ... from None` hides + # the JSONDecodeError chain since it's noise: the + # QURLError already captures "the body wasn't a + # parseable envelope" as its detail. + raise parse_error(response) from None return envelope.get("data"), envelope.get("meta") err = parse_error(response) diff --git a/tests/test_client.py b/tests/test_client.py index bd2dacb..2189142 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2181,6 +2181,38 @@ def test_create_rejects_negative_max_sessions(client: QURLClient) -> None: client.create(target_url="https://example.com", max_sessions=-1) +def test_require_max_sessions_in_range_rejects_bool() -> None: + """Direct unit test for the ``bool`` rejection in + :func:`_require_max_sessions_in_range`. + + ``bool`` is a subclass of ``int`` in Python (``True == 1``, + ``False == 0``), so a naive ``isinstance(value, int)`` check + would silently accept ``max_sessions=True``. The validator has + an explicit ``isinstance(value, bool)`` rejection to catch this. + Reviewer's argument for this test: a future simplification that + drops the bool guard would trip this regression immediately. + + The rejection is already exercised indirectly via batch bool- + counts tests, but a direct unit test makes the intent explicit. + """ + import layerv_qurl._utils as utils + + # `True` and `False` are type-compatible with `int` in Python (bool + # is a subclass of int), so mypy doesn't need a type ignore — the + # test exercises a RUNTIME check that catches what the type system + # can't. + with pytest.raises(ValueError, match="max_sessions"): + utils._require_max_sessions_in_range(True) + with pytest.raises(ValueError, match="max_sessions"): + utils._require_max_sessions_in_range(False) + # Sanity: plain ints still pass through. + utils._require_max_sessions_in_range(0) + utils._require_max_sessions_in_range(500) + utils._require_max_sessions_in_range(1000) + # And None (the "not provided" sentinel) is still a no-op. + utils._require_max_sessions_in_range(None) + + @respx.mock def test_create_accepts_max_sessions_boundaries(client: QURLClient) -> None: """max_sessions=0 (unlimited) and max_sessions=1000 (hard limit) are both valid.""" @@ -2902,6 +2934,152 @@ def test_batch_create_rejects_counts_arithmetic_mismatch( client.batch_create([{"target_url": "https://example.com"}]) +# ---- Async mirrors of the batch shape-guard tests ------------------------- +# Sync/async parity: every sync shape-guard test above has an async twin. +# Without these, an async-specific regression (e.g. a refactor that diverged +# the two code paths) could slip past CI. + + +@respx.mock +@pytest.mark.asyncio +async def test_async_batch_create_rejects_unexpected_400_body_shape( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of test_batch_create_rejects_unexpected_400_body_shape.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={"data": {"unexpected": "not a batch envelope"}}, + ) + ) + with pytest.raises(QURLError, match="Unexpected response shape"): + await async_client.batch_create([{"target_url": "https://example.com"}]) + + +@respx.mock +@pytest.mark.asyncio +async def test_async_batch_create_rejects_400_body_with_non_boolean_success( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of test_batch_create_rejects_400_body_with_non_boolean_success.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={ + "data": { + "succeeded": 0, + "failed": 1, + "results": [ + {"index": 0, "success": "oops"}, # should be bool + ], + }, + }, + ) + ) + with pytest.raises(QURLError, match="Unexpected response shape"): + await async_client.batch_create([{"target_url": "https://example.com"}]) + + +@respx.mock +@pytest.mark.asyncio +async def test_async_batch_create_rejects_400_body_with_bool_counts( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of test_batch_create_rejects_400_body_with_bool_counts.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={ + "data": { + "succeeded": True, # bool should be rejected + "failed": False, + "results": [], + }, + }, + ) + ) + with pytest.raises(QURLError, match="Unexpected response shape"): + await async_client.batch_create([{"target_url": "https://example.com"}]) + + +@respx.mock +@pytest.mark.asyncio +async def test_async_batch_create_rejects_counts_arithmetic_mismatch( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of test_batch_create_rejects_counts_arithmetic_mismatch.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={ + "data": { + "succeeded": 5, # claims 5 succeeded + "failed": 0, + "results": [ + # …but only 1 entry + {"index": 0, "success": True, "resource_id": "r_only1"}, + ], + }, + }, + ) + ) + with pytest.raises(QURLError, match="Unexpected response shape"): + await async_client.batch_create([{"target_url": "https://example.com"}]) + + +@respx.mock +def test_batch_create_falls_through_to_parse_error_on_non_json_body( + client: QURLClient, +) -> None: + """Defense-in-depth: if a whitelisted 400 (or other status in + ``allow_statuses``) comes back with non-JSON content — e.g. a proxy + HTML error page, a CDN captive portal, a gateway plaintext error — + the SDK must NOT raise a raw ``JSONDecodeError`` from inside + ``response.json()``. Instead it should fall through to + ``parse_error``, which handles non-JSON error bodies gracefully + and returns a well-formed ``QURLError`` with the response status. + + Without this guard, a JSONDecodeError would propagate raw to the + caller, bypassing both the batch shape guard and the standard + error path — confusing and hard to handle. Note: this test does + NOT assert on detail content, because `parse_error` intentionally + echoes non-JSON response body text into the detail field (so + callers can see plaintext gateway errors); that behavior is + scoped to this bug fix only via the early-return path. + """ + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + text="Bad Gateway", + ) + ) + # Load-bearing assertion: raises a QURLError (not raw + # JSONDecodeError/ValueError from response.json()) with the + # correct HTTP status. The class hierarchy dispatch in parse_error + # maps 400 → ValidationError, and we accept any QURLError subclass + # here since the specific class isn't the contract under test. + with pytest.raises(QURLError) as exc_info: + client.batch_create([{"target_url": "https://example.com"}]) + assert exc_info.value.status == 400 + + +@respx.mock +@pytest.mark.asyncio +async def test_async_batch_create_falls_through_to_parse_error_on_non_json_body( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of the non-JSON body fall-through test.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + text="Bad Gateway", + ) + ) + with pytest.raises(QURLError) as exc_info: + await async_client.batch_create([{"target_url": "https://example.com"}]) + assert exc_info.value.status == 400 + + @respx.mock def test_batch_create_accepts_access_policy_dataclass( client: QURLClient, From f6d3967ac56f67399df62959ba254273b568c369 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 15:40:05 -0500 Subject: [PATCH 29/31] =?UTF-8?q?test:=20address=20review=20=E2=80=94=20ac?= =?UTF-8?q?cess=5Fpolicy=20immutability=20invariant=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Short round on PR #8 — 1 test addition + 1 PR body update. The reviewer's overall verdict was "no blocking issues found." Added: - test_update_rejects_access_policy_kwarg (sync) — locks in that `access_policy` is immutable on update() per the OpenAPI spec's UpdateQurlRequest schema. Currently the signature itself enforces this (TypeError on unknown kwargs), but this test is an explicit invariant guard: a future refactor adding **kwargs to update() for forward-compat pass-through would silently accept access_policy again, breaking the spec-enforced contract. Callers needing policy changes should create a new resource or use mint_link() per-token overrides. - test_async_update_rejects_access_policy_kwarg — async mirror. Tests: 142 → 144 passing. PR description updated separately (not in this commit): - Added Quota.plan default sentinel change to the breaking-changes summary (`""` → `"unknown"` — affects `if not quota.plan` checks). Deferred: none. The rest of the review was either strengths, "intentional per the comment", or explicit non-issues. --- tests/test_client.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index 2189142..0fa7a0a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1963,6 +1963,24 @@ async def test_async_update_with_tags(async_client: AsyncQURLClient) -> None: assert body == {"tags": ["internal"]} +async def test_async_update_rejects_access_policy_kwarg( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of `test_update_rejects_access_policy_kwarg`. + + Locks in the immutability invariant on the async surface. The + reasoning is identical: a future refactor adding `**kwargs` to + `async_client.update()` would silently accept `access_policy`, + breaking the spec-enforced contract. This guards against that + independently of the function signature. + """ + with pytest.raises(TypeError, match="access_policy"): + await async_client.update( + "r_abc", + access_policy={"ai_agent_policy": "allow"}, # type: ignore[call-arg] + ) + + # --- batch_create validation --- @@ -2347,6 +2365,23 @@ def test_update_rejects_both_extend_by_and_expires_at(client: QURLClient) -> Non client.update("r_abc", extend_by="24h", expires_at="2026-04-01T00:00:00Z") +def test_update_rejects_access_policy_kwarg(client: QURLClient) -> None: + """Lock in that `access_policy` is immutable on update(). + + Per the OpenAPI spec's `UpdateQurlRequest` schema, access policy is + set only at create time and cannot be modified on an existing + resource. The signature itself currently enforces this (Python + raises `TypeError` on unknown kwargs), but this test is an explicit + invariant guard: a future refactor adding `**kwargs` to `update()` + for forward-compat pass-through would silently accept + `access_policy` again, breaking the spec-enforced immutability + contract. Callers needing policy changes should create a new + resource or mint a per-token override via `mint_link()`. + """ + with pytest.raises(TypeError, match="access_policy"): + client.update("r_abc", access_policy={"ai_agent_policy": "allow"}) # type: ignore[call-arg] + + # ---- Spec-derived input validation (mint_link) ----------------------------- From ecb791313f18115ae57df962ad3b8c4b3aca8592 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 21:27:17 -0500 Subject: [PATCH 30/31] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20def?= =?UTF-8?q?ensive=20guards=20+=20limit=20validation=20+=2013=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6 fix-now items (2 defensive bug fixes, 1 signature improvement, 3 test coverage gaps), 4 deferrals (2 filed as issues). Fixed: - _parse_access_policy: add isinstance(ap, dict) guard against non-dict ai_agent_policy values. Without this, a bare string/bool from the API would raise AttributeError on .get("block_all"). Consistent with the defensive posture in _validate_batch_create_shape. - QURLError.detail: change default from str="" to str|None=None with `detail if detail is not None else title`. Distinguishes "not provided" (falls back to title) from "explicitly empty" (stored as-is). The old `detail or title` coercion silently converted `detail=""` to title, which surprised callers who explicitly passed empty string. - build_list_params: client-side limit validation (1-100) per OpenAPI spec (GET /v1/qurls → limit: integer, minimum: 1, maximum: 100, default: 20). Rejects 0, negative, floats, and bool (bool is subclass of int — must be explicitly rejected). Python-parity follow-up from qurl-typescript PR #14 round 11 (commit 3d471fa). Tests: 144 → 157 passing (+13 new regression guards). - batch_create 207 Multi-Status routes through success path (sync+async) - create(expires_at=...) TypeError invariant guard (sync+async) - _parse_access_policy null/missing ai_agent_policy yields None - _parse_access_policy non-dict ai_agent_policy is silently ignored - list() rejects limit: 0 / 101 / negative / float / bool - list() accepts limit at boundaries (1 and 100) - list() omitted limit produces no query param Deferred: - sync/async duplication refactor — filed as #20 - test_client.py split — filed as #21 - QURLError.type builtin shadow — docstring already acknowledges - http:// URL scheme warning — caller's responsibility per reviewer --- src/layerv_qurl/_utils.py | 22 +++- src/layerv_qurl/errors.py | 11 +- tests/test_client.py | 224 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 251 insertions(+), 6 deletions(-) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 7854916..9c840e3 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -277,8 +277,12 @@ def require_resource_id_prefix(resource_id: str, operation: str = "delete") -> N def _parse_access_policy(data: dict[str, Any]) -> AccessPolicy: """Parse an AccessPolicy from API response data.""" ai_policy = None - if data.get("ai_agent_policy") is not None: - ap = data["ai_agent_policy"] + ap = data.get("ai_agent_policy") + # Guard against non-dict values (e.g. API returning a bare string + # or boolean for ai_agent_policy). Without this, `.get("block_all")` + # would raise AttributeError. Consistent with the defensive posture + # in `_validate_batch_create_shape`. + if ap is not None and isinstance(ap, dict): ai_policy = AIAgentPolicy( block_all=ap.get("block_all"), deny_categories=ap.get("deny_categories"), @@ -653,6 +657,20 @@ def build_list_params( expires_after: datetime | str | None = None, ) -> dict[str, str]: """Build query params for list endpoints, dropping None values.""" + # Per the OpenAPI spec (GET /v1/qurls → limit: integer, minimum: 1, + # maximum: 100, default: 20). Client-side validation catches obvious + # mistakes before a round-trip, matching the existing style for + # max_sessions, tag count, URL length. Omitting `limit` (None) lets + # the server apply its default page size. + if limit is not None: + if not isinstance(limit, int) or isinstance(limit, bool): + raise ValueError( + f"limit: must be an integer between 1 and 100 (got {type(limit).__name__})" + ) + if limit < 1 or limit > 100: + raise ValueError( + f"limit: must be an integer between 1 and 100 (got {limit})" + ) # ``status`` is a QURLStatus (Literal | str) — covered by the ``str`` arm. # Ordered most-specific to least-specific: datetime/int are concrete # types, str is the widening arm, None is the "drop" sentinel. diff --git a/src/layerv_qurl/errors.py b/src/layerv_qurl/errors.py index e59774e..1804ea4 100644 --- a/src/layerv_qurl/errors.py +++ b/src/layerv_qurl/errors.py @@ -50,7 +50,7 @@ def __init__( status: int, code: str, title: str, - detail: str = "", + detail: str | None = None, type: str | None = None, instance: str | None = None, invalid_fields: dict[str, str] | None = None, @@ -58,9 +58,12 @@ def __init__( retry_after: int | None = None, ) -> None: # RFC 7807 leaves `detail` optional, and `title` is always present. - # Falling back to title keeps the Exception message meaningful - # instead of producing "Title (403): " with a trailing empty string. - message_detail = detail or title + # When `detail` is `None` (omitted), fall back to `title` so the + # Exception message stays meaningful instead of producing + # "Title (403): None". An explicit empty string is stored as-is — + # the caller opted in. Uses `is not None` rather than truthiness + # so `detail=""` is distinguishable from "not provided". + message_detail = detail if detail is not None else title super().__init__(f"{title} ({status}): {message_detail}") self.status = status self.code = code diff --git a/tests/test_client.py b/tests/test_client.py index 0fa7a0a..d433f09 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1590,6 +1590,92 @@ def test_batch_create_all_succeed(client: QURLClient) -> None: assert result.results[1].expires_at is None +@respx.mock +def test_batch_create_207_multi_status(client: QURLClient) -> None: + """HTTP 207 Multi-Status routes through the success path (< 400). + + The success path in ``_raw_request`` is gated by + ``response.status_code < 400``, so 207 flows through naturally like + 200/201. This test locks in the status routing against a future + refactor that might accidentally narrow the range (e.g. ``== 200`` + or ``< 300``). Without this, a partial-success 207 response + could be misclassified as an error. + """ + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 207, + json={ + "data": { + "succeeded": 1, + "failed": 1, + "results": [ + { + "index": 0, + "success": True, + "resource_id": "r_multi", + "qurl_link": "https://qurl.link/#at_multi", + "qurl_site": "https://r_multi.qurl.site", + }, + { + "index": 1, + "success": False, + "error": { + "code": "validation_error", + "message": "items[1]: target_url must be HTTPS", + }, + }, + ], + }, + }, + ) + ) + + result = client.batch_create( + [ + {"target_url": "https://a.com"}, + {"target_url": "https://b.com"}, + ] + ) + assert result.succeeded == 1 + assert result.failed == 1 + assert result.results[0].success is True + assert result.results[0].resource_id == "r_multi" + assert result.results[1].success is False + + +@respx.mock +async def test_async_batch_create_207_multi_status( + async_client: AsyncQURLClient, +) -> None: + """Async mirror: HTTP 207 Multi-Status routes through success path.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 207, + json={ + "data": { + "succeeded": 1, + "failed": 0, + "results": [ + { + "index": 0, + "success": True, + "resource_id": "r_async207", + "qurl_link": "https://qurl.link/#at_a207", + "qurl_site": "https://r_async207.qurl.site", + }, + ], + }, + }, + ) + ) + + result = await async_client.batch_create( + [{"target_url": "https://a.com"}] + ) + assert result.succeeded == 1 + assert result.results[0].resource_id == "r_async207" + + @respx.mock def test_batch_create_partial_failure(client: QURLClient) -> None: """batch_create() correctly parses partial failures.""" @@ -2094,6 +2180,56 @@ def test_create_with_minimal_ai_agent_policy_only(client: QURLClient) -> None: assert "deny_categories" not in body["access_policy"]["ai_agent_policy"] +# --- _parse_access_policy deserialization edge cases --- + + +def test_parse_access_policy_null_ai_agent_policy() -> None: + """``ai_agent_policy: null`` / missing yields ``ai_agent_policy=None``. + + The happy-path test (test_create_with_access_policy) covers a + populated AIAgentPolicy, but the None branch wasn't directly + asserted. This locks in that null and missing are both normalised + to ``None``, not to an empty ``AIAgentPolicy()``. + """ + import layerv_qurl._utils as utils + + # Explicit null + policy = utils._parse_access_policy( + {"ip_allowlist": ["10.0.0.0/8"], "ai_agent_policy": None} + ) + assert policy.ai_agent_policy is None + assert policy.ip_allowlist == ["10.0.0.0/8"] + + # Key missing entirely + policy2 = utils._parse_access_policy({"ip_denylist": ["192.168.0.0/16"]}) + assert policy2.ai_agent_policy is None + assert policy2.ip_denylist == ["192.168.0.0/16"] + + +def test_parse_access_policy_non_dict_ai_agent_policy_is_ignored() -> None: + """Non-dict ``ai_agent_policy`` (e.g. bare string or bool) is ignored. + + Without the ``isinstance(ap, dict)`` guard in ``_parse_access_policy``, + ``.get("block_all")`` would raise ``AttributeError`` on a non-dict + value. This locks in the defensive posture against unexpected API + shapes — a string or boolean should be treated as "no policy" + rather than crashing the entire response parser. + """ + import layerv_qurl._utils as utils + + # Bare string + policy = utils._parse_access_policy({"ai_agent_policy": "unexpected_string"}) + assert policy.ai_agent_policy is None + + # Boolean + policy2 = utils._parse_access_policy({"ai_agent_policy": True}) + assert policy2.ai_agent_policy is None + + # Integer + policy3 = utils._parse_access_policy({"ai_agent_policy": 42}) + assert policy3.ai_agent_policy is None + + # --- _serialize_value end-to-end with nested dataclasses --- @@ -2382,6 +2518,32 @@ def test_update_rejects_access_policy_kwarg(client: QURLClient) -> None: client.update("r_abc", access_policy={"ai_agent_policy": "allow"}) # type: ignore[call-arg] +def test_create_rejects_expires_at_kwarg(client: QURLClient) -> None: + """Lock in that ``expires_at`` was removed from ``create()``. + + Per the OpenAPI spec's ``CreateQurlRequest`` schema, creation accepts + only ``expires_in`` (relative duration), not ``expires_at`` (absolute + timestamp). The old ``expires_at`` parameter was removed in this PR. + Like the ``access_policy`` invariant guard on ``update()``, this test + is an explicit safety net: a future refactor adding ``**kwargs`` + would silently re-accept the removed parameter. Callers needing an + absolute expiry should use ``create(expires_in=...) + update(expires_at=...)``. + """ + with pytest.raises(TypeError, match="expires_at"): + client.create(target_url="https://example.com", expires_at="2026-04-01T00:00:00Z") # type: ignore[call-arg] + + +async def test_async_create_rejects_expires_at_kwarg( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of ``test_create_rejects_expires_at_kwarg``.""" + with pytest.raises(TypeError, match="expires_at"): + await async_client.create( # type: ignore[call-arg] + target_url="https://example.com", + expires_at="2026-04-01T00:00:00Z", + ) + + # ---- Spec-derived input validation (mint_link) ----------------------------- @@ -2428,6 +2590,68 @@ def test_delete_error_does_not_leak_full_resource_id(client: QURLClient) -> None assert "sensitive_suffix" not in msg +# ---- list() limit validation (OpenAPI spec: integer, 1-100) ------------------ + + +def test_list_rejects_limit_zero(client: QURLClient) -> None: + """Per OpenAPI (GET /v1/qurls → limit: minimum: 1, maximum: 100).""" + with pytest.raises(ValueError, match="1 and 100"): + client.list(limit=0) + + +def test_list_rejects_limit_above_100(client: QURLClient) -> None: + with pytest.raises(ValueError, match="1 and 100"): + client.list(limit=101) + + +def test_list_rejects_negative_limit(client: QURLClient) -> None: + with pytest.raises(ValueError, match="1 and 100"): + client.list(limit=-5) + + +def test_list_rejects_non_integer_limit(client: QURLClient) -> None: + """Floats pass Python's ``int | None`` type annotation at runtime + but violate the spec's ``type: integer``.""" + with pytest.raises(ValueError, match="integer"): + client.list(limit=2.5) # type: ignore[arg-type] + + +def test_list_rejects_bool_limit(client: QURLClient) -> None: + """``bool`` is a subclass of ``int`` — must be explicitly rejected + like ``_require_max_sessions_in_range``.""" + with pytest.raises(ValueError): + client.list(limit=True) # type: ignore[arg-type] + + +@respx.mock +def test_list_accepts_limit_at_boundaries(client: QURLClient) -> None: + """Boundary regression: 1 and 100 are both valid.""" + respx.get(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 200, + json={"data": [], "meta": {"has_more": False}}, + ) + ) + result_1 = client.list(limit=1) + assert result_1.has_more is False + result_100 = client.list(limit=100) + assert result_100.has_more is False + + +@respx.mock +def test_list_omitted_limit_uses_server_default(client: QURLClient) -> None: + """Omitting ``limit`` entirely must not trip the validator and must + not produce a ``limit=`` query param.""" + route = respx.get(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 200, + json={"data": [], "meta": {"has_more": False}}, + ) + ) + client.list() + assert "limit" not in str(route.calls[0].request.url) + + # ---- batch_create per-item validation & async validators ------------------- From 818919b39da50e03d8e34727d09f3d0f185a479b Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 11 Apr 2026 21:35:23 -0500 Subject: [PATCH 31/31] =?UTF-8?q?test:=20address=20review=20=E2=80=94=20da?= =?UTF-8?q?tetime=20serialization=20integration=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Short round on PR #8. Reviewer's verdict: "Strong PR. Fix the three missing @pytest.mark.asyncio decorators and this is ready to merge." Fixed: - _serialize_value datetime integration test: the unit test covers the function directly, but there was no integration test passing a datetime object through the full client method pipeline. Added test_update_serializes_datetime_expires_at which passes datetime(2026, 4, 1, tzinfo=timezone.utc) to update(expires_at=...) and asserts the wire body contains "2026-04-01T00:00:00+00:00". Closes the build_body → _serialize_value → .isoformat() loop. Tests: 157 → 158 passing. Rebutted: - Missing @pytest.mark.asyncio on 3 async tests: the reviewer's main finding is factually incorrect. pyproject.toml:68 sets asyncio_mode = "auto", which automatically marks all async def test_* functions — no explicit decorator needed. Verified by running the 3 specific tests: "3 passed, 154 deselected." The async parity coverage IS being exercised. No deferrals — all other items were either positive observations or explicitly "not a blocker." --- tests/test_client.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index d433f09..1e38638 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -517,6 +517,37 @@ def test_update_combined(client: QURLClient) -> None: assert body == {"extend_by": "7d", "description": "updated"} +@respx.mock +def test_update_serializes_datetime_expires_at(client: QURLClient) -> None: + """Integration test: a ``datetime`` object passed as ``expires_at`` + is serialized to an ISO 8601 string in the wire body via the + ``build_body → _serialize_value`` pipeline. The unit test + ``test_serialize_value_datetime`` covers the function directly; + this closes the loop through the full client method path. + """ + route = respx.patch(f"{BASE_URL}/v1/qurls/r_abc").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "resource_id": "r_abc", + "target_url": "https://example.com", + "status": "active", + "created_at": "2026-03-10T10:00:00Z", + "expires_at": "2026-04-01T00:00:00+00:00", + }, + }, + ) + ) + + client.update( + "r_abc", + expires_at=datetime(2026, 4, 1, 0, 0, 0, tzinfo=timezone.utc), + ) + body = json.loads(route.calls[0].request.content) + assert body["expires_at"] == "2026-04-01T00:00:00+00:00" + + @respx.mock def test_mint_link(client: QURLClient) -> None: respx.post(f"{BASE_URL}/v1/qurls/r_abc123def45/mint_link").mock(