diff --git a/README.md b/README.md index c1cca3c..a3b0644 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,28 @@ Exception hierarchy: - `InsforgeValidationError` - Pydantic validation failures - `InsforgeSerializationError` - serialization failures +## Logging + +The SDK uses Python's built-in `logging` module under the `insforge` logger. By default no logs are emitted. Call `setup_logging` to enable output: + +```python +import insforge + +# INFO — SDK initialization details and important operation results +insforge.setup_logging("INFO") + +# DEBUG — full HTTP request/response (method, URL, params, status code) +insforge.setup_logging("DEBUG") +``` + +You can also configure the `insforge` logger directly with the standard `logging` module for more advanced setups: + +```python +import logging + +logging.getLogger("insforge").setLevel(logging.DEBUG) +``` + ## Authentication Model The SDK is **stateless** - it never stores or caches tokens. Every method that requires user auth takes an explicit `access_token` parameter. The API key is sent as `X-API-Key` on every request automatically. diff --git a/insforge/__init__.py b/insforge/__init__.py index dda0873..b4865fe 100644 --- a/insforge/__init__.py +++ b/insforge/__init__.py @@ -1,4 +1,5 @@ from .client import InsforgeClient +from ._logging import setup_logging -__all__ = ["InsforgeClient"] +__all__ = ["InsforgeClient", "setup_logging"] diff --git a/insforge/_base_client.py b/insforge/_base_client.py index af5638c..de07099 100644 --- a/insforge/_base_client.py +++ b/insforge/_base_client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from typing import Any from typing import Mapping from urllib.parse import urlsplit @@ -8,6 +9,40 @@ from .exceptions import InsforgeHTTPError from ._utils import normalize_base_url +from ._version import VERSION, USER_AGENT + +logger = logging.getLogger("insforge") + +_SENSITIVE_KEYS = frozenset({ + "password", + "new_password", + "newPassword", + "token", + "otp", + "code", + "access_token", + "accessToken", + "refresh_token", + "refreshToken", + "api_key", + "apiKey", +}) + +_REDACTED = "***" + + +def _sanitize_body(body: Any) -> Any: + """Return a copy of *body* with sensitive values replaced by ``'***'``.""" + if body is None: + return None + if isinstance(body, dict): + return { + k: _REDACTED if k in _SENSITIVE_KEYS else _sanitize_body(v) + for k, v in body.items() + } + if isinstance(body, list): + return [_sanitize_body(item) for item in body] + return body def build_headers( @@ -15,8 +50,8 @@ def build_headers( access_token: str | None = None, extra_headers: Mapping[str, str] | None = None, ) -> dict[str, str]: - headers: dict[str, str] = {"X-API-Key": api_key} - reserved_headers = {"authorization", "x-api-key"} + headers: dict[str, str] = {"X-API-Key": api_key, "User-Agent": USER_AGENT} + reserved_headers = {"authorization", "x-api-key", "user-agent"} if access_token: headers["Authorization"] = f"Bearer {access_token}" @@ -33,6 +68,7 @@ def __init__(self, base_url: str, api_key: str) -> None: self.base_url = normalize_base_url(base_url) self.api_key = api_key self.http_client = httpx.AsyncClient() + logger.info("InsforgeClient initialized (version=%s, base_url=%s)", VERSION, self.base_url) def _build_headers( self, @@ -125,9 +161,12 @@ async def _request( extra_headers: Mapping[str, str] | None = None, exception_cls: type[InsforgeHTTPError] = InsforgeHTTPError, ) -> httpx.Response: + url = self._build_url(path) + logger.debug(">>> %s %s params=%s body=%s", method, url, params, _sanitize_body(json)) + response = await self.http_client.request( method, - self._build_url(path), + url, params=params, json=json, headers=self._build_headers( @@ -136,6 +175,8 @@ async def _request( ), ) + logger.debug("<<< %s %s status=%d", method, url, response.status_code) + if response.is_error: raise exception_cls.from_response(method, path, response) diff --git a/insforge/_logging.py b/insforge/_logging.py new file mode 100644 index 0000000..9c264b6 --- /dev/null +++ b/insforge/_logging.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import logging + + +def setup_logging(level: int | str = logging.WARNING) -> None: + """Configure the ``insforge`` logger. + + Parameters + ---------- + level: + Logging level. Accepts standard :mod:`logging` constants + (``logging.DEBUG``, ``logging.INFO``, …) or their string names + (``"DEBUG"``, ``"INFO"``, …). + + Examples + -------- + Show SDK configuration and important operation results:: + + import insforge + insforge.setup_logging("INFO") + + Show full HTTP request / response details for debugging:: + + import insforge + insforge.setup_logging("DEBUG") + """ + logger = logging.getLogger("insforge") + logger.setLevel(level) + + if not logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter( + logging.Formatter("[%(name)s %(levelname)s] %(message)s"), + ) + logger.addHandler(handler) diff --git a/insforge/_version.py b/insforge/_version.py new file mode 100644 index 0000000..527d936 --- /dev/null +++ b/insforge/_version.py @@ -0,0 +1,8 @@ +from importlib.metadata import version, PackageNotFoundError + +try: + VERSION = version("insforge") +except PackageNotFoundError: + VERSION = "0.0.0-dev" + +USER_AGENT = f"InsForge-Python/{VERSION}" diff --git a/insforge/storage/client.py b/insforge/storage/client.py index b72de14..a41402f 100644 --- a/insforge/storage/client.py +++ b/insforge/storage/client.py @@ -6,8 +6,12 @@ import httpx +import logging + from .._base_client import BaseClient from ..exceptions import InsforgeHTTPError + +logger = logging.getLogger("insforge") from .._utils import quote_path_segment from .models import DownloadStrategy from .models import StorageBucketListResponse @@ -130,9 +134,12 @@ async def upload_object( extra_headers: Mapping[str, str] | None = None, ) -> StorageObjectResponse: path = self._object_url_path(bucket_name, object_key) + url = self._client._build_url(path) + logger.debug(">>> PUT %s file=%s size=%d", url, object_key, len(data)) + response = await self._client.http_client.request( "PUT", - self._client._build_url(path), + url, files={"file": (object_key, data, content_type or "application/octet-stream")}, headers=self._client._build_headers( access_token=access_token, @@ -140,6 +147,8 @@ async def upload_object( ), ) + logger.debug("<<< PUT %s status=%d", url, response.status_code) + if response.is_error: raise InsforgeHTTPError.from_response( "PUT", @@ -158,15 +167,20 @@ async def download_object( extra_headers: Mapping[str, str] | None = None, ) -> StorageDownloadResult: path = self._object_url_path(bucket_name, object_key) + url = self._client._build_url(path) + logger.debug(">>> GET %s", url) + response = await self._client.http_client.request( "GET", - self._client._build_url(path), + url, headers=self._client._build_headers( access_token=access_token, extra_headers=extra_headers, ), ) + logger.debug("<<< GET %s status=%d", url, response.status_code) + if response.is_error: raise InsforgeHTTPError.from_response( "GET", @@ -214,13 +228,18 @@ async def upload_object_auto( access_token: str | None = None, ) -> StorageObjectResponse: path = f"/api/storage/buckets/{quote_path_segment(bucket_name)}/objects" + url = self._client._build_url(path) + logger.debug(">>> POST %s file=%s size=%d", url, filename, len(data)) + response = await self._client.http_client.request( "POST", - self._client._build_url(path), + url, files={"file": (filename, data, content_type or "application/octet-stream")}, headers=self._client._build_headers(access_token=access_token), ) + logger.debug("<<< POST %s status=%d", url, response.status_code) + if response.is_error: raise InsforgeHTTPError.from_response("POST", path, response)