cmp-consent 0.1.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.
@@ -0,0 +1,36 @@
1
+ """cmp-consent — Python SDK for the CMP officer-assisted deferred consent flow.
2
+
3
+ Server-to-server: raise consent requests, gate PII processing on verify(), and
4
+ validate CMP's signed webhooks. The application API key stays on your backend.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .aio import AsyncCMPClient
10
+ from .client import CMPClient
11
+ from .errors import (
12
+ CMPError,
13
+ ConsentDeclined,
14
+ ConsentExpired,
15
+ ConsentPending,
16
+ SignatureError,
17
+ )
18
+ from .models import ConsentRequest, RequestStatus, VerifyResult, WebhookEvent
19
+ from .webhooks import verify_signature
20
+
21
+ __version__ = "0.1.1"
22
+
23
+ __all__ = [
24
+ "CMPClient",
25
+ "AsyncCMPClient",
26
+ "ConsentRequest",
27
+ "RequestStatus",
28
+ "VerifyResult",
29
+ "WebhookEvent",
30
+ "verify_signature",
31
+ "CMPError",
32
+ "ConsentPending",
33
+ "ConsentDeclined",
34
+ "ConsentExpired",
35
+ "SignatureError",
36
+ ]
cmp_consent/aio.py ADDED
@@ -0,0 +1,118 @@
1
+ """Asynchronous CMP consent client — the awaitable mirror of CMPClient."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Mapping
7
+
8
+ import httpx
9
+
10
+ from .client import _envelope_error, _raise_not_allowed
11
+ from .errors import CMPError, SignatureError
12
+ from .models import ConsentRequest, RequestStatus, VerifyResult, WebhookEvent
13
+ from .webhooks import verify_signature
14
+
15
+
16
+ class AsyncCMPClient:
17
+ def __init__(
18
+ self,
19
+ api_base: str,
20
+ api_key: str,
21
+ *,
22
+ webhook_secret: str | None = None,
23
+ timeout: float = 10.0,
24
+ retries: int = 2,
25
+ ):
26
+ self.api_base = api_base.rstrip("/")
27
+ self.api_key = api_key
28
+ self.webhook_secret = webhook_secret
29
+ self._http = httpx.AsyncClient(
30
+ base_url=self.api_base,
31
+ timeout=timeout,
32
+ headers={"X-API-Key": api_key},
33
+ transport=httpx.AsyncHTTPTransport(retries=retries),
34
+ )
35
+
36
+ async def aclose(self) -> None:
37
+ await self._http.aclose()
38
+
39
+ async def __aenter__(self) -> "AsyncCMPClient":
40
+ return self
41
+
42
+ async def __aexit__(self, *_: Any) -> None:
43
+ await self.aclose()
44
+
45
+ async def _request(self, method: str, path: str, **kw: Any) -> Any:
46
+ try:
47
+ resp = await self._http.request(method, path, **kw)
48
+ except httpx.HTTPError as e:
49
+ raise CMPError(f"request failed: {e}") from e
50
+ if resp.status_code >= 400:
51
+ raise _envelope_error(resp)
52
+ return resp.json() if resp.content else {}
53
+
54
+ async def create_consent_request(
55
+ self,
56
+ *,
57
+ email: str,
58
+ notice: str,
59
+ requested_by: str,
60
+ idempotency_key: str | None = None,
61
+ link_expire_seconds: int | None = None,
62
+ ) -> ConsentRequest:
63
+ """See CMPClient.create_consent_request. One request covers one notice."""
64
+ body: dict[str, Any] = {
65
+ "email": email,
66
+ "notice_key": notice,
67
+ "requested_by": requested_by,
68
+ }
69
+ if idempotency_key:
70
+ body["idempotency_key"] = idempotency_key
71
+ if link_expire_seconds:
72
+ body["link_expire_seconds"] = link_expire_seconds
73
+ return ConsentRequest(
74
+ **await self._request("POST", "/api/v1/consent/request/s2s", json=body)
75
+ )
76
+
77
+ async def get_request(self, request_id: str) -> RequestStatus:
78
+ return RequestStatus(
79
+ **await self._request("GET", "/api/v1/consent/request/status",
80
+ params={"request_id": request_id})
81
+ )
82
+
83
+ async def cancel(self, request_id: str) -> dict[str, Any]:
84
+ return await self._request("POST", f"/api/v1/consent/request/{request_id}/cancel")
85
+
86
+ async def resend(self, request_id: str) -> dict[str, Any]:
87
+ return await self._request("POST", f"/api/v1/consent/request/{request_id}/resend")
88
+
89
+ async def verify(self, *, consent_id: str, purpose: str) -> VerifyResult:
90
+ """Fail-closed gate (see CMPClient.verify)."""
91
+ try:
92
+ data = await self._request(
93
+ "POST", "/api/v1/consent/verify",
94
+ json={"consent_id": consent_id, "purpose": purpose},
95
+ )
96
+ except CMPError:
97
+ return VerifyResult(
98
+ allowed=False, status="UNKNOWN", purpose=purpose,
99
+ purpose_state="NOT_PRESENTED", reason="VERIFY_UNAVAILABLE",
100
+ )
101
+ return VerifyResult(**data)
102
+
103
+ async def require_consent(self, *, consent_id: str, purpose: str) -> VerifyResult:
104
+ res = await self.verify(consent_id=consent_id, purpose=purpose)
105
+ if not res.allowed:
106
+ _raise_not_allowed(res)
107
+ return res
108
+
109
+ def verify_webhook(self, headers: Mapping[str, str], body: bytes | str) -> WebhookEvent:
110
+ """Local (no I/O) — same as the sync client."""
111
+ if not self.webhook_secret:
112
+ raise CMPError("verify_webhook requires webhook_secret at construction")
113
+ sig = headers.get("X-CMP-Signature") or headers.get("x-cmp-signature")
114
+ if not sig:
115
+ raise SignatureError("missing X-CMP-Signature header")
116
+ raw = body.encode() if isinstance(body, str) else body
117
+ verify_signature(self.webhook_secret, raw, sig)
118
+ return WebhookEvent(**json.loads(raw))
cmp_consent/client.py ADDED
@@ -0,0 +1,166 @@
1
+ """Synchronous CMP consent client.
2
+
3
+ Server-to-server only — holds the application API key. Never ship the key to a
4
+ browser. See AsyncCMPClient for the awaitable variant.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any, Mapping
11
+
12
+ import httpx
13
+
14
+ from .errors import CMPError, ConsentDeclined, ConsentExpired, ConsentPending, SignatureError
15
+ from .models import ConsentRequest, RequestStatus, VerifyResult, WebhookEvent
16
+ from .webhooks import verify_signature
17
+
18
+
19
+ def _envelope_error(resp: httpx.Response) -> CMPError:
20
+ code, message = "error", resp.text or f"HTTP {resp.status_code}"
21
+ try:
22
+ body = resp.json()
23
+ err = body.get("error") or body.get("detail") or {}
24
+ if isinstance(err, dict):
25
+ code = err.get("code", code)
26
+ message = err.get("message", message)
27
+ elif isinstance(err, str):
28
+ message = err
29
+ except Exception: # noqa: BLE001 — non-JSON error body
30
+ pass
31
+ return CMPError(message, status=resp.status_code, code=code)
32
+
33
+
34
+ def _raise_not_allowed(res: VerifyResult) -> None:
35
+ """Map a not-allowed VerifyResult onto a typed exception for gate flow."""
36
+ st = res.status
37
+ if st in ("EXPIRED", "CONSENT_EXPIRED"):
38
+ raise ConsentExpired(res.reason, code=res.reason)
39
+ if st in ("DECLINED", "WITHDRAWN", "CANCELLED"):
40
+ raise ConsentDeclined(res.reason, code=res.reason)
41
+ # PENDING, NOT_FOUND, PARTIALLY_GRANTED(opt-out), VERIFY_UNAVAILABLE, …
42
+ raise ConsentPending(res.reason, code=res.reason)
43
+
44
+
45
+ class CMPClient:
46
+ """
47
+ cmp = CMPClient(api_base="https://cmp-api…", api_key="cmpk_live_…",
48
+ webhook_secret="whsec_…")
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ api_base: str,
54
+ api_key: str,
55
+ *,
56
+ webhook_secret: str | None = None,
57
+ timeout: float = 10.0,
58
+ retries: int = 2,
59
+ ):
60
+ self.api_base = api_base.rstrip("/")
61
+ self.api_key = api_key
62
+ self.webhook_secret = webhook_secret
63
+ self._http = httpx.Client(
64
+ base_url=self.api_base,
65
+ timeout=timeout,
66
+ headers={"X-API-Key": api_key},
67
+ transport=httpx.HTTPTransport(retries=retries),
68
+ )
69
+
70
+ # ── lifecycle ────────────────────────────────────────────────────────────
71
+ def close(self) -> None:
72
+ self._http.close()
73
+
74
+ def __enter__(self) -> "CMPClient":
75
+ return self
76
+
77
+ def __exit__(self, *_: Any) -> None:
78
+ self.close()
79
+
80
+ def _request(self, method: str, path: str, **kw: Any) -> Any:
81
+ try:
82
+ resp = self._http.request(method, path, **kw)
83
+ except httpx.HTTPError as e:
84
+ raise CMPError(f"request failed: {e}") from e
85
+ if resp.status_code >= 400:
86
+ raise _envelope_error(resp)
87
+ return resp.json() if resp.content else {}
88
+
89
+ # ── request lifecycle (officer-raised) ─────────────────────────────────────
90
+ def create_consent_request(
91
+ self,
92
+ *,
93
+ email: str,
94
+ notice: str,
95
+ requested_by: str,
96
+ idempotency_key: str | None = None,
97
+ link_expire_seconds: int | None = None,
98
+ ) -> ConsentRequest:
99
+ """Raise a PENDING request; CMP emails the magic link and returns consent_id
100
+ at t=0. Store consent_id against your record immediately.
101
+
102
+ One request covers one notice. For two notices, make two calls — each
103
+ returns its own consent_id and its own link."""
104
+ body: dict[str, Any] = {
105
+ "email": email,
106
+ "notice_key": notice,
107
+ "requested_by": requested_by,
108
+ }
109
+ if idempotency_key:
110
+ body["idempotency_key"] = idempotency_key
111
+ if link_expire_seconds:
112
+ body["link_expire_seconds"] = link_expire_seconds
113
+ return ConsentRequest(**self._request("POST", "/api/v1/consent/request/s2s", json=body))
114
+
115
+ def get_request(self, request_id: str) -> RequestStatus:
116
+ """Poll a request's status (fallback if a webhook is missed)."""
117
+ return RequestStatus(
118
+ **self._request("GET", "/api/v1/consent/request/status",
119
+ params={"request_id": request_id})
120
+ )
121
+
122
+ def cancel(self, request_id: str) -> dict[str, Any]:
123
+ """Cancel a pending request (e.g. cancel-on-save-failure). Idempotent."""
124
+ return self._request("POST", f"/api/v1/consent/request/{request_id}/cancel")
125
+
126
+ def resend(self, request_id: str) -> dict[str, Any]:
127
+ """Re-mint a fresh magic link for a still-pending request."""
128
+ return self._request("POST", f"/api/v1/consent/request/{request_id}/resend")
129
+
130
+ # ── the enforcement gate ───────────────────────────────────────────────────
131
+ def verify(self, *, consent_id: str, purpose: str) -> VerifyResult:
132
+ """'May I process this consent for this purpose?' FAIL-CLOSED — any error or
133
+ timeout returns allowed=False (never raises), so the PII is never processed
134
+ on an ambiguous result."""
135
+ try:
136
+ data = self._request(
137
+ "POST", "/api/v1/consent/verify",
138
+ json={"consent_id": consent_id, "purpose": purpose},
139
+ )
140
+ except CMPError:
141
+ return VerifyResult(
142
+ allowed=False, status="UNKNOWN", purpose=purpose,
143
+ purpose_state="NOT_PRESENTED", reason="VERIFY_UNAVAILABLE",
144
+ )
145
+ return VerifyResult(**data)
146
+
147
+ def require_consent(self, *, consent_id: str, purpose: str) -> VerifyResult:
148
+ """Like verify() but RAISES a typed exception when not allowed — so gated
149
+ code simply can't run without consent."""
150
+ res = self.verify(consent_id=consent_id, purpose=purpose)
151
+ if not res.allowed:
152
+ _raise_not_allowed(res)
153
+ return res
154
+
155
+ # ── inbound webhooks ────────────────────────────────────────────────────────
156
+ def verify_webhook(self, headers: Mapping[str, str], body: bytes | str) -> WebhookEvent:
157
+ """Validate the X-CMP-Signature over the RAW body and return the parsed event.
158
+ Dedupe by event.event_id, order by event.consent_seq."""
159
+ if not self.webhook_secret:
160
+ raise CMPError("verify_webhook requires webhook_secret at construction")
161
+ sig = headers.get("X-CMP-Signature") or headers.get("x-cmp-signature")
162
+ if not sig:
163
+ raise SignatureError("missing X-CMP-Signature header")
164
+ raw = body.encode() if isinstance(body, str) else body
165
+ verify_signature(self.webhook_secret, raw, sig)
166
+ return WebhookEvent(**json.loads(raw))
cmp_consent/errors.py ADDED
@@ -0,0 +1,32 @@
1
+ """Typed exceptions for the CMP consent SDK.
2
+
3
+ Callers map these to control flow: a gate that raises ConsentPending/Declined/Expired
4
+ means "do not process the PII"; CMPError is any other failure (network, 4xx/5xx).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class CMPError(Exception):
11
+ """Base error. Carries the backend envelope code + HTTP status when available."""
12
+
13
+ def __init__(self, message: str, *, status: int | None = None, code: str | None = None):
14
+ super().__init__(message)
15
+ self.status = status
16
+ self.code = code
17
+
18
+
19
+ class ConsentPending(CMPError):
20
+ """The consent has been requested but not yet granted — processing must wait."""
21
+
22
+
23
+ class ConsentDeclined(CMPError):
24
+ """The principal declined (or withdrew) — processing is not permitted."""
25
+
26
+
27
+ class ConsentExpired(CMPError):
28
+ """The consent/request has expired — a fresh request or re-consent is needed."""
29
+
30
+
31
+ class SignatureError(CMPError):
32
+ """An inbound webhook's X-CMP-Signature failed verification (forged/replayed)."""
cmp_consent/models.py ADDED
@@ -0,0 +1,78 @@
1
+ """Typed request/response models (Pydantic v2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class ConsentRequest(BaseModel):
11
+ """Returned by create_consent_request — `consent_id` is the durable key to store
12
+ against the PII from t=0."""
13
+ consent_id: str
14
+ request_id: str
15
+ status: str
16
+ request_ref: str
17
+ magic_link: Optional[str] = None # only when email mode = return_link
18
+
19
+
20
+ class RequestStatus(BaseModel):
21
+ request_id: str
22
+ consent_id: str
23
+ status: str # PENDING|GRANTED|PARTIALLY_GRANTED|DECLINED|EXPIRED|CANCELLED|SUPERSEDED
24
+
25
+
26
+ class VerifyResult(BaseModel):
27
+ """The enforcement gate result. `allowed` is the only thing to branch on for
28
+ processing; the rest is for auditing/telemetry."""
29
+ allowed: bool
30
+ status: str
31
+ purpose: str
32
+ purpose_state: str
33
+ reason: str
34
+ consent_record_id: Optional[str] = None # set only when allowed
35
+ integrity_hash: Optional[str] = None # audit proof, set only when allowed
36
+ consent_seq: Optional[int] = None
37
+ needs_reconsent: bool = False
38
+ expires_at: Optional[str] = None
39
+
40
+
41
+ class WebhookEvent(BaseModel):
42
+ """A verified inbound webhook (envelope). Use `type`, `consent_id`, `consent_seq`
43
+ to route + dedupe (by `event_id`)."""
44
+ event_id: str
45
+ event_type: str
46
+ application_id: Optional[str] = None
47
+ occurred_at: str
48
+ data: Dict[str, Any] = {} # Dict (not dict[...]) so the model builds on Python 3.8
49
+
50
+ @property
51
+ def type(self) -> str:
52
+ return self.event_type
53
+
54
+ @property
55
+ def consent_id(self) -> Optional[str]:
56
+ return self.data.get("consent_id")
57
+
58
+ @property
59
+ def request_id(self) -> Optional[str]:
60
+ return self.data.get("request_id")
61
+
62
+ @property
63
+ def consent_seq(self) -> Optional[int]:
64
+ return self.data.get("consent_seq")
65
+
66
+ @property
67
+ def decision(self) -> Optional[str]:
68
+ """FULL | PARTIAL | DECLINED (grant/decline events)."""
69
+ return self.data.get("decision")
70
+
71
+ @property
72
+ def granted_purposes(self) -> list[str]:
73
+ """Purpose CODES the principal opted in to — for a direct membership check."""
74
+ return self.data.get("granted_purposes", [])
75
+
76
+ @property
77
+ def denied_purposes(self) -> list[str]:
78
+ return self.data.get("denied_purposes", [])
@@ -0,0 +1,48 @@
1
+ """Inbound webhook signature verification.
2
+
3
+ CMP signs each delivery Stripe/GitHub-style:
4
+ header X-CMP-Signature: t=<unix_ts>,v1=<hex hmac_sha256>
5
+ signed "<ts>." + <raw body bytes> (key = the subscription's whsec_ secret)
6
+
7
+ Verifying the raw body (not a re-serialized dict) is essential — the signature is
8
+ over the exact bytes CMP sent.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import hmac
15
+ import time
16
+
17
+ from .errors import SignatureError
18
+
19
+
20
+ def _parse_header(header: str) -> tuple[str, str]:
21
+ parts = dict(
22
+ p.split("=", 1) for p in header.split(",") if "=" in p
23
+ )
24
+ ts, sig = parts.get("t"), parts.get("v1")
25
+ if not ts or not sig:
26
+ raise SignatureError("malformed X-CMP-Signature header")
27
+ return ts, sig
28
+
29
+
30
+ def verify_signature(
31
+ secret: str, payload: bytes, header: str, *, tolerance_seconds: int = 300
32
+ ) -> None:
33
+ """Raise SignatureError unless `header` is a valid signature of `payload`.
34
+
35
+ tolerance_seconds guards against replay (0 disables the timestamp check).
36
+ """
37
+ ts, sig = _parse_header(header)
38
+ signed = ts.encode() + b"." + payload
39
+ expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
40
+ if not hmac.compare_digest(expected, sig):
41
+ raise SignatureError("webhook signature mismatch")
42
+ if tolerance_seconds:
43
+ try:
44
+ drift = abs(time.time() - int(ts))
45
+ except ValueError as e:
46
+ raise SignatureError("bad signature timestamp") from e
47
+ if drift > tolerance_seconds:
48
+ raise SignatureError("webhook timestamp outside tolerance")
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: cmp-consent
3
+ Version: 0.1.1
4
+ Summary: Python SDK for the CMP officer-assisted deferred consent flow (raise requests, gate on verify, verify webhooks).
5
+ Author: Pentafox
6
+ License: Proprietary
7
+ Keywords: cmp,compliance,consent,dpdp,privacy
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: Other/Proprietary License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.8
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.8
20
+ Requires-Dist: httpx>=0.24
21
+ Requires-Dist: pydantic>=2.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.0; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # cmp-consent
27
+
28
+ Python SDK for the CMP **officer-assisted deferred consent** flow. Runs in your
29
+ backend (server-to-server) and holds the application API key — never ship the key to
30
+ a browser.
31
+
32
+ Three jobs: **raise** consent requests, **gate** PII processing on `verify()`, and
33
+ **verify** CMP's signed webhooks.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install cmp-consent
39
+ ```
40
+
41
+ ## Authentication
42
+
43
+ | Credential | Direction | Header |
44
+ |---|---|---|
45
+ | API key `cmpk_live_…` | SDK → CMP | `X-API-Key` (application-scoped) |
46
+ | Webhook secret `whsec_…` | CMP → SDK | HMAC `X-CMP-Signature` (for `verify_webhook`) |
47
+
48
+ ```python
49
+ from cmp_consent import CMPClient
50
+
51
+ cmp = CMPClient(
52
+ api_base="https://cmp-api.yourdomain",
53
+ api_key="cmpk_live_…",
54
+ webhook_secret="whsec_…", # only needed for verify_webhook
55
+ timeout=10, retries=2,
56
+ )
57
+ ```
58
+
59
+ ## 1) Raise a request (officer submitted)
60
+
61
+ ```python
62
+ req = cmp.create_consent_request(
63
+ email="user@example.com",
64
+ notice="kyc-notice", # the notice key for the application
65
+ requested_by=officer.id,
66
+ idempotency_key=submit_id, # a retried submit won't double-send the email
67
+ )
68
+ save(applicant_id, consent_id=req.consent_id) # store consent_id at t=0
69
+ ```
70
+
71
+ CMP creates a PENDING request, emails the principal a single-use magic link, and
72
+ returns `consent_id` immediately.
73
+
74
+ ## 2) Gate every use of the PII
75
+
76
+ ```python
77
+ from cmp_consent import ConsentPending
78
+
79
+ res = cmp.verify(consent_id=req.consent_id, purpose="bureau_pull")
80
+ if not res.allowed:
81
+ raise ConsentPending(res.reason) # PENDING/DECLINED/EXPIRED → do not process
82
+ proceed(proof=res.integrity_hash) # allowed → consent-backed
83
+ ```
84
+
85
+ `verify()` is **fail-closed**: any network error or timeout returns `allowed=False`
86
+ (it never raises), so PII is never processed on an ambiguous result. Prefer
87
+ `require_consent(...)` when you want it to raise instead:
88
+
89
+ ```python
90
+ cmp.require_consent(consent_id=cid, purpose="bureau_pull") # raises if not granted
91
+ ```
92
+
93
+ ## 3) Receive the callback
94
+
95
+ ```python
96
+ # in your webhook route — pass the RAW body bytes
97
+ event = cmp.verify_webhook(request.headers, request.body)
98
+ if event.type == "CONSENT_GRANTED" and is_new(event.event_id): # dedupe by event_id
99
+ unblock(event.consent_id)
100
+ ```
101
+
102
+ Webhooks are at-least-once and unordered: **dedupe by `event.event_id`, order by
103
+ `event.consent_seq`**, and treat `verify()` as the source of truth on any ambiguity.
104
+
105
+ ## Manage
106
+
107
+ ```python
108
+ cmp.get_request(req.request_id) # poll status (fallback if a webhook is missed)
109
+ cmp.resend(req.request_id) # re-mint an expired magic link
110
+ cmp.cancel(req.request_id) # cancel-on-save-failure
111
+ ```
112
+
113
+ ## Async
114
+
115
+ `AsyncCMPClient` mirrors the same surface with `await`:
116
+
117
+ ```python
118
+ from cmp_consent import AsyncCMPClient
119
+
120
+ async with AsyncCMPClient(api_base=…, api_key=…) as cmp:
121
+ req = await cmp.create_consent_request(email=e, notice="kyc-notice", requested_by=o)
122
+ res = await cmp.verify(consent_id=req.consent_id, purpose="bureau_pull")
123
+ ```
124
+
125
+ ## Exceptions
126
+
127
+ `ConsentPending` · `ConsentDeclined` · `ConsentExpired` · `SignatureError` ·
128
+ `CMPError` (base). Each carries `.status` (HTTP) and `.code` (envelope code) when
129
+ available.
@@ -0,0 +1,9 @@
1
+ cmp_consent/__init__.py,sha256=JGwZynmXqyJUbeeJUooyu1znCAzCJzny_-xL_rMDxgI,905
2
+ cmp_consent/aio.py,sha256=1sZjHagewU_SWgGoH0DFT8Jr6tFiw_axA5b5i7FD9MQ,4426
3
+ cmp_consent/client.py,sha256=ltP-q0lXpMqPUuvQgTHZkBefVWj0S46eTdeZtW7q2yg,7147
4
+ cmp_consent/errors.py,sha256=S_r96YYvT7vXBnpxLpeUJkf6XbuwOkOt_4oNKYKLoOY,1060
5
+ cmp_consent/models.py,sha256=JVoptfHzrm4TevpE9_WVyPIk8_LeHhGwDTUrXM_RvQU,2435
6
+ cmp_consent/webhooks.py,sha256=JqxO97fFrb3XpoqMZlWoEWVwrQj9qQ33G62RywYyF4g,1644
7
+ cmp_consent-0.1.1.dist-info/METADATA,sha256=3MRnmLKEcsR2CTXL8NWvjFdVJu6tqzXO1wwvRyqOAhw,4196
8
+ cmp_consent-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ cmp_consent-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any