ontiver 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.
- ontiver/__init__.py +33 -0
- ontiver/client.py +75 -0
- ontiver/config.py +67 -0
- ontiver/errors.py +78 -0
- ontiver/http.py +161 -0
- ontiver/py.typed +1 -0
- ontiver/resources/__init__.py +1 -0
- ontiver/resources/proofs.py +57 -0
- ontiver/resources/screening.py +78 -0
- ontiver/resources/verifications.py +22 -0
- ontiver/resources/webhook_management.py +60 -0
- ontiver/webhooks.py +22 -0
- ontiver-0.1.0.dist-info/METADATA +238 -0
- ontiver-0.1.0.dist-info/RECORD +16 -0
- ontiver-0.1.0.dist-info/WHEEL +5 -0
- ontiver-0.1.0.dist-info/top_level.txt +1 -0
ontiver/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from .client import OntiverClient
|
|
2
|
+
from .errors import (
|
|
3
|
+
AuthenticationError,
|
|
4
|
+
NetworkError,
|
|
5
|
+
NotFoundError,
|
|
6
|
+
OntiverAPIError,
|
|
7
|
+
OntiverConfigError,
|
|
8
|
+
OntiverError,
|
|
9
|
+
PermissionDeniedError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
ServerError,
|
|
12
|
+
TimeoutError,
|
|
13
|
+
ValidationError,
|
|
14
|
+
WebhookSignatureError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"OntiverClient",
|
|
21
|
+
"OntiverError",
|
|
22
|
+
"OntiverConfigError",
|
|
23
|
+
"OntiverAPIError",
|
|
24
|
+
"AuthenticationError",
|
|
25
|
+
"PermissionDeniedError",
|
|
26
|
+
"ValidationError",
|
|
27
|
+
"RateLimitError",
|
|
28
|
+
"NotFoundError",
|
|
29
|
+
"ServerError",
|
|
30
|
+
"TimeoutError",
|
|
31
|
+
"NetworkError",
|
|
32
|
+
"WebhookSignatureError",
|
|
33
|
+
]
|
ontiver/client.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .config import OntiverConfig
|
|
9
|
+
from .http import OntiverHTTPClient
|
|
10
|
+
from .resources.proofs import ProofsResource
|
|
11
|
+
from .resources.screening import ScreeningResource
|
|
12
|
+
from .resources.verifications import VerificationsResource
|
|
13
|
+
from .resources.webhook_management import WebhookManagementResource
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OntiverClient:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
api_key: str | None = None,
|
|
20
|
+
base_url: str | None = None,
|
|
21
|
+
environment: str | None = None,
|
|
22
|
+
timeout: float = 30,
|
|
23
|
+
max_retries: int = 2,
|
|
24
|
+
webhook_secret: str | None = None,
|
|
25
|
+
http_client: httpx.Client | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.config = OntiverConfig.from_env(
|
|
28
|
+
api_key=api_key,
|
|
29
|
+
base_url=base_url,
|
|
30
|
+
environment=environment,
|
|
31
|
+
webhook_secret=webhook_secret,
|
|
32
|
+
timeout=timeout,
|
|
33
|
+
max_retries=max_retries,
|
|
34
|
+
)
|
|
35
|
+
self._http = OntiverHTTPClient(self.config, client=http_client)
|
|
36
|
+
self.webhooks = WebhookManagementResource(self._http)
|
|
37
|
+
self.verifications = VerificationsResource(self._http)
|
|
38
|
+
self.screening = ScreeningResource(self._http)
|
|
39
|
+
self.proofs = ProofsResource(self._http)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_env(cls) -> "OntiverClient":
|
|
43
|
+
return cls()
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
self._http.close()
|
|
47
|
+
|
|
48
|
+
def __enter__(self) -> "OntiverClient":
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def __exit__(self, *_: object) -> None:
|
|
52
|
+
self.close()
|
|
53
|
+
|
|
54
|
+
def validate_api_key(self) -> dict[str, Any]:
|
|
55
|
+
return self._http.request("GET", "/api-keys/validate/current")
|
|
56
|
+
|
|
57
|
+
def raw_request(
|
|
58
|
+
self,
|
|
59
|
+
method: str,
|
|
60
|
+
path: str,
|
|
61
|
+
*,
|
|
62
|
+
json: Any | None = None,
|
|
63
|
+
params: Mapping[str, Any] | None = None,
|
|
64
|
+
headers: Mapping[str, str] | None = None,
|
|
65
|
+
idempotency_key: str | None = None,
|
|
66
|
+
) -> Any:
|
|
67
|
+
return self._http.request(
|
|
68
|
+
method,
|
|
69
|
+
path,
|
|
70
|
+
json=json,
|
|
71
|
+
params=params,
|
|
72
|
+
headers=headers,
|
|
73
|
+
idempotency_key=idempotency_key,
|
|
74
|
+
unwrap=False,
|
|
75
|
+
)
|
ontiver/config.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from .errors import OntiverConfigError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_URL = "https://api.ontiver.com"
|
|
11
|
+
VALID_ENVIRONMENTS = {"production", "sandbox"}
|
|
12
|
+
LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class OntiverConfig:
|
|
17
|
+
api_key: str | None = None
|
|
18
|
+
base_url: str = DEFAULT_BASE_URL
|
|
19
|
+
environment: str = "production"
|
|
20
|
+
webhook_secret: str | None = None
|
|
21
|
+
timeout: float = 30
|
|
22
|
+
max_retries: int = 2
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_env(
|
|
26
|
+
cls,
|
|
27
|
+
*,
|
|
28
|
+
api_key: str | None = None,
|
|
29
|
+
base_url: str | None = None,
|
|
30
|
+
environment: str | None = None,
|
|
31
|
+
webhook_secret: str | None = None,
|
|
32
|
+
timeout: float = 30,
|
|
33
|
+
max_retries: int = 2,
|
|
34
|
+
) -> "OntiverConfig":
|
|
35
|
+
return cls(
|
|
36
|
+
api_key=api_key if api_key is not None else os.getenv("ONTIVER_API_KEY"),
|
|
37
|
+
base_url=base_url or os.getenv("ONTIVER_BASE_URL") or DEFAULT_BASE_URL,
|
|
38
|
+
environment=environment or os.getenv("ONTIVER_ENVIRONMENT") or "production",
|
|
39
|
+
webhook_secret=webhook_secret if webhook_secret is not None else os.getenv("ONTIVER_WEBHOOK_SECRET"),
|
|
40
|
+
timeout=timeout,
|
|
41
|
+
max_retries=max_retries,
|
|
42
|
+
).validated()
|
|
43
|
+
|
|
44
|
+
def validated(self) -> "OntiverConfig":
|
|
45
|
+
environment = str(self.environment or "").strip().lower()
|
|
46
|
+
if environment not in VALID_ENVIRONMENTS:
|
|
47
|
+
raise OntiverConfigError("environment must be 'production' or 'sandbox'")
|
|
48
|
+
base_url = normalize_base_url(self.base_url)
|
|
49
|
+
return OntiverConfig(
|
|
50
|
+
api_key=str(self.api_key).strip() if self.api_key else None,
|
|
51
|
+
base_url=base_url,
|
|
52
|
+
environment=environment,
|
|
53
|
+
webhook_secret=str(self.webhook_secret).strip() if self.webhook_secret else None,
|
|
54
|
+
timeout=float(self.timeout),
|
|
55
|
+
max_retries=max(0, int(self.max_retries)),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def normalize_base_url(value: str | None) -> str:
|
|
60
|
+
raw = str(value or DEFAULT_BASE_URL).strip().rstrip("/")
|
|
61
|
+
parsed = urlparse(raw)
|
|
62
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
63
|
+
raise OntiverConfigError("base_url must be an absolute HTTP(S) URL")
|
|
64
|
+
hostname = (parsed.hostname or "").lower()
|
|
65
|
+
if parsed.scheme != "https" and hostname not in LOCAL_HOSTS:
|
|
66
|
+
raise OntiverConfigError("HTTPS is required for non-local Ontiver API base URLs")
|
|
67
|
+
return raw
|
ontiver/errors.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_SENSITIVE_PATTERNS = [
|
|
8
|
+
re.compile(r"Bearer\s+[A-Za-z0-9._\-]+", re.IGNORECASE),
|
|
9
|
+
re.compile(r"\b(prod|sb)_[A-Za-z0-9._\-]+"),
|
|
10
|
+
re.compile(r"\bwhsec_[A-Za-z0-9._\-]+"),
|
|
11
|
+
re.compile(r"\bsec_[A-Za-z0-9._\-]+"),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def redact(value: Any) -> str:
|
|
16
|
+
text = str(value or "")
|
|
17
|
+
for pattern in _SENSITIVE_PATTERNS:
|
|
18
|
+
text = pattern.sub("[REDACTED]", text)
|
|
19
|
+
return text
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OntiverError(Exception):
|
|
23
|
+
"""Base SDK exception."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OntiverConfigError(OntiverError):
|
|
27
|
+
"""Raised when SDK configuration is missing or unsafe."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OntiverAPIError(OntiverError):
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
message: str,
|
|
34
|
+
*,
|
|
35
|
+
status_code: int | None = None,
|
|
36
|
+
code: str | None = None,
|
|
37
|
+
request_id: str | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.status_code = status_code
|
|
40
|
+
self.code = code
|
|
41
|
+
self.request_id = request_id
|
|
42
|
+
super().__init__(redact(message))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AuthenticationError(OntiverAPIError):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PermissionDeniedError(OntiverAPIError):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ValidationError(OntiverAPIError):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RateLimitError(OntiverAPIError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class NotFoundError(OntiverAPIError):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ServerError(OntiverAPIError):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TimeoutError(OntiverAPIError):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class NetworkError(OntiverAPIError):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class WebhookSignatureError(OntiverError):
|
|
78
|
+
pass
|
ontiver/http.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .config import OntiverConfig
|
|
11
|
+
from .errors import (
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
NetworkError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
OntiverAPIError,
|
|
16
|
+
OntiverConfigError,
|
|
17
|
+
PermissionDeniedError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
ServerError,
|
|
20
|
+
TimeoutError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
redact,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
|
|
27
|
+
MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|
28
|
+
USER_AGENT_VERSION = "0.1.0"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OntiverHTTPClient:
|
|
32
|
+
def __init__(self, config: OntiverConfig, *, client: httpx.Client | None = None) -> None:
|
|
33
|
+
self.config = config
|
|
34
|
+
self._client = client or httpx.Client(timeout=config.timeout)
|
|
35
|
+
|
|
36
|
+
def close(self) -> None:
|
|
37
|
+
self._client.close()
|
|
38
|
+
|
|
39
|
+
def request(
|
|
40
|
+
self,
|
|
41
|
+
method: str,
|
|
42
|
+
path: str,
|
|
43
|
+
*,
|
|
44
|
+
json: Any | None = None,
|
|
45
|
+
params: Mapping[str, Any] | None = None,
|
|
46
|
+
headers: Mapping[str, str] | None = None,
|
|
47
|
+
idempotency_key: str | None = None,
|
|
48
|
+
unwrap: bool = True,
|
|
49
|
+
authenticated: bool = True,
|
|
50
|
+
auto_idempotency: bool = False,
|
|
51
|
+
) -> Any:
|
|
52
|
+
method = method.upper()
|
|
53
|
+
url = self._url(path)
|
|
54
|
+
request_headers = self._headers(headers, authenticated=authenticated)
|
|
55
|
+
if method in MUTATING_METHODS and auto_idempotency and not idempotency_key:
|
|
56
|
+
idempotency_key = f"ontiver-sdk-{uuid.uuid4()}"
|
|
57
|
+
if idempotency_key:
|
|
58
|
+
request_headers["Idempotency-Key"] = idempotency_key
|
|
59
|
+
|
|
60
|
+
can_retry_mutation = method not in MUTATING_METHODS or bool(idempotency_key)
|
|
61
|
+
attempts = self.config.max_retries + 1 if can_retry_mutation else 1
|
|
62
|
+
last_exc: Exception | None = None
|
|
63
|
+
for attempt in range(attempts):
|
|
64
|
+
try:
|
|
65
|
+
response = self._client.request(method, url, json=json, params=params, headers=request_headers)
|
|
66
|
+
except httpx.TimeoutException as exc:
|
|
67
|
+
last_exc = exc
|
|
68
|
+
if attempt + 1 >= attempts:
|
|
69
|
+
raise TimeoutError("Ontiver request timed out") from exc
|
|
70
|
+
self._sleep(attempt, None)
|
|
71
|
+
continue
|
|
72
|
+
except httpx.HTTPError as exc:
|
|
73
|
+
last_exc = exc
|
|
74
|
+
if attempt + 1 >= attempts:
|
|
75
|
+
raise NetworkError("Ontiver network request failed") from exc
|
|
76
|
+
self._sleep(attempt, None)
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
if response.status_code in RETRYABLE_STATUS_CODES and attempt + 1 < attempts:
|
|
80
|
+
self._sleep(attempt, response)
|
|
81
|
+
continue
|
|
82
|
+
return self._parse_response(response, unwrap=unwrap)
|
|
83
|
+
|
|
84
|
+
raise NetworkError(f"Ontiver request failed: {redact(last_exc)}")
|
|
85
|
+
|
|
86
|
+
def _url(self, path: str) -> str:
|
|
87
|
+
if not path.startswith("/"):
|
|
88
|
+
raise ValidationError("API path must start with '/'")
|
|
89
|
+
normalized = path if path.startswith("/api/") else f"/api/v1/banking{path}"
|
|
90
|
+
return f"{self.config.base_url}{normalized}"
|
|
91
|
+
|
|
92
|
+
def _headers(self, headers: Mapping[str, str] | None, *, authenticated: bool) -> dict[str, str]:
|
|
93
|
+
out = {
|
|
94
|
+
"Accept": "application/json",
|
|
95
|
+
"Content-Type": "application/json",
|
|
96
|
+
"User-Agent": f"ontiver-python/{USER_AGENT_VERSION}",
|
|
97
|
+
}
|
|
98
|
+
if headers:
|
|
99
|
+
for key, value in headers.items():
|
|
100
|
+
if key.lower() == "authorization":
|
|
101
|
+
continue
|
|
102
|
+
out[key] = value
|
|
103
|
+
if authenticated:
|
|
104
|
+
if not self.config.api_key:
|
|
105
|
+
raise OntiverConfigError("ONTIVER_API_KEY is required for authenticated requests")
|
|
106
|
+
out["Authorization"] = f"Bearer {self.config.api_key}"
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
def _parse_response(self, response: httpx.Response, *, unwrap: bool) -> Any:
|
|
110
|
+
request_id = response.headers.get("x-request-id") or response.headers.get("X-Request-Id")
|
|
111
|
+
try:
|
|
112
|
+
body = response.json()
|
|
113
|
+
except ValueError:
|
|
114
|
+
body = {"message": response.text}
|
|
115
|
+
if response.status_code >= 400:
|
|
116
|
+
raise self._error_for_response(response.status_code, body, request_id)
|
|
117
|
+
if unwrap and isinstance(body, dict) and "success" in body and "data" in body:
|
|
118
|
+
return body["data"]
|
|
119
|
+
return body
|
|
120
|
+
|
|
121
|
+
def _error_for_response(self, status_code: int, body: Any, request_id: str | None) -> OntiverAPIError:
|
|
122
|
+
code, message = self._extract_error(body)
|
|
123
|
+
kwargs = {"status_code": status_code, "code": code, "request_id": request_id}
|
|
124
|
+
if status_code == 401:
|
|
125
|
+
return AuthenticationError(message, **kwargs)
|
|
126
|
+
if status_code == 403:
|
|
127
|
+
return PermissionDeniedError(message, **kwargs)
|
|
128
|
+
if status_code == 404:
|
|
129
|
+
return NotFoundError(message, **kwargs)
|
|
130
|
+
if status_code in {400, 409, 422}:
|
|
131
|
+
return ValidationError(message, **kwargs)
|
|
132
|
+
if status_code == 408:
|
|
133
|
+
return TimeoutError(message, **kwargs)
|
|
134
|
+
if status_code == 429:
|
|
135
|
+
return RateLimitError(message, **kwargs)
|
|
136
|
+
if status_code >= 500:
|
|
137
|
+
return ServerError(message, **kwargs)
|
|
138
|
+
return OntiverAPIError(message, **kwargs)
|
|
139
|
+
|
|
140
|
+
def _extract_error(self, body: Any) -> tuple[str | None, str]:
|
|
141
|
+
if not isinstance(body, dict):
|
|
142
|
+
return None, "Ontiver API request failed"
|
|
143
|
+
detail = body.get("detail") or body.get("error") or body.get("message")
|
|
144
|
+
if isinstance(detail, dict):
|
|
145
|
+
code = detail.get("code")
|
|
146
|
+
message = detail.get("message") or detail.get("detail") or "Ontiver API request failed"
|
|
147
|
+
return str(code) if code else None, redact(message)
|
|
148
|
+
if isinstance(detail, str):
|
|
149
|
+
return str(body.get("code")) if body.get("code") else None, redact(detail)
|
|
150
|
+
return str(body.get("code")) if body.get("code") else None, "Ontiver API request failed"
|
|
151
|
+
|
|
152
|
+
def _sleep(self, attempt: int, response: httpx.Response | None) -> None:
|
|
153
|
+
retry_after = response.headers.get("Retry-After") if response is not None else None
|
|
154
|
+
if retry_after:
|
|
155
|
+
try:
|
|
156
|
+
delay = min(float(retry_after), 5.0)
|
|
157
|
+
except ValueError:
|
|
158
|
+
delay = 0.25
|
|
159
|
+
else:
|
|
160
|
+
delay = min(0.25 * (2**attempt), 2.0)
|
|
161
|
+
time.sleep(delay)
|
ontiver/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..http import OntiverHTTPClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProofsResource:
|
|
9
|
+
def __init__(self, http: OntiverHTTPClient) -> None:
|
|
10
|
+
self._http = http
|
|
11
|
+
|
|
12
|
+
def generate(
|
|
13
|
+
self,
|
|
14
|
+
proof_type: str,
|
|
15
|
+
statement: str,
|
|
16
|
+
*,
|
|
17
|
+
verification_id: str | None = None,
|
|
18
|
+
witness: dict[str, Any] | None = None,
|
|
19
|
+
public_signals: dict[str, Any] | None = None,
|
|
20
|
+
disclosure_fields: list[str] | None = None,
|
|
21
|
+
idempotency_key: str | None = None,
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
return self._http.request(
|
|
24
|
+
"POST",
|
|
25
|
+
"/zk-proof/generate",
|
|
26
|
+
json={
|
|
27
|
+
"proofType": proof_type,
|
|
28
|
+
"statement": statement,
|
|
29
|
+
"verificationId": verification_id,
|
|
30
|
+
"witness": witness,
|
|
31
|
+
"publicSignals": public_signals,
|
|
32
|
+
"disclosureFields": disclosure_fields,
|
|
33
|
+
},
|
|
34
|
+
idempotency_key=idempotency_key,
|
|
35
|
+
auto_idempotency=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def get_for_verification(self, verification_id: str) -> dict[str, Any]:
|
|
39
|
+
return self._http.request("GET", f"/zk-proof/verification/{verification_id}")
|
|
40
|
+
|
|
41
|
+
def verify(self, proof_id: str, *, proof: dict[str, Any] | None = None, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
42
|
+
return self._http.request(
|
|
43
|
+
"POST",
|
|
44
|
+
"/zk-proof/verify",
|
|
45
|
+
json={"proofId": proof_id, "proof": proof},
|
|
46
|
+
idempotency_key=idempotency_key,
|
|
47
|
+
auto_idempotency=True,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def disclose(self, proof_id: str, *, fields: list[str] | None = None, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
51
|
+
return self._http.request(
|
|
52
|
+
"POST",
|
|
53
|
+
"/zk-proof/disclose",
|
|
54
|
+
json={"proofId": proof_id, "fields": fields or []},
|
|
55
|
+
idempotency_key=idempotency_key,
|
|
56
|
+
auto_idempotency=True,
|
|
57
|
+
)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..http import OntiverHTTPClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ScreeningResource:
|
|
9
|
+
def __init__(self, http: OntiverHTTPClient) -> None:
|
|
10
|
+
self._http = http
|
|
11
|
+
|
|
12
|
+
def pep_check(
|
|
13
|
+
self,
|
|
14
|
+
*,
|
|
15
|
+
name: str | None = None,
|
|
16
|
+
first_name: str | None = None,
|
|
17
|
+
last_name: str | None = None,
|
|
18
|
+
date_of_birth: str | None = None,
|
|
19
|
+
nationality: str | None = None,
|
|
20
|
+
fuzzy_matching: bool = True,
|
|
21
|
+
match_threshold: int = 90,
|
|
22
|
+
idempotency_key: str | None = None,
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
return self._check("/screening/pep/check", name, first_name, last_name, date_of_birth, nationality, fuzzy_matching, match_threshold, idempotency_key)
|
|
25
|
+
|
|
26
|
+
def sanctions_check(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
name: str | None = None,
|
|
30
|
+
first_name: str | None = None,
|
|
31
|
+
last_name: str | None = None,
|
|
32
|
+
date_of_birth: str | None = None,
|
|
33
|
+
nationality: str | None = None,
|
|
34
|
+
fuzzy_matching: bool = True,
|
|
35
|
+
match_threshold: int = 90,
|
|
36
|
+
idempotency_key: str | None = None,
|
|
37
|
+
) -> dict[str, Any]:
|
|
38
|
+
return self._check("/screening/sanctions/check", name, first_name, last_name, date_of_birth, nationality, fuzzy_matching, match_threshold, idempotency_key)
|
|
39
|
+
|
|
40
|
+
def adverse_media_check(
|
|
41
|
+
self,
|
|
42
|
+
first_name: str,
|
|
43
|
+
last_name: str,
|
|
44
|
+
*,
|
|
45
|
+
fuzzy_matching: bool = True,
|
|
46
|
+
match_threshold: int = 90,
|
|
47
|
+
idempotency_key: str | None = None,
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
return self._http.request(
|
|
50
|
+
"POST",
|
|
51
|
+
"/screening/adverse-media/check",
|
|
52
|
+
json={"firstName": first_name, "lastName": last_name, "fuzzyMatching": fuzzy_matching, "matchThreshold": match_threshold},
|
|
53
|
+
idempotency_key=idempotency_key,
|
|
54
|
+
auto_idempotency=True,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def _check(
|
|
58
|
+
self,
|
|
59
|
+
path: str,
|
|
60
|
+
name: str | None,
|
|
61
|
+
first_name: str | None,
|
|
62
|
+
last_name: str | None,
|
|
63
|
+
date_of_birth: str | None,
|
|
64
|
+
nationality: str | None,
|
|
65
|
+
fuzzy_matching: bool,
|
|
66
|
+
match_threshold: int,
|
|
67
|
+
idempotency_key: str | None,
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
payload = {
|
|
70
|
+
"name": name,
|
|
71
|
+
"firstName": first_name,
|
|
72
|
+
"lastName": last_name,
|
|
73
|
+
"dateOfBirth": date_of_birth,
|
|
74
|
+
"nationality": nationality,
|
|
75
|
+
"fuzzyMatching": fuzzy_matching,
|
|
76
|
+
"matchThreshold": match_threshold,
|
|
77
|
+
}
|
|
78
|
+
return self._http.request("POST", path, json=payload, idempotency_key=idempotency_key, auto_idempotency=True)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..http import OntiverHTTPClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class VerificationsResource:
|
|
9
|
+
def __init__(self, http: OntiverHTTPClient) -> None:
|
|
10
|
+
self._http = http
|
|
11
|
+
|
|
12
|
+
def create(self, payload: dict[str, Any], *, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
13
|
+
return self._http.request(
|
|
14
|
+
"POST",
|
|
15
|
+
"/kyc/individual/verify",
|
|
16
|
+
json=payload,
|
|
17
|
+
idempotency_key=idempotency_key,
|
|
18
|
+
auto_idempotency=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def get(self, verification_id: str) -> dict[str, Any]:
|
|
22
|
+
return self._http.request("GET", f"/kyc/individual/{verification_id}")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..http import OntiverHTTPClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WebhookManagementResource:
|
|
9
|
+
def __init__(self, http: OntiverHTTPClient) -> None:
|
|
10
|
+
self._http = http
|
|
11
|
+
|
|
12
|
+
def register(
|
|
13
|
+
self,
|
|
14
|
+
url: str,
|
|
15
|
+
events: list[str],
|
|
16
|
+
*,
|
|
17
|
+
secret: str | None = None,
|
|
18
|
+
active: bool = True,
|
|
19
|
+
idempotency_key: str | None = None,
|
|
20
|
+
) -> dict[str, Any]:
|
|
21
|
+
return self._http.request(
|
|
22
|
+
"POST",
|
|
23
|
+
"/webhooks/register",
|
|
24
|
+
json={"url": url, "events": events, "secret": secret, "active": active},
|
|
25
|
+
idempotency_key=idempotency_key,
|
|
26
|
+
auto_idempotency=True,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def list(self, *, active: bool | None = None) -> dict[str, Any]:
|
|
30
|
+
params = {} if active is None else {"active": active}
|
|
31
|
+
return self._http.request("GET", "/webhooks", params=params)
|
|
32
|
+
|
|
33
|
+
def delete(self, webhook_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
34
|
+
return self._http.request(
|
|
35
|
+
"DELETE",
|
|
36
|
+
f"/webhooks/{webhook_id}",
|
|
37
|
+
idempotency_key=idempotency_key,
|
|
38
|
+
auto_idempotency=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def test(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
webhook_id: str | None = None,
|
|
45
|
+
url: str | None = None,
|
|
46
|
+
event_type: str = "verification.completed",
|
|
47
|
+
payload: dict[str, Any] | None = None,
|
|
48
|
+
idempotency_key: str | None = None,
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
body = {"webhookId": webhook_id, "webhookUrl": url, "eventType": event_type, "payload": payload or {}}
|
|
51
|
+
path = f"/webhooks/{webhook_id}/test" if webhook_id else "/webhooks/test"
|
|
52
|
+
return self._http.request("POST", path, json=body, idempotency_key=idempotency_key, auto_idempotency=True)
|
|
53
|
+
|
|
54
|
+
def rotate_secret(self, webhook_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
55
|
+
return self._http.request(
|
|
56
|
+
"POST",
|
|
57
|
+
f"/webhooks/{webhook_id}/rotate-secret",
|
|
58
|
+
idempotency_key=idempotency_key,
|
|
59
|
+
auto_idempotency=True,
|
|
60
|
+
)
|
ontiver/webhooks.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
from hashlib import sha256
|
|
5
|
+
|
|
6
|
+
from .errors import WebhookSignatureError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def verify_webhook_signature(payload: bytes | str, signature: str | None, secret: str) -> bool:
|
|
10
|
+
if not signature or not secret:
|
|
11
|
+
return False
|
|
12
|
+
raw = payload.encode("utf-8") if isinstance(payload, str) else payload
|
|
13
|
+
provided = signature.strip()
|
|
14
|
+
if provided.startswith("sha256="):
|
|
15
|
+
provided = provided[len("sha256=") :]
|
|
16
|
+
digest = hmac.new(secret.encode("utf-8"), raw, sha256).hexdigest()
|
|
17
|
+
return hmac.compare_digest(digest, provided)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def require_webhook_signature(payload: bytes | str, signature: str | None, secret: str) -> None:
|
|
21
|
+
if not verify_webhook_signature(payload, signature, secret):
|
|
22
|
+
raise WebhookSignatureError("Invalid Ontiver webhook signature")
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ontiver
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Ontiver API
|
|
5
|
+
Author: Ontiver
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Project-URL: Homepage, https://ontiver.com
|
|
8
|
+
Project-URL: Documentation, https://docs.ontiver.com
|
|
9
|
+
Project-URL: Repository, https://github.com/joinverza/Ontiver
|
|
10
|
+
Keywords: ontiver,kyc,aml,identity,proofs,webhooks
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: httpx<1,>=0.27
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
22
|
+
Provides-Extra: build
|
|
23
|
+
Requires-Dist: build>=1.2; extra == "build"
|
|
24
|
+
Requires-Dist: twine>=5; extra == "build"
|
|
25
|
+
|
|
26
|
+
# Ontiver Python SDK
|
|
27
|
+
|
|
28
|
+
Official Python SDK for integrating Ontiver verification, screening, proofs, and webhooks into enterprise backends.
|
|
29
|
+
|
|
30
|
+
The dashboard remains the control plane for API keys, scopes, billing, team roles, webhooks, and audit logs. The SDK is the integration layer for Django, Flask, FastAPI, workers, and internal services.
|
|
31
|
+
|
|
32
|
+
## Install Locally
|
|
33
|
+
|
|
34
|
+
From this repository:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cd sdks/python
|
|
38
|
+
python -m pip install -e ".[test]"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
After publication:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
python -m pip install ontiver
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
Create an API key from the Ontiver enterprise dashboard and store it in your secret manager.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
ONTIVER_API_KEY=prod_xxx
|
|
53
|
+
ONTIVER_BASE_URL=https://api.ontiver.com
|
|
54
|
+
ONTIVER_ENVIRONMENT=production
|
|
55
|
+
ONTIVER_WEBHOOK_SECRET=whsec_xxx
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Use sandbox keys in non-production environments:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
ONTIVER_API_KEY=sb_xxx
|
|
62
|
+
ONTIVER_ENVIRONMENT=sandbox
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Quickstart
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from ontiver import OntiverClient
|
|
69
|
+
|
|
70
|
+
client = OntiverClient.from_env()
|
|
71
|
+
|
|
72
|
+
startup_check = client.validate_api_key()
|
|
73
|
+
print(startup_check["valid"])
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Verification
|
|
77
|
+
|
|
78
|
+
SDK v1 routes flexible verification payloads to:
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
POST /api/v1/banking/kyc/individual/verify
|
|
82
|
+
GET /api/v1/banking/kyc/individual/{verificationId}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
result = client.verifications.create(
|
|
87
|
+
{
|
|
88
|
+
"customerId": "cust_123",
|
|
89
|
+
"firstName": "Ada",
|
|
90
|
+
"lastName": "Okafor",
|
|
91
|
+
"country": "NG",
|
|
92
|
+
"idDocumentType": "passport",
|
|
93
|
+
"idDocumentNumber": "A12345678",
|
|
94
|
+
},
|
|
95
|
+
idempotency_key="verify-cust-123",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
status = client.verifications.get(result["verificationId"])
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Screening
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
screening = client.screening.sanctions_check(
|
|
105
|
+
first_name="Ada",
|
|
106
|
+
last_name="Okafor",
|
|
107
|
+
nationality="NG",
|
|
108
|
+
idempotency_key="sanctions-cust-123",
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Supported methods:
|
|
113
|
+
|
|
114
|
+
- `client.screening.pep_check(...)`
|
|
115
|
+
- `client.screening.sanctions_check(...)`
|
|
116
|
+
- `client.screening.adverse_media_check(first_name, last_name, ...)`
|
|
117
|
+
|
|
118
|
+
## Proofs
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
proof = client.proofs.get_for_verification("ver_123")
|
|
122
|
+
|
|
123
|
+
verified = client.proofs.verify(
|
|
124
|
+
proof_id=proof["proofId"],
|
|
125
|
+
proof=proof.get("proof"),
|
|
126
|
+
)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Supported methods:
|
|
130
|
+
|
|
131
|
+
- `client.proofs.generate(...)`
|
|
132
|
+
- `client.proofs.get_for_verification(verification_id)`
|
|
133
|
+
- `client.proofs.verify(proof_id, proof=None)`
|
|
134
|
+
- `client.proofs.disclose(proof_id, fields=[...])`
|
|
135
|
+
|
|
136
|
+
## Webhooks
|
|
137
|
+
|
|
138
|
+
Register a webhook:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
webhook = client.webhooks.register(
|
|
142
|
+
"https://example.com/webhooks/ontiver",
|
|
143
|
+
["verification.completed", "proof.issued"],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Store this once. It will not be returned by list endpoints.
|
|
147
|
+
secret = webhook["secret"]
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Verify webhook signatures using the raw request body:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from ontiver.webhooks import verify_webhook_signature
|
|
154
|
+
|
|
155
|
+
is_valid = verify_webhook_signature(raw_body, request.headers["X-Ontiver-Signature"], secret)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Header format:
|
|
159
|
+
|
|
160
|
+
```text
|
|
161
|
+
X-Ontiver-Signature: sha256=<hex_digest>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Raw Request Escape Hatch
|
|
165
|
+
|
|
166
|
+
Use `raw_request()` for routes that do not have first-class wrappers yet:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
result = client.raw_request(
|
|
170
|
+
"POST",
|
|
171
|
+
"/aml/smile-checks",
|
|
172
|
+
json={"fullName": "Ada Okafor", "countries": ["NG"]},
|
|
173
|
+
idempotency_key="aml-cust-123",
|
|
174
|
+
)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Errors
|
|
178
|
+
|
|
179
|
+
Catch specific exceptions:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from ontiver import AuthenticationError, PermissionDeniedError, RateLimitError, ValidationError
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
client.validate_api_key()
|
|
186
|
+
except AuthenticationError:
|
|
187
|
+
...
|
|
188
|
+
except PermissionDeniedError:
|
|
189
|
+
...
|
|
190
|
+
except RateLimitError:
|
|
191
|
+
...
|
|
192
|
+
except ValidationError:
|
|
193
|
+
...
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The SDK redacts API keys, bearer tokens, webhook secrets, and secret prefixes from exception messages.
|
|
197
|
+
|
|
198
|
+
## Security Notes
|
|
199
|
+
|
|
200
|
+
- Never commit API keys or webhook secrets.
|
|
201
|
+
- Store production secrets in a secret manager.
|
|
202
|
+
- Use least-privilege API key scopes.
|
|
203
|
+
- Use sandbox keys outside production.
|
|
204
|
+
- Verify webhook signatures over the raw body bytes before parsing JSON.
|
|
205
|
+
- Do not log raw identity documents, ID numbers, biometric payloads, or full provider payloads.
|
|
206
|
+
|
|
207
|
+
## Build And Publish
|
|
208
|
+
|
|
209
|
+
Build locally:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
cd sdks/python
|
|
213
|
+
python -m pip install -e ".[test]"
|
|
214
|
+
pytest
|
|
215
|
+
python -m pip install ".[build]"
|
|
216
|
+
python -m build
|
|
217
|
+
python -m twine check dist/*
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Publish first to TestPyPI:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
python -m twine upload --repository testpypi dist/*
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Install from TestPyPI in a clean environment:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
python -m pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple ontiver
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Publish to PyPI after TestPyPI install passes:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
python -m twine upload dist/*
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Target package name: `ontiver`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
ontiver/__init__.py,sha256=27ivsYmiNZDcUcEgjJ0ii4Z_iopJg73swm_jOC3_Dco,651
|
|
2
|
+
ontiver/client.py,sha256=lP_39wundvdjRS5xHXHG3n7_nnuw_bxggNwnLmDQ8fo,2201
|
|
3
|
+
ontiver/config.py,sha256=RcxzeBEtb4DPvKbeTSF2uWte2azoK0kX_1FsNRTfq38,2500
|
|
4
|
+
ontiver/errors.py,sha256=7GuKHLwMTG2T7iFxxzmgK9vd6jPsGtCOUuNPRU2Hz40,1459
|
|
5
|
+
ontiver/http.py,sha256=F9DrwXSv-4YwUiE1FeFmIHCQVHK5UBD82HWM2AIGx7U,6375
|
|
6
|
+
ontiver/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
7
|
+
ontiver/webhooks.py,sha256=kxE7xcnqCh3gBFFlKrRKPm2Hs56OcZy8YySQW9d0w4U,823
|
|
8
|
+
ontiver/resources/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
9
|
+
ontiver/resources/proofs.py,sha256=evPvliiFueeBBuO3dGpOWvDLpD0obWw2krEcrbgtrqw,1949
|
|
10
|
+
ontiver/resources/screening.py,sha256=k-efMsRZh28A2sbzFiT9SN0gljFbSuLViLUyrbtOFvI,2611
|
|
11
|
+
ontiver/resources/verifications.py,sha256=vHv02UTKNzGRt-wpftOnsMia8aOJuoTTJeHw3BKbLrQ,664
|
|
12
|
+
ontiver/resources/webhook_management.py,sha256=fwyR8dnc6a4_Wn0-9ouvjXCsoDsY3p8nq0uSZJi3O9Y,2074
|
|
13
|
+
ontiver-0.1.0.dist-info/METADATA,sha256=5j-2s9i5ZyUP-SGhFBjutcGc0dpOKI1oTa1czCnB2zM,5669
|
|
14
|
+
ontiver-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
15
|
+
ontiver-0.1.0.dist-info/top_level.txt,sha256=umC5Q1gRMQ-rSI3UCMlFG-c-jrJ_T0J47iLkC7hEHUE,8
|
|
16
|
+
ontiver-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ontiver
|