Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion insforge/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .client import InsforgeClient
from ._logging import setup_logging

__all__ = ["InsforgeClient"]
__all__ = ["InsforgeClient", "setup_logging"]

47 changes: 44 additions & 3 deletions insforge/_base_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import logging
from typing import Any
from typing import Mapping
from urllib.parse import urlsplit
Expand All @@ -8,15 +9,49 @@

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(
api_key: str,
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}"
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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)

Expand Down
36 changes: 36 additions & 0 deletions insforge/_logging.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 8 additions & 0 deletions insforge/_version.py
Original file line number Diff line number Diff line change
@@ -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}"
25 changes: 22 additions & 3 deletions insforge/storage/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -130,16 +134,21 @@ 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,
extra_headers=extra_headers,
),
)

logger.debug("<<< PUT %s status=%d", url, response.status_code)

if response.is_error:
raise InsforgeHTTPError.from_response(
"PUT",
Expand All @@ -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",
Expand Down Expand Up @@ -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)

Expand Down
Loading