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/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
+ """
@@ -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