Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,16 @@ jobs:
# Determine status
if [[ "$AGGREGATE" == "success" ]]; then
COLOR="#36a64f"; EMOJI="white_check_mark"
HEADER="QURL Python SDK Build"; STATUS_TEXT="successful"
HEADER="qURL Python SDK Build"; STATUS_TEXT="successful"
elif [[ "$AGGREGATE" == "failure" ]]; then
COLOR="#dc3545"; EMOJI="x"
HEADER="QURL Python SDK Build"; STATUS_TEXT="failed"
HEADER="qURL Python SDK Build"; STATUS_TEXT="failed"
elif [[ "$AGGREGATE" == "cancelled" ]]; then
COLOR="#ffc107"; EMOJI="warning"
HEADER="QURL Python SDK Build"; STATUS_TEXT="cancelled"
HEADER="qURL Python SDK Build"; STATUS_TEXT="cancelled"
else
COLOR="#ffc107"; EMOJI="warning"
HEADER="QURL Python SDK"; STATUS_TEXT="incomplete"
HEADER="qURL Python SDK"; STATUS_TEXT="incomplete"
fi

# Determine per-gate emoji so the Slack message shows which
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

## Project

Python SDK for the QURL API (`pip install layerv-qurl`). Extracted from `layervai/qurl-integrations`.
Python SDK for the qURL API (`pip install layerv-qurl`). Extracted from `layervai/qurl-integrations`.

## Commands

Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
[![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.
Python SDK for the [qURL™ API](https://docs.layerv.ai) — secure, time-limited access links for AI agents.

## Why QURL?
> **Quantum URL (qURL)** · The internet has a hidden layer. This is how you enter.

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:
## 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
Expand Down Expand Up @@ -47,7 +49,7 @@ print(result.qurl_link) # Share this link
access = client.resolve("at_k8xqp9h2sj9lx7r4a")
print(f"Access granted to {access.target_url} for {access.access_grant.expires_in}s")

# Extend a QURL's expiration
# Extend a qURL's expiration
qurl = client.extend("r_xxx", "7d")

# Update metadata and policy
Expand All @@ -74,7 +76,7 @@ asyncio.run(main())
## Pagination

```python
# Iterate all active QURLs (auto-paginates)
# Iterate all active qURLs (auto-paginates)
for qurl in client.list_all(status="active"):
print(f"{qurl.resource_id}: {qurl.target_url}")

Expand Down Expand Up @@ -136,7 +138,7 @@ All error classes inherit from `QURLError`, so `except QURLError` catches everyt
```python
quota = client.get_quota()
print(f"Plan: {quota.plan}")
print(f"Active QURLs: {quota.usage.active_qurls}")
print(f"Active qURLs: {quota.usage.active_qurls}")
print(f"Rate limit: {quota.rate_limits.create_per_minute}/min")
```

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "layerv-qurl"
version = "0.1.0"
description = "Python SDK for the QURL API - secure, time-limited access links"
description = "Python SDK for the qURL™ API secure, time-limited access links. Quantum URL is how you enter the hidden layer of the internet."
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
Expand Down
2 changes: 1 addition & 1 deletion src/layerv_qurl/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""QURL Python SDK — secure, time-limited access links for AI agents."""
"""qURL Python SDK — secure, time-limited access links for AI agents."""

from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version
Expand Down
2 changes: 1 addition & 1 deletion src/layerv_qurl/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ def _parse_access_token(data: dict[str, Any]) -> AccessToken:


def parse_qurl(data: dict[str, Any]) -> QURL:
"""Parse a QURL resource from API response data."""
"""Parse a qURL resource from API response data."""
tokens = None
# API returns "qurls" array; SDK exposes as "access_tokens" for clarity.
raw_tokens = data.get("qurls") if "qurls" in data else data.get("access_tokens")
Expand Down
66 changes: 33 additions & 33 deletions src/layerv_qurl/async_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Asynchronous QURL API client.
"""Asynchronous qURL API client.

NOTE: Business logic mirrors client.py — keep both in sync.
"""
Expand Down Expand Up @@ -64,7 +64,7 @@


class AsyncQURLClient:
"""Asynchronous QURL API client.
"""Asynchronous qURL API client.

Usage::

Expand Down Expand Up @@ -132,7 +132,7 @@ async def create(
access_policy: AccessPolicy | None = None,
custom_domain: str | None = None,
) -> CreateOutput:
"""Create a new QURL.
"""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
Expand All @@ -149,13 +149,13 @@ async def create(
expires_in: Duration string (e.g. ``"24h"``, ``"7d"``). The API
uses ``expires_in`` on create; use :meth:`update` with
``expires_at`` if you need an absolute expiry afterwards.
label: Human-readable label for the QURL. Max length 500.
one_time_use: If True, the QURL is consumed on first access.
label: Human-readable label for the qURL. Max length 500.
one_time_use: If True, the qURL is consumed on first access.
max_sessions: Maximum concurrent sessions (0 = unlimited).
Must be between 0 and 1000 inclusive.
session_duration: Duration string for sessions (e.g. ``"1h"``).
access_policy: IP/geo/user-agent access restrictions.
custom_domain: Custom domain for the QURL link. Max length 253.
custom_domain: Custom domain for the qURL link. Max length 253.

Raises:
ValueError: If any field violates the documented API constraints.
Expand All @@ -182,14 +182,14 @@ async def create(
return parse_create_output(resp)

async def get(self, resource_id: str) -> QURL:
"""Get a QURL resource and its access tokens.
"""Get a qURL resource and its access tokens.

Accepts either a resource ID (``r_`` prefix) or a QURL display ID
Accepts either a resource ID (``r_`` prefix) or a qURL display ID
(``q_`` prefix); the API resolves ``q_`` IDs to the parent resource
automatically.

Args:
resource_id: The resource or QURL display ID.
resource_id: The resource or qURL display ID.
"""
validate_id(resource_id)
resp = await self._request("GET", f"/v1/qurls/{resource_id}")
Expand All @@ -208,25 +208,25 @@ async def list(
expires_before: datetime | str | None = None,
expires_after: datetime | str | None = None,
) -> ListOutput:
"""List QURLs with optional filters.
"""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"``, ``"revoked"``).
status: Filter by qURL status (``"active"``, ``"revoked"``).
q: Search query string.
sort: Sort order (e.g. ``"created_at"``, ``"-created_at"``).
created_after: Filter QURLs created after this timestamp.
created_after: Filter qURLs created after this timestamp.
Accepts a :class:`datetime` (serialized via ``.isoformat()``)
or a string. String values must be ISO 8601 / RFC 3339
format (e.g. ``"2026-04-01T00:00:00Z"``) and are passed
through to the API unvalidated — the server rejects
malformed timestamps with a 400.
created_before: Filter QURLs created before this timestamp.
created_before: Filter qURLs created before this timestamp.
Same format rules as ``created_after``.
expires_before: Filter QURLs expiring before this timestamp.
expires_before: Filter qURLs expiring before this timestamp.
Same format rules as ``created_after``.
expires_after: Filter QURLs expiring after this timestamp.
expires_after: Filter qURLs expiring after this timestamp.
Same format rules as ``created_after``.
"""
params = build_list_params(
Expand Down Expand Up @@ -255,7 +255,7 @@ async def list_all(
expires_before: datetime | str | None = None,
expires_after: datetime | str | None = None,
) -> AsyncIterator[QURL]:
"""Iterate over all QURLs, automatically paginating.
"""Iterate over all qURLs, automatically paginating.

Yields individual :class:`QURL` objects, fetching pages transparently.

Expand All @@ -264,17 +264,17 @@ async def list_all(
q: Search query string.
sort: Sort order.
page_size: Number of items per page (default 50).
created_after: Filter QURLs created after this timestamp.
created_after: Filter qURLs created after this timestamp.
Accepts a :class:`datetime` (serialized via ``.isoformat()``)
or a string. String values must be ISO 8601 / RFC 3339
format (e.g. ``"2026-04-01T00:00:00Z"``) and are passed
through to the API unvalidated — the server rejects
malformed timestamps with a 400.
created_before: Filter QURLs created before this timestamp.
created_before: Filter qURLs created before this timestamp.
Same format rules as ``created_after``.
expires_before: Filter QURLs expiring before this timestamp.
expires_before: Filter qURLs expiring before this timestamp.
Same format rules as ``created_after``.
expires_after: Filter QURLs expiring after this timestamp.
expires_after: Filter qURLs expiring after this timestamp.
Same format rules as ``created_after``.
"""
cursor: str | None = None
Expand All @@ -297,9 +297,9 @@ async def list_all(
cursor = page.next_cursor

async def delete(self, resource_id: str) -> None:
"""Delete (revoke) a QURL resource and all its access tokens.
"""Delete (revoke) a qURL resource and all its access tokens.

Only accepts a resource ID (``r_`` prefix), not a QURL display ID
Only accepts a resource ID (``r_`` prefix), not a qURL display ID
(``q_`` prefix). Per the OpenAPI spec:
*"Requires a resource ID (r_ prefix). To revoke a single token,
use DELETE /v1/resources/:id/qurls/:qurl_id"*.
Expand All @@ -319,15 +319,15 @@ async def delete(self, resource_id: str) -> None:
await self._request("DELETE", f"/v1/qurls/{resource_id}")

async def extend(self, resource_id: str, duration: str) -> QURL:
"""Extend a QURL's expiration.
"""Extend a qURL's expiration.

Convenience method — equivalent to ``await update(resource_id, extend_by=duration)``.
Accepts either a resource ID (``r_`` prefix) or a QURL display ID
Accepts either a resource ID (``r_`` prefix) or a qURL display ID
(``q_`` prefix); the API resolves ``q_`` IDs to the parent resource
automatically.

Args:
resource_id: Resource or QURL display ID.
resource_id: Resource or qURL display ID.
duration: Duration to add (e.g. ``"7d"``, ``"24h"``).
"""
return await self.update(resource_id, extend_by=duration)
Expand All @@ -341,9 +341,9 @@ async def update(
description: str | None = None,
tags: builtins.list[str] | None = None,
) -> QURL:
"""Update a QURL — extend expiration, change description, set tags.
"""Update a qURL — extend expiration, change description, set tags.

Accepts either a resource ID (``r_`` prefix) or a QURL display ID
Accepts either a resource ID (``r_`` prefix) or a qURL display ID
(``q_`` prefix). All fields are optional, but at least one must be
provided. ``extend_by`` and ``expires_at`` are mutually exclusive.

Expand All @@ -355,7 +355,7 @@ async def update(
scope, base resource policy unchanged).

Args:
resource_id: Resource or QURL display ID.
resource_id: Resource or qURL display ID.
extend_by: Duration to add (e.g. ``"7d"``). Mutually exclusive
with ``expires_at``.
expires_at: New absolute expiry. Mutually exclusive with
Expand Down Expand Up @@ -420,14 +420,14 @@ async def mint_link(
session_duration: str | None = None,
access_policy: AccessPolicy | None = None,
) -> MintOutput:
"""Mint a new access link for a QURL.
"""Mint a new access link for a qURL.

Accepts either a resource ID (``r_`` prefix) or a QURL display ID
Accepts either a resource ID (``r_`` prefix) or a qURL display ID
(``q_`` prefix). ``expires_in`` and ``expires_at`` are mutually
exclusive — if neither is set, the link defaults to 24 hours.

Args:
resource_id: Resource or QURL display ID.
resource_id: Resource or qURL display ID.
expires_at: Absolute expiry for the minted link. Mutually
exclusive with ``expires_in``.
expires_in: Duration string for the link (e.g. ``"24h"``).
Expand Down Expand Up @@ -468,7 +468,7 @@ async def batch_create(
self,
items: Sequence[BatchCreateItem],
) -> BatchCreateOutput:
"""Create multiple QURLs at once (1-100 items).
"""Create multiple qURLs at once (1-100 items).

Each item is validated against the same spec constraints as
:meth:`create` before the request is sent, with per-item errors
Expand Down Expand Up @@ -545,7 +545,7 @@ async def batch_create(
return parse_batch_create_output(resp)

async def resolve(self, access_token: str) -> ResolveOutput:
"""Resolve a QURL access token (headless).
"""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.
Expand Down
Loading
Loading