Metadata-Version: 2.4
Name: keychains
Version: 0.1.11
Summary: Python SDK for Keychains.dev — credential proxy for API calls
Project-URL: Homepage, https://keychains.dev
Project-URL: Repository, https://github.com/keychains-dev/keychains-python
Project-URL: Documentation, https://keychains.dev/docs/python
Author: Keychains.dev
License-Expression: MIT
Keywords: api,credentials,keychains,oauth,proxy
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
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: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: python-dotenv>=1.0
Provides-Extra: test
Requires-Dist: anyio[trio]>=4; extra == 'test'
Requires-Dist: pytest-anyio>=0.0.0; extra == 'test'
Requires-Dist: pytest>=8; extra == 'test'
Description-Content-Type: text/markdown

# keychains

Minimal Python SDK for making authenticated API calls through the [Keychains.dev](https://keychains.dev) proxy. Your code never touches real credentials — the proxy injects them at runtime.

## Quickstart

### 1. Install

```bash
pip install keychains
```

### 2. Run with a fresh token

The `keychains token` command registers your machine (if needed), creates a wildcard permission, and mints a short-lived proxy token — all in one step:

```bash
KEYCHAINS_TOKEN=$(npx -y keychains token) \
  python your_script.py
```

### 3. Write your script

Use `keychains.get()` as a drop-in replacement for `requests.get()`. The only difference? You can replace any credential with a template variable:

```python
import keychains

# Gmail — get last 10 emails from my inbox
response = keychains.get(
    "https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=10",
    headers={
        "Authorization": "Bearer {{OAUTH2_ACCESS_TOKEN}}",
    },
)

emails = response.json()
print(emails)
```

That's it. The proxy resolves `{{OAUTH2_ACCESS_TOKEN}}` with the user's real Google OAuth token — your code never sees it.

### Running multiple scripts

Tokens expire after **15 minutes**. To reuse the same token across multiple commands in a shell session, use `eval`:

```bash
eval $(npx -y keychains token --env)
# KEYCHAINS_TOKEN is now set for the next 15 minutes
python script_a.py
python script_b.py
```

---

## Template Variables

### How to write them

Template variables use the `{{VARIABLE_NAME}}` syntax. The variable name tells the proxy which type of credential to inject:

| Prefix | Type | Supported Variables |
|--------|------|---------------------|
| `OAUTH2_` | OAuth 2.0 token | `{{OAUTH2_ACCESS_TOKEN}}`, `{{OAUTH2_REFRESH_TOKEN}}` |
| `OAUTH1_` | OAuth 1.0 token | `{{OAUTH1_ACCESS_TOKEN}}`, `{{OAUTH1_REQUEST_TOKEN}}` |
| Anything else | API key | `{{LIFX_PERSONAL_ACCESS_TOKEN}}`, `{{OPENAI_API_KEY}}`, etc. |

### Where to put them

Place them exactly where you'd normally put the real credential — **headers**, **body**, or **query parameters**:

```python
import keychains

# In a header (most common)
response = keychains.get(
    "https://api.lifx.com/v1/lights/all",
    headers={"Authorization": "Bearer {{LIFX_PERSONAL_ACCESS_TOKEN}}"},
)

# In the request body
response = keychains.post(
    "https://slack.com/api/chat.postMessage",
    headers={
        "Authorization": "Bearer {{OAUTH2_ACCESS_TOKEN}}",
        "Content-Type": "application/json",
    },
    json={"channel": "#general", "text": "Hello!"},
)

# In query parameters
response = keychains.get(
    "https://api.example.com/data?api_key={{MY_API_KEY}}&format=json",
)
```

---

## What Happens Next

When you call `keychains.get()`:

1. **URL rewriting** — `https://api.lifx.com/v1/lights/all` becomes `https://keychains.dev/api.lifx.com/v1/lights/all`
2. **Token injection** — your proxy token is sent via `X-Proxy-Authorization` so the proxy knows who you are
3. **Scope check** — the proxy verifies the user has approved the required credentials for this API
4. **Credential resolution** — the proxy replaces `{{LIFX_PERSONAL_ACCESS_TOKEN}}` with the real API key stored in the user's vault
5. **Request forwarding** — the proxy forwards the request to the upstream API with real credentials injected
6. **Response passthrough** — the upstream response is returned to you as-is

### Handling missing approvals

With wildcard permissions, users approve scopes on demand. The first time your code hits a new API, the user may not have approved it yet. When that happens, the SDK raises an `ApprovalRequired` exception containing an `approval_url` — share it with the user so they can grant access:

```python
import keychains
from keychains.exceptions import ApprovalRequired

try:
    response = keychains.get(
        "https://api.github.com/user",
        headers={"Authorization": "Bearer {{OAUTH2_ACCESS_TOKEN}}"},
    )
    print(response.json())
except ApprovalRequired as err:
    # The user hasn't approved GitHub yet — show them the link
    print("Please approve access:", err.approval_url)
    # Once approved, retry the same call and it will succeed
```

The exception includes useful details:

| Property        | Type             | Description |
|-----------------|------------------|-------------|
| `approval_url`  | `str \| None`    | URL the user should visit to approve the missing scopes |
| `missing_scopes`| `list[str] \| None` | Scopes that need approval |
| `refused_scopes`| `list[str] \| None` | Scopes explicitly refused by the user |
| `code`          | `str`            | Error code (`insufficient_scope`, `scope_refused`, `permission_denied`, etc.) |

---

## Session (Connection Pooling)

For multiple requests, use `Session` to reuse connections — just like `requests.Session`:

```python
import keychains

with keychains.Session() as s:
    s.headers.update({"Authorization": "Bearer {{OAUTH2_ACCESS_TOKEN}}"})

    repos = s.get("https://api.github.com/user/repos")
    for repo in repos.json():
        issues = s.get(f"https://api.github.com/repos/{repo['full_name']}/issues")
        print(f"{repo['name']}: {len(issues.json())} issues")
```

## Async

For asyncio codebases, use `AsyncClient`:

```python
import keychains

async with keychains.AsyncClient() as client:
    response = await client.get("https://api.github.com/user/repos")
    repos = response.json()
```

---

## Configuration

The SDK automatically loads variables from a `.env` file in your working directory (via [python-dotenv](https://pypi.org/project/python-dotenv/)).

| Environment variable | Description |
| --- | --- |
| `KEYCHAINS_TOKEN` | Proxy token — a JWT minted by `npx -y keychains token` |

Tokens can also be passed explicitly:

```python
keychains.get(url, token="ey...")
keychains.Session(token="ey...")
keychains.AsyncClient(token="ey...")
```

### Security benefits

- Secrets never leave the Keychains.dev servers — your code, logs, and environment stay clean
- Users approve exactly which scopes and APIs an agent can access
- Credentials can only be sent to the APIs of the providers they belong to
- Every proxied request is audited with full traceability
- Permissions can be revoked instantly from the [dashboard](https://keychains.dev/dashboard)

---

## Bug Reports & Feedback

Found a bug or have a suggestion? Submit it straight from your terminal:

```bash
# Report a bug
npx -y keychains feedback "The proxy returns 502 on large POST bodies"

# Send feedback
npx -y keychains feedback --type feedback "Love the wildcard permissions!"

# With more detail
npx -y keychains feedback --type bug \
  --title "502 on large POST" \
  --description "When sending >1MB body to Slack API..." \
  --contact you@example.com
```

The `keychains feedback` command (alias: `keychains bug`) sends your report directly to the engineering team.

---

## More Info

Let's meet on [keychains.dev](https://keychains.dev)!
