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.
@@ -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
+ 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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+