From b2e1e167eca9a163ad7367856f1637ff3fdee519 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 11 Mar 2026 14:45:51 -0500 Subject: [PATCH 1/8] feat: initial Python SDK setup Extracted from layervai/qurl-integrations (apps/sdk-python/). Includes: - Sync and async QURL API clients - LangChain tool integration - Full test suite - CI, Release Please, and PyPI publish workflows - Claude code review workflow Co-Authored-By: Claude Opus 4.6 --- .github/dependabot.yml | 10 + .github/workflows/ci.yml | 29 + .github/workflows/claude-code-review.yml | 42 ++ .github/workflows/release-please.yml | 62 ++ .gitignore | 8 + .release-please-manifest.json | 1 + CHANGELOG.md | 1 + CLAUDE.md | 33 + LICENSE | 21 + README.md | 125 ++++ pyproject.toml | 61 ++ release-please-config.json | 12 + src/layerv_qurl/__init__.py | 44 ++ src/layerv_qurl/_utils.py | 229 ++++++ src/layerv_qurl/async_client.py | 338 +++++++++ src/layerv_qurl/client.py | 351 +++++++++ src/layerv_qurl/errors.py | 52 ++ src/layerv_qurl/langchain.py | 162 +++++ src/layerv_qurl/types.py | 127 ++++ tests/__init__.py | 0 tests/test_client.py | 871 +++++++++++++++++++++++ tests/test_langchain.py | 140 ++++ 22 files changed, 2719 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/claude-code-review.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .gitignore create mode 100644 .release-please-manifest.json create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 release-please-config.json create mode 100644 src/layerv_qurl/__init__.py create mode 100644 src/layerv_qurl/_utils.py create mode 100644 src/layerv_qurl/async_client.py create mode 100644 src/layerv_qurl/client.py create mode 100644 src/layerv_qurl/errors.py create mode 100644 src/layerv_qurl/langchain.py create mode 100644 src/layerv_qurl/types.py create mode 100644 tests/__init__.py create mode 100644 tests/test_client.py create mode 100644 tests/test_langchain.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..abd2e58 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + - package-ecosystem: pip + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5d11804 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.12", "3.13"] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: pip install -e ".[dev]" + - name: Lint + run: ruff check + - name: Type check + if: matrix.python-version == '3.12' + run: mypy src/ + - name: Test + run: pytest tests/ -v diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..e06e15c --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,42 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + +jobs: + claude-review: + if: github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + uses: anthropics/claude-code-action@1fc90f3ed982521116d8ff6d85b948c9b12cae3e # v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. + + Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. + + claude_args: '--model claude-opus-4-6 --allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..9563152 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,62 @@ +name: Release Please + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4 + id: release + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + test: + needs: release-please + if: needs.release-please.outputs.release_created + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: pip install -e ".[dev]" + - name: Test + run: pytest tests/ -v + + publish: + needs: [release-please, test] + if: needs.release-please.outputs.release_created + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Install build tools + run: pip install build + - name: Build + run: python -m build + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@76f52bc884231f62b54a752585ace28ca25210fc # v1.12.4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f5f02a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..8e1b0d9 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1 @@ +{".": "0.1.0"} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..825c32f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d8128bf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,33 @@ +# CLAUDE.md — qurl-python + +## Critical Rules + +- **NEVER push directly to `main`.** Always create a branch and PR. +- All commits must be signed. + +## Project + +Python SDK for the QURL API (`pip install layerv-qurl`). Extracted from `layervai/qurl-integrations`. + +## Commands + +```bash +pip install -e ".[dev]" # Install with dev dependencies +ruff check # Lint +mypy src/ # Type check +pytest tests/ -v # Test +``` + +## Commit Format + +``` +: + +type: feat | fix | chore | docs | test | refactor | ci +``` + +Conventional commits drive Release Please versioning. + +## Release + +Merging to `main` triggers Release Please. Merging the release PR publishes to PyPI via OIDC. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..30b94ab --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 LayerV, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9884eb --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# layerv-qurl + +Python SDK for the [QURL API](https://docs.layerv.ai) — secure, time-limited access links for AI agents. + +## Installation + +```bash +pip install layerv-qurl +``` + +For LangChain integration: + +```bash +pip install layerv-qurl[langchain] +``` + +## Quick Start + +```python +from layerv_qurl import QURLClient + +client = QURLClient(api_key="lv_live_xxx") + +# Create a protected link +result = client.create( + target_url="https://api.example.com/data", + expires_in="24h", + description="API access for agent", +) +print(result.qurl_link) + +# Resolve a token (opens firewall for your IP) +access = client.resolve("at_k8xqp9h2sj9lx7r4a") +print(f"Access granted to {access.target_url} for {access.access_grant.expires_in}s") +# access.access_grant is None if no firewall grant was needed + +# Update a QURL (extend, change description, etc.) +qurl = client.update("r_xxx", extend_by="7d", description="extended") +``` + +## Async Usage + +```python +import asyncio +from layerv_qurl import AsyncQURLClient + +async def main(): + async with AsyncQURLClient(api_key="lv_live_xxx") as client: + result = await client.create(target_url="https://example.com", expires_in="1h") + access = await client.resolve("at_...") + +asyncio.run(main()) +``` + +## Pagination + +```python +# Iterate all active QURLs (auto-paginates) +for qurl in client.list_all(status="active"): + print(f"{qurl.resource_id}: {qurl.target_url}") + +# Or fetch a single page +page = client.list(status="active", limit=10) +for qurl in page.qurls: + print(qurl.resource_id) +``` + +## Error Handling + +```python +from layerv_qurl import QURLClient, QURLError, QURLNetworkError, QURLTimeoutError + +client = QURLClient(api_key="lv_live_xxx") + +try: + client.resolve("at_k8xqp9h2sj9lx7r4a") +except QURLTimeoutError: + # Request timed out — server may be slow + print("Timed out, retrying...") +except QURLNetworkError as e: + # Transport errors (DNS, connection refused) + print(f"Network error: {e}") +except QURLError as e: + # API errors (4xx/5xx) with structured detail + print(f"API error: {e.status} {e.code} — {e.detail}") + if e.invalid_fields: + print(f"Invalid fields: {e.invalid_fields}") + if e.request_id: + print(f"Request ID: {e.request_id}") +``` + +## Typed Quota + +```python +quota = client.get_quota() +print(f"Plan: {quota.plan}") +print(f"Active QURLs: {quota.usage.active_qurls}") +print(f"Rate limit: {quota.rate_limits.create_per_minute}/min") +``` + +## LangChain Integration + +```python +from layerv_qurl import QURLClient +from layerv_qurl.langchain import QURLToolkit + +client = QURLClient(api_key="lv_live_xxx") +toolkit = QURLToolkit(client=client) +tools = toolkit.get_tools() # [CreateQURLTool, ResolveQURLTool, ListQURLsTool, DeleteQURLTool] +``` + +## Configuration + +| Parameter | Required | Default | +|-----------|----------|---------| +| `api_key` | Yes | — | +| `base_url` | No | `https://api.layerv.ai` | +| `timeout` | No | `30.0` | +| `max_retries` | No | `3` | +| `user_agent` | No | `qurl-python-sdk/` | +| `http_client` | No | Auto-created `httpx.Client` | + +## License + +MIT diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7598cf5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,61 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "layerv-qurl" +version = "0.1.0" +description = "Python SDK for the QURL API - secure, time-limited access links" +readme = "README.md" +license = "MIT" +requires-python = ">=3.10" +authors = [ + { name = "LayerV AI", email = "engineering@layerv.ai" }, +] +keywords = ["qurl", "layerv", "security", "ai-agents", "zero-trust"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +dependencies = [ + "httpx>=0.27,<1", +] + +[project.optional-dependencies] +langchain = ["langchain-core>=0.3,<1"] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.25", + "respx>=0.22", + "ruff>=0.11", + "mypy>=1.14", +] + +[project.urls] +Homepage = "https://github.com/layervai/qurl-python" +Repository = "https://github.com/layervai/qurl-python" +Documentation = "https://docs.layerv.ai" + +[tool.hatch.build.targets.wheel] +packages = ["src/layerv_qurl"] + +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "TCH"] + +[tool.mypy] +strict = true +python_version = "3.10" + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..272b383 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "python", + "changelog-path": "CHANGELOG.md", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "extra-files": ["pyproject.toml"] + } + } +} diff --git a/src/layerv_qurl/__init__.py b/src/layerv_qurl/__init__.py new file mode 100644 index 0000000..85fb1ee --- /dev/null +++ b/src/layerv_qurl/__init__.py @@ -0,0 +1,44 @@ +"""QURL Python SDK — secure, time-limited access links for AI agents.""" + +from importlib.metadata import version as _pkg_version + +from layerv_qurl.async_client import AsyncQURLClient +from layerv_qurl.client import QURLClient +from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError +from layerv_qurl.types import ( + QURL, + AccessGrant, + AccessPolicy, + CreateOutput, + ListOutput, + MintOutput, + Quota, + QURLStatus, + RateLimits, + ResolveOutput, + Usage, +) + +__all__ = [ + "AsyncQURLClient", + "QURLClient", + "QURLError", + "QURLNetworkError", + "QURLStatus", + "QURLTimeoutError", + "AccessGrant", + "AccessPolicy", + "CreateOutput", + "ListOutput", + "MintOutput", + "QURL", + "Quota", + "RateLimits", + "ResolveOutput", + "Usage", +] + +try: + __version__ = _pkg_version("layerv-qurl") +except Exception: + __version__ = "dev" diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py new file mode 100644 index 0000000..c98dce9 --- /dev/null +++ b/src/layerv_qurl/_utils.py @@ -0,0 +1,229 @@ +"""Shared utilities for sync and async clients.""" + +from __future__ import annotations + +import dataclasses +import random +import re +from datetime import datetime +from importlib.metadata import version as _pkg_version +from typing import TYPE_CHECKING, Any + +from layerv_qurl.errors import QURLError +from layerv_qurl.types import ( + QURL, + AccessGrant, + AccessPolicy, + CreateOutput, + ListOutput, + MintOutput, + Quota, + RateLimits, + ResolveOutput, + Usage, + _parse_dt, +) + +if TYPE_CHECKING: + import httpx + +DEFAULT_BASE_URL = "https://api.layerv.ai" +DEFAULT_TIMEOUT = 30.0 +DEFAULT_MAX_RETRIES = 3 +RETRYABLE_STATUS = {429, 502, 503, 504} +RETRYABLE_STATUS_POST = {429} # POST is not idempotent — only retry rate limits + +_cached_user_agent: str | None = None +_RESOURCE_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]+$") + + +def default_user_agent() -> str: + """Return the default User-Agent string, caching the version lookup.""" + global _cached_user_agent # noqa: PLW0603 + if _cached_user_agent is None: + try: + v = _pkg_version("layerv-qurl") + except Exception: + v = "dev" + _cached_user_agent = f"qurl-python-sdk/{v}" + return _cached_user_agent + + +def validate_id(value: str, name: str = "resource_id") -> str: + """Validate that an ID is non-empty and contains no path traversal characters.""" + if not value or not _RESOURCE_ID_RE.match(value): + raise ValueError(f"Invalid {name}: {value!r}") + return value + + +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. + """ + 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] = { + fk: fv + for fk, fv in dataclasses.asdict(v).items() + if fv is not None + } + else: + body[k] = v + return body + + +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"), + qurl_site=data.get("qurl_site"), + qurl_link=data.get("qurl_link"), + access_policy=policy, + ) + + +def parse_create_output(data: dict[str, Any]) -> CreateOutput: + """Parse a CreateOutput from API response data.""" + 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")), + ) + + +def parse_mint_output(data: dict[str, Any]) -> MintOutput: + """Parse a MintOutput from API response data.""" + return MintOutput( + qurl_link=data["qurl_link"], + expires_at=_parse_dt(data.get("expires_at")), + ) + + +def parse_resolve_output(data: dict[str, Any]) -> ResolveOutput: + """Parse a ResolveOutput from API response data.""" + grant = None + if data.get("access_grant"): + g = data["access_grant"] + grant = AccessGrant( + expires_in=g["expires_in"], + granted_at=_parse_dt(g.get("granted_at")), + src_ip=g.get("src_ip", ""), + ) + return ResolveOutput( + target_url=data["target_url"], + resource_id=data["resource_id"], + access_grant=grant, + ) + + +def parse_quota(data: dict[str, Any]) -> Quota: + """Parse a Quota from API response data.""" + rl = None + if data.get("rate_limits"): + r = data["rate_limits"] + rl = RateLimits( + create_per_minute=r.get("create_per_minute", 0), + create_per_hour=r.get("create_per_hour", 0), + list_per_minute=r.get("list_per_minute", 0), + 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), + ) + usage = None + if data.get("usage"): + u = data["usage"] + 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), + total_accesses=u.get("total_accesses", 0), + ) + return Quota( + plan=data.get("plan", ""), + period_start=_parse_dt(data.get("period_start")), + period_end=_parse_dt(data.get("period_end")), + rate_limits=rl, + usage=usage, + ) + + +def parse_list_output(data: Any, meta: dict[str, Any] | None) -> ListOutput: + """Parse a ListOutput from API response data.""" + qurls = [parse_qurl(q) for q in data] if isinstance(data, list) else [] + return ListOutput( + qurls=qurls, + next_cursor=meta.get("next_cursor") if meta else None, + has_more=meta.get("has_more", False) if meta else False, + ) + + +def parse_error(response: httpx.Response) -> QURLError: + """Parse an API error response into a QURLError.""" + retry_after = None + if response.status_code == 429: + ra = response.headers.get("Retry-After") + if ra and ra.isdigit(): + retry_after = int(ra) + + try: + envelope = response.json() + err = envelope.get("error", {}) + return QURLError( + status=err.get("status", response.status_code), + code=err.get("code", "unknown"), + title=err.get("title", response.reason_phrase or ""), + detail=err.get("detail", ""), + invalid_fields=err.get("invalid_fields"), + request_id=envelope.get("meta", {}).get("request_id"), + retry_after=retry_after, + ) + except (ValueError, KeyError, AttributeError): + return QURLError( + status=response.status_code, + code="unknown", + title=response.reason_phrase or "", + detail=response.text, + retry_after=retry_after, + ) + + +def retry_delay(attempt: int, last_error: Exception | None) -> float: + """Compute retry delay with exponential backoff, jitter, and Retry-After cap.""" + if isinstance(last_error, QURLError) and last_error.retry_after: + return min(float(last_error.retry_after), 30.0) + base = 0.5 * (2 ** (attempt - 1)) + jitter = random.random() * base * 0.5 # noqa: S311 + return min(base + jitter, 30.0) + + +def mask_key(api_key: str) -> str: + """Mask an API key for display, showing first 4 + last 4 chars.""" + if len(api_key) > 8: + return api_key[:4] + "***" + api_key[-4:] + return "***" diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py new file mode 100644 index 0000000..009cb88 --- /dev/null +++ b/src/layerv_qurl/async_client.py @@ -0,0 +1,338 @@ +"""Asynchronous QURL API client.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + +import httpx + +from layerv_qurl._utils import ( + DEFAULT_BASE_URL, + DEFAULT_MAX_RETRIES, + DEFAULT_TIMEOUT, + RETRYABLE_STATUS, + RETRYABLE_STATUS_POST, + build_body, + default_user_agent, + mask_key, + parse_create_output, + parse_error, + parse_list_output, + parse_mint_output, + parse_quota, + parse_qurl, + parse_resolve_output, + retry_delay, + validate_id, +) +from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + from datetime import datetime + + from layerv_qurl.types import ( + QURL, + AccessPolicy, + CreateOutput, + ListOutput, + MintOutput, + Quota, + QURLStatus, + ResolveOutput, + ) + + +class AsyncQURLClient: + """Asynchronous QURL API client. + + Usage:: + + import asyncio + from layerv_qurl import AsyncQURLClient + + async def main(): + async with AsyncQURLClient(api_key="lv_live_xxx") as client: + result = await client.create(target_url="https://example.com") + access = await client.resolve("at_k8xqp9h2sj9lx7r4a") + + asyncio.run(main()) + """ + + def __init__( + self, + api_key: str, + *, + base_url: str = DEFAULT_BASE_URL, + timeout: float = DEFAULT_TIMEOUT, + max_retries: int = DEFAULT_MAX_RETRIES, + user_agent: str | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + if not api_key or not api_key.strip(): + raise ValueError("api_key must not be empty") + + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._max_retries = max_retries + self._user_agent = user_agent or default_user_agent() + self._client = http_client or httpx.AsyncClient(timeout=timeout) + self._owns_client = http_client is None + self._base_headers: dict[str, str] = { + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + "User-Agent": self._user_agent, + } + + def __repr__(self) -> str: + return f"AsyncQURLClient(api_key='{mask_key(self._api_key)}', base_url='{self._base_url}')" + + async def close(self) -> None: + """Close the underlying HTTP client (only if owned by this instance).""" + if self._owns_client: + await self._client.aclose() + + async def __aenter__(self) -> AsyncQURLClient: + return self + + async def __aexit__(self, *_: object) -> None: + await self.close() + + # --- Public API --- + + async def create( + self, + target_url: str, + *, + 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, + ) -> CreateOutput: + """Create a new QURL. + + Args: + 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. + """ + 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) + return parse_create_output(resp) + + async def get(self, resource_id: str) -> QURL: + """Get a QURL by ID.""" + validate_id(resource_id) + resp = await self._request("GET", f"/v1/qurls/{resource_id}") + return parse_qurl(resp) + + async def list( + self, + *, + limit: int | None = None, + cursor: str | None = None, + status: QURLStatus | None = None, + q: str | None = None, + sort: 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"``). + q: Search query string. + sort: Sort order (e.g. ``"created_at"``, ``"-created_at"``). + """ + 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 + + data, meta = await self._raw_request("GET", "/v1/qurls", params=params) + return parse_list_output(data, meta) + + async def list_all( + self, + *, + status: QURLStatus | None = None, + q: str | None = None, + sort: str | None = None, + page_size: int = 50, + ) -> AsyncIterator[QURL]: + """Iterate over all QURLs, automatically paginating. + + Yields individual :class:`QURL` objects, fetching pages transparently. + """ + cursor: str | None = None + while True: + page = await self.list( + limit=page_size, cursor=cursor, status=status, q=q, sort=sort, + ) + for qurl in page.qurls: + yield qurl + if not page.has_more or not page.next_cursor: + break + cursor = page.next_cursor + + async def delete(self, resource_id: str) -> None: + """Delete (revoke) a QURL.""" + validate_id(resource_id) + await self._request("DELETE", f"/v1/qurls/{resource_id}") + + async def update( + self, + resource_id: str, + *, + extend_by: str | None = None, + expires_at: datetime | str | None = None, + description: str | None = None, + access_policy: AccessPolicy | None = None, + ) -> QURL: + """Update a QURL — extend expiration, change description, etc. + + All fields are optional; only provided fields are sent. + + Args: + resource_id: QURL resource ID. + extend_by: Duration to add (e.g. ``"7d"``). + expires_at: New absolute expiry. + description: New description. + access_policy: New access restrictions. + """ + validate_id(resource_id) + body = build_body({ + "extend_by": extend_by, + "expires_at": expires_at, + "description": description, + "access_policy": access_policy, + }) + resp = await self._request("PATCH", f"/v1/qurls/{resource_id}", body=body) + return parse_qurl(resp) + + async def mint_link( + self, + resource_id: str, + *, + expires_at: datetime | str | 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. + """ + validate_id(resource_id) + body = build_body({"expires_at": expires_at}) + resp = await self._request("POST", f"/v1/qurls/{resource_id}/mint_link", body=body) + return parse_mint_output(resp) + + async def resolve(self, access_token: str) -> ResolveOutput: + """Resolve a QURL access token (headless). + + Triggers an NHP knock to open firewall access for the caller's IP. + Requires ``qurl:resolve`` scope on the API key. + + Args: + access_token: The access token string (e.g. ``"at_k8xqp9h2sj9lx7r4a"``). + """ + validate_id(access_token, "access_token") + resp = await self._request("POST", "/v1/resolve", body={"access_token": access_token}) + return parse_resolve_output(resp) + + async def get_quota(self) -> Quota: + """Get quota and usage information.""" + resp = await self._request("GET", "/v1/quota") + return parse_quota(resp) + + # --- Internal HTTP plumbing --- + + async def _request( + self, + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + params: dict[str, str] | None = None, + ) -> Any: + data, _ = await self._raw_request(method, path, body=body, params=params) + return data + + async def _raw_request( + self, + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + params: dict[str, str] | None = None, + ) -> tuple[Any, dict[str, Any] | None]: + url = f"{self._base_url}{path}" + last_error: Exception | None = None + + for attempt in range(self._max_retries + 1): + if attempt > 0: + delay = retry_delay(attempt, last_error) + await asyncio.sleep(delay) + + try: + response = await self._client.request( + method, + url, + json=body if body is not None else None, + params=params, + headers=self._base_headers, + ) + except httpx.TimeoutException as exc: + if attempt < self._max_retries: + last_error = exc + continue + raise QURLTimeoutError(str(exc), cause=exc) from exc + except httpx.TransportError as exc: + if attempt < self._max_retries: + last_error = exc + continue + raise QURLNetworkError(str(exc), cause=exc) from exc + + if response.status_code < 400: + if response.status_code == 204 or not response.content: + return None, None + envelope = response.json() + return envelope.get("data"), envelope.get("meta") + + err = parse_error(response) + retryable = RETRYABLE_STATUS_POST if method == "POST" else RETRYABLE_STATUS + if response.status_code in retryable and attempt < self._max_retries: + last_error = err + continue + raise err + + if isinstance(last_error, httpx.TimeoutException): + raise QURLTimeoutError(str(last_error), cause=last_error) from last_error + if isinstance(last_error, httpx.TransportError): + raise QURLNetworkError(str(last_error), cause=last_error) from last_error + raise last_error or QURLError( + status=0, code="unknown", title="Request failed", detail="Exhausted retries" + ) diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py new file mode 100644 index 0000000..e26843a --- /dev/null +++ b/src/layerv_qurl/client.py @@ -0,0 +1,351 @@ +"""Synchronous QURL API client.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any + +import httpx + +from layerv_qurl._utils import ( + DEFAULT_BASE_URL, + DEFAULT_MAX_RETRIES, + DEFAULT_TIMEOUT, + RETRYABLE_STATUS, + RETRYABLE_STATUS_POST, + build_body, + default_user_agent, + mask_key, + parse_create_output, + parse_error, + parse_list_output, + parse_mint_output, + parse_quota, + parse_qurl, + parse_resolve_output, + retry_delay, + validate_id, +) +from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError + +if TYPE_CHECKING: + from collections.abc import Iterator + from datetime import datetime + + from layerv_qurl.types import ( + QURL, + AccessPolicy, + CreateOutput, + ListOutput, + MintOutput, + Quota, + QURLStatus, + ResolveOutput, + ) + + +class QURLClient: + """Synchronous QURL API client. + + Usage:: + + from layerv_qurl import QURLClient + + client = QURLClient(api_key="lv_live_xxx") + + # Create a protected link + result = client.create(target_url="https://example.com", expires_in="24h") + + # Resolve an access token (opens firewall for your IP) + access = client.resolve("at_k8xqp9h2sj9lx7r4a") + + # Update a QURL (extend, change description, etc.) + qurl = client.update("r_xxx", extend_by="7d", description="updated") + + # Iterate all active QURLs + for qurl in client.list_all(status="active"): + print(qurl.resource_id) + """ + + def __init__( + self, + api_key: str, + *, + base_url: str = DEFAULT_BASE_URL, + timeout: float = DEFAULT_TIMEOUT, + max_retries: int = DEFAULT_MAX_RETRIES, + user_agent: str | None = None, + http_client: httpx.Client | None = None, + ) -> None: + if not api_key or not api_key.strip(): + raise ValueError("api_key must not be empty") + + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._max_retries = max_retries + self._user_agent = user_agent or default_user_agent() + self._client = http_client or httpx.Client(timeout=timeout) + self._owns_client = http_client is None + self._base_headers: dict[str, str] = { + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + "User-Agent": self._user_agent, + } + + def __repr__(self) -> str: + return f"QURLClient(api_key='{mask_key(self._api_key)}', base_url='{self._base_url}')" + + def close(self) -> None: + """Close the underlying HTTP client (only if owned by this instance).""" + if self._owns_client: + self._client.close() + + def __enter__(self) -> QURLClient: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + # --- Public API --- + + def create( + self, + target_url: str, + *, + 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, + ) -> CreateOutput: + """Create a new QURL. + + Args: + 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. + """ + 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) + return parse_create_output(resp) + + def get(self, resource_id: str) -> QURL: + """Get a QURL by ID.""" + validate_id(resource_id) + resp = self._request("GET", f"/v1/qurls/{resource_id}") + return parse_qurl(resp) + + def list( + self, + *, + limit: int | None = None, + cursor: str | None = None, + status: QURLStatus | None = None, + q: str | None = None, + sort: 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"``). + q: Search query string. + sort: Sort order (e.g. ``"created_at"``, ``"-created_at"``). + """ + 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 + + data, meta = self._raw_request("GET", "/v1/qurls", params=params) + return parse_list_output(data, meta) + + def list_all( + self, + *, + status: QURLStatus | None = None, + q: str | None = None, + sort: str | None = None, + page_size: int = 50, + ) -> Iterator[QURL]: + """Iterate over all QURLs, automatically paginating. + + Yields individual :class:`QURL` objects, fetching pages transparently. + + Args: + status: Filter by status (``"active"``, ``"expired"``, etc.). + q: Search query string. + sort: Sort order. + page_size: Number of items per page (default 50). + """ + cursor: str | None = None + while True: + page = self.list( + limit=page_size, cursor=cursor, status=status, q=q, sort=sort, + ) + yield from page.qurls + if not page.has_more or not page.next_cursor: + break + cursor = page.next_cursor + + def delete(self, resource_id: str) -> None: + """Delete (revoke) a QURL.""" + validate_id(resource_id) + self._request("DELETE", f"/v1/qurls/{resource_id}") + + def update( + self, + resource_id: str, + *, + extend_by: str | None = None, + expires_at: datetime | str | None = None, + description: str | None = None, + access_policy: AccessPolicy | None = None, + ) -> QURL: + """Update a QURL — extend expiration, change description, etc. + + Combines the old ``extend()`` and ``update()`` into a single method. + All fields are optional; only provided fields are sent. + + Args: + resource_id: QURL resource ID. + extend_by: Duration to add (e.g. ``"7d"``). + expires_at: New absolute expiry. + description: New description. + access_policy: New access restrictions. + """ + validate_id(resource_id) + body = build_body({ + "extend_by": extend_by, + "expires_at": expires_at, + "description": description, + "access_policy": access_policy, + }) + resp = self._request("PATCH", f"/v1/qurls/{resource_id}", body=body) + return parse_qurl(resp) + + def mint_link( + self, + resource_id: str, + *, + expires_at: datetime | str | 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. + """ + validate_id(resource_id) + body = build_body({"expires_at": expires_at}) + resp = self._request("POST", f"/v1/qurls/{resource_id}/mint_link", body=body) + return parse_mint_output(resp) + + def resolve(self, access_token: str) -> ResolveOutput: + """Resolve a QURL access token (headless). + + Triggers an NHP knock to open firewall access for the caller's IP. + Requires ``qurl:resolve`` scope on the API key. + + Args: + access_token: The access token string (e.g. ``"at_k8xqp9h2sj9lx7r4a"``). + """ + validate_id(access_token, "access_token") + resp = self._request("POST", "/v1/resolve", body={"access_token": access_token}) + return parse_resolve_output(resp) + + def get_quota(self) -> Quota: + """Get quota and usage information.""" + resp = self._request("GET", "/v1/quota") + return parse_quota(resp) + + # --- Internal HTTP plumbing --- + + def _request( + self, + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + params: dict[str, str] | None = None, + ) -> Any: + data, _ = self._raw_request(method, path, body=body, params=params) + return data + + def _raw_request( + self, + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + params: dict[str, str] | None = None, + ) -> tuple[Any, dict[str, Any] | None]: + url = f"{self._base_url}{path}" + last_error: Exception | None = None + + for attempt in range(self._max_retries + 1): + if attempt > 0: + delay = retry_delay(attempt, last_error) + time.sleep(delay) + + try: + response = self._client.request( + method, + url, + json=body if body is not None else None, + params=params, + headers=self._base_headers, + ) + except httpx.TimeoutException as exc: + if attempt < self._max_retries: + last_error = exc + continue + raise QURLTimeoutError(str(exc), cause=exc) from exc + except httpx.TransportError as exc: + if attempt < self._max_retries: + last_error = exc + continue + raise QURLNetworkError(str(exc), cause=exc) from exc + + if response.status_code < 400: + if response.status_code == 204 or not response.content: + return None, None + envelope = response.json() + return envelope.get("data"), envelope.get("meta") + + err = parse_error(response) + retryable = RETRYABLE_STATUS_POST if method == "POST" else RETRYABLE_STATUS + if response.status_code in retryable and attempt < self._max_retries: + last_error = err + continue + raise err + + if isinstance(last_error, httpx.TimeoutException): + raise QURLTimeoutError(str(last_error), cause=last_error) from last_error + if isinstance(last_error, httpx.TransportError): + raise QURLNetworkError(str(last_error), cause=last_error) from last_error + raise last_error or QURLError( + status=0, code="unknown", title="Request failed", detail="Exhausted retries" + ) diff --git a/src/layerv_qurl/errors.py b/src/layerv_qurl/errors.py new file mode 100644 index 0000000..aac9be7 --- /dev/null +++ b/src/layerv_qurl/errors.py @@ -0,0 +1,52 @@ +"""Error types for the QURL API client.""" + +from __future__ import annotations + + +class QURLError(Exception): + """Error raised for API-level errors (4xx/5xx responses).""" + + def __init__( + self, + *, + status: int, + code: str, + title: str, + detail: str, + invalid_fields: dict[str, str] | None = None, + request_id: str | None = None, + retry_after: int | None = None, + ) -> None: + super().__init__(f"{title} ({status}): {detail}") + self.status = status + self.code = code + self.title = title + self.detail = detail + self.invalid_fields = invalid_fields + self.request_id = request_id + self.retry_after = retry_after + + +class QURLNetworkError(Exception): + """Error raised for transport-level failures (DNS, connection refused).""" + + def __init__(self, message: str, cause: Exception | None = None) -> None: + super().__init__(message) + self.__cause__ = cause + + +class QURLTimeoutError(QURLNetworkError): + """Error raised when a request times out. + + Subclass of :class:`QURLNetworkError` — caught by + ``except QURLNetworkError`` but can also be caught specifically:: + + try: + client.resolve("at_xxx") + except QURLTimeoutError: + print("Request timed out — server may be slow") + except QURLNetworkError: + print("Network issue — DNS, connection, etc.") + except QURLError: + print("API error — 4xx/5xx") + """ diff --git a/src/layerv_qurl/langchain.py b/src/layerv_qurl/langchain.py new file mode 100644 index 0000000..f6f7849 --- /dev/null +++ b/src/layerv_qurl/langchain.py @@ -0,0 +1,162 @@ +"""LangChain tool integration for QURL. + +Install with: pip install layerv-qurl[langchain] +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +try: + from langchain_core.tools import BaseTool + _HAS_LANGCHAIN = True +except ImportError: + _HAS_LANGCHAIN = False + BaseTool = object # type: ignore[misc,assignment] + +if TYPE_CHECKING: + from langchain_core.callbacks import CallbackManagerForToolRun + + from layerv_qurl.client import QURLClient + +__all__ = [ + "CreateQURLTool", + "DeleteQURLTool", + "ListQURLsTool", + "QURLToolkit", + "ResolveQURLTool", +] + + +class CreateQURLTool(BaseTool): + """Create a secure, time-limited access link to a protected URL.""" + + name: str = "create_qurl" + 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)." + ) + client: Any = None # QURLClient, typed as Any for Pydantic compatibility + + 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, + 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, + ) + return ( + f"Created QURL {result.resource_id}\n" + f"Link: {result.qurl_link}\n" + f"Site: {result.qurl_site}\n" + f"Expires: {result.expires_at or 'N/A'}" + ) + + +class ResolveQURLTool(BaseTool): + """Resolve a QURL access token to open firewall access.""" + + name: str = "resolve_qurl" + description: str = ( + "Resolve a QURL access token to gain firewall access to the protected resource. " + "Input should be the access token string (e.g. 'at_k8xqp9h2sj9lx7r4a')." + ) + client: Any = None + + def _run( + self, + access_token: str, + run_manager: CallbackManagerForToolRun | None = None, + ) -> str: + result = self.client.resolve(access_token) + grant = result.access_grant + lines = [ + f"Resolved: {result.target_url}", + f"Resource: {result.resource_id}", + ] + if grant: + lines.append(f"Access expires in: {grant.expires_in}s") + lines.append(f"Granted to IP: {grant.src_ip}") + return "\n".join(lines) + + +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)." + ) + client: Any = None + + def _run( + self, + status: str = "active", + limit: int = 10, + run_manager: CallbackManagerForToolRun | None = None, + ) -> str: + result = self.client.list(status=status, limit=limit) + if not result.qurls: + return "No QURLs found." + lines = [] + for q in result.qurls: + lines.append(f"- {q.resource_id}: {q.target_url} [{q.status}] expires={q.expires_at}") + return "\n".join(lines) + + +class DeleteQURLTool(BaseTool): + """Revoke a QURL, immediately ending all access.""" + + name: str = "delete_qurl" + description: str = "Revoke (delete) a QURL by resource ID (e.g. 'r_k8xqp9h2sj9')." + client: Any = None + + def _run( + self, + resource_id: str, + run_manager: CallbackManagerForToolRun | None = None, + ) -> str: + self.client.delete(resource_id) + return f"QURL {resource_id} has been revoked." + + +class QURLToolkit: + """LangChain toolkit providing all QURL tools. + + Usage:: + + from layerv_qurl import QURLClient + from layerv_qurl.langchain import QURLToolkit + + client = QURLClient(api_key="lv_live_xxx") + toolkit = QURLToolkit(client=client) + tools = toolkit.get_tools() + """ + + def __init__(self, client: QURLClient) -> None: + if not _HAS_LANGCHAIN: + raise ImportError( + "langchain-core is required for LangChain integration. " + "Install with: pip install layerv-qurl[langchain]" + ) + self.client = client + + def get_tools(self) -> list[BaseTool]: + """Return all QURL tools configured with the client.""" + return [ + CreateQURLTool(client=self.client), + ResolveQURLTool(client=self.client), + ListQURLsTool(client=self.client), + DeleteQURLTool(client=self.client), + ] diff --git a/src/layerv_qurl/types.py b/src/layerv_qurl/types.py new file mode 100644 index 0000000..9f8a39c --- /dev/null +++ b/src/layerv_qurl/types.py @@ -0,0 +1,127 @@ +"""Type definitions for the QURL API.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +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. +QURLStatus = Literal["active", "expired", "revoked", "consumed", "frozen"] | str + + +def _parse_dt(s: str | None) -> datetime | None: + """Parse an ISO 8601 datetime string, handling Z suffix for Python 3.10 compat.""" + if s is None: + return None + if s.endswith("Z"): + s = s[:-1] + "+00:00" + return datetime.fromisoformat(s) + + +@dataclass +class AccessPolicy: + """Access control policy for a QURL.""" + + ip_allowlist: list[str] | None = None + ip_denylist: list[str] | None = None + geo_allowlist: list[str] | None = None + geo_denylist: list[str] | None = None + user_agent_allow_regex: str | None = None + user_agent_deny_regex: str | None = None + + +@dataclass +class QURL: + """A QURL resource as returned by the API.""" + + resource_id: str + target_url: str + 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 + + +@dataclass +class CreateOutput: + """Response from creating a QURL.""" + + resource_id: str + qurl_link: str + qurl_site: str + expires_at: datetime | None = None + + +@dataclass +class MintOutput: + """Response from minting an access link.""" + + qurl_link: str + expires_at: datetime | None = None + + +@dataclass +class AccessGrant: + """Details of the firewall access that was granted.""" + + expires_in: int + granted_at: datetime | None = None + src_ip: str = "" + + +@dataclass +class ResolveOutput: + """Response from headless resolution.""" + + target_url: str + resource_id: str + access_grant: AccessGrant | None = None + + +@dataclass +class ListOutput: + """Response from listing QURLs.""" + + qurls: list[QURL] = field(default_factory=list) + next_cursor: str | None = None + has_more: bool = False + + +@dataclass +class RateLimits: + """Rate limit configuration.""" + + create_per_minute: int = 0 + create_per_hour: int = 0 + list_per_minute: int = 0 + resolve_per_minute: int = 0 + max_active_qurls: int = 0 + max_tokens_per_qurl: int = 0 + + +@dataclass +class Usage: + """Usage statistics.""" + + qurls_created: int = 0 + active_qurls: int = 0 + active_qurls_percent: float = 0.0 + total_accesses: int = 0 + + +@dataclass +class Quota: + """Quota and usage information.""" + + plan: str = "" + period_start: datetime | None = None + period_end: datetime | None = None + rate_limits: RateLimits | None = None + usage: Usage | None = None diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..3710912 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,871 @@ +"""Tests for the QURL Python client.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from unittest.mock import patch + +import httpx +import pytest +import respx + +from layerv_qurl import ( + AsyncQURLClient, + QURLClient, + QURLError, + QURLNetworkError, + QURLTimeoutError, +) + +BASE_URL = "https://api.test.layerv.ai" + +_ERR_429 = { + "error": { + "status": 429, "code": "rate_limited", + "title": "Rate Limited", "detail": "Slow down", + }, +} +_ERR_503 = { + "error": { + "status": 503, "code": "unavailable", + "title": "Unavailable", "detail": "Down", + }, +} +_QUOTA_OK = { + "data": { + "plan": "growth", + "period_start": "2026-03-01T00:00:00Z", + "period_end": "2026-04-01T00:00:00Z", + }, +} + + +def _qurl_item(rid: str, url: str) -> dict: + return { + "resource_id": rid, + "target_url": url, + "status": "active", + "created_at": "2026-03-10T10:00:00Z", + } + + +@pytest.fixture +def client() -> QURLClient: + return QURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) + + +@pytest.fixture +def retry_client() -> QURLClient: + return QURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=2) + + +# --- Constructor tests --- + + +def test_path_traversal_rejected(client: QURLClient) -> None: + """resource_id with path traversal characters is rejected.""" + with pytest.raises(ValueError, match="Invalid resource_id"): + client.get("../../admin/secrets") + + +def test_empty_resource_id_rejected(client: QURLClient) -> None: + with pytest.raises(ValueError, match="Invalid resource_id"): + client.delete("") + + +def test_empty_api_key_raises() -> None: + with pytest.raises(ValueError, match="api_key must not be empty"): + QURLClient(api_key="") + + +def test_whitespace_api_key_raises() -> None: + with pytest.raises(ValueError, match="api_key must not be empty"): + QURLClient(api_key=" ") + + +def test_repr_masks_api_key() -> None: + c = QURLClient(api_key="lv_live_abcdefghij", base_url=BASE_URL) + r = repr(c) + assert "lv_l" in r + assert "ghij" in r + assert "abcdefghij" not in r + assert "QURLClient(" in r + c.close() + + +def test_repr_short_api_key() -> None: + c = QURLClient(api_key="short123", base_url=BASE_URL) + r = repr(c) + assert "***" in r + assert "short123" not in r + c.close() + + +# --- CRUD tests with kwargs API --- + + +@respx.mock +def test_create(client: QURLClient) -> None: + respx.post(f"{BASE_URL}/v1/qurl").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_abc123def45", + "qurl_link": "https://qurl.link/#at_test", + "qurl_site": "https://r_abc123def45.qurl.site", + "expires_at": "2026-03-15T10:00:00Z", + }, + "meta": {"request_id": "req_1"}, + }, + ) + ) + + result = client.create(target_url="https://example.com", expires_in="24h") + assert result.resource_id == "r_abc123def45" + assert result.qurl_link == "https://qurl.link/#at_test" + assert result.qurl_site == "https://r_abc123def45.qurl.site" + assert isinstance(result.expires_at, datetime) + + +@respx.mock +def test_create_sends_correct_body(client: QURLClient) -> None: + route = respx.post(f"{BASE_URL}/v1/qurl").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_abc", + "qurl_link": "https://qurl.link/#at_test", + "qurl_site": "https://r_abc.qurl.site", + }, + }, + ) + ) + + client.create( + target_url="https://example.com", + expires_in="24h", + description="test", + one_time_use=True, + ) + body = json.loads(route.calls[0].request.content) + assert body == { + "target_url": "https://example.com", + "expires_in": "24h", + "description": "test", + "one_time_use": True, + } + + +@respx.mock +def test_create_omits_none_values(client: QURLClient) -> None: + route = respx.post(f"{BASE_URL}/v1/qurl").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_abc", + "qurl_link": "https://qurl.link/#at_test", + "qurl_site": "https://r_abc.qurl.site", + }, + }, + ) + ) + + client.create(target_url="https://example.com") + 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 + + +@respx.mock +def test_get(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", + "expires_at": "2026-03-15T10:00:00Z", + "one_time_use": False, + }, + "meta": {"request_id": "req_2"}, + }, + ) + ) + + result = client.get("r_abc123def45") + assert result.resource_id == "r_abc123def45" + assert result.status == "active" + assert isinstance(result.created_at, datetime) + assert result.created_at == datetime(2026, 3, 10, 10, 0, 0, tzinfo=timezone.utc) + + +@respx.mock +def test_list(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/qurls").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", + } + ], + "meta": {"has_more": False, "page_size": 20}, + }, + ) + ) + + result = client.list(status="active", limit=10) + assert len(result.qurls) == 1 + assert result.qurls[0].resource_id == "r_abc123def45" + assert result.has_more is False + + +@respx.mock +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}, + }), + ] + + all_qurls = list(client.list_all(status="active", page_size=2)) + assert len(all_qurls) == 3 + assert [q.resource_id for q in all_qurls] == ["r_1", "r_2", "r_3"] + assert route.call_count == 2 + + +@respx.mock +def test_delete(client: QURLClient) -> None: + respx.delete(f"{BASE_URL}/v1/qurls/r_abc123def45").mock( + return_value=httpx.Response(204) + ) + client.delete("r_abc123def45") # Should not raise + + +@respx.mock +def test_update_with_extend(client: QURLClient) -> None: + route = respx.patch(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", + "expires_at": "2026-03-20T10:00:00Z", + }, + }, + ) + ) + + result = client.update("r_abc123def45", extend_by="7d") + assert isinstance(result.expires_at, datetime) + body = json.loads(route.calls[0].request.content) + assert body == {"extend_by": "7d"} + + +@respx.mock +def test_update_with_description(client: QURLClient) -> None: + route = respx.patch(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", + "description": "new desc", + }, + }, + ) + ) + + result = client.update("r_abc123def45", description="new desc") + assert result.description == "new desc" + body = json.loads(route.calls[0].request.content) + assert body == {"description": "new desc"} + + +@respx.mock +def test_update_combined(client: QURLClient) -> None: + """update() can extend and change description in one call.""" + 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-03-20T10:00:00Z", + "description": "updated", + }, + }, + ) + ) + + client.update("r_abc", extend_by="7d", description="updated") + body = json.loads(route.calls[0].request.content) + assert body == {"extend_by": "7d", "description": "updated"} + + +@respx.mock +def test_mint_link(client: QURLClient) -> None: + respx.post(f"{BASE_URL}/v1/qurls/r_abc123def45/mint_link").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "qurl_link": "https://qurl.link/#at_newtoken", + "expires_at": "2026-03-20T10:00:00Z", + }, + }, + ) + ) + + result = client.mint_link("r_abc123def45", expires_at="2026-03-20T10:00:00Z") + assert result.qurl_link == "https://qurl.link/#at_newtoken" + assert isinstance(result.expires_at, datetime) + + +@respx.mock +def test_mint_link_no_input(client: QURLClient) -> None: + respx.post(f"{BASE_URL}/v1/qurls/r_abc123def45/mint_link").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "qurl_link": "https://qurl.link/#at_default", + }, + }, + ) + ) + + result = client.mint_link("r_abc123def45") + assert result.qurl_link == "https://qurl.link/#at_default" + assert result.expires_at is None + + +@respx.mock +def test_resolve_plain_string(client: QURLClient) -> None: + """resolve() accepts a plain string token.""" + respx.post(f"{BASE_URL}/v1/resolve").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "target_url": "https://api.example.com/data", + "resource_id": "r_abc123def45", + "access_grant": { + "expires_in": 305, + "granted_at": "2026-03-10T15:30:00Z", + "src_ip": "203.0.113.42", + }, + }, + }, + ) + ) + + result = client.resolve("at_k8xqp9h2sj9lx7r4a") + assert result.target_url == "https://api.example.com/data" + assert result.access_grant is not None + assert result.access_grant.expires_in == 305 + assert result.access_grant.src_ip == "203.0.113.42" + assert isinstance(result.access_grant.granted_at, datetime) + + +@respx.mock +def test_error_handling(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/qurls/r_notfound0000").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", + "code": "not_found", + }, + "meta": {"request_id": "req_err"}, + }, + ) + ) + + with pytest.raises(QURLError) as exc_info: + client.get("r_notfound0000") + + err = exc_info.value + assert err.status == 404 + assert err.code == "not_found" + assert err.request_id == "req_err" + + +@respx.mock +def test_quota_typed(client: QURLClient) -> None: + """get_quota() returns typed RateLimits and Usage objects.""" + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "plan": "growth", + "period_start": "2026-03-01T00:00:00Z", + "period_end": "2026-04-01T00:00:00Z", + "rate_limits": { + "create_per_minute": 60, + "create_per_hour": 1000, + "list_per_minute": 120, + "resolve_per_minute": 300, + "max_active_qurls": 5000, + "max_tokens_per_qurl": 10, + }, + "usage": { + "qurls_created": 10, + "active_qurls": 5, + "active_qurls_percent": 0.1, + "total_accesses": 42, + }, + }, + }, + ) + ) + + result = client.get_quota() + assert result.plan == "growth" + assert isinstance(result.period_start, datetime) + + # Typed RateLimits + assert result.rate_limits is not None + assert result.rate_limits.create_per_minute == 60 + assert result.rate_limits.max_active_qurls == 5000 + + # 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 + + +# --- Injected http_client --- + + +@respx.mock +def test_injected_http_client_gets_auth_headers() -> None: + custom_client = httpx.Client(timeout=10) + qurl = QURLClient(api_key="lv_live_custom", base_url=BASE_URL, http_client=custom_client) + + route = respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "plan": "free", + "period_start": "2026-03-01T00:00:00Z", + "period_end": "2026-04-01T00:00:00Z", + }, + }, + ) + ) + + qurl.get_quota() + assert route.called + req = route.calls[0].request + assert req.headers["authorization"] == "Bearer lv_live_custom" + # Content-Type should NOT be set for GET requests + assert "content-type" not in req.headers + custom_client.close() + + +# --- Retry logic --- + + +@respx.mock +def test_retry_success_after_429(retry_client: QURLClient) -> None: + route = respx.get(f"{BASE_URL}/v1/quota") + route.side_effect = [ + httpx.Response(429, json=_ERR_429), + httpx.Response(200, json=_QUOTA_OK), + ] + + with patch("layerv_qurl.client.time.sleep"): + result = retry_client.get_quota() + + assert result.plan == "growth" + assert route.call_count == 2 + + +@respx.mock +def test_retry_exhausted_raises_last_error(retry_client: QURLClient) -> None: + route = respx.get(f"{BASE_URL}/v1/quota") + route.side_effect = [ + httpx.Response(503, json=_ERR_503), + httpx.Response(503, json=_ERR_503), + httpx.Response(503, json=_ERR_503), + ] + + with patch("layerv_qurl.client.time.sleep"), pytest.raises(QURLError) as exc_info: + retry_client.get_quota() + + assert exc_info.value.status == 503 + assert route.call_count == 3 + + +@respx.mock +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, + ), + httpx.Response(200, json=_QUOTA_OK), + ] + + with patch("layerv_qurl.client.time.sleep") as mock_sleep: + result = retry_client.get_quota() + + assert result.plan == "growth" + mock_sleep.assert_called_once_with(5.0) + + +@respx.mock +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, + ), + httpx.Response(200, json=_QUOTA_OK), + ] + + with patch("layerv_qurl.client.time.sleep") as mock_sleep: + retry_client.get_quota() + + mock_sleep.assert_called_once_with(30.0) + + +# --- Non-JSON error --- + + +@respx.mock +def test_non_json_error_response(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/qurls/r_bad").mock( + return_value=httpx.Response( + 500, + text="Internal Server Error", + headers={"content-type": "text/plain"}, + ) + ) + + with pytest.raises(QURLError) as exc_info: + client.get("r_bad") + + err = exc_info.value + assert err.status == 500 + assert err.code == "unknown" + assert "Internal Server Error" in err.detail + + +# --- Network error wrapping --- + + +@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") + ) + + with pytest.raises(QURLNetworkError, match="Connection refused"): + client.get_quota() + + +@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") + ) + + with pytest.raises(QURLNetworkError) as exc_info: + client.get_quota() + + assert isinstance(exc_info.value.__cause__, httpx.ConnectError) + + +@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") + ) + + with pytest.raises(QURLTimeoutError, match="Read timed out"): + client.get_quota() + + +@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") + ) + + with pytest.raises(QURLNetworkError): + client.get_quota() + + +@respx.mock +def test_timeout_retried_then_wrapped() -> None: + """Timeout errors are retried, then wrapped as QURLTimeoutError.""" + c = QURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=1) + route = respx.get(f"{BASE_URL}/v1/quota") + route.side_effect = [ + httpx.ReadTimeout("timeout 1"), + httpx.ReadTimeout("timeout 2"), + ] + + with patch("layerv_qurl.client.time.sleep"), pytest.raises(QURLTimeoutError): + c.get_quota() + + assert route.call_count == 2 + + +@respx.mock +def test_network_error_retried_then_wrapped() -> None: + """Network errors are retried, then wrapped if all retries fail.""" + c = QURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=1) + route = respx.get(f"{BASE_URL}/v1/quota") + route.side_effect = [ + httpx.ConnectError("fail 1"), + httpx.ConnectError("fail 2"), + ] + + with patch("layerv_qurl.client.time.sleep"), pytest.raises(QURLNetworkError): + c.get_quota() + + assert route.call_count == 2 + + +# --- Context manager / close() tests --- + + +def test_close_closes_owned_client() -> None: + c = QURLClient(api_key="lv_live_test", base_url=BASE_URL) + assert c._owns_client is True + c.close() + assert c._client.is_closed + + +def test_close_does_not_close_injected_client() -> None: + custom = httpx.Client(timeout=10) + c = QURLClient(api_key="lv_live_test", base_url=BASE_URL, http_client=custom) + assert c._owns_client is False + c.close() + assert not custom.is_closed + custom.close() + + +def test_context_manager_closes_owned_client() -> None: + with QURLClient(api_key="lv_live_test", base_url=BASE_URL) as c: + assert c._owns_client is True + assert c._client.is_closed + + +def test_context_manager_does_not_close_injected_client() -> None: + custom = httpx.Client(timeout=10) + with QURLClient(api_key="lv_live_test", base_url=BASE_URL, http_client=custom) as c: + assert c._owns_client is False + assert not custom.is_closed + custom.close() + + +# --- Content-Type header tests --- + + +@respx.mock +def test_get_request_has_no_content_type(client: QURLClient) -> None: + """GET requests should not send Content-Type header.""" + route = respx.get(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", + }, + }, + ) + ) + + client.get("r_abc") + req = route.calls[0].request + assert "content-type" not in req.headers + + +@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( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_abc", + "qurl_link": "https://qurl.link/#at_test", + "qurl_site": "https://r_abc.qurl.site", + }, + }, + ) + ) + + client.create(target_url="https://example.com") + req = route.calls[0].request + assert req.headers["content-type"] == "application/json" + + +# --- Async client --- + + +@respx.mock +@pytest.mark.asyncio +async def test_async_create() -> None: + respx.post(f"{BASE_URL}/v1/qurl").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_async", + "qurl_link": "https://qurl.link/#at_async", + "qurl_site": "https://r_async.qurl.site", + "expires_at": "2026-03-15T10:00:00Z", + }, + }, + ) + ) + + async with AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) as client: + result = await client.create(target_url="https://example.com", expires_in="24h") + + assert result.resource_id == "r_async" + assert isinstance(result.expires_at, datetime) + + +@respx.mock +@pytest.mark.asyncio +async def test_async_resolve() -> None: + respx.post(f"{BASE_URL}/v1/resolve").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "target_url": "https://api.example.com/data", + "resource_id": "r_async", + "access_grant": { + "expires_in": 305, + "granted_at": "2026-03-10T15:30:00Z", + "src_ip": "203.0.113.42", + }, + }, + }, + ) + ) + + async with AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) as client: + result = await client.resolve("at_test_token") + + assert result.target_url == "https://api.example.com/data" + assert result.access_grant is not None + assert result.access_grant.expires_in == 305 + + +@respx.mock +@pytest.mark.asyncio +async def test_async_list_all() -> 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}, + }), + ] + + async with AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) as client: + all_qurls = [q async for q in client.list_all(status="active", page_size=1)] + + assert len(all_qurls) == 2 + assert [q.resource_id for q in all_qurls] == ["r_1", "r_2"] + + +@respx.mock +@pytest.mark.asyncio +async def test_async_network_error_wrapped() -> None: + respx.get(f"{BASE_URL}/v1/quota").mock( + side_effect=httpx.ConnectError("Connection refused") + ) + + async with AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) as client: + with pytest.raises(QURLNetworkError, match="Connection refused"): + await client.get_quota() + + +@respx.mock +@pytest.mark.asyncio +async def test_async_timeout_error_wrapped() -> None: + """Async: httpx.TimeoutException is wrapped in QURLTimeoutError.""" + respx.get(f"{BASE_URL}/v1/quota").mock( + side_effect=httpx.ReadTimeout("Read timed out") + ) + + async with AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) as client: + with pytest.raises(QURLTimeoutError, match="Read timed out"): + await client.get_quota() + + +@respx.mock +@pytest.mark.asyncio +async def test_async_timeout_is_network_error() -> None: + """Async: QURLTimeoutError is caught by except QURLNetworkError.""" + respx.get(f"{BASE_URL}/v1/quota").mock( + side_effect=httpx.ReadTimeout("Read timed out") + ) + + async with AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) as client: + with pytest.raises(QURLNetworkError): + await client.get_quota() + + +def test_async_repr() -> None: + c = AsyncQURLClient(api_key="lv_live_abcdefghij", base_url=BASE_URL) + r = repr(c) + assert "AsyncQURLClient(" in r + assert "lv_l" in r + assert "ghij" in r + assert "abcdefghij" not in r diff --git a/tests/test_langchain.py b/tests/test_langchain.py new file mode 100644 index 0000000..492aa04 --- /dev/null +++ b/tests/test_langchain.py @@ -0,0 +1,140 @@ +"""Tests for the LangChain tool integration.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import MagicMock + +from layerv_qurl.langchain import ( + CreateQURLTool, + DeleteQURLTool, + ListQURLsTool, + QURLToolkit, + ResolveQURLTool, +) +from layerv_qurl.types import ( + QURL, + AccessGrant, + CreateOutput, + ListOutput, + ResolveOutput, +) + + +def _mock_client() -> MagicMock: + return MagicMock() + + +def test_create_qurl_tool() -> None: + client = _mock_client() + client.create.return_value = CreateOutput( + resource_id="r_abc123def45", + qurl_link="https://qurl.link/#at_test", + qurl_site="https://r_abc123def45.qurl.site", + expires_at=datetime(2026, 3, 15, 10, 0, 0, tzinfo=timezone.utc), + ) + + tool = CreateQURLTool(client=client) + result = tool._run(target_url="https://example.com", expires_in="24h") + + assert "r_abc123def45" in result + assert "https://qurl.link/#at_test" in result + client.create.assert_called_once_with( + target_url="https://example.com", + expires_in="24h", + description=None, + one_time_use=None, + max_sessions=None, + ) + + +def test_resolve_qurl_tool() -> None: + client = _mock_client() + client.resolve.return_value = ResolveOutput( + target_url="https://api.example.com/data", + resource_id="r_abc123def45", + access_grant=AccessGrant( + expires_in=305, + granted_at=datetime(2026, 3, 10, 15, 30, 0, tzinfo=timezone.utc), + src_ip="203.0.113.42", + ), + ) + + tool = ResolveQURLTool(client=client) + result = tool._run(access_token="at_k8xqp9h2sj9lx7r4a") + + assert "https://api.example.com/data" in result + assert "305" in result + assert "203.0.113.42" in result + # resolve() now takes a plain string + client.resolve.assert_called_once_with("at_k8xqp9h2sj9lx7r4a") + + +def test_resolve_qurl_tool_no_grant() -> None: + client = _mock_client() + client.resolve.return_value = ResolveOutput( + target_url="https://api.example.com/data", + resource_id="r_abc123def45", + access_grant=None, + ) + + tool = ResolveQURLTool(client=client) + result = tool._run(access_token="at_k8xqp9h2sj9lx7r4a") + + assert "https://api.example.com/data" in result + assert "r_abc123def45" in result + + +def test_list_qurls_tool() -> None: + client = _mock_client() + client.list.return_value = ListOutput( + qurls=[ + QURL( + resource_id="r_abc123def45", + target_url="https://example.com", + status="active", + created_at=datetime(2026, 3, 10, 10, 0, 0, tzinfo=timezone.utc), + expires_at=datetime(2026, 3, 15, 10, 0, 0, tzinfo=timezone.utc), + ) + ], + has_more=False, + ) + + tool = ListQURLsTool(client=client) + result = tool._run(status="active", limit=10) + + assert "r_abc123def45" in result + assert "https://example.com" in result + client.list.assert_called_once_with(status="active", limit=10) + + +def test_list_qurls_tool_empty() -> None: + client = _mock_client() + client.list.return_value = ListOutput(qurls=[], has_more=False) + + tool = ListQURLsTool(client=client) + result = tool._run() + + assert result == "No QURLs found." + + +def test_delete_qurl_tool() -> None: + client = _mock_client() + client.delete.return_value = None + + tool = DeleteQURLTool(client=client) + result = tool._run(resource_id="r_abc123def45") + + assert "r_abc123def45" in result + assert "revoked" in result + client.delete.assert_called_once_with("r_abc123def45") + + +def test_toolkit_returns_all_tools() -> None: + client = _mock_client() + toolkit = QURLToolkit(client=client) + tools = toolkit.get_tools() + + assert len(tools) == 4 + names = {t.name for t in tools} + assert names == {"create_qurl", "resolve_qurl", "list_qurls", "delete_qurl"} From 039319837fce871c128c3fa5b1a4f7533fe2f16e Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 11 Mar 2026 15:00:47 -0500 Subject: [PATCH 2/8] fix: skip langchain tests when langchain-core is not installed CI installs `.[dev]` which doesn't include langchain-core. Without it, BaseTool falls back to `object` and tool classes reject kwargs. Use pytest.importorskip to skip gracefully. Co-Authored-By: Claude Opus 4.6 --- tests/test_langchain.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_langchain.py b/tests/test_langchain.py index 492aa04..1f7e074 100644 --- a/tests/test_langchain.py +++ b/tests/test_langchain.py @@ -2,6 +2,10 @@ from __future__ import annotations +import pytest + +pytest.importorskip("langchain_core", reason="langchain-core not installed") + from datetime import datetime, timezone from unittest.mock import MagicMock From c6275ebfdf6b7126cba87be0cf5583d27b96874f Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 11 Mar 2026 15:01:35 -0500 Subject: [PATCH 3/8] fix: install langchain extra in CI to test langchain integration The dev extra doesn't include langchain-core. Install with `.[dev,langchain]` so the langchain tool tests actually run. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 2 +- .github/workflows/release-please.yml | 2 +- tests/test_langchain.py | 4 ---- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d11804..1dcff4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: pip install -e ".[dev]" + run: pip install -e ".[dev,langchain]" - name: Lint run: ruff check - name: Type check diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 9563152..a86eb95 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -36,7 +36,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: pip install -e ".[dev]" + run: pip install -e ".[dev,langchain]" - name: Test run: pytest tests/ -v diff --git a/tests/test_langchain.py b/tests/test_langchain.py index 1f7e074..492aa04 100644 --- a/tests/test_langchain.py +++ b/tests/test_langchain.py @@ -2,10 +2,6 @@ from __future__ import annotations -import pytest - -pytest.importorskip("langchain_core", reason="langchain-core not installed") - from datetime import datetime, timezone from unittest.mock import MagicMock From d69c3abdd6681f644dda32724fd5fcd96c7b14ba Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 11 Mar 2026 15:08:48 -0500 Subject: [PATCH 4/8] chore: add OSS readiness files - Add py.typed marker for PEP 561 - Add README badges (PyPI, CI, Python versions, license) - Add CONTRIBUTING.md, SECURITY.md - Add issue and PR templates Co-Authored-By: Claude Opus 4.6 --- .github/ISSUE_TEMPLATE/bug_report.md | 25 +++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 13 +++++++ .github/PULL_REQUEST_TEMPLATE.md | 11 ++++++ CONTRIBUTING.md | 45 +++++++++++++++++++++++ README.md | 5 +++ SECURITY.md | 17 +++++++++ src/layerv_qurl/py.typed | 0 7 files changed, 116 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 src/layerv_qurl/py.typed diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..de99516 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,25 @@ +--- +name: Bug Report +about: Report a bug in layerv-qurl +labels: bug +--- + +## Description + + + +## Expected Behavior + + + +## Steps to Reproduce + +1. +2. +3. + +## Environment + +- Python version: +- layerv-qurl version: +- OS: diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..1a68c61 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,13 @@ +--- +name: Feature Request +about: Suggest a feature for layerv-qurl +labels: enhancement +--- + +## Description + + + +## Use Case + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d38bb7b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## Summary + + + +## Test plan + + + +- [ ] `ruff check` passes +- [ ] `mypy src/` passes +- [ ] `pytest tests/ -v` passes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8458031 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,45 @@ +# Contributing + +Thanks for your interest in contributing to `layerv-qurl`! + +## Development Setup + +```bash +git clone https://github.com/layervai/qurl-python.git +cd qurl-python +python -m venv .venv && source .venv/bin/activate +pip install -e ".[dev,langchain]" +``` + +## Running Checks + +```bash +ruff check # Lint +mypy src/ # Type check +pytest tests/ -v # Tests +``` + +All three must pass before submitting a PR. + +## Pull Requests + +1. Fork the repo and create a branch from `main` +2. Follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, etc.) +3. Add tests for new functionality +4. Ensure all checks pass +5. Open a PR — CI runs automatically + +## Commit Signing + +All commits must be GPG or SSH signed. GitHub will reject unsigned commits. + +## Releases + +Releases are automated via [Release Please](https://github.com/googleapis/release-please). Conventional commit messages drive version bumps: +- `feat:` → minor version bump +- `fix:` → patch version bump +- `feat!:` or `BREAKING CHANGE:` → major version bump + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/README.md b/README.md index f9884eb..1c30a52 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # layerv-qurl +[![PyPI](https://img.shields.io/pypi/v/layerv-qurl)](https://pypi.org/project/layerv-qurl/) +[![CI](https://github.com/layervai/qurl-python/actions/workflows/ci.yml/badge.svg)](https://github.com/layervai/qurl-python/actions/workflows/ci.yml) +[![Python](https://img.shields.io/pypi/pyversions/layerv-qurl)](https://pypi.org/project/layerv-qurl/) +[![License](https://img.shields.io/github/license/layervai/qurl-python)](LICENSE) + Python SDK for the [QURL API](https://docs.layerv.ai) — secure, time-limited access links for AI agents. ## Installation diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..030166a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability, please report it responsibly: + +**Email:** security@layerv.ai + +Please do **not** open a public GitHub issue for security vulnerabilities. + +We will acknowledge your report within 48 hours and aim to provide a fix within 7 days for critical issues. + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| 0.x | Yes | diff --git a/src/layerv_qurl/py.typed b/src/layerv_qurl/py.typed new file mode 100644 index 0000000..e69de29 From 417a275fdf0f7f1835bc0b3327ea4cee9e4d75b1 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 11 Mar 2026 15:12:29 -0500 Subject: [PATCH 5/8] fix: annotate base as float to satisfy mypy strict no-any-return `2 ** (attempt - 1)` returns int, making `min()` return type ambiguous under mypy strict mode. Explicit annotation resolves it. Co-Authored-By: Claude Opus 4.6 --- src/layerv_qurl/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index c98dce9..f4a8e91 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -217,7 +217,7 @@ def retry_delay(attempt: int, last_error: Exception | None) -> float: """Compute retry delay with exponential backoff, jitter, and Retry-After cap.""" if isinstance(last_error, QURLError) and last_error.retry_after: return min(float(last_error.retry_after), 30.0) - base = 0.5 * (2 ** (attempt - 1)) + base: float = 0.5 * (2 ** (attempt - 1)) jitter = random.random() * base * 0.5 # noqa: S311 return min(base + jitter, 30.0) From ea2afd71e68858f4bb2169c01faaf8395822381f Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 11 Mar 2026 15:45:45 -0500 Subject: [PATCH 6/8] feat: granular error classes, extend() convenience, debug logging - Add AuthenticationError, AuthorizationError, NotFoundError, ValidationError, RateLimitError, ServerError subclasses of QURLError so callers can catch specific HTTP status codes - Add extend() convenience method to both sync and async clients - Add debug logging throughout request lifecycle (enable with logging.getLogger("layerv_qurl").setLevel(logging.DEBUG)) - Improve create() docstring to clarify CreateOutput vs QURL - Export all new error classes from __init__.py - Add 11 new tests: error subclasses, extend(), AccessPolicy serialization - Update README with "Why QURL?" section, granular error handling examples, debug logging docs Co-Authored-By: Claude Opus 4.6 --- README.md | 78 ++++++++-- src/layerv_qurl/__init__.py | 22 ++- src/layerv_qurl/_utils.py | 51 ++++-- src/layerv_qurl/async_client.py | 23 +++ src/layerv_qurl/client.py | 36 ++++- src/layerv_qurl/errors.py | 55 ++++++- tests/test_client.py | 267 ++++++++++++++++++++++++++++++++ 7 files changed, 499 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 1c30a52..3640cc7 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,15 @@ Python SDK for the [QURL API](https://docs.layerv.ai) — secure, time-limited access links for AI agents. +## Why QURL? + +AI agents need to access APIs, databases, and internal tools — but permanent credentials are a security risk. QURL creates **time-limited, auditable access links** that automatically expire: + +- **Time-limited** — links expire after minutes, hours, or days +- **IP-scoped** — firewall opens only for the requesting IP via NHP +- **Auditable** — every access is logged with who, when, and from where +- **Revocable** — kill access instantly if something goes wrong + ## Installation ```bash @@ -32,15 +41,17 @@ result = client.create( expires_in="24h", description="API access for agent", ) -print(result.qurl_link) +print(result.qurl_link) # Share this link # Resolve a token (opens firewall for your IP) access = client.resolve("at_k8xqp9h2sj9lx7r4a") print(f"Access granted to {access.target_url} for {access.access_grant.expires_in}s") -# access.access_grant is None if no firewall grant was needed -# Update a QURL (extend, change description, etc.) -qurl = client.update("r_xxx", extend_by="7d", description="extended") +# Extend a QURL's expiration +qurl = client.extend("r_xxx", "7d") + +# Update metadata and policy +qurl = client.update("r_xxx", description="extended", extend_by="7d") ``` ## Async Usage @@ -54,6 +65,9 @@ async def main(): result = await client.create(target_url="https://example.com", expires_in="1h") access = await client.resolve("at_...") + # Extend expiration + qurl = await client.extend("r_xxx", "7d") + asyncio.run(main()) ``` @@ -72,28 +86,51 @@ for qurl in page.qurls: ## Error Handling +Every API error maps to a specific exception class, so you can catch exactly what you need: + ```python -from layerv_qurl import QURLClient, QURLError, QURLNetworkError, QURLTimeoutError +from layerv_qurl import ( + QURLClient, + QURLError, + QURLNetworkError, + QURLTimeoutError, +) +from layerv_qurl.errors import ( + AuthenticationError, + AuthorizationError, + NotFoundError, + RateLimitError, + ValidationError, +) client = QURLClient(api_key="lv_live_xxx") try: client.resolve("at_k8xqp9h2sj9lx7r4a") +except AuthenticationError: + print("Bad API key") +except AuthorizationError: + print("Valid key but missing qurl:resolve scope") +except NotFoundError: + print("Token doesn't exist or already expired") +except RateLimitError as e: + print(f"Rate limited — retry in {e.retry_after}s") +except ValidationError as e: + print(f"Bad request: {e.detail}") + if e.invalid_fields: + for field, reason in e.invalid_fields.items(): + print(f" {field}: {reason}") except QURLTimeoutError: - # Request timed out — server may be slow - print("Timed out, retrying...") + print("Request timed out") except QURLNetworkError as e: - # Transport errors (DNS, connection refused) print(f"Network error: {e}") except QURLError as e: - # API errors (4xx/5xx) with structured detail - print(f"API error: {e.status} {e.code} — {e.detail}") - if e.invalid_fields: - print(f"Invalid fields: {e.invalid_fields}") - if e.request_id: - print(f"Request ID: {e.request_id}") + # Catch-all for any other API error + print(f"API error {e.status}: {e.detail}") ``` +All error classes inherit from `QURLError`, so `except QURLError` catches everything. + ## Typed Quota ```python @@ -103,6 +140,19 @@ print(f"Active QURLs: {quota.usage.active_qurls}") print(f"Rate limit: {quota.rate_limits.create_per_minute}/min") ``` +## Debug Logging + +Enable debug logs to see every request and retry: + +```python +import logging +logging.getLogger("layerv_qurl").setLevel(logging.DEBUG) + +# Output: +# DEBUG:layerv_qurl:POST https://api.layerv.ai/v1/qurl +# DEBUG:layerv_qurl:POST https://api.layerv.ai/v1/qurl → 201 +``` + ## LangChain Integration ```python diff --git a/src/layerv_qurl/__init__.py b/src/layerv_qurl/__init__.py index 85fb1ee..d38a362 100644 --- a/src/layerv_qurl/__init__.py +++ b/src/layerv_qurl/__init__.py @@ -4,7 +4,17 @@ from layerv_qurl.async_client import AsyncQURLClient from layerv_qurl.client import QURLClient -from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError +from layerv_qurl.errors import ( + AuthenticationError, + AuthorizationError, + NotFoundError, + QURLError, + QURLNetworkError, + QURLTimeoutError, + RateLimitError, + ServerError, + ValidationError, +) from layerv_qurl.types import ( QURL, AccessGrant, @@ -22,10 +32,18 @@ __all__ = [ "AsyncQURLClient", "QURLClient", + # Errors + "AuthenticationError", + "AuthorizationError", + "NotFoundError", "QURLError", "QURLNetworkError", - "QURLStatus", "QURLTimeoutError", + "RateLimitError", + "ServerError", + "ValidationError", + # Types + "QURLStatus", "AccessGrant", "AccessPolicy", "CreateOutput", diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index f4a8e91..678204f 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -3,13 +3,23 @@ from __future__ import annotations import dataclasses +import functools +import logging import random import re from datetime import datetime from importlib.metadata import version as _pkg_version from typing import TYPE_CHECKING, Any -from layerv_qurl.errors import QURLError +from layerv_qurl.errors import ( + AuthenticationError, + AuthorizationError, + NotFoundError, + QURLError, + RateLimitError, + ServerError, + ValidationError, +) from layerv_qurl.types import ( QURL, AccessGrant, @@ -27,26 +37,34 @@ if TYPE_CHECKING: import httpx +logger = logging.getLogger("layerv_qurl") + DEFAULT_BASE_URL = "https://api.layerv.ai" DEFAULT_TIMEOUT = 30.0 DEFAULT_MAX_RETRIES = 3 RETRYABLE_STATUS = {429, 502, 503, 504} RETRYABLE_STATUS_POST = {429} # POST is not idempotent — only retry rate limits -_cached_user_agent: str | None = None _RESOURCE_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]+$") +_ERROR_CLASS_MAP: dict[int, type[QURLError]] = { + 400: ValidationError, + 401: AuthenticationError, + 403: AuthorizationError, + 404: NotFoundError, + 422: ValidationError, + 429: RateLimitError, +} + +@functools.lru_cache(maxsize=1) def default_user_agent() -> str: """Return the default User-Agent string, caching the version lookup.""" - global _cached_user_agent # noqa: PLW0603 - if _cached_user_agent is None: - try: - v = _pkg_version("layerv-qurl") - except Exception: - v = "dev" - _cached_user_agent = f"qurl-python-sdk/{v}" - return _cached_user_agent + try: + v = _pkg_version("layerv-qurl") + except Exception: + v = "dev" + return f"qurl-python-sdk/{v}" def validate_id(value: str, name: str = "resource_id") -> str: @@ -184,17 +202,24 @@ def parse_list_output(data: Any, meta: dict[str, Any] | None) -> ListOutput: def parse_error(response: httpx.Response) -> QURLError: - """Parse an API error response into a QURLError.""" + """Parse an API error response into the appropriate QURLError subclass.""" retry_after = None if response.status_code == 429: ra = response.headers.get("Retry-After") if ra and ra.isdigit(): retry_after = int(ra) + # Pick the right subclass, defaulting to ServerError for 5xx or QURLError + cls: type[QURLError] + if response.status_code >= 500: + cls = ServerError + else: + cls = _ERROR_CLASS_MAP.get(response.status_code, QURLError) + try: envelope = response.json() err = envelope.get("error", {}) - return QURLError( + return cls( status=err.get("status", response.status_code), code=err.get("code", "unknown"), title=err.get("title", response.reason_phrase or ""), @@ -204,7 +229,7 @@ def parse_error(response: httpx.Response) -> QURLError: retry_after=retry_after, ) except (ValueError, KeyError, AttributeError): - return QURLError( + return cls( status=response.status_code, code="unknown", title=response.reason_phrase or "", diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index 009cb88..0ac1286 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -15,6 +15,7 @@ RETRYABLE_STATUS_POST, build_body, default_user_agent, + logger, mask_key, parse_create_output, parse_error, @@ -114,6 +115,10 @@ async def create( ) -> CreateOutput: """Create a new QURL. + Returns a :class:`CreateOutput` with the ``resource_id``, ``qurl_link``, + ``qurl_site``, and ``expires_at``. Use :meth:`get` to fetch the full + :class:`QURL` object with status, timestamps, and policy details. + Args: target_url: The URL to protect. expires_in: Duration string (e.g. ``"24h"``, ``"7d"``). @@ -203,6 +208,17 @@ async def delete(self, resource_id: str) -> None: validate_id(resource_id) 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)``. + + Args: + resource_id: QURL resource ID. + duration: Duration to add (e.g. ``"7d"``, ``"24h"``). + """ + return await self.update(resource_id, extend_by=duration) + async def update( self, resource_id: str, @@ -295,8 +311,11 @@ async def _raw_request( for attempt in range(self._max_retries + 1): if attempt > 0: delay = retry_delay(attempt, last_error) + logger.debug("Retry %d/%d after %.1fs", attempt, self._max_retries, delay) await asyncio.sleep(delay) + logger.debug("%s %s", method, url) + try: response = await self._client.request( method, @@ -306,16 +325,20 @@ async def _raw_request( headers=self._base_headers, ) except httpx.TimeoutException as exc: + logger.debug("%s %s timed out", method, url) if attempt < self._max_retries: last_error = exc continue raise QURLTimeoutError(str(exc), cause=exc) from exc except httpx.TransportError as exc: + logger.debug("%s %s transport error: %s", method, url, exc) if attempt < self._max_retries: last_error = exc continue raise QURLNetworkError(str(exc), cause=exc) from exc + logger.debug("%s %s → %d", method, url, response.status_code) + if response.status_code < 400: if response.status_code == 204 or not response.content: return None, None diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index e26843a..dd046f3 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -15,6 +15,7 @@ RETRYABLE_STATUS_POST, build_body, default_user_agent, + logger, mask_key, parse_create_output, parse_error, @@ -59,12 +60,20 @@ class QURLClient: # Resolve an access token (opens firewall for your IP) access = client.resolve("at_k8xqp9h2sj9lx7r4a") - # Update a QURL (extend, change description, etc.) - qurl = client.update("r_xxx", extend_by="7d", description="updated") + # Extend a QURL's expiration + qurl = client.extend("r_xxx", "7d") + + # Update metadata + qurl = client.update("r_xxx", description="updated") # Iterate all active QURLs for qurl in client.list_all(status="active"): print(qurl.resource_id) + + Enable debug logging to see requests:: + + import logging + logging.getLogger("layerv_qurl").setLevel(logging.DEBUG) """ def __init__( @@ -121,6 +130,10 @@ def create( ) -> CreateOutput: """Create a new QURL. + Returns a :class:`CreateOutput` with the ``resource_id``, ``qurl_link``, + ``qurl_site``, and ``expires_at``. Use :meth:`get` to fetch the full + :class:`QURL` object with status, timestamps, and policy details. + Args: target_url: The URL to protect. expires_in: Duration string (e.g. ``"24h"``, ``"7d"``). @@ -215,6 +228,17 @@ def delete(self, resource_id: str) -> None: validate_id(resource_id) 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)``. + + Args: + resource_id: QURL resource ID. + duration: Duration to add (e.g. ``"7d"``, ``"24h"``). + """ + return self.update(resource_id, extend_by=duration) + def update( self, resource_id: str, @@ -226,7 +250,6 @@ def update( ) -> QURL: """Update a QURL — extend expiration, change description, etc. - Combines the old ``extend()`` and ``update()`` into a single method. All fields are optional; only provided fields are sent. Args: @@ -308,8 +331,11 @@ def _raw_request( for attempt in range(self._max_retries + 1): if attempt > 0: delay = retry_delay(attempt, last_error) + logger.debug("Retry %d/%d after %.1fs", attempt, self._max_retries, delay) time.sleep(delay) + logger.debug("%s %s", method, url) + try: response = self._client.request( method, @@ -319,16 +345,20 @@ def _raw_request( headers=self._base_headers, ) except httpx.TimeoutException as exc: + logger.debug("%s %s timed out", method, url) if attempt < self._max_retries: last_error = exc continue raise QURLTimeoutError(str(exc), cause=exc) from exc except httpx.TransportError as exc: + logger.debug("%s %s transport error: %s", method, url, exc) if attempt < self._max_retries: last_error = exc continue raise QURLNetworkError(str(exc), cause=exc) from exc + logger.debug("%s %s → %d", method, url, response.status_code) + if response.status_code < 400: if response.status_code == 204 or not response.content: return None, None diff --git a/src/layerv_qurl/errors.py b/src/layerv_qurl/errors.py index aac9be7..5dc1ddd 100644 --- a/src/layerv_qurl/errors.py +++ b/src/layerv_qurl/errors.py @@ -4,7 +4,21 @@ class QURLError(Exception): - """Error raised for API-level errors (4xx/5xx responses).""" + """Error raised for API-level errors (4xx/5xx responses). + + Catch specific subclasses for fine-grained handling:: + + try: + client.resolve("at_xxx") + except AuthenticationError: + print("Bad API key") + except NotFoundError: + print("QURL doesn't exist") + except RateLimitError as e: + print(f"Rate limited — retry in {e.retry_after}s") + except QURLError as e: + print(f"API error: {e.status} {e.code}") + """ def __init__( self, @@ -27,6 +41,45 @@ def __init__( self.retry_after = retry_after +class AuthenticationError(QURLError): + """401 Unauthorized — invalid or missing API key.""" + + +class AuthorizationError(QURLError): + """403 Forbidden — valid key but insufficient permissions/scope.""" + + +class NotFoundError(QURLError): + """404 Not Found — resource does not exist.""" + + +class ValidationError(QURLError): + """400/422 — invalid request parameters. + + Check :attr:`invalid_fields` for per-field details:: + + except ValidationError as e: + if e.invalid_fields: + for field, reason in e.invalid_fields.items(): + print(f" {field}: {reason}") + """ + + +class RateLimitError(QURLError): + """429 Too Many Requests. + + Check :attr:`retry_after` for the server-suggested wait time:: + + except RateLimitError as e: + if e.retry_after: + time.sleep(e.retry_after) + """ + + +class ServerError(QURLError): + """5xx server-side error.""" + + class QURLNetworkError(Exception): """Error raised for transport-level failures (DNS, connection refused).""" diff --git a/tests/test_client.py b/tests/test_client.py index 3710912..220cc0a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -17,6 +17,15 @@ QURLNetworkError, QURLTimeoutError, ) +from layerv_qurl.errors import ( + AuthenticationError, + AuthorizationError, + NotFoundError, + RateLimitError, + ServerError, + ValidationError, +) +from layerv_qurl.types import AccessPolicy BASE_URL = "https://api.test.layerv.ai" @@ -869,3 +878,261 @@ def test_async_repr() -> None: assert "lv_l" in r assert "ghij" in r assert "abcdefghij" not in r + + +# --- Error subclass tests --- + + +@respx.mock +def test_401_raises_authentication_error(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 401, + json={ + "error": { + "status": 401, "code": "unauthorized", + "title": "Unauthorized", "detail": "Invalid API key", + }, + }, + ) + ) + + with pytest.raises(AuthenticationError) as exc_info: + client.get_quota() + assert exc_info.value.status == 401 + # Also caught by parent QURLError + with pytest.raises(QURLError): + client.get_quota() + + +@respx.mock +def test_403_raises_authorization_error(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 403, + json={ + "error": { + "status": 403, "code": "forbidden", + "title": "Forbidden", "detail": "Insufficient scope", + }, + }, + ) + ) + + with pytest.raises(AuthorizationError) as exc_info: + client.get_quota() + assert exc_info.value.status == 403 + + +@respx.mock +def test_404_raises_not_found_error(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/qurls/r_notfound0000").mock( + return_value=httpx.Response( + 404, + json={ + "error": { + "status": 404, "code": "not_found", + "title": "Not Found", "detail": "QURL not found", + }, + "meta": {"request_id": "req_err"}, + }, + ) + ) + + with pytest.raises(NotFoundError) as exc_info: + client.get("r_notfound0000") + assert exc_info.value.status == 404 + assert exc_info.value.request_id == "req_err" + + +@respx.mock +def test_422_raises_validation_error(client: QURLClient) -> None: + respx.post(f"{BASE_URL}/v1/qurl").mock( + return_value=httpx.Response( + 422, + json={ + "error": { + "status": 422, "code": "validation_error", + "title": "Validation Error", "detail": "Invalid target_url", + "invalid_fields": {"target_url": "must be a valid URL"}, + }, + }, + ) + ) + + with pytest.raises(ValidationError) as exc_info: + client.create(target_url="not-a-url") + assert exc_info.value.invalid_fields == {"target_url": "must be a valid URL"} + + +@respx.mock +def test_429_raises_rate_limit_error(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 429, + headers={"Retry-After": "10"}, + json={ + "error": { + "status": 429, "code": "rate_limited", + "title": "Rate Limited", "detail": "Slow down", + }, + }, + ) + ) + + with pytest.raises(RateLimitError) as exc_info: + client.get_quota() + assert exc_info.value.retry_after == 10 + + +@respx.mock +def test_500_raises_server_error(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 500, + json={ + "error": { + "status": 500, "code": "internal", + "title": "Internal Server Error", "detail": "Something broke", + }, + }, + ) + ) + + with pytest.raises(ServerError) as exc_info: + client.get_quota() + assert exc_info.value.status == 500 + + +@respx.mock +def test_400_raises_validation_error(client: QURLClient) -> None: + respx.post(f"{BASE_URL}/v1/qurl").mock( + return_value=httpx.Response( + 400, + json={ + "error": { + "status": 400, "code": "bad_request", + "title": "Bad Request", "detail": "Missing target_url", + }, + }, + ) + ) + + with pytest.raises(ValidationError): + client.create(target_url="") + + +# --- extend() convenience method --- + + +@respx.mock +def test_extend(client: QURLClient) -> None: + """extend() delegates to update(extend_by=...).""" + route = respx.patch(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", + "expires_at": "2026-03-20T10:00:00Z", + }, + }, + ) + ) + + result = client.extend("r_abc123def45", "7d") + assert isinstance(result.expires_at, datetime) + body = json.loads(route.calls[0].request.content) + assert body == {"extend_by": "7d"} + + +@respx.mock +@pytest.mark.asyncio +async def test_async_extend() -> None: + """Async extend() delegates to update(extend_by=...).""" + 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-03-20T10:00:00Z", + }, + }, + ) + ) + + async with AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) as client: + result = await client.extend("r_abc", "24h") + + assert result.resource_id == "r_abc" + body = json.loads(route.calls[0].request.content) + assert body == {"extend_by": "24h"} + + +# --- AccessPolicy serialization --- + + +@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( + 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", + }, + }, + ) + ) + + policy = AccessPolicy( + ip_allowlist=["10.0.0.0/8"], + geo_denylist=["CN", "RU"], + ) + client.create(target_url="https://example.com", access_policy=policy) + 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"] + + +@respx.mock +def test_access_policy_in_update(client: QURLClient) -> None: + """AccessPolicy can also be passed to update().""" + 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", + "access_policy": { + "user_agent_deny_regex": "curl.*", + }, + }, + }, + ) + ) + + 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.*" + body = json.loads(route.calls[0].request.content) + assert body["access_policy"] == {"user_agent_deny_regex": "curl.*"} From c8a71a9457301f3c8e2f181a434d4b227e99442c Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 11 Mar 2026 15:54:00 -0500 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20simplify=20review=20=E2=80=94?= =?UTF-8?q?=20deduplicate,=20tighten=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace dataclasses.asdict() with fields()/getattr() to avoid unnecessary deep copy on every request with AccessPolicy - Extract build_list_params() to _utils.py, deduplicating list() query-param building between sync and async clients - Narrow except clauses: Exception → PackageNotFoundError for version lookup, AttributeError → TypeError in parse_error() fallback - Add async_client test fixture to reduce boilerplate across 8 tests - Add sync note to async_client.py module docstring Co-Authored-By: Claude Opus 4.6 --- src/layerv_qurl/__init__.py | 3 +- src/layerv_qurl/_utils.py | 33 +++++++++++++++++---- src/layerv_qurl/async_client.py | 19 ++++-------- src/layerv_qurl/client.py | 14 ++------- tests/test_client.py | 52 +++++++++++++++------------------ 5 files changed, 62 insertions(+), 59 deletions(-) diff --git a/src/layerv_qurl/__init__.py b/src/layerv_qurl/__init__.py index d38a362..da3c2d3 100644 --- a/src/layerv_qurl/__init__.py +++ b/src/layerv_qurl/__init__.py @@ -1,5 +1,6 @@ """QURL Python SDK — secure, time-limited access links for AI agents.""" +from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _pkg_version from layerv_qurl.async_client import AsyncQURLClient @@ -58,5 +59,5 @@ try: __version__ = _pkg_version("layerv-qurl") -except Exception: +except PackageNotFoundError: __version__ = "dev" diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 678204f..3d8d8d9 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -8,6 +8,7 @@ import random import re from datetime import datetime +from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _pkg_version from typing import TYPE_CHECKING, Any @@ -62,7 +63,7 @@ def default_user_agent() -> str: """Return the default User-Agent string, caching the version lookup.""" try: v = _pkg_version("layerv-qurl") - except Exception: + except PackageNotFoundError: v = "dev" return f"qurl-python-sdk/{v}" @@ -88,9 +89,9 @@ def build_body(kwargs: dict[str, Any]) -> dict[str, Any]: body[k] = v.isoformat() elif dataclasses.is_dataclass(v) and not isinstance(v, type): body[k] = { - fk: fv - for fk, fv in dataclasses.asdict(v).items() - if fv is not None + f.name: getattr(v, f.name) + for f in dataclasses.fields(v) + if getattr(v, f.name) is not None } else: body[k] = v @@ -228,7 +229,7 @@ def parse_error(response: httpx.Response) -> QURLError: request_id=envelope.get("meta", {}).get("request_id"), retry_after=retry_after, ) - except (ValueError, KeyError, AttributeError): + except (ValueError, KeyError, TypeError): return cls( status=response.status_code, code="unknown", @@ -247,6 +248,28 @@ def retry_delay(attempt: int, last_error: Exception | None) -> float: return min(base + jitter, 30.0) +def build_list_params( + limit: int | None, + cursor: str | None, + status: str | None, + q: str | None, + sort: str | 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 + return params + + def mask_key(api_key: str) -> str: """Mask an API key for display, showing first 4 + last 4 chars.""" if len(api_key) > 8: diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index 0ac1286..710643e 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -1,4 +1,7 @@ -"""Asynchronous QURL API client.""" +"""Asynchronous QURL API client. + +NOTE: Business logic mirrors client.py — keep both in sync. +""" from __future__ import annotations @@ -14,6 +17,7 @@ RETRYABLE_STATUS, RETRYABLE_STATUS_POST, build_body, + build_list_params, default_user_agent, logger, mask_key, @@ -165,18 +169,7 @@ async def list( q: Search query string. sort: Sort order (e.g. ``"created_at"``, ``"-created_at"``). """ - 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 - + params = build_list_params(limit, cursor, status, q, sort) data, meta = await self._raw_request("GET", "/v1/qurls", params=params) return parse_list_output(data, meta) diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index dd046f3..005287a 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -14,6 +14,7 @@ RETRYABLE_STATUS, RETRYABLE_STATUS_POST, build_body, + build_list_params, default_user_agent, logger, mask_key, @@ -180,18 +181,7 @@ def list( q: Search query string. sort: Sort order (e.g. ``"created_at"``, ``"-created_at"``). """ - 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 - + params = build_list_params(limit, cursor, status, q, sort) data, meta = self._raw_request("GET", "/v1/qurls", params=params) return parse_list_output(data, meta) diff --git a/tests/test_client.py b/tests/test_client.py index 220cc0a..481605e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -64,6 +64,13 @@ def client() -> QURLClient: return QURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) +@pytest.fixture +async def async_client() -> AsyncQURLClient: + client = AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) + yield client # type: ignore[misc] + await client.close() + + @pytest.fixture def retry_client() -> QURLClient: return QURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=2) @@ -761,7 +768,7 @@ def test_post_request_has_content_type(client: QURLClient) -> None: @respx.mock @pytest.mark.asyncio -async def test_async_create() -> None: +async def test_async_create(async_client: AsyncQURLClient) -> None: respx.post(f"{BASE_URL}/v1/qurl").mock( return_value=httpx.Response( 201, @@ -776,16 +783,14 @@ async def test_async_create() -> None: ) ) - async with AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) as client: - result = await client.create(target_url="https://example.com", expires_in="24h") - + result = await async_client.create(target_url="https://example.com", expires_in="24h") assert result.resource_id == "r_async" assert isinstance(result.expires_at, datetime) @respx.mock @pytest.mark.asyncio -async def test_async_resolve() -> None: +async def test_async_resolve(async_client: AsyncQURLClient) -> None: respx.post(f"{BASE_URL}/v1/resolve").mock( return_value=httpx.Response( 200, @@ -803,9 +808,7 @@ async def test_async_resolve() -> None: ) ) - async with AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) as client: - result = await client.resolve("at_test_token") - + result = await async_client.resolve("at_test_token") assert result.target_url == "https://api.example.com/data" assert result.access_grant is not None assert result.access_grant.expires_in == 305 @@ -813,7 +816,7 @@ async def test_async_resolve() -> None: @respx.mock @pytest.mark.asyncio -async def test_async_list_all() -> 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={ @@ -826,49 +829,44 @@ async def test_async_list_all() -> None: }), ] - async with AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) as client: - all_qurls = [q async for q in client.list_all(status="active", page_size=1)] - + all_qurls = [q async for q in async_client.list_all(status="active", page_size=1)] assert len(all_qurls) == 2 assert [q.resource_id for q in all_qurls] == ["r_1", "r_2"] @respx.mock @pytest.mark.asyncio -async def test_async_network_error_wrapped() -> None: +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") ) - async with AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) as client: - with pytest.raises(QURLNetworkError, match="Connection refused"): - await client.get_quota() + with pytest.raises(QURLNetworkError, match="Connection refused"): + await async_client.get_quota() @respx.mock @pytest.mark.asyncio -async def test_async_timeout_error_wrapped() -> None: +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") ) - async with AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) as client: - with pytest.raises(QURLTimeoutError, match="Read timed out"): - await client.get_quota() + with pytest.raises(QURLTimeoutError, match="Read timed out"): + await async_client.get_quota() @respx.mock @pytest.mark.asyncio -async def test_async_timeout_is_network_error() -> None: +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") ) - async with AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) as client: - with pytest.raises(QURLNetworkError): - await client.get_quota() + with pytest.raises(QURLNetworkError): + await async_client.get_quota() def test_async_repr() -> None: @@ -1051,7 +1049,7 @@ def test_extend(client: QURLClient) -> None: @respx.mock @pytest.mark.asyncio -async def test_async_extend() -> None: +async def test_async_extend(async_client: AsyncQURLClient) -> None: """Async extend() delegates to update(extend_by=...).""" route = respx.patch(f"{BASE_URL}/v1/qurls/r_abc").mock( return_value=httpx.Response( @@ -1068,9 +1066,7 @@ async def test_async_extend() -> None: ) ) - async with AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) as client: - result = await client.extend("r_abc", "24h") - + result = await async_client.extend("r_abc", "24h") assert result.resource_id == "r_abc" body = json.loads(route.calls[0].request.content) assert body == {"extend_by": "24h"} From 5ba6fee85040037fdf471857a06296fb78dc7de5 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 11 Mar 2026 15:59:54 -0500 Subject: [PATCH 8/8] refactor: remove no-op ternary, simplify test assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - json=body if body is not None else None → json=body (httpx treats None as no-body already) - Replace redundant HTTP call in test_401 with isinstance check Co-Authored-By: Claude Opus 4.6 --- src/layerv_qurl/async_client.py | 2 +- src/layerv_qurl/client.py | 2 +- tests/test_client.py | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index 710643e..6159aea 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -313,7 +313,7 @@ async def _raw_request( response = await self._client.request( method, url, - json=body if body is not None else None, + json=body, params=params, headers=self._base_headers, ) diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index 005287a..b63b9e1 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -330,7 +330,7 @@ def _raw_request( response = self._client.request( method, url, - json=body if body is not None else None, + json=body, params=params, headers=self._base_headers, ) diff --git a/tests/test_client.py b/tests/test_client.py index 481605e..f2c4579 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -898,9 +898,7 @@ def test_401_raises_authentication_error(client: QURLClient) -> None: with pytest.raises(AuthenticationError) as exc_info: client.get_quota() assert exc_info.value.status == 401 - # Also caught by parent QURLError - with pytest.raises(QURLError): - client.get_quota() + assert isinstance(exc_info.value, QURLError) @respx.mock