Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cce5bd3
Add Base API Clients for building authenticated HTTP clients
MichelEdkrantz Feb 10, 2026
22d2475
Improve serde: recursive serialization, to_json method, split tests
MichelEdkrantz Feb 10, 2026
33f9a72
Add Pydantic v2 support and improve type checking
MichelEdkrantz Feb 10, 2026
35021e3
Add serialization examples to README
MichelEdkrantz Feb 10, 2026
2094bb1
Use uv for CI instead of pip
MichelEdkrantz Feb 10, 2026
e9686e5
fix publish flow to publish release candidate
MichelEdkrantz Feb 10, 2026
97c12d5
Add overridable json_serializer parameter to base clients
MichelEdkrantz Feb 11, 2026
6df5be7
interface polishing
MichelEdkrantz Feb 11, 2026
0fb659a
Fix sunset UTC handling, add credentials tests, polish interfaces
MichelEdkrantz Feb 11, 2026
5af9793
Fix sync/async asymmetries: path validation, docstrings, unused impor…
MichelEdkrantz Feb 12, 2026
78c6521
Replace os.unlink with Path.unlink in credential parser tests
MichelEdkrantz Feb 12, 2026
e28bde6
Rename CLI --config to --env-config-file-path
MichelEdkrantz Feb 12, 2026
eea9fa2
Rename DEFAULT_CONFIG_PATH to DEFAULT_ENV_CONFIG_FILE_PATH
MichelEdkrantz Feb 12, 2026
4716fbd
Fix test_valid_config to use correct config keys (contexts/default_co…
MichelEdkrantz Feb 12, 2026
99a53cf
Rename config file keys from contexts/default_context to environments…
MichelEdkrantz Feb 12, 2026
9b36b63
Accept PathLike in all path-accepting interfaces
MichelEdkrantz Feb 12, 2026
a37c170
Add .json suffix check for PathLike auth credentials
MichelEdkrantz Feb 12, 2026
779e791
Include path in credentials file validation error message
MichelEdkrantz Feb 12, 2026
da8fd6b
Configure CLI logging to write warnings to stderr
MichelEdkrantz Feb 12, 2026
6978ba2
Rename default config file from config.json to environments.json
MichelEdkrantz Feb 13, 2026
4320f18
Use Self return type on base clients and validate CLI input before auth
MichelEdkrantz Feb 13, 2026
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
17 changes: 7 additions & 10 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,13 @@ jobs:
with:
# needed for setuptools_scm to work
fetch-depth: 0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: 'pyproject.toml'
enable-cache: true
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip pytest
python -m pip install ".[full]"
run: uv sync --all-extras
- name: Test with pytest
run: |
pytest tests/*
run: uv run pytest tests/
8 changes: 4 additions & 4 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@

name: Upload Python Package

on:
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
tags:
- 'v[0-9]+.[0-9]+.[0-9]+*'

permissions:
contents: read
Expand All @@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v6
with:
# needed for setuptools_scm to work
fetch-depth: 0
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@

**/key.json
src/kognic/auth/_version.py
.claude
.claude/
150 changes: 128 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,18 @@ sess.get("https://api.app.kognic.com")
The package provides a command-line interface for generating access tokens and making authenticated API calls.
This is great for LLM use cases, the `kognic-auth call` is a lightweight curl, that hides any complexity of authentication and context management,
so you can just focus on the API call you want to make. This also avoids tokens being leaked to the shell history,
as you can use named contexts and config files to manage your credentials.
as you can use named environments and config files to manage your credentials.

The interface is currently marked experimental, and breaking changes may be made without a major version bump. Feedback is welcome to help stabilize the design.

### Configuration file

The CLI can be configured with a JSON file at `~/.config/kognic/config.json`. This lets you define named contexts for different environments, each with its own host, auth server, and credentials.
The CLI can be configured with a JSON file at `~/.config/kognic/environments.json`. This lets you define named environments, each with its own host, auth server, and credentials.

```json
{
"default_context": "production",
"contexts": {
"default_environment": "production",
"environments": {
"production": {
"host": "app.kognic.com",
"auth_server": "https://auth.app.kognic.com",
Expand All @@ -60,28 +60,28 @@ The CLI can be configured with a JSON file at `~/.config/kognic/config.json`. Th
}
```

Each context has the following fields:
- `host` - The API hostname, used by `call` to automatically match a context based on the request URL.
Each environment has the following fields:
- `host` - The API hostname, used by `call` to automatically match an environment based on the request URL.
- `auth_server` - The OAuth server URL used to fetch tokens.
- `credentials` *(optional)* - Path to a JSON credentials file. Tilde (`~`) is expanded. If omitted, credentials are read from environment variables.

`default_context` specifies which context to use as a fallback when no `--context` flag is given and no URL match is found.
`default_environment` specifies which environment to use as a fallback when no `--env` flag is given and no URL match is found.

### get-access-token

Generate an access token for Kognic API authentication.

```bash
kognic-auth get-access-token [--server SERVER] [--credentials FILE] [--context NAME] [--config FILE]
kognic-auth get-access-token [--server SERVER] [--credentials FILE] [--env NAME] [--env-config-file-path FILE]
```

**Options:**
- `--server` - Authentication server URL (default: `https://auth.app.kognic.com`)
- `--credentials` - Path to JSON credentials file. If not provided, credentials are read from environment variables.
- `--context` - Use a named context from the config file.
- `--config` - Config file path (default: `~/.config/kognic/config.json`)
- `--env` - Use a named environment from the config file.
- `--env-config-file-path` - Environment config file path (default: `~/.config/kognic/environments.json`)

When `--context` is provided, the auth server and credentials are resolved from the config file. Explicit `--server` or `--credentials` flags override the context values.
When `--env` is provided, the auth server and credentials are resolved from the config file. Explicit `--server` or `--credentials` flags override the environment values.

**Examples:**
```bash
Expand All @@ -91,19 +91,19 @@ kognic-auth get-access-token
# Using a credentials file
kognic-auth get-access-token --credentials ~/.config/kognic/credentials.json

# Using a named context
kognic-auth get-access-token --context demo
# Using a named environment
kognic-auth get-access-token --env demo

# Using a context but overriding the server
kognic-auth get-access-token --context demo --server https://custom.server
# Using an environment but overriding the server
kognic-auth get-access-token --env demo --server https://custom.server
```

### call

Make an authenticated HTTP request to a Kognic API.

```bash
kognic-auth call URL [-X METHOD] [-d DATA] [-H HEADER] [--format FORMAT] [--context NAME] [--config FILE]
kognic-auth call URL [-X METHOD] [-d DATA] [-H HEADER] [--format FORMAT] [--env NAME] [--env-config-file-path FILE]
```

**Options:**
Expand All @@ -112,18 +112,18 @@ kognic-auth call URL [-X METHOD] [-d DATA] [-H HEADER] [--format FORMAT] [--cont
- `-d`, `--data` - Request body (JSON string)
- `-H`, `--header` - Header in `Key: Value` format (repeatable)
- `--format` - Output format (default: `json`). See [Output formats](#output-formats) below.
- `--context` - Force a specific context (skip URL-based matching)
- `--config` - Config file path (default: `~/.config/kognic/config.json`)
- `--env` - Force a specific environment (skip URL-based matching)
- `--env-config-file-path` - Environment config file path (default: `~/.config/kognic/environments.json`)

When `--context` is not provided, the context is automatically resolved by matching the request URL's hostname against the `host` field of each context in the config file.
When `--env` is not provided, the environment is automatically resolved by matching the request URL's hostname against the `host` field of each environment in the config file.

**Examples:**
```bash
# GET request (default method), context auto-resolved from URL hostname
# GET request (default method), environment auto-resolved from URL hostname
kognic-auth call https://app.kognic.com/v1/projects

# Explicit context
kognic-auth call https://demo.kognic.com/v1/projects --context demo
# Explicit environment
kognic-auth call https://demo.kognic.com/v1/projects --env demo

# POST with JSON body
kognic-auth call https://app.kognic.com/v1/projects -X POST -d '{"name": "test"}'
Expand Down Expand Up @@ -164,5 +164,111 @@ kognic-auth call https://app.kognic.com/v1/projects --format=table
- `0` - Success (HTTP 2xx)
- `1` - Error (HTTP error, missing credentials, invalid input, etc.)

## Base API Clients

For building API clients that need authenticated HTTP requests, use the base clients.
These provide a `requests`/`httpx`-compatible interface with enhancements:

- OAuth2 authentication with automatic token refresh
- Automatic JSON serialization for jsonable objects
- Retry logic for transient errors (502, 503, 504)
- Sunset header handling (logs warnings for deprecated endpoints)
- Enhanced error messages with response body details

### Sync Client (requests)

```python
from kognic.auth.requests import BaseApiClient

class MyApiClient(BaseApiClient):
def get_resource(self, resource_id: str):
response = self.session.get(f"https://api.app.kognic.com/v1/resources/{resource_id}")
return response.json()

# Usage with environment variables
client = MyApiClient()

# Or with explicit credentials
client = MyApiClient(auth=("my-client-id", "my-client-secret"))

# Or with credentials file
client = MyApiClient(auth="~/.config/kognic/credentials.json")
```

### Async Client (httpx)

```python
from kognic.auth.httpx import BaseAsyncApiClient

class MyAsyncApiClient(BaseAsyncApiClient):
async def get_resource(self, resource_id: str):
session = await self.session
response = await session.get(f"https://api.app.kognic.com/v1/resources/{resource_id}")
return response.json()

# Usage as async context manager
async with MyAsyncApiClient() as client:
resource = await client.get_resource("123")
```

## Serialization & Deserialization

The `kognic.auth.serde` module provides utilities for serializing request bodies and deserializing responses.

### Serialization

`serialize_body()` converts objects to JSON-compatible dicts. Supports:
- Pydantic v2 models (`model_dump()`)
- Objects with `to_json()` or `to_dict()` methods
- Nested objects in dicts/lists are recursively serialized

```python
from pydantic import BaseModel
from kognic.auth.serde import serialize_body

class CreateRequest(BaseModel):
name: str
value: int

# Pydantic models
request = CreateRequest(name="test", value=42)
serialize_body(request) # {"name": "test", "value": 42}

# Nested in containers
serialize_body({"items": [request]}) # {"items": [{"name": "test", "value": 42}]}

# Custom classes with to_dict()
class MyModel:
def to_dict(self):
return {"key": "value"}

serialize_body(MyModel()) # {"key": "value"}
```

### Deserialization

`deserialize()` extracts and converts API responses. Supports:
- Pydantic v2 models (`model_validate()`)
- Classes with `from_dict()` or `from_json()` methods
- Automatic envelope extraction (default key: `"data"`)

```python
from kognic.auth.serde import deserialize

# Deserialize to Pydantic model
response = client.session.get("https://api.app.kognic.com/v1/resource/123")
resource = deserialize(response, cls=ResourceModel)

# Deserialize list of models
response = client.session.get("https://api.app.kognic.com/v1/resources")
resources = deserialize(response, cls=list[ResourceModel])

# Custom envelope key
data = deserialize(response, cls=MyModel, enveloped_key="result")

# No envelope
data = deserialize(response, cls=MyModel, enveloped_key=None)
```

## Changelog
See Github releases from v3.1.0, historic changelog is available in CHANGELOG.md
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ homepage = "https://github.com/annotell/kognic-auth-python"

[project.scripts]
kognic-auth = "kognic.auth.cli:main"
kog = "kognic.auth.cli.api_request:main"

[tool.setuptools_scm]
write_to = "src/kognic/auth/_version.py"
Expand All @@ -50,12 +51,13 @@ full = [
dev = [
"httpx>=0.20,<1",
"requests>=2.20,<3",
"pydantic>=2",
"pytest",
]

[tool.ruff]
line-length = 120
target-version = "py38"
target-version = "py39"

[tool.ruff.lint]
select = ["E", "F", "B", "W", "I001", "PTH"]
31 changes: 31 additions & 0 deletions src/kognic/auth/_protocols.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Protocol definitions for URL, Request, and Response types."""

from typing import Dict, Protocol, Union, runtime_checkable


@runtime_checkable
class Url(Protocol):
"""Protocol for URL objects (httpx URL)."""

scheme: str
host: str
path: str


@runtime_checkable
class Request(Protocol):
"""Protocol for HTTP request objects."""

method: str
url: Union[str, Url]


@runtime_checkable
class Response(Protocol):
"""Protocol for HTTP response objects."""

headers: Dict[str, str]
request: Request

def json(self) -> dict:
raise NotImplementedError
55 changes: 55 additions & 0 deletions src/kognic/auth/_sunset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Sunset header handling for deprecated API endpoints."""

import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Optional, Union

if TYPE_CHECKING:
from ._protocols import Response, Url

SUNSET_HEADER = "sunset-date"
SUNSET_DIFF_THRESHOLD = 14 * 60 * 60 * 24 # two weeks

# Expected formats of sunset date
DATETIME_FMT = "%Y-%m-%dT%H:%M:%S.%fZ"
DATETIME_FMT_NO_MICRO = "%Y-%m-%dT%H:%M:%SZ"

logger = logging.getLogger(__name__)


def handle_sunset(response: "Response") -> None:
"""Check for Sunset header and log warnings/errors.

Args:
response: The HTTP response object (requests.Response or httpx.Response)
"""
sunset_string = response.headers.get(SUNSET_HEADER)
sunset_date = _parse_date(sunset_string) if sunset_string else None
if not sunset_date:
return None

now = datetime.now(tz=timezone.utc)
diff = sunset_date - now

log_method = logger.warning if diff.total_seconds() > SUNSET_DIFF_THRESHOLD else logger.error
log_method(
f"Endpoint has been deprecated and will be removed at {sunset_date}. Please update your client. "
f"Endpoint: {response.request.method} {_parse_url(response.request.url)}"
)


def _parse_date(date: str) -> Optional[datetime]:
"""Parse sunset date string to UTC datetime."""
for fmt in (DATETIME_FMT, DATETIME_FMT_NO_MICRO):
try:
return datetime.strptime(date, fmt).replace(tzinfo=timezone.utc)
except ValueError:
continue
return None


def _parse_url(url: Union[str, "Url"]) -> str:
"""Extract clean URL without query parameters."""
if isinstance(url, str):
return url.split("?")[0]
return f"{url.scheme}://{url.host}{url.path}"
Loading