permitly 0.1.0__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.
permitly/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ from .client import PermitlyClient
2
+ from .exceptions import AuthError, PermitlyError, UsageLimitError, ValidationError
3
+ from .models import AuditEvent, ConsentRequest, PageResult
4
+ from .scope_templates import ScopeTemplates
5
+ from .webhook import verify_signature
6
+
7
+ __all__ = [
8
+ "PermitlyClient",
9
+ "PermitlyError",
10
+ "AuthError",
11
+ "ValidationError",
12
+ "UsageLimitError",
13
+ "ScopeTemplates",
14
+ "verify_signature",
15
+ "AuditEvent",
16
+ "ConsentRequest",
17
+ "PageResult",
18
+ ]
permitly/client.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import types
4
+ from typing import Optional, Type
5
+
6
+ import httpx
7
+
8
+ from .exceptions import AuthError, PermitlyError, UsageLimitError, ValidationError
9
+
10
+
11
+ class PermitlyClient:
12
+ """Synchronous HTTP client for the Permitly API."""
13
+
14
+ def __init__(
15
+ self,
16
+ api_key: str,
17
+ base_url: str = "https://permitly.dev",
18
+ timeout: int = 30,
19
+ ) -> None:
20
+ from .resources.consent import ConsentResource
21
+ from .resources.audit import AuditResource
22
+
23
+ self._http = httpx.Client(
24
+ base_url=base_url,
25
+ headers={
26
+ "Authorization": f"Bearer {api_key}",
27
+ "Content-Type": "application/json",
28
+ "Accept": "application/json",
29
+ },
30
+ timeout=timeout,
31
+ )
32
+ self.consent = ConsentResource(self)
33
+ self.audit = AuditResource(self)
34
+
35
+ def _request(self, method: str, path: str, **kwargs) -> dict:
36
+ """Execute an HTTP request and return the parsed JSON body.
37
+
38
+ Raises a typed exception for any non-2xx response.
39
+ """
40
+ response = self._http.request(method, path, **kwargs)
41
+ self._raise_for_status(response)
42
+ return response.json()
43
+
44
+ def _raise_for_status(self, response: httpx.Response) -> None:
45
+ """Translate HTTP error responses into typed SDK exceptions."""
46
+ if response.is_success:
47
+ return
48
+
49
+ try:
50
+ body = response.json()
51
+ error = body.get("error", {})
52
+ code = error.get("code", "unknown_error")
53
+ message = error.get("message", response.text)
54
+ errors = error.get("errors")
55
+ except Exception:
56
+ code = "unknown_error"
57
+ message = response.text
58
+ errors = None
59
+
60
+ if response.status_code == 401:
61
+ raise AuthError(message, code=code, status_code=401)
62
+ elif response.status_code == 422:
63
+ raise ValidationError(message, code=code, status_code=422, errors=errors)
64
+ elif response.status_code == 429:
65
+ raise UsageLimitError(message, code=code, status_code=429)
66
+ else:
67
+ raise PermitlyError(message, code=code, status_code=response.status_code)
68
+
69
+ def close(self) -> None:
70
+ """Close the underlying HTTP connection pool."""
71
+ self._http.close()
72
+
73
+ def __enter__(self) -> PermitlyClient:
74
+ return self
75
+
76
+ def __exit__(
77
+ self,
78
+ exc_type: Optional[Type[BaseException]],
79
+ exc_val: Optional[BaseException],
80
+ exc_tb: Optional[types.TracebackType],
81
+ ) -> None:
82
+ self.close()
permitly/exceptions.py ADDED
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class PermitlyError(Exception):
7
+ def __init__(self, message: str, *, code: str = "unknown_error", status_code: int = 0):
8
+ super().__init__(message)
9
+ self.code = code
10
+ self.status_code = status_code
11
+
12
+
13
+ class AuthError(PermitlyError):
14
+ """Raised on 401 — invalid or missing API key."""
15
+
16
+
17
+ class ValidationError(PermitlyError):
18
+ """Raised on 422 — request payload failed server-side validation."""
19
+
20
+ def __init__(
21
+ self,
22
+ message: str,
23
+ *,
24
+ code: str = "validation_error",
25
+ status_code: int = 422,
26
+ errors: Optional[dict] = None,
27
+ ):
28
+ super().__init__(message, code=code, status_code=status_code)
29
+ self.errors = errors or {}
30
+
31
+
32
+ class UsageLimitError(PermitlyError):
33
+ """Raised on 429 — monthly consent request limit reached."""
permitly/models.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class ConsentRequest:
9
+ id: str
10
+ status: str
11
+ consent_url: Optional[str] = None
12
+ end_user_ref: Optional[str] = None
13
+ scopes: Optional[list] = None
14
+ created_at: Optional[str] = None
15
+ decided_at: Optional[str] = None
16
+ expires_at: Optional[str] = None
17
+
18
+
19
+ @dataclass
20
+ class AuditEvent:
21
+ id: str
22
+ event_type: str
23
+ actor: Optional[str] = None
24
+ consent_id: Optional[str] = None
25
+ created_at: Optional[str] = None
26
+
27
+
28
+ @dataclass
29
+ class PageResult:
30
+ data: list
31
+ cursor: Optional[str]
32
+ has_more: bool
33
+
34
+
35
+ def _parse_consent(data: dict) -> ConsentRequest:
36
+ return ConsentRequest(
37
+ id=data["id"],
38
+ status=data["status"],
39
+ consent_url=data.get("consent_url"),
40
+ end_user_ref=data.get("end_user_ref"),
41
+ scopes=data.get("scopes"),
42
+ created_at=data.get("created_at"),
43
+ decided_at=data.get("decided_at"),
44
+ expires_at=data.get("expires_at"),
45
+ )
46
+
47
+
48
+ def _parse_audit_event(data: dict) -> AuditEvent:
49
+ return AuditEvent(
50
+ id=data["id"],
51
+ event_type=data["event_type"],
52
+ actor=data.get("actor"),
53
+ consent_id=data.get("consent_id"),
54
+ created_at=data.get("created_at"),
55
+ )
File without changes
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Optional
4
+
5
+ from ..models import AuditEvent, PageResult, _parse_audit_event
6
+
7
+ if TYPE_CHECKING:
8
+ from ..client import PermitlyClient
9
+
10
+
11
+ class AuditResource:
12
+ """Provides access to audit log operations."""
13
+
14
+ def __init__(self, client: "PermitlyClient") -> None:
15
+ self._client = client
16
+
17
+ def list(
18
+ self,
19
+ *,
20
+ consent_id: Optional[str] = None,
21
+ event_type: Optional[str] = None,
22
+ limit: int = 20,
23
+ cursor: Optional[str] = None,
24
+ ) -> PageResult:
25
+ """List audit events with optional filtering and pagination.
26
+
27
+ Args:
28
+ consent_id: Filter to events for a specific consent request.
29
+ event_type: Filter by event type (e.g., 'approved', 'declined', 'revoked').
30
+ limit: Maximum number of results to return (default 20).
31
+ cursor: Pagination cursor from a previous response.
32
+
33
+ Returns:
34
+ A PageResult containing a list of AuditEvents plus pagination info.
35
+ """
36
+ params: dict = {"limit": limit}
37
+ if consent_id is not None:
38
+ params["consent_id"] = consent_id
39
+ if event_type is not None:
40
+ params["event_type"] = event_type
41
+ if cursor is not None:
42
+ params["cursor"] = cursor
43
+
44
+ body = self._client._request("GET", "/v1/audit", params=params)
45
+ return PageResult(
46
+ data=[_parse_audit_event(item) for item in body["data"]],
47
+ cursor=body["meta"]["cursor"],
48
+ has_more=body["meta"]["has_more"],
49
+ )
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Optional
4
+
5
+ from ..models import ConsentRequest, PageResult, _parse_consent
6
+
7
+ if TYPE_CHECKING:
8
+ from ..client import PermitlyClient
9
+
10
+
11
+ class ConsentResource:
12
+ """Provides access to consent-request lifecycle operations."""
13
+
14
+ def __init__(self, client: PermitlyClient) -> None:
15
+ self._client = client
16
+
17
+ def request(
18
+ self,
19
+ end_user_ref: str,
20
+ scopes: list[dict],
21
+ redirect_url: str,
22
+ *,
23
+ end_user_email: Optional[str] = None,
24
+ expires_in: Optional[int] = None,
25
+ metadata: Optional[dict[str, Any]] = None,
26
+ ) -> ConsentRequest:
27
+ """Create a new consent request and return the hosted consent URL.
28
+
29
+ Args:
30
+ end_user_ref: Stable identifier for the end user in the builder's system.
31
+ scopes: List of scope dicts to request approval for.
32
+ redirect_url: URL the consent page redirects to after a decision.
33
+ end_user_email: Optional email shown on the consent page.
34
+ expires_in: Optional TTL in seconds before the request auto-expires.
35
+ metadata: Optional freeform dict stored alongside the request.
36
+
37
+ Returns:
38
+ A ConsentRequest with id, status, consent_url, and expires_at populated.
39
+ """
40
+ payload: dict = {
41
+ "end_user_ref": end_user_ref,
42
+ "scopes": scopes,
43
+ "redirect_url": redirect_url,
44
+ }
45
+ if end_user_email is not None:
46
+ payload["end_user_email"] = end_user_email
47
+ if expires_in is not None:
48
+ payload["expires_in"] = expires_in
49
+ if metadata is not None:
50
+ payload["metadata"] = metadata
51
+
52
+ body = self._client._request("POST", "/v1/consent/request", json=payload)
53
+ return _parse_consent(body["data"])
54
+
55
+ def get(self, id: str) -> ConsentRequest:
56
+ """Retrieve a consent request by ID.
57
+
58
+ Args:
59
+ id: The consent request UUID.
60
+
61
+ Returns:
62
+ The full ConsentRequest including scopes and decision timestamps.
63
+ """
64
+ body = self._client._request("GET", f"/v1/consent/{id}")
65
+ return _parse_consent(body["data"])
66
+
67
+ def verify(self, token: str) -> dict[str, Any]:
68
+ """Verify a consent JWT and return its decoded claims.
69
+
70
+ Args:
71
+ token: The signed JWT returned to the agent after consent was approved.
72
+
73
+ Returns:
74
+ Dict of JWT claims: sub, agt, cid, scopes, iat, exp, jti.
75
+ """
76
+ body = self._client._request("POST", "/v1/consent/verify", json={"token": token})
77
+ return body["data"]
78
+
79
+ def revoke(self, id: str) -> ConsentRequest:
80
+ """Revoke an existing consent request.
81
+
82
+ Args:
83
+ id: The consent request UUID to revoke.
84
+
85
+ Returns:
86
+ The updated ConsentRequest with status set to 'revoked'.
87
+ """
88
+ body = self._client._request("DELETE", f"/v1/consent/{id}")
89
+ return _parse_consent(body["data"])
90
+
91
+ def list(
92
+ self,
93
+ *,
94
+ status: Optional[str] = None,
95
+ end_user_ref: Optional[str] = None,
96
+ limit: int = 20,
97
+ cursor: Optional[str] = None,
98
+ ) -> PageResult:
99
+ """List consent requests with optional filtering and pagination.
100
+
101
+ Args:
102
+ status: Filter by status ('pending', 'approved', 'declined', 'revoked', 'expired').
103
+ end_user_ref: Filter to requests for a specific end user.
104
+ limit: Maximum number of results to return (default 20).
105
+ cursor: Pagination cursor from a previous response.
106
+
107
+ Returns:
108
+ A PageResult containing a list of ConsentRequests plus pagination info.
109
+ """
110
+ params: dict = {"limit": limit}
111
+ if status is not None:
112
+ params["status"] = status
113
+ if end_user_ref is not None:
114
+ params["end_user_ref"] = end_user_ref
115
+ if cursor is not None:
116
+ params["cursor"] = cursor
117
+
118
+ body = self._client._request("GET", "/v1/consent", params=params)
119
+ return PageResult(
120
+ data=[_parse_consent(item) for item in body["data"]],
121
+ cursor=body["meta"]["cursor"],
122
+ has_more=body["meta"]["has_more"],
123
+ )
124
+
125
+ def manage_url(self, end_user_ref: str) -> str:
126
+ """Get a signed URL for the end-user's self-service consent management page.
127
+
128
+ Args:
129
+ end_user_ref: The end user's stable identifier.
130
+
131
+ Returns:
132
+ A signed, time-limited URL the builder can redirect the end user to.
133
+ """
134
+ body = self._client._request(
135
+ "GET", "/v1/consent/manage-url", params={"end_user_ref": end_user_ref}
136
+ )
137
+ return body["data"]["url"]
@@ -0,0 +1,73 @@
1
+ class ScopeTemplates:
2
+ """Pre-built scope templates for common permissions."""
3
+
4
+ SEND_EMAIL = {
5
+ "key": "send_email",
6
+ "label": "Send Emails",
7
+ "description": "Send emails on your behalf",
8
+ "reversible": True,
9
+ "risk_level": "medium",
10
+ }
11
+ READ_EMAIL = {
12
+ "key": "read_email",
13
+ "label": "Read Emails",
14
+ "description": "Read your email inbox and messages",
15
+ "reversible": True,
16
+ "risk_level": "medium",
17
+ }
18
+ READ_CALENDAR = {
19
+ "key": "read_calendar",
20
+ "label": "Read Calendar",
21
+ "description": "View your calendar events and availability",
22
+ "reversible": True,
23
+ "risk_level": "low",
24
+ }
25
+ WRITE_CALENDAR = {
26
+ "key": "write_calendar",
27
+ "label": "Modify Calendar",
28
+ "description": "Create and modify calendar events",
29
+ "reversible": True,
30
+ "risk_level": "medium",
31
+ }
32
+ BOOK_APPOINTMENT = {
33
+ "key": "book_appointment",
34
+ "label": "Book Appointments",
35
+ "description": "Schedule appointments and meetings on your behalf",
36
+ "reversible": True,
37
+ "risk_level": "medium",
38
+ }
39
+ SEND_MESSAGE = {
40
+ "key": "send_message",
41
+ "label": "Send Messages",
42
+ "description": "Send messages and chat communications on your behalf",
43
+ "reversible": True,
44
+ "risk_level": "medium",
45
+ }
46
+ READ_FILES = {
47
+ "key": "read_files",
48
+ "label": "Read Files",
49
+ "description": "Read and access your files and documents",
50
+ "reversible": True,
51
+ "risk_level": "low",
52
+ }
53
+ WRITE_FILES = {
54
+ "key": "write_files",
55
+ "label": "Modify Files",
56
+ "description": "Create, edit, and delete your files and documents",
57
+ "reversible": False,
58
+ "risk_level": "high",
59
+ }
60
+ MAKE_PURCHASE = {
61
+ "key": "make_purchase",
62
+ "label": "Make Purchases",
63
+ "description": "Make purchases and payments using your saved payment methods",
64
+ "reversible": False,
65
+ "risk_level": "high",
66
+ }
67
+ SUBMIT_FORM = {
68
+ "key": "submit_form",
69
+ "label": "Submit Forms",
70
+ "description": "Submit forms, applications, and official documents on your behalf",
71
+ "reversible": False,
72
+ "risk_level": "high",
73
+ }
permitly/webhook.py ADDED
@@ -0,0 +1,27 @@
1
+ import hashlib
2
+ import hmac
3
+
4
+
5
+ def verify_signature(payload: str, signature: str, secret: str) -> bool:
6
+ """Verify a Permitly webhook signature.
7
+
8
+ The signature header value is: sha256=<hmac_sha256_hex_of_payload>
9
+ Returns True if valid, False otherwise. Uses constant-time comparison.
10
+
11
+ Args:
12
+ payload: The raw request body as a string.
13
+ signature: The X-Permitly-Signature header value.
14
+ secret: The webhook secret for this agent.
15
+
16
+ Returns:
17
+ True if the signature is valid, False otherwise.
18
+ """
19
+ if not signature.startswith("sha256="):
20
+ return False
21
+ expected_digest = hmac.new(
22
+ secret.encode("utf-8"),
23
+ payload.encode("utf-8"),
24
+ hashlib.sha256,
25
+ ).hexdigest()
26
+ received_digest = signature[len("sha256=") :]
27
+ return hmac.compare_digest(expected_digest, received_digest)
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: permitly
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Permitly — consent and delegation for AI agents
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: httpx>=0.27.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8.0; extra == 'dev'
10
+ Requires-Dist: respx>=0.21.0; extra == 'dev'
@@ -0,0 +1,12 @@
1
+ permitly/__init__.py,sha256=6p0DnW5yWBTDOZOTAuj3hi9kI7TrtxgNEZtK_1ZDdyg,484
2
+ permitly/client.py,sha256=ZbKwgUr2TqdTYjxmfzf4rhQ5gBAEjUz6hzitGuL8efo,2624
3
+ permitly/exceptions.py,sha256=qZMo-G2BZ2uuG7md565oyak8YIrSHTe_0QT_wkzx6bo,889
4
+ permitly/models.py,sha256=fVqnkQjbSHiM9MM5uBjsb36JKOYyKUrzWTyi7VJNUWM,1300
5
+ permitly/scope_templates.py,sha256=-3XowWRUNgaIVcuxA0j7P0Xnxr6sYlr5OjRGSEeaHbs,2312
6
+ permitly/webhook.py,sha256=HlBKl5tuwhLv59VIY1j8R5S4mlQ8PYBFQHb65Tbnzok,862
7
+ permitly/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ permitly/resources/audit.py,sha256=xxuLywp3SX7Op_hHK-wr251GzlE768-dWcMIi4jdIWw,1603
9
+ permitly/resources/consent.py,sha256=dadfA2lZozwwljy-FpYriS3L1CAMVYjWZEuVaM-IptQ,4769
10
+ permitly-0.1.0.dist-info/METADATA,sha256=Rqt4gAGI_Af_c4_HyYt5X6KJo51Nx-XtAylFGdUeAks,310
11
+ permitly-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
12
+ permitly-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any