Metadata-Version: 2.4
Name: ouroboros_api
Version: 1.0.0
Summary: Universal, lightweight HTTP API wrapper with unified response handling, structured error diagnostics, and configurable JSON decoding strategies.
Author: Flavio Brandolini
License: Copyright (c) 2026 Flavio Brandolini
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
Keywords: api,http,rest,client,requests,wrapper,json,automation
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: License :: OSI Approved :: MIT License
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.34.2
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == "test"
Provides-Extra: dev
Requires-Dist: ouroboros_api[test]; extra == "dev"
Requires-Dist: ruff; extra == "dev"
Requires-Dist: mypy; extra == "dev"
Dynamic: license-file

# ouroboros_api

A universal, lightweight HTTP API wrapper with unified response handling, structured error diagnostics, and configurable JSON decoding strategies.

---

## Features

- **Unified response object** — every call returns `APIResponse`, no raw exceptions leak
- **Intelligent JSON handling** — `expect_json=True | False | "auto"`
- **Full HTTP verb coverage** — GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
- **Generic `request()` method** — for arbitrary HTTP verbs (LOCK, PROPFIND, etc.)
- **Retry with backoff** — `request_with_retry()` for transient failure recovery
- **File download** — streaming `download_file()` with chunk-based I/O
- **Health check** — lightweight `health_check()` for endpoint availability
- **Auth helpers** — Bearer, Basic, API Key header builders
- **URL builder** — safe path join with slash normalization
- **Structured errors** — `APIErrorDetail` with UTC timestamps
- **Exception hierarchy** — typed exceptions with `raise_for_error()` integration
- **Fully typed** — PEP 561 `py.typed`, type aliases, NumPy-style docstrings

---

## Installation

```bash
pip install ouroboros_api
```

Or install from source:

```bash
pip install -e .
```

With test dependencies:

```bash
pip install -e ".[test]"
```

---

## Quick Start

```python
from ouroboros_api import BaseAPI

resp = BaseAPI.make_get_request("https://api.example.com/v1/users")
if resp:  # APIResponse supports bool() — equivalent to resp.ok
    print("Users:", resp.data)
else:
    print("Error:", resp.error)
```

---

## Architecture

```
ouroboros_api/
├── __init__.py          # Public API exports
├── base.py              # BaseAPI facade (composes AuthMixin + VerbMixin)
├── _types.py            # Type aliases (JSONMode, Headers, Cookies, etc.)
├── _response.py         # APIResponse + APIErrorDetail
├── _auth.py             # AuthMixin: Bearer, Basic, API Key, URL join
├── _client.py           # ClientMixin: request dispatcher, retry, headers
├── _verbs.py            # VerbMixin: HTTP verb methods + download + health
├── exceptions.py        # Exception hierarchy
├── py.typed             # PEP 561 typed marker
└── tests/               # Unit tests (106 tests)
```

---

## API Reference — BaseAPI

All methods are `@staticmethod` — no instance required.

### Authentication Helpers

| Method | Description | Returns |
|--------|-------------|---------|
| `build_bearer_token(token)` | Format `Authorization: Bearer` header value | `str` |
| `build_basic_auth(username, password)` | Base64-encode `Authorization: Basic` header value | `str` |
| `build_api_key_header(key, header_name="X-API-Key")` | Build a single-entry header dict for API key auth | `dict[str, str]` |
| `join_url(base, *parts)` | Safely join base URL with path segments (slash-safe) | `str` |

```python
# Bearer token
token = BaseAPI.build_bearer_token("eyJhbGci...")
resp = BaseAPI.make_get_request(url, auth_token=token)

# Basic auth
auth = BaseAPI.build_basic_auth("admin", "secret")
resp = BaseAPI.make_get_request(url, auth_token=auth)

# API key
headers = BaseAPI.build_api_key_header("my-key-123")
resp = BaseAPI.make_get_request(url, headers_param=headers)

# URL builder
url = BaseAPI.join_url("https://api.example.com/v1", "users", "42")
# → "https://api.example.com/v1/users/42"
```

### HTTP Verb Methods

| Method | Default Success Statuses | Notes |
|--------|--------------------------|-------|
| `make_get_request(url, ...)` | 200, 201, 202, 203, 204, 304 | Standard GET |
| `make_post_request(url, ...)` | 200, 201, 202, 204 | Supports `payload`, `data`, `files` |
| `make_put_request(url, ...)` | 200, 201, 202, 204 | Full resource replacement |
| `make_patch_request(url, ...)` | 200, 201, 202, 204 | Partial resource update |
| `make_delete_request(url, ...)` | 200, 202, 204 | Resource deletion |
| `make_head_request(url, ...)` | 200, 204, 301, 302, 304 | Metadata only, no body |
| `make_options_request(url, ...)` | 200, 204 | CORS pre-flight / allowed methods |

**Common parameters** (all verb methods):

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `url` | `str` | — | Fully-qualified request URL |
| `auth_token` | `str` | `""` | `Authorization` header value |
| `headers_param` | `Mapping[str, str] \| None` | `None` | Additional headers |
| `cookies_param` | `dict[str, str] \| None` | `None` | Request cookies |
| `params` | `dict[str, Any] \| None` | `None` | Query-string parameters |
| `expect_json` | `bool \| "auto"` | `True` | JSON decoding strategy |
| `success_statuses` | `Sequence[int] \| None` | varies | Custom success codes |
| `timeout` | `float \| tuple[float, float]` | `10` | Timeout in seconds |
| `verify` | `bool` | `True` | SSL certificate verification |

**Body parameters** (POST, PUT, PATCH only):

| Parameter | Type | Description |
|-----------|------|-------------|
| `payload` | `Any` | JSON-serializable body (sets `Content-Type: application/json`) |
| `data` | `Any` | Raw or form-encoded body |
| `files` | `Any` | Multipart file-upload mapping |

> **Precedence**: When multiple body params are supplied: `files > data > payload`.

### Generic Request

```python
# Arbitrary HTTP verbs
resp = BaseAPI.request("LOCK", "https://dav.example.com/file.txt")

# All keyword args from verb methods are available
resp = BaseAPI.request("POST", url, payload={"key": "value"}, timeout=30)
```

### Retry

```python
resp = BaseAPI.request_with_retry(
    "GET",
    "https://api.example.com/v1/data",
    retries=3,              # max attempts (default: 3)
    retry_delay=1.0,        # seconds between retries (default: 1.0)
    retry_on_statuses=(408, 429, 500, 502, 503, 504),  # default
)
```

### File Download

```python
resp = BaseAPI.download_file(
    "https://cdn.example.com/report.pdf",
    "/tmp/report.pdf",
    auth_token=token,
    chunk_size=8192,        # bytes per chunk (default: 8192)
    timeout=60,             # default: 60
)
if resp.ok:
    print(f"Saved to: {resp.data}")  # data contains local file path
```

### Health Check

```python
if BaseAPI.health_check("https://api.example.com/health"):
    print("Service is up")
else:
    print("Service is down")
```

---

## APIResponse

Every HTTP call returns an `APIResponse` dataclass.

### Fields

| Field | Type | Description |
|-------|------|-------------|
| `ok` | `bool` | `True` if status is in success codes and body satisfies `expect_json` |
| `status` | `int` | HTTP status code (`0` when request could not be sent) |
| `data` | `Any` | Parsed JSON, raw `bytes`, or `None` on error |
| `error` | `str \| None` | Multi-line error report (`None` on success) |
| `raw_text` | `str` | Full response body as text |
| `raw_bytes` | `bytes` | Original binary payload |
| `headers` | `dict[str, str]` | Response headers |
| `elapsed` | `float \| None` | Wall-clock request time in seconds |

### Properties & Methods

| Name | Returns | Description |
|------|---------|-------------|
| `bool(resp)` | `bool` | Shorthand for `resp.ok` |
| `resp.is_json` | `bool` | `True` if `data` is `dict` or `list` |
| `resp.json_data` | `dict \| list \| None` | Returns `data` if JSON, else `None` |
| `resp.content_type` | `str` | `Content-Type` header (empty if missing) |
| `resp.content_length` | `int` | `Content-Length` header (`0` if missing) |
| `resp.raise_for_error()` | `None` | Raises typed exception if `ok is False` |

---

## JSON Decoding Strategies

| `expect_json` | Behaviour |
|---------------|-----------|
| `True` *(default)* | JSON mandatory — non-JSON body is treated as an error |
| `False` | Raw bytes returned in `data` — no JSON parsing |
| `"auto"` | Try JSON first, silently fall back to raw bytes |

```python
# Strict JSON (default)
resp = BaseAPI.make_get_request(url)

# Raw binary download
resp = BaseAPI.make_get_request(url, expect_json=False)
print(type(resp.data))  # <class 'bytes'>

# Auto-detect
resp = BaseAPI.make_delete_request(url, expect_json="auto")
if resp.is_json:
    print(resp.json_data)
```

---

## Error Handling

### Pattern 1: Check `.ok`

```python
resp = BaseAPI.make_post_request(url, payload={"key": "value"})
if not resp.ok:
    print(resp.error)  # multi-line formatted error report
```

### Pattern 2: Raise Exceptions

```python
from ouroboros_api import APIResponseError, APITimeoutError, APIConnectionError

try:
    resp = BaseAPI.make_post_request(url, payload={"key": "value"})
    resp.raise_for_error()
except APITimeoutError:
    print("Request timed out")
except APIConnectionError:
    print("Cannot reach server")
except APIResponseError as e:
    print(f"API error (HTTP {e.status_code}): {e}")
```

### Exception Hierarchy

```
APIError
├── APIRequestError          ← network / transport failures
│   ├── APITimeoutError      ← connect or read timeout
│   └── APIConnectionError   ← DNS / TCP / TLS failures
└── APIResponseError         ← HTTP response received but invalid / unexpected
```

All exceptions have a `status_code` attribute (except `APIError` base).

---

## Type Aliases

Importable from `ouroboros_api` for use in your own type hints:

| Alias | Definition | Description |
|-------|------------|-------------|
| `JSONMode` | `bool \| Literal["auto"]` | JSON decoding strategy |
| `Headers` | `Mapping[str, str] \| None` | HTTP headers |
| `Cookies` | `dict[str, str] \| None` | Cookie jar |
| `QueryParams` | `dict[str, Any] \| None` | Query parameters |
| `TimeoutType` | `float \| tuple[float, float]` | Timeout value |
| `SuccessStatuses` | `Sequence[int] \| None` | Success status codes |

```python
from ouroboros_api import Headers, TimeoutType

def fetch(url: str, headers: Headers = None, timeout: TimeoutType = 10):
    return BaseAPI.make_get_request(url, headers_param=headers, timeout=timeout)
```

---

## Timeout

```python
# Single timeout (connect + read)
resp = BaseAPI.make_get_request(url, timeout=30)

# Tuple timeout (connect, read) — independent control
resp = BaseAPI.make_get_request(url, timeout=(5, 30))
```

---

## Multipart Upload

```python
resp = BaseAPI.make_post_request(
    "https://api.example.com/upload",
    files={"file": open("report.zip", "rb")},
)
```

> `Content-Type` is automatically set by `requests` with the multipart boundary.

---

## Running Tests

```bash
pip install -e ".[test]"
python -m pytest tests/ -v
```

---

## License

This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

---

## Authors

Flavio Brandolini
