kobil-core 1.2.0__tar.gz
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.
- kobil_core-1.2.0/PKG-INFO +29 -0
- kobil_core-1.2.0/README.md +19 -0
- kobil_core-1.2.0/kobil/core/__init__.py +18 -0
- kobil_core-1.2.0/kobil/core/client.py +93 -0
- kobil_core-1.2.0/kobil/core/credentials.py +41 -0
- kobil_core-1.2.0/kobil/core/error.py +26 -0
- kobil_core-1.2.0/kobil/core/token.py +73 -0
- kobil_core-1.2.0/kobil_core.egg-info/PKG-INFO +29 -0
- kobil_core-1.2.0/kobil_core.egg-info/SOURCES.txt +11 -0
- kobil_core-1.2.0/kobil_core.egg-info/dependency_links.txt +1 -0
- kobil_core-1.2.0/kobil_core.egg-info/top_level.txt +1 -0
- kobil_core-1.2.0/pyproject.toml +19 -0
- kobil_core-1.2.0/setup.cfg +4 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kobil-core
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Shared foundation for the KOBIL SDK family: OAuth token acquisition/caching and an HTTP base for typed clients.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/uveysservetoglu/kobil-sdks
|
|
7
|
+
Keywords: kobil,sdk,oauth2,core
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# kobil-core
|
|
12
|
+
|
|
13
|
+
Shared foundation for the KOBIL SDK family (`kobil-payment`, `kobil-chat`, `kobil-sign`, …).
|
|
14
|
+
|
|
15
|
+
You normally don't use this directly — the module packages depend on it. Provides:
|
|
16
|
+
|
|
17
|
+
- **`DefaultTokenProvider`** — OAuth2 `client_credentials` token acquisition with in-memory
|
|
18
|
+
caching (honours `expires_in`). Modes: `post` (form body, default) or `basic`.
|
|
19
|
+
- **`KobilError`** — base exception all module exceptions extend (`status_code`, `response_body`).
|
|
20
|
+
- **`ServiceClient`** — HTTP base for typed clients (bearer + send + error wrapping; JSON,
|
|
21
|
+
multipart, and binary-download helpers) built on the standard library (`urllib`).
|
|
22
|
+
- **`credentials_from_env()`** — reads `KOBIL_TOKEN_ENDPOINT`, `KOBIL_CLIENT_ID`,
|
|
23
|
+
`KOBIL_CLIENT_SECRET`, `KOBIL_TOKEN_AUTH_MODE`.
|
|
24
|
+
|
|
25
|
+
Pure standard library — no third-party dependencies. Python 3.8+.
|
|
26
|
+
|
|
27
|
+
## License
|
|
28
|
+
|
|
29
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# kobil-core
|
|
2
|
+
|
|
3
|
+
Shared foundation for the KOBIL SDK family (`kobil-payment`, `kobil-chat`, `kobil-sign`, …).
|
|
4
|
+
|
|
5
|
+
You normally don't use this directly — the module packages depend on it. Provides:
|
|
6
|
+
|
|
7
|
+
- **`DefaultTokenProvider`** — OAuth2 `client_credentials` token acquisition with in-memory
|
|
8
|
+
caching (honours `expires_in`). Modes: `post` (form body, default) or `basic`.
|
|
9
|
+
- **`KobilError`** — base exception all module exceptions extend (`status_code`, `response_body`).
|
|
10
|
+
- **`ServiceClient`** — HTTP base for typed clients (bearer + send + error wrapping; JSON,
|
|
11
|
+
multipart, and binary-download helpers) built on the standard library (`urllib`).
|
|
12
|
+
- **`credentials_from_env()`** — reads `KOBIL_TOKEN_ENDPOINT`, `KOBIL_CLIENT_ID`,
|
|
13
|
+
`KOBIL_CLIENT_SECRET`, `KOBIL_TOKEN_AUTH_MODE`.
|
|
14
|
+
|
|
15
|
+
Pure standard library — no third-party dependencies. Python 3.8+.
|
|
16
|
+
|
|
17
|
+
## License
|
|
18
|
+
|
|
19
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""kobil.core — shared foundation for the KOBIL SDK family."""
|
|
2
|
+
|
|
3
|
+
from .client import ServiceClient, build_multipart, new_uuid, prune
|
|
4
|
+
from .credentials import Credentials, credentials_from_env
|
|
5
|
+
from .error import KobilError
|
|
6
|
+
from .token import DefaultTokenProvider, TokenProvider
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Credentials",
|
|
10
|
+
"credentials_from_env",
|
|
11
|
+
"KobilError",
|
|
12
|
+
"TokenProvider",
|
|
13
|
+
"DefaultTokenProvider",
|
|
14
|
+
"ServiceClient",
|
|
15
|
+
"build_multipart",
|
|
16
|
+
"new_uuid",
|
|
17
|
+
"prune",
|
|
18
|
+
]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""HTTP base for typed KOBIL service clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any, Dict, Mapping, Optional
|
|
10
|
+
|
|
11
|
+
from .error import KobilError
|
|
12
|
+
from .token import TokenProvider
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def new_uuid() -> str:
|
|
16
|
+
return str(uuid.uuid4())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def prune(data: Mapping[str, Any]) -> Dict[str, Any]:
|
|
20
|
+
"""Removes keys whose value is None so optional fields are omitted."""
|
|
21
|
+
return {k: v for k, v in data.items() if v is not None}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_multipart(fields: Mapping[str, str], files: Mapping[str, Mapping[str, Any]]) -> "tuple[str, bytes]":
|
|
25
|
+
"""Builds a multipart/form-data body. Returns (content_type, body_bytes)."""
|
|
26
|
+
boundary = "----KobilBoundary" + uuid.uuid4().hex
|
|
27
|
+
eol = b"\r\n"
|
|
28
|
+
body = bytearray()
|
|
29
|
+
|
|
30
|
+
for name, value in fields.items():
|
|
31
|
+
body += b"--" + boundary.encode() + eol
|
|
32
|
+
body += f'Content-Disposition: form-data; name="{name}"'.encode() + eol + eol
|
|
33
|
+
body += str(value).encode() + eol
|
|
34
|
+
|
|
35
|
+
for name, file in files.items():
|
|
36
|
+
filename = file.get("filename", "file")
|
|
37
|
+
content_type = file.get("content_type", "application/octet-stream")
|
|
38
|
+
content = file["content"]
|
|
39
|
+
if isinstance(content, str):
|
|
40
|
+
content = content.encode()
|
|
41
|
+
body += b"--" + boundary.encode() + eol
|
|
42
|
+
body += f'Content-Disposition: form-data; name="{name}"; filename="{filename}"'.encode() + eol
|
|
43
|
+
body += f"Content-Type: {content_type}".encode() + eol + eol
|
|
44
|
+
body += content + eol
|
|
45
|
+
|
|
46
|
+
body += b"--" + boundary.encode() + b"--" + eol
|
|
47
|
+
return f"multipart/form-data; boundary={boundary}", bytes(body)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ServiceClient:
|
|
51
|
+
"""Base client: attaches the bearer token, sends the request, wraps failures."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, tokens: TokenProvider) -> None:
|
|
54
|
+
self._tokens = tokens
|
|
55
|
+
|
|
56
|
+
def _make_error(self, message: str, status_code: Optional[int] = None,
|
|
57
|
+
response_body: Optional[str] = None, cause: Optional[BaseException] = None) -> KobilError:
|
|
58
|
+
return KobilError(message, status_code, response_body, cause)
|
|
59
|
+
|
|
60
|
+
def send_json(self, method: str, url: str, body: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]:
|
|
61
|
+
headers = {"Accept": "application/json"}
|
|
62
|
+
data = None
|
|
63
|
+
if body is not None:
|
|
64
|
+
headers["Content-Type"] = "application/json"
|
|
65
|
+
data = json.dumps(prune(body), ensure_ascii=False).encode()
|
|
66
|
+
raw = self._request(method, url, headers, data)
|
|
67
|
+
return json.loads(raw) if raw else {}
|
|
68
|
+
|
|
69
|
+
def send_multipart(self, method: str, url: str, fields: Mapping[str, str],
|
|
70
|
+
files: Mapping[str, Mapping[str, Any]]) -> Dict[str, Any]:
|
|
71
|
+
content_type, data = build_multipart(fields, files)
|
|
72
|
+
headers = {"Accept": "application/json", "Content-Type": content_type}
|
|
73
|
+
raw = self._request(method, url, headers, data)
|
|
74
|
+
return json.loads(raw) if raw else {}
|
|
75
|
+
|
|
76
|
+
def send_for_bytes(self, method: str, url: str) -> bytes:
|
|
77
|
+
return self._request(method, url, {"Accept": "application/octet-stream"}, None, decode=False)
|
|
78
|
+
|
|
79
|
+
def _request(self, method: str, url: str, headers: Mapping[str, str],
|
|
80
|
+
data: Optional[bytes], decode: bool = True):
|
|
81
|
+
merged = {**headers, "Authorization": f"Bearer {self._tokens.get_token()}"}
|
|
82
|
+
request = urllib.request.Request(url, data=data, method=method, headers=merged)
|
|
83
|
+
try:
|
|
84
|
+
with urllib.request.urlopen(request) as response:
|
|
85
|
+
raw = response.read()
|
|
86
|
+
except urllib.error.HTTPError as exc:
|
|
87
|
+
error_body = exc.read().decode(errors="replace")
|
|
88
|
+
raise self._make_error(
|
|
89
|
+
f"KOBIL API returned {exc.code} for {method} {url}.", exc.code, error_body
|
|
90
|
+
) from exc
|
|
91
|
+
except Exception as exc: # noqa: BLE001 - wrap transport errors
|
|
92
|
+
raise self._make_error(f"An error occurred while calling {method} {url}.", cause=exc) from exc
|
|
93
|
+
return raw if not decode else raw.decode()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Shared OAuth2 credentials for the KOBIL SDK family."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Credentials:
|
|
12
|
+
"""OAuth2 credentials shared by every KOBIL SDK module."""
|
|
13
|
+
|
|
14
|
+
token_endpoint: str = ""
|
|
15
|
+
client_id: str = ""
|
|
16
|
+
client_secret: str = ""
|
|
17
|
+
token_auth_mode: str = "post" # "post" or "basic"
|
|
18
|
+
default_token_ttl_seconds: int = 280
|
|
19
|
+
token_expiry_skew_seconds: int = 20
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def credentials_from_env(**overrides: Any) -> Credentials:
|
|
23
|
+
"""Build credentials, falling back to KOBIL_* environment variables."""
|
|
24
|
+
|
|
25
|
+
def pick(key: str, env: str) -> str:
|
|
26
|
+
value = overrides.get(key)
|
|
27
|
+
if isinstance(value, str) and value:
|
|
28
|
+
return value
|
|
29
|
+
return os.environ.get(env, "")
|
|
30
|
+
|
|
31
|
+
raw_mode = overrides.get("token_auth_mode") or os.environ.get("KOBIL_TOKEN_AUTH_MODE") or "post"
|
|
32
|
+
mode = "basic" if str(raw_mode).lower() in ("basic", "client_secret_basic") else "post"
|
|
33
|
+
|
|
34
|
+
return Credentials(
|
|
35
|
+
token_endpoint=pick("token_endpoint", "KOBIL_TOKEN_ENDPOINT"),
|
|
36
|
+
client_id=pick("client_id", "KOBIL_CLIENT_ID"),
|
|
37
|
+
client_secret=pick("client_secret", "KOBIL_CLIENT_SECRET"),
|
|
38
|
+
token_auth_mode=mode,
|
|
39
|
+
default_token_ttl_seconds=int(overrides.get("default_token_ttl_seconds") or 280),
|
|
40
|
+
token_expiry_skew_seconds=int(overrides.get("token_expiry_skew_seconds") or 20),
|
|
41
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Base exception for the KOBIL SDK family."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class KobilError(Exception):
|
|
9
|
+
"""Base exception for all KOBIL SDK modules.
|
|
10
|
+
|
|
11
|
+
Module-specific exceptions (KobilPaymentError, KobilChatError, …) extend this,
|
|
12
|
+
so callers can catch ``KobilError`` to handle any KOBIL failure uniformly.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
message: str,
|
|
18
|
+
status_code: Optional[int] = None,
|
|
19
|
+
response_body: Optional[str] = None,
|
|
20
|
+
cause: Optional[BaseException] = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.status_code = status_code
|
|
24
|
+
self.response_body = response_body
|
|
25
|
+
if cause is not None:
|
|
26
|
+
self.__cause__ = cause
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""OAuth2 token acquisition and caching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
from .credentials import Credentials
|
|
13
|
+
from .error import KobilError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TokenProvider:
|
|
17
|
+
"""Provides (and caches) OAuth2 access tokens shared across modules."""
|
|
18
|
+
|
|
19
|
+
def get_token(self) -> str: # pragma: no cover - interface
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DefaultTokenProvider(TokenProvider):
|
|
24
|
+
"""Acquires tokens via the client_credentials grant (post or basic) and caches them."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, credentials: Credentials) -> None:
|
|
27
|
+
self._c = credentials
|
|
28
|
+
self._token: str = ""
|
|
29
|
+
self._expires_at: float = 0.0
|
|
30
|
+
|
|
31
|
+
def get_token(self) -> str:
|
|
32
|
+
if self._token and time.time() < self._expires_at:
|
|
33
|
+
return self._token
|
|
34
|
+
|
|
35
|
+
c = self._c
|
|
36
|
+
if not c.token_endpoint:
|
|
37
|
+
raise KobilError("credentials.token_endpoint is required.")
|
|
38
|
+
if not c.client_id:
|
|
39
|
+
raise KobilError("credentials.client_id is required.")
|
|
40
|
+
if not c.client_secret:
|
|
41
|
+
raise KobilError("credentials.client_secret is required.")
|
|
42
|
+
|
|
43
|
+
fields = {"grant_type": "client_credentials"}
|
|
44
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
45
|
+
if c.token_auth_mode == "basic":
|
|
46
|
+
basic = base64.b64encode(f"{c.client_id}:{c.client_secret}".encode()).decode()
|
|
47
|
+
headers["Authorization"] = f"Basic {basic}"
|
|
48
|
+
else:
|
|
49
|
+
fields["client_id"] = c.client_id
|
|
50
|
+
fields["client_secret"] = c.client_secret
|
|
51
|
+
|
|
52
|
+
data = urllib.parse.urlencode(fields).encode()
|
|
53
|
+
request = urllib.request.Request(c.token_endpoint, data=data, method="POST", headers=headers)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
with urllib.request.urlopen(request) as response:
|
|
57
|
+
body = response.read().decode()
|
|
58
|
+
except urllib.error.HTTPError as exc:
|
|
59
|
+
error_body = exc.read().decode(errors="replace")
|
|
60
|
+
raise KobilError(f"Token endpoint returned {exc.code}.", exc.code, error_body) from exc
|
|
61
|
+
except Exception as exc: # noqa: BLE001 - wrap transport errors
|
|
62
|
+
raise KobilError("An error occurred while acquiring an access token.", cause=exc) from exc
|
|
63
|
+
|
|
64
|
+
parsed = json.loads(body)
|
|
65
|
+
token = parsed.get("access_token")
|
|
66
|
+
if not token:
|
|
67
|
+
raise KobilError("Token endpoint response did not contain an access_token.", None, body)
|
|
68
|
+
|
|
69
|
+
expires_in = parsed.get("expires_in") or c.default_token_ttl_seconds
|
|
70
|
+
ttl = max(1, int(expires_in) - c.token_expiry_skew_seconds)
|
|
71
|
+
self._token = token
|
|
72
|
+
self._expires_at = time.time() + ttl
|
|
73
|
+
return token
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kobil-core
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Shared foundation for the KOBIL SDK family: OAuth token acquisition/caching and an HTTP base for typed clients.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/uveysservetoglu/kobil-sdks
|
|
7
|
+
Keywords: kobil,sdk,oauth2,core
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# kobil-core
|
|
12
|
+
|
|
13
|
+
Shared foundation for the KOBIL SDK family (`kobil-payment`, `kobil-chat`, `kobil-sign`, …).
|
|
14
|
+
|
|
15
|
+
You normally don't use this directly — the module packages depend on it. Provides:
|
|
16
|
+
|
|
17
|
+
- **`DefaultTokenProvider`** — OAuth2 `client_credentials` token acquisition with in-memory
|
|
18
|
+
caching (honours `expires_in`). Modes: `post` (form body, default) or `basic`.
|
|
19
|
+
- **`KobilError`** — base exception all module exceptions extend (`status_code`, `response_body`).
|
|
20
|
+
- **`ServiceClient`** — HTTP base for typed clients (bearer + send + error wrapping; JSON,
|
|
21
|
+
multipart, and binary-download helpers) built on the standard library (`urllib`).
|
|
22
|
+
- **`credentials_from_env()`** — reads `KOBIL_TOKEN_ENDPOINT`, `KOBIL_CLIENT_ID`,
|
|
23
|
+
`KOBIL_CLIENT_SECRET`, `KOBIL_TOKEN_AUTH_MODE`.
|
|
24
|
+
|
|
25
|
+
Pure standard library — no third-party dependencies. Python 3.8+.
|
|
26
|
+
|
|
27
|
+
## License
|
|
28
|
+
|
|
29
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
kobil/core/__init__.py
|
|
4
|
+
kobil/core/client.py
|
|
5
|
+
kobil/core/credentials.py
|
|
6
|
+
kobil/core/error.py
|
|
7
|
+
kobil/core/token.py
|
|
8
|
+
kobil_core.egg-info/PKG-INFO
|
|
9
|
+
kobil_core.egg-info/SOURCES.txt
|
|
10
|
+
kobil_core.egg-info/dependency_links.txt
|
|
11
|
+
kobil_core.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kobil
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kobil-core"
|
|
7
|
+
version = "1.2.0"
|
|
8
|
+
description = "Shared foundation for the KOBIL SDK family: OAuth token acquisition/caching and an HTTP base for typed clients."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
keywords = ["kobil", "sdk", "oauth2", "core"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Repository = "https://github.com/uveysservetoglu/kobil-sdks"
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
include = ["kobil*"]
|
|
19
|
+
namespaces = true
|