Initial commit

This commit is contained in:
2026-09-09 16:21:13 +03:00
commit 2067d8c298
17 changed files with 1887 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
"""Reusable iklim.co API client components."""
from .client import ApiResponse, IklimClient
from .config import Settings
from .errors import ApiError, ConfigurationError
from .logging_config import configure_logging
__all__ = [
"ApiError",
"ApiResponse",
"ConfigurationError",
"IklimClient",
"Settings",
"configure_logging",
]
+384
View File
@@ -0,0 +1,384 @@
"""Reusable authenticated HTTP client for iklim.co APIs."""
from __future__ import annotations
import json
import logging
import re
import time
import uuid
from dataclasses import dataclass
from email.utils import parsedate_to_datetime
from pathlib import Path
from typing import Any, Callable
import requests
from .config import Settings
from .errors import ApiError
from .security import TokenStore, create_signed_headers, token_is_valid
LOGGER = logging.getLogger(__name__)
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
SENSITIVE_KEY_PARTS = {
"apikey",
"authorization",
"cookie",
"password",
"secret",
"signature",
"token",
"username",
}
REDACTED = "<redacted>"
@dataclass(frozen=True)
class ApiResponse:
status_code: int
data: Any
def _error_message(response: requests.Response, data: Any) -> str:
if isinstance(data, dict):
message = data.get("message") or data.get("messages") or data.get("error")
if isinstance(message, list):
return "; ".join(str(item) for item in message)
if message:
return str(message)
return response.reason or "Bilinmeyen API hatası"
def _normalized_key(value: object) -> str:
return "".join(character for character in str(value).lower() if character.isalnum())
def _redact(value: Any, parent_key: str = "") -> Any:
if isinstance(value, dict):
redact_all_values = _normalized_key(parent_key) == "requestheaders"
result = {}
for key, item in value.items():
normalized = _normalized_key(key)
sensitive = redact_all_values or any(
part in normalized for part in SENSITIVE_KEY_PARTS
)
result[key] = REDACTED if sensitive else _redact(item, str(key))
return result
if isinstance(value, list):
return [_redact(item, parent_key) for item in value]
return value
def _redact_text(value: str) -> str:
value = re.sub(r"(?i)Bearer\s+\S+", f"Bearer {REDACTED}", value)
return re.sub(
r"\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b",
REDACTED,
value,
)
def _request_body_for_log(body: str) -> Any:
if not body:
return None
try:
return _redact(json.loads(body))
except json.JSONDecodeError:
return _redact_text(body[:20_000])
def _response_body_for_log(response: requests.Response) -> Any:
if not response.content:
return None
try:
return _redact(response.json())
except requests.exceptions.JSONDecodeError:
return _redact_text(response.text[:20_000])
class AuthenticationManager:
LOGIN_PATH = "/v1/auth/login"
REFRESH_PATH = "/v1/auth/refresh"
def __init__(self, client: "IklimClient", token_store: TokenStore) -> None:
self.client = client
self.token_store = token_store
def ensure_access_token(self) -> str:
access_token = self.token_store.access_token
if not access_token:
LOGGER.info("Access token bulunamadı; otomatik login yapılıyor.")
self.login()
return self._required_saved_access_token()
if token_is_valid(access_token, self.client.settings.token_expiry_skew_seconds):
return access_token
LOGGER.info("Access token süresi dolmuş veya dolmak üzere.")
refresh_token = self.token_store.refresh_token
if token_is_valid(
refresh_token, self.client.settings.token_expiry_skew_seconds
):
self.refresh()
else:
LOGGER.info("Refresh token geçerli değil; otomatik login yapılıyor.")
self.login()
return self._required_saved_access_token()
def recover_after_unauthorized(self) -> None:
refresh_token = self.token_store.refresh_token
if token_is_valid(
refresh_token, self.client.settings.token_expiry_skew_seconds
):
LOGGER.info("HTTP 401 alındı; access token yenileniyor.")
self.refresh()
else:
LOGGER.info("HTTP 401 alındı ve refresh token geçersiz; yeniden login yapılıyor.")
self.login()
def login(self) -> dict[str, Any]:
username, password = self.client.settings.credentials()
LOGGER.info("iklim.co API login işlemi başlatıldı.")
response = self.client.request_json(
"POST",
self.LOGIN_PATH,
json_body={"username": username, "password": password},
authenticated=False,
)
self._save_auth_response(response.data)
LOGGER.info("Login başarılı; tokenlar .env dosyasına kaydedildi.")
return response.data
def refresh(self) -> dict[str, Any]:
refresh_token = self.token_store.refresh_token
if not refresh_token:
LOGGER.info("Refresh token bulunamadı; otomatik login yapılıyor.")
return self.login()
LOGGER.info("Access token refresh işlemi başlatıldı.")
try:
response = self.client.request_json(
"POST",
self.REFRESH_PATH,
json_body={"refreshToken": refresh_token},
authenticated=False,
)
except ApiError as exc:
if exc.status_code in {400, 401, 403}:
LOGGER.info("Refresh token API tarafından reddedildi; yeniden login yapılıyor.")
return self.login()
raise
self._save_auth_response(response.data)
LOGGER.info("Token yenileme başarılı; yeni tokenlar .env dosyasına kaydedildi.")
return response.data
def _save_auth_response(self, data: Any) -> None:
if not isinstance(data, dict):
raise ApiError("Kimlik doğrulama endpoint'i beklenen JSON nesnesini döndürmedi.")
self.token_store.save(data)
def _required_saved_access_token(self) -> str:
access_token = self.token_store.access_token
if not access_token:
raise ApiError("Login/refresh sonrasında access token kaydedilemedi.")
return access_token
class IklimClient:
def __init__(
self,
settings: Settings,
*,
session: requests.Session | None = None,
sleep: Callable[[float], None] = time.sleep,
) -> None:
self.settings = settings
self.session = session or requests.Session()
self.sleep = sleep
self.token_store = TokenStore(settings.env_file)
self.auth = AuthenticationManager(self, self.token_store)
@classmethod
def from_env(cls, env_file: Path | str | None = None) -> "IklimClient":
settings = Settings.from_env() if env_file is None else Settings.from_env(env_file)
return cls(settings)
def request_json(
self,
method: str,
path_with_query: str,
*,
json_body: Any = None,
authenticated: bool = True,
idempotency_key: str | None = None,
) -> ApiResponse:
method = method.upper()
if not path_with_query.startswith("/"):
raise ValueError("API yolu '/' ile başlamalıdır.")
body = (
""
if json_body is None
else json.dumps(json_body, ensure_ascii=False, separators=(",", ":"))
)
operation_idempotency_key = idempotency_key or str(uuid.uuid4())
response: requests.Response | None = None
for auth_round in range(2):
access_token = self.auth.ensure_access_token() if authenticated else None
response = self._send_with_retries(
method=method,
path_with_query=path_with_query,
body=body,
access_token=access_token,
idempotency_key=operation_idempotency_key,
)
if response.status_code != 401 or not authenticated:
break
if auth_round == 0:
self.auth.recover_after_unauthorized()
if response is None:
raise ApiError("API isteği için yanıt alınamadı.")
data = self._decode_response(response)
if not response.ok:
raise ApiError(
f"HTTP {response.status_code}: {_error_message(response, data)}",
status_code=response.status_code,
response_data=data,
)
return ApiResponse(response.status_code, data)
def _send_with_retries(
self,
*,
method: str,
path_with_query: str,
body: str,
access_token: str | None,
idempotency_key: str,
) -> requests.Response:
for attempt in range(self.settings.max_retries + 1):
headers = create_signed_headers(
method=method,
path_with_query=path_with_query,
body=body,
secret=self.settings.hmac_secret,
accept_language=self.settings.accept_language,
idempotency_key=idempotency_key,
)
if access_token:
headers["Authorization"] = f"Bearer {access_token}"
url = f"{self.settings.base_url}{path_with_query}"
LOGGER.debug(
"HTTP request | %s",
json.dumps(
{
"attempt": attempt + 1,
"method": method,
"url": url,
"headers": _redact(headers),
"body": _request_body_for_log(body),
},
ensure_ascii=False,
default=str,
),
)
try:
response = self.session.request(
method,
url,
data=None if not body else body.encode("utf-8"),
headers=headers,
timeout=self.settings.timeout_seconds,
)
except requests.RequestException as exc:
LOGGER.debug(
"HTTP network error | %s",
json.dumps(
{
"attempt": attempt + 1,
"method": method,
"url": url,
"error": _redact_text(str(exc)),
},
ensure_ascii=False,
),
)
if attempt >= self.settings.max_retries:
raise ApiError(
f"Ağ hatası; maksimum deneme sayısına ulaşıldı: {exc}"
) from exc
self._wait_before_retry(attempt, None)
continue
LOGGER.debug(
"HTTP response | %s",
json.dumps(
{
"attempt": attempt + 1,
"method": method,
"url": url,
"status": response.status_code,
"headers": _redact(dict(response.headers)),
"body": _response_body_for_log(response),
},
ensure_ascii=False,
default=str,
),
)
if (
response.status_code in RETRYABLE_STATUS_CODES
and attempt < self.settings.max_retries
):
self._wait_before_retry(attempt, response)
continue
return response
raise ApiError("API isteği beklenmeyen biçimde tamamlanamadı.")
def _wait_before_retry(
self, attempt: int, response: requests.Response | None
) -> None:
delay = self.settings.retry_base_seconds * (2**attempt)
if response is not None and response.status_code == 429:
delay = self._retry_after_seconds(response, delay)
LOGGER.warning(
"İstek yeniden denenecek (%s/%s); %.1f saniye bekleniyor.",
attempt + 1,
self.settings.max_retries,
delay,
)
self.sleep(delay)
@staticmethod
def _retry_after_seconds(response: requests.Response, fallback: float) -> float:
raw_value = response.headers.get("Retry-After", "").strip()
if not raw_value:
return fallback
try:
return max(0.0, float(raw_value))
except ValueError:
try:
retry_at = parsedate_to_datetime(raw_value)
return max(0.0, retry_at.timestamp() - time.time())
except (TypeError, ValueError, OverflowError):
return fallback
@staticmethod
def _decode_response(response: requests.Response) -> Any:
if not response.content:
return None
try:
return response.json()
except requests.exceptions.JSONDecodeError as exc:
raise ApiError(
f"API JSON olmayan bir yanıt döndürdü (HTTP {response.status_code}).",
status_code=response.status_code,
) from exc
+110
View File
@@ -0,0 +1,110 @@
"""Environment-backed configuration shared by every service client."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlsplit
from dotenv import load_dotenv
from .errors import ConfigurationError
DEFAULT_ENV_FILE = Path(__file__).resolve().parent.parent / ".env"
def _required(name: str) -> str:
value = os.getenv(name, "").strip()
if not value:
raise ConfigurationError(f".env içinde {name} eksik veya boş.")
if value.lower() == "change-me":
raise ConfigurationError(f".env içindeki {name} placeholder değeri değiştirilmemiş.")
return value
def _positive_float(name: str, default: str) -> float:
raw_value = os.getenv(name, default).strip()
try:
value = float(raw_value)
except ValueError as exc:
raise ConfigurationError(f"{name} sayısal bir değer olmalıdır.") from exc
if value <= 0:
raise ConfigurationError(f"{name} sıfırdan büyük olmalıdır.")
return value
def _non_negative_int(name: str, default: str) -> int:
raw_value = os.getenv(name, default).strip()
try:
value = int(raw_value)
except ValueError as exc:
raise ConfigurationError(f"{name} tam sayı olmalıdır.") from exc
if value < 0:
raise ConfigurationError(f"{name} negatif olamaz.")
return value
def env_bool(name: str, default: bool = False) -> bool:
raw_value = os.getenv(name)
if raw_value is None or not raw_value.strip():
return default
normalized = raw_value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
raise ConfigurationError(f"{name} true veya false olmalıdır.")
@dataclass(frozen=True)
class Settings:
env_file: Path
base_url: str
hmac_secret: str
accept_language: str
timeout_seconds: float
token_expiry_skew_seconds: int
max_retries: int
retry_base_seconds: float
@classmethod
def from_env(cls, env_file: Path = DEFAULT_ENV_FILE) -> "Settings":
env_file = Path(env_file).resolve()
if not env_file.is_file():
raise ConfigurationError(f".env dosyası bulunamadı: {env_file}")
load_dotenv(env_file, override=True)
environment = _required("IKLIM_API_ENV").lower()
url_variables = {
"test": "IKLIM_API_TEST_URL",
"prod": "IKLIM_API_PROD_URL",
}
if environment not in url_variables:
raise ConfigurationError("IKLIM_API_ENV yalnızca test veya prod olabilir.")
base_url = _required(url_variables[environment]).rstrip("/")
parsed_url = urlsplit(base_url)
if parsed_url.scheme != "https" or not parsed_url.netloc:
raise ConfigurationError("Seçilen API adresi geçerli bir HTTPS adresi olmalıdır.")
language = os.getenv("IKLIM_ACCEPT_LANGUAGE", "en").strip().lower() or "en"
if language not in {"tr", "en"}:
raise ConfigurationError("IKLIM_ACCEPT_LANGUAGE yalnızca tr veya en olabilir.")
return cls(
env_file=env_file,
base_url=base_url,
hmac_secret=_required("IKLIM_HMAC_SECRET"),
accept_language=language,
timeout_seconds=_positive_float("IKLIM_TIMEOUT_SECONDS", "30"),
token_expiry_skew_seconds=_non_negative_int(
"IKLIM_TOKEN_EXPIRY_SKEW_SECONDS", "60"
),
max_retries=_non_negative_int("IKLIM_MAX_RETRIES", "3"),
retry_base_seconds=_positive_float("IKLIM_RETRY_BASE_SECONDS", "2"),
)
def credentials(self) -> tuple[str, str]:
return _required("IKLIM_USERNAME"), _required("IKLIM_PASSWORD")
+24
View File
@@ -0,0 +1,24 @@
"""Shared exceptions for iklim.co clients."""
from __future__ import annotations
from typing import Any
class ConfigurationError(ValueError):
"""Raised when a required local setting is missing or invalid."""
class ApiError(RuntimeError):
"""Raised after an API request fails and eligible retries are exhausted."""
def __init__(
self,
message: str,
*,
status_code: int | None = None,
response_data: Any = None,
) -> None:
super().__init__(message)
self.status_code = status_code
self.response_data = response_data
+37
View File
@@ -0,0 +1,37 @@
"""Console and rotating debug-file logging configuration."""
from __future__ import annotations
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
def configure_logging(debug_log_file: Path) -> Path:
debug_log_file = debug_log_file.resolve()
debug_log_file.parent.mkdir(parents=True, exist_ok=True)
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
root_logger.handlers.clear()
formatter = logging.Formatter(
"%(asctime)s | %(levelname)s | %(name)s | %(message)s"
)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(formatter)
file_handler = RotatingFileHandler(
debug_log_file,
maxBytes=5 * 1024 * 1024,
backupCount=3,
encoding="utf-8",
)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
root_logger.addHandler(console_handler)
root_logger.addHandler(file_handler)
logging.getLogger(__name__).info("Debug log dosyası: %s", debug_log_file)
return debug_log_file
+111
View File
@@ -0,0 +1,111 @@
"""HMAC request signing and local JWT token persistence."""
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import json
import os
import time
import uuid
from pathlib import Path
from typing import Any
from dotenv import set_key
from .errors import ApiError
def jwt_expiration(token: str) -> int:
"""Read a JWT exp claim; this does not replace server-side verification."""
try:
encoded_payload = token.split(".")[1]
padding = "=" * (-len(encoded_payload) % 4)
payload = json.loads(
base64.urlsafe_b64decode(encoded_payload + padding).decode("utf-8")
)
return int(payload["exp"])
except (
IndexError,
KeyError,
TypeError,
ValueError,
UnicodeDecodeError,
binascii.Error,
) as exc:
raise ApiError("API geçerli bir JWT token döndürmedi.") from exc
def token_is_valid(token: str | None, skew_seconds: int) -> bool:
if not token:
return False
try:
return jwt_expiration(token) > int(time.time()) + skew_seconds
except ApiError:
return False
def create_signed_headers(
*,
method: str,
path_with_query: str,
body: str,
secret: str,
accept_language: str,
idempotency_key: str,
) -> dict[str, str]:
timestamp = str(int(time.time() * 1000))
data_to_sign = f"{method.upper()}|{path_with_query}|{timestamp}|{body}"
signature = hmac.new(
secret.encode("utf-8"),
data_to_sign.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return {
"Accept": "application/json",
"Accept-Language": accept_language,
"Content-Type": "application/json",
"X-Signature": signature,
"X-Timestamp": timestamp,
"X-Nonce": str(uuid.uuid4()),
"X-Idempotency-Key": idempotency_key,
}
class TokenStore:
def __init__(self, env_file: Path) -> None:
self.env_file = env_file
@property
def access_token(self) -> str | None:
return os.getenv("IKLIM_ACCESS_TOKEN", "").strip() or None
@property
def refresh_token(self) -> str | None:
return os.getenv("IKLIM_REFRESH_TOKEN", "").strip() or None
def save(self, response_data: dict[str, Any]) -> None:
access_token = response_data.get("accessToken")
refresh_token = response_data.get("refreshToken")
if not isinstance(access_token, str) or not access_token:
raise ApiError("Kimlik doğrulama yanıtında accessToken bulunamadı.")
if not isinstance(refresh_token, str) or not refresh_token:
raise ApiError("Kimlik doğrulama yanıtında refreshToken bulunamadı.")
values = {
"IKLIM_ACCESS_TOKEN": access_token,
"IKLIM_REFRESH_TOKEN": refresh_token,
"IKLIM_ACCESS_TOKEN_EXPIRES_AT": str(jwt_expiration(access_token)),
"IKLIM_REFRESH_TOKEN_EXPIRES_AT": str(jwt_expiration(refresh_token)),
"IKLIM_TOKENS_UPDATED_AT": str(int(time.time())),
}
for key, value in values.items():
set_key(self.env_file, key, value, quote_mode="never")
os.environ[key] = value
def set_webhook_initialized(self, initialized: bool) -> None:
value = "true" if initialized else "false"
set_key(self.env_file, "IKLIM_WEBHOOK_INITIALIZED", value, quote_mode="never")
os.environ["IKLIM_WEBHOOK_INITIALIZED"] = value