qurl-python 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- layerv_qurl/__init__.py +161 -0
- layerv_qurl/_utils.py +1414 -0
- layerv_qurl/async_client.py +1512 -0
- layerv_qurl/client.py +1533 -0
- layerv_qurl/errors.py +152 -0
- layerv_qurl/langchain.py +159 -0
- layerv_qurl/py.typed +0 -0
- layerv_qurl/types.py +690 -0
- qurl_python-0.2.1.dist-info/METADATA +282 -0
- qurl_python-0.2.1.dist-info/RECORD +12 -0
- qurl_python-0.2.1.dist-info/WHEEL +4 -0
- qurl_python-0.2.1.dist-info/licenses/LICENSE +21 -0
layerv_qurl/errors.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Error types for the qURL API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class QURLError(Exception):
|
|
9
|
+
"""Error raised for API-level errors (4xx/5xx responses).
|
|
10
|
+
|
|
11
|
+
Carries the full RFC 7807 Problem Details shape when the API provides
|
|
12
|
+
it: ``status``, ``code``, ``title``, ``detail``, plus the optional
|
|
13
|
+
``type`` (problem-type URI) and ``instance`` (occurrence URI).
|
|
14
|
+
|
|
15
|
+
.. note::
|
|
16
|
+
|
|
17
|
+
``detail`` is **always non-empty** on the instance — the
|
|
18
|
+
constructor falls back to ``title`` when the API omits detail
|
|
19
|
+
(RFC 7807 allows this). Use ``code`` / ``status`` / ``type`` to
|
|
20
|
+
distinguish between error cases rather than inspecting ``detail``
|
|
21
|
+
for the "was it absent?" signal.
|
|
22
|
+
|
|
23
|
+
.. note::
|
|
24
|
+
|
|
25
|
+
``type`` shadows Python's built-in ``type()`` inside method
|
|
26
|
+
bodies. This is intentional — the name mirrors the RFC 7807 field
|
|
27
|
+
name and matches the other SDKs (``qurl-typescript``,
|
|
28
|
+
``qurl-mcp``). The shadowing only matters inside ``QURLError``
|
|
29
|
+
method definitions; external code can still use ``type(err)``
|
|
30
|
+
safely since attribute access doesn't shadow the builtin in that
|
|
31
|
+
scope.
|
|
32
|
+
|
|
33
|
+
Catch specific subclasses for fine-grained handling::
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
client.resolve("at_xxx")
|
|
37
|
+
except AuthenticationError:
|
|
38
|
+
print("Bad API key")
|
|
39
|
+
except NotFoundError:
|
|
40
|
+
print("qURL doesn't exist")
|
|
41
|
+
except RateLimitError as e:
|
|
42
|
+
print(f"Rate limited — retry in {e.retry_after}s")
|
|
43
|
+
except QURLError as e:
|
|
44
|
+
print(f"API error: {e.status} {e.code}")
|
|
45
|
+
if e.type:
|
|
46
|
+
print(f" problem type: {e.type}")
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
status: int,
|
|
53
|
+
code: str,
|
|
54
|
+
title: str,
|
|
55
|
+
detail: str | None = None,
|
|
56
|
+
type: str | None = None,
|
|
57
|
+
instance: str | None = None,
|
|
58
|
+
invalid_fields: dict[str, str] | None = None,
|
|
59
|
+
request_id: str | None = None,
|
|
60
|
+
retry_after: int | None = None,
|
|
61
|
+
meta: dict[str, Any] | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
# RFC 7807 leaves `detail` optional, and `title` is always present.
|
|
64
|
+
# When `detail` is `None` (omitted), fall back to `title` so the
|
|
65
|
+
# Exception message stays meaningful instead of producing
|
|
66
|
+
# "Title (403): None". An explicit empty string is stored as-is —
|
|
67
|
+
# the caller opted in. Uses `is not None` rather than truthiness
|
|
68
|
+
# so `detail=""` is distinguishable from "not provided".
|
|
69
|
+
message_detail = detail if detail is not None else title
|
|
70
|
+
super().__init__(f"{title} ({status}): {message_detail}")
|
|
71
|
+
self.status = status
|
|
72
|
+
self.code = code
|
|
73
|
+
self.title = title
|
|
74
|
+
self.detail = message_detail
|
|
75
|
+
self.type = type
|
|
76
|
+
self.instance = instance
|
|
77
|
+
self.invalid_fields = invalid_fields
|
|
78
|
+
self.request_id = request_id
|
|
79
|
+
self.retry_after = retry_after
|
|
80
|
+
self.meta = meta
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AuthenticationError(QURLError):
|
|
84
|
+
"""401 Unauthorized — invalid or missing API key."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AuthorizationError(QURLError):
|
|
88
|
+
"""403 Forbidden — valid key but insufficient permissions/scope."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class NotFoundError(QURLError):
|
|
92
|
+
"""404 Not Found — resource does not exist."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ConflictError(QURLError):
|
|
96
|
+
"""409 Conflict — resource already exists or cannot be mutated in this state."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class GoneError(QURLError):
|
|
100
|
+
"""410 Gone — resource lifecycle has closed."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ValidationError(QURLError):
|
|
104
|
+
"""400/422 — invalid request parameters.
|
|
105
|
+
|
|
106
|
+
Check :attr:`invalid_fields` for per-field details::
|
|
107
|
+
|
|
108
|
+
except ValidationError as e:
|
|
109
|
+
if e.invalid_fields:
|
|
110
|
+
for field, reason in e.invalid_fields.items():
|
|
111
|
+
print(f" {field}: {reason}")
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class RateLimitError(QURLError):
|
|
116
|
+
"""429 Too Many Requests.
|
|
117
|
+
|
|
118
|
+
Check :attr:`retry_after` for the server-suggested wait time::
|
|
119
|
+
|
|
120
|
+
except RateLimitError as e:
|
|
121
|
+
if e.retry_after:
|
|
122
|
+
time.sleep(e.retry_after)
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ServerError(QURLError):
|
|
127
|
+
"""5xx server-side error."""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class QURLNetworkError(Exception):
|
|
131
|
+
"""Error raised for transport-level failures (DNS, connection refused)."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, message: str, cause: Exception | None = None) -> None:
|
|
134
|
+
super().__init__(message)
|
|
135
|
+
self.__cause__ = cause
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class QURLTimeoutError(QURLNetworkError):
|
|
139
|
+
"""Error raised when a request times out.
|
|
140
|
+
|
|
141
|
+
Subclass of :class:`QURLNetworkError` — caught by
|
|
142
|
+
``except QURLNetworkError`` but can also be caught specifically::
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
client.resolve("at_xxx")
|
|
146
|
+
except QURLTimeoutError:
|
|
147
|
+
print("Request timed out — server may be slow")
|
|
148
|
+
except QURLNetworkError:
|
|
149
|
+
print("Network issue — DNS, connection, etc.")
|
|
150
|
+
except QURLError:
|
|
151
|
+
print("API error — 4xx/5xx")
|
|
152
|
+
"""
|
layerv_qurl/langchain.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""LangChain tool integration for qURL.
|
|
2
|
+
|
|
3
|
+
Install with: pip install qurl-python[langchain]
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from langchain_core.tools import BaseTool
|
|
12
|
+
|
|
13
|
+
_HAS_LANGCHAIN = True
|
|
14
|
+
except ImportError:
|
|
15
|
+
_HAS_LANGCHAIN = False
|
|
16
|
+
BaseTool = object # type: ignore[misc,assignment]
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from langchain_core.callbacks import CallbackManagerForToolRun
|
|
20
|
+
|
|
21
|
+
from layerv_qurl.client import QURLClient
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CreateQURLTool",
|
|
25
|
+
"DeleteQURLTool",
|
|
26
|
+
"ListQURLsTool",
|
|
27
|
+
"QURLToolkit",
|
|
28
|
+
"ResolveQURLTool",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CreateQURLTool(BaseTool):
|
|
33
|
+
"""Create a secure, time-limited access link to a protected URL."""
|
|
34
|
+
|
|
35
|
+
name: str = "create_qurl"
|
|
36
|
+
description: str = (
|
|
37
|
+
"Create a qURL — a secure, time-limited access link. "
|
|
38
|
+
"Input should be a JSON string with 'target_url' (required), "
|
|
39
|
+
"and optionally 'expires_in' (e.g. '24h', '7d'), 'label'."
|
|
40
|
+
)
|
|
41
|
+
client: Any = None # QURLClient, typed as Any for Pydantic compatibility
|
|
42
|
+
|
|
43
|
+
def _run(
|
|
44
|
+
self,
|
|
45
|
+
target_url: str,
|
|
46
|
+
expires_in: str = "24h",
|
|
47
|
+
label: str | None = None,
|
|
48
|
+
run_manager: CallbackManagerForToolRun | None = None,
|
|
49
|
+
) -> str:
|
|
50
|
+
result = self.client.create(
|
|
51
|
+
target_url=target_url,
|
|
52
|
+
expires_in=expires_in,
|
|
53
|
+
label=label,
|
|
54
|
+
)
|
|
55
|
+
return (
|
|
56
|
+
f"Created qURL {result.resource_id}\n"
|
|
57
|
+
f"Link: {result.qurl_link}\n"
|
|
58
|
+
f"Site: {result.qurl_site}\n"
|
|
59
|
+
f"Expires: {result.expires_at or 'N/A'}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ResolveQURLTool(BaseTool):
|
|
64
|
+
"""Resolve a qURL access token to grant network access."""
|
|
65
|
+
|
|
66
|
+
name: str = "resolve_qurl"
|
|
67
|
+
description: str = (
|
|
68
|
+
"Resolve a qURL access token to gain network access to the protected resource. "
|
|
69
|
+
"Input should be the access token string (e.g. 'at_k8xqp9h2sj9lx7r4a')."
|
|
70
|
+
)
|
|
71
|
+
client: Any = None
|
|
72
|
+
|
|
73
|
+
def _run(
|
|
74
|
+
self,
|
|
75
|
+
access_token: str,
|
|
76
|
+
run_manager: CallbackManagerForToolRun | None = None,
|
|
77
|
+
) -> str:
|
|
78
|
+
result = self.client.resolve(access_token)
|
|
79
|
+
grant = result.access_grant
|
|
80
|
+
target = result.target_url or "<redacted>"
|
|
81
|
+
lines = [
|
|
82
|
+
f"Resolved: {target}",
|
|
83
|
+
f"Resource: {result.resource_id}",
|
|
84
|
+
]
|
|
85
|
+
if grant:
|
|
86
|
+
lines.append(f"Access expires in: {grant.expires_in}s")
|
|
87
|
+
lines.append(f"Granted to IP: {grant.src_ip}")
|
|
88
|
+
return "\n".join(lines)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ListQURLsTool(BaseTool):
|
|
92
|
+
"""List active qURL links."""
|
|
93
|
+
|
|
94
|
+
name: str = "list_qurls"
|
|
95
|
+
description: str = "List active qURL links. Optionally filter by status (active, revoked)."
|
|
96
|
+
client: Any = None
|
|
97
|
+
|
|
98
|
+
def _run(
|
|
99
|
+
self,
|
|
100
|
+
status: str = "active",
|
|
101
|
+
limit: int = 10,
|
|
102
|
+
run_manager: CallbackManagerForToolRun | None = None,
|
|
103
|
+
) -> str:
|
|
104
|
+
result = self.client.list(status=status, limit=limit)
|
|
105
|
+
if not result.qurls:
|
|
106
|
+
return "No qURLs found."
|
|
107
|
+
lines = []
|
|
108
|
+
for q in result.qurls:
|
|
109
|
+
target = q.target_url or "<redacted>"
|
|
110
|
+
expires = q.expires_at or "N/A"
|
|
111
|
+
lines.append(f"- {q.resource_id}: {target} [{q.status}] expires={expires}")
|
|
112
|
+
return "\n".join(lines)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class DeleteQURLTool(BaseTool):
|
|
116
|
+
"""Revoke a qURL, immediately ending all access."""
|
|
117
|
+
|
|
118
|
+
name: str = "delete_qurl"
|
|
119
|
+
description: str = "Revoke (delete) a qURL by resource ID (e.g. 'r_k8xqp9h2sj9')."
|
|
120
|
+
client: Any = None
|
|
121
|
+
|
|
122
|
+
def _run(
|
|
123
|
+
self,
|
|
124
|
+
resource_id: str,
|
|
125
|
+
run_manager: CallbackManagerForToolRun | None = None,
|
|
126
|
+
) -> str:
|
|
127
|
+
self.client.delete(resource_id)
|
|
128
|
+
return f"qURL {resource_id} has been revoked."
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class QURLToolkit:
|
|
132
|
+
"""LangChain toolkit providing all qURL tools.
|
|
133
|
+
|
|
134
|
+
Usage::
|
|
135
|
+
|
|
136
|
+
from layerv_qurl import QURLClient
|
|
137
|
+
from layerv_qurl.langchain import QURLToolkit
|
|
138
|
+
|
|
139
|
+
client = QURLClient(api_key="lv_live_xxx")
|
|
140
|
+
toolkit = QURLToolkit(client=client)
|
|
141
|
+
tools = toolkit.get_tools()
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(self, client: QURLClient) -> None:
|
|
145
|
+
if not _HAS_LANGCHAIN:
|
|
146
|
+
raise ImportError(
|
|
147
|
+
"langchain-core is required for LangChain integration. "
|
|
148
|
+
"Install with: pip install qurl-python[langchain]"
|
|
149
|
+
)
|
|
150
|
+
self.client = client
|
|
151
|
+
|
|
152
|
+
def get_tools(self) -> list[BaseTool]:
|
|
153
|
+
"""Return all qURL tools configured with the client."""
|
|
154
|
+
return [
|
|
155
|
+
CreateQURLTool(client=self.client),
|
|
156
|
+
ResolveQURLTool(client=self.client),
|
|
157
|
+
ListQURLsTool(client=self.client),
|
|
158
|
+
DeleteQURLTool(client=self.client),
|
|
159
|
+
]
|
layerv_qurl/py.typed
ADDED
|
File without changes
|