From f134666ccf7ef27af388db9ddd3bdb34c111db6e Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 10 Apr 2026 01:48:26 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat!:=20restructure=20QURL=20type=20?= =?UTF-8?q?=E2=80=94=20add=20AccessToken,=20qurl=5Fcount,=20access=5Ftoken?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Per-QURL fields (one_time_use, max_sessions, access_policy, qurl_link) removed from QURL dataclass. These were phantom fields — the API never populated them (always False/None at runtime). - Add AccessToken dataclass for per-token details - Add qurl_count and access_tokens fields to QURL - parse_qurl() maps API "qurls" array to access_tokens - Export AccessToken from package - Major version bump to 1.0.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 2 +- src/layerv_qurl/__init__.py | 2 ++ src/layerv_qurl/_utils.py | 55 +++++++++++++++++++++++++++---------- src/layerv_qurl/types.py | 23 +++++++++++++--- tests/test_client.py | 35 ++++++++++++++++++----- 5 files changed, 90 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7598cf5..9fc71f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "layerv-qurl" -version = "0.1.0" +version = "1.0.0" description = "Python SDK for the QURL API - secure, time-limited access links" readme = "README.md" license = "MIT" diff --git a/src/layerv_qurl/__init__.py b/src/layerv_qurl/__init__.py index da3c2d3..4fb9e56 100644 --- a/src/layerv_qurl/__init__.py +++ b/src/layerv_qurl/__init__.py @@ -20,6 +20,7 @@ QURL, AccessGrant, AccessPolicy, + AccessToken, CreateOutput, ListOutput, MintOutput, @@ -47,6 +48,7 @@ "QURLStatus", "AccessGrant", "AccessPolicy", + "AccessToken", "CreateOutput", "ListOutput", "MintOutput", diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 3d8d8d9..ad6be30 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -25,6 +25,7 @@ QURL, AccessGrant, AccessPolicy, + AccessToken, CreateOutput, ListOutput, MintOutput, @@ -98,31 +99,55 @@ def build_body(kwargs: dict[str, Any]) -> dict[str, Any]: return body -def parse_qurl(data: dict[str, Any]) -> QURL: - """Parse a QURL resource from API response data.""" +def _parse_access_policy(data: dict[str, Any]) -> AccessPolicy: + """Parse an AccessPolicy from API response data.""" + 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"), + ) + + +def _parse_access_token(data: dict[str, Any]) -> AccessToken: + """Parse an AccessToken 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"), - ) + policy = _parse_access_policy(data["access_policy"]) + return AccessToken( + qurl_id=data["qurl_id"], + status=data["status"], + one_time_use=data.get("one_time_use", False), + max_sessions=data.get("max_sessions", 0), + session_duration=data.get("session_duration", 0), + use_count=data.get("use_count", 0), + label=data.get("label"), + qurl_site=data.get("qurl_site"), + access_policy=policy, + created_at=_parse_dt(data.get("created_at")), + expires_at=_parse_dt(data.get("expires_at")), + ) + + +def parse_qurl(data: dict[str, Any]) -> QURL: + """Parse a QURL resource from API response data.""" + tokens = None + # API returns "qurls" array; SDK exposes as "access_tokens" for clarity. + raw_tokens = data.get("qurls") or data.get("access_tokens") + if raw_tokens: + tokens = [_parse_access_token(t) for t in raw_tokens] 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"), qurl_site=data.get("qurl_site"), - qurl_link=data.get("qurl_link"), - access_policy=policy, + qurl_count=data.get("qurl_count"), + access_tokens=tokens, ) diff --git a/src/layerv_qurl/types.py b/src/layerv_qurl/types.py index 9f8a39c..d793b9b 100644 --- a/src/layerv_qurl/types.py +++ b/src/layerv_qurl/types.py @@ -32,6 +32,23 @@ class AccessPolicy: user_agent_deny_regex: str | None = None +@dataclass +class AccessToken: + """An individual access token within a QURL.""" + + qurl_id: str + status: QURLStatus + one_time_use: bool = False + max_sessions: int = 0 + session_duration: int = 0 + use_count: int = 0 + label: str | None = None + qurl_site: str | None = None + access_policy: AccessPolicy | None = None + created_at: datetime | None = None + expires_at: datetime | None = None + + @dataclass class QURL: """A QURL resource as returned by the API.""" @@ -41,12 +58,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 qurl_site: str | None = None - qurl_link: str | None = None - access_policy: AccessPolicy | None = None + qurl_count: int | None = None + access_tokens: list[AccessToken] | None = None @dataclass diff --git a/tests/test_client.py b/tests/test_client.py index f2c4579..856bec6 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, AccessToken BASE_URL = "https://api.test.layerv.ai" @@ -209,7 +209,21 @@ 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, + "qurl_count": 2, + "access_tokens": [ + { + "qurl_id": "at_abc", + "status": "active", + "one_time_use": True, + "max_sessions": 5, + "session_duration": 300, + "use_count": 1, + "label": "test token", + "qurl_site": "https://r_abc123def45.qurl.site", + "created_at": "2026-03-10T10:00:00Z", + "expires_at": "2026-03-15T10:00:00Z", + }, + ], }, "meta": {"request_id": "req_2"}, }, @@ -221,6 +235,17 @@ def test_get(client: QURLClient) -> None: assert result.status == "active" assert isinstance(result.created_at, datetime) assert result.created_at == datetime(2026, 3, 10, 10, 0, 0, tzinfo=timezone.utc) + assert result.qurl_count == 2 + assert result.access_tokens is not None + assert len(result.access_tokens) == 1 + token = result.access_tokens[0] + assert isinstance(token, AccessToken) + assert token.qurl_id == "at_abc" + assert token.one_time_use is True + assert token.max_sessions == 5 + assert token.session_duration == 300 + assert token.use_count == 1 + assert token.label == "test token" @respx.mock @@ -1116,9 +1141,6 @@ 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.*", - }, }, }, ) @@ -1126,7 +1148,6 @@ def test_access_policy_in_update(client: QURLClient) -> None: 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.*" + assert result.resource_id == "r_abc" body = json.loads(route.calls[0].request.content) assert body["access_policy"] == {"user_agent_deny_regex": "curl.*"} From 38e3d0c6df586cab862dba6863e3baeb268bb5b9 Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 10 Apr 2026 02:49:43 -0500 Subject: [PATCH 2/3] fix: test mock wire format, falsy empty list bug, version bump, edge case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DE review fixes: 1. Test mock used "access_tokens" (SDK name) instead of "qurls" (API wire format) — the qurls→access_tokens mapping was never exercised 2. parse_qurl used `or` for falsy check — empty list [] from API would be treated as falsy and fall through. Now uses explicit `in` check 3. Revert manual version bump to 0.1.0 — Release Please handles versioning from conventional commit prefix (feat!) 4. Add test_get_without_tokens — verifies access_tokens is None when API returns no qurls array Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 2 +- src/layerv_qurl/_utils.py | 4 ++-- tests/test_client.py | 27 ++++++++++++++++++++++++++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9fc71f1..7598cf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "layerv-qurl" -version = "1.0.0" +version = "0.1.0" description = "Python SDK for the QURL API - secure, time-limited access links" readme = "README.md" license = "MIT" diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index ad6be30..3ee9833 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -135,8 +135,8 @@ def parse_qurl(data: dict[str, Any]) -> QURL: """Parse a QURL resource from API response data.""" tokens = None # API returns "qurls" array; SDK exposes as "access_tokens" for clarity. - raw_tokens = data.get("qurls") or data.get("access_tokens") - if raw_tokens: + raw_tokens = data.get("qurls") if "qurls" in data else data.get("access_tokens") + if raw_tokens is not None: tokens = [_parse_access_token(t) for t in raw_tokens] return QURL( resource_id=data["resource_id"], diff --git a/tests/test_client.py b/tests/test_client.py index 856bec6..8cbcfda 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -210,7 +210,8 @@ def test_get(client: QURLClient) -> None: "created_at": "2026-03-10T10:00:00Z", "expires_at": "2026-03-15T10:00:00Z", "qurl_count": 2, - "access_tokens": [ + # API wire format uses "qurls"; parse_qurl maps to access_tokens + "qurls": [ { "qurl_id": "at_abc", "status": "active", @@ -248,6 +249,30 @@ def test_get(client: QURLClient) -> None: assert token.label == "test token" +@respx.mock +def test_get_without_tokens(client: QURLClient) -> None: + 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": 0, + }, + "meta": {"request_id": "req_x"}, + }, + ) + ) + + result = client.get("r_abc123def45") + assert result.resource_id == "r_abc123def45" + assert result.qurl_count == 0 + assert result.access_tokens is None + + @respx.mock def test_list(client: QURLClient) -> None: respx.get(f"{BASE_URL}/v1/qurls").mock( From 635760fd246be258ca53f2a9cc88f733b12827b6 Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 10 Apr 2026 14:14:14 -0500 Subject: [PATCH 3/3] fix: access_policy falsy check, add token policy + sparse token tests CR feedback: 1. _parse_access_token: `if data.get("access_policy"):` would drop an empty dict `{}` from the API. Changed to `is not None` check. 2. Add test_get_token_with_access_policy: exercises the access_policy parsing branch inside _parse_access_token, and doubles as a sparse token test verifying all defaults (one_time_use=False, max_sessions=0, label=None, etc.) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layerv_qurl/_utils.py | 2 +- tests/test_client.py | 44 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 3ee9833..f35efcb 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -114,7 +114,7 @@ def _parse_access_policy(data: dict[str, Any]) -> AccessPolicy: def _parse_access_token(data: dict[str, Any]) -> AccessToken: """Parse an AccessToken from API response data.""" policy = None - if data.get("access_policy"): + if data.get("access_policy") is not None: policy = _parse_access_policy(data["access_policy"]) return AccessToken( qurl_id=data["qurl_id"], diff --git a/tests/test_client.py b/tests/test_client.py index 8cbcfda..f4b9020 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -249,6 +249,50 @@ def test_get(client: QURLClient) -> None: assert token.label == "test token" +@respx.mock +def test_get_token_with_access_policy(client: QURLClient) -> None: + 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", + "qurls": [ + { + "qurl_id": "q_abc12345678", + "status": "active", + "access_policy": { + "ip_allowlist": ["10.0.0.0/8"], + "geo_denylist": ["CN"], + }, + }, + ], + }, + "meta": {"request_id": "req_p"}, + }, + ) + ) + + result = client.get("r_abc123def45") + assert result.access_tokens is not None + token = result.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_denylist == ["CN"] + # Verify defaults on sparse token + assert token.one_time_use is False + assert token.max_sessions == 0 + assert token.session_duration == 0 + assert token.use_count == 0 + assert token.label is None + assert token.qurl_site is None + assert token.created_at is None + assert token.expires_at is None + + @respx.mock def test_get_without_tokens(client: QURLClient) -> None: respx.get(f"{BASE_URL}/v1/qurls/r_abc123def45").mock(