Metadata-Version: 2.4
Name: spaps
Version: 0.5.1
Summary: Sweet Potato Authentication & Payment Service Python client
Project-URL: Homepage, https://api.sweetpotato.dev
Project-URL: Repository, https://github.com/sweet-potato/spaps
Author-email: buildooor <buildooor@gmail.com>
License: MIT License
        
        Copyright (c) 2025 Sweet Potato Team
        
        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.
        
License-File: LICENSE
Keywords: authentication,payments,spaps,sweet-potato
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Requires-Python: >=3.9
Requires-Dist: httpx<0.29.0,>=0.27.0
Requires-Dist: pydantic<3.0.0,>=2.7.0
Provides-Extra: dev
Requires-Dist: build<2.0.0,>=1.2.1; extra == 'dev'
Requires-Dist: httpx<0.29.0,>=0.27.0; extra == 'dev'
Requires-Dist: mypy<2.0.0,>=1.10.0; extra == 'dev'
Requires-Dist: pydantic<3.0.0,>=2.7.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Requires-Dist: respx<0.23.0,>=0.22.0; extra == 'dev'
Requires-Dist: ruff<1.0.0,>=0.5.5; extra == 'dev'
Requires-Dist: tomli<3.0.0,>=2.0.1; extra == 'dev'
Description-Content-Type: text/markdown

# spaps (Python client)

Python SDK for SPAPS-compatible APIs. The distribution name is `spaps`; the import path is `spaps_client`.

Examples in this README use placeholders such as `user@example.com`, `admin@example.com`, `spaps_test_key`, and `https://api.example.test`. Replace them with values from your own deployment.

## Install

```bash
pip install spaps
```

This package targets Python 3.9+.

## When It Fits

| Need | Package gives you |
| --- | --- |
| Sync and async clients | `SpapsClient` and `AsyncSpapsClient` with a similar surface |
| Typed responses | Pydantic models for auth, sessions, payments, entitlements, webhooks, and more |
| HTTP control points | Retry config, logging hooks, injected `httpx` clients, and custom token storage |
| Narrow integrations | Standalone helpers for device flow, webhooks, users, email, entitlements, marketing, and permission checks |

## Quick Start

```python
from spaps_client import SpapsClient

client = SpapsClient(
    base_url="http://localhost:3301",
    api_key="spaps_test_key",
)

try:
    tokens = client.auth.sign_in_with_password(
        email="user@example.com",
        password="correct-horse-battery-staple",
    )
    session = client.sessions.get_current_session()
    products = client.payments.list_products(
        category="subscription",
        active=True,
        limit=5,
    )

    print(tokens.user.email)
    print(session.session_id)
    print(products.total)
finally:
    client.close()
```

## Async Example

```python
import asyncio

from spaps_client import AsyncSpapsClient


async def main() -> None:
    client = AsyncSpapsClient(
        base_url="http://localhost:3301",
        api_key="spaps_test_key",
    )
    try:
        await client.auth.sign_in_with_password(
            email="user@example.com",
            password="correct-horse-battery-staple",
        )
        sessions = await client.sessions.list_sessions()
        print(sessions.total)
    finally:
        await client.aclose()


asyncio.run(main())
```

## Core Surface

### High-Level Clients

| Client | Best for |
| --- | --- |
| `SpapsClient` | Sync auth, sessions, payments, usage, whitelist, secure messages, issue reporting, marketing, metrics, skill evals, and support telemetry |
| `AsyncSpapsClient` | Async auth, sessions, payments, usage, whitelist, secure messages, issue reporting, metrics, entitlements, skill evals, dayrate, and users |

### Standalone Helpers

| Helper | Purpose |
| --- | --- |
| `EntitlementsClient` / `AsyncEntitlementsClient` | Resource entitlements, purchase history, manual grants, and revokes |
| `UsageClient` / `AsyncUsageClient` | Secret-key usage authorization and immutable usage recording |
| `EmailClient` / `AsyncEmailClient` | Template lookup, preview, and send flows |
| `UsersClient` / `AsyncUsersClient` | Batch user and email lookups |
| `MarketingClient` | Browser-safe marketing event emission and server-side experiment results |
| `DeviceFlowClient` | Device-code login workflows |
| `PermissionChecker` | Role and admin convenience checks |
| `verify_spaps_webhook` | Signature verification for incoming SPAPS webhooks |

## Configuration

Constructor values override package defaults.

```python
from spaps_client import SpapsClient, RetryConfig, default_logging_hooks

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
    retry_config=RetryConfig(max_attempts=4, backoff_factor=0.2),
    logging_hooks=default_logging_hooks(),
)
```

Common parameters:

| Parameter | Purpose |
| --- | --- |
| `base_url` | Target API origin |
| `api_key` | Application or service API key |
| `request_timeout` | Per-request timeout |
| `token_storage` | Custom token persistence backend |
| `http_client` | Injected `httpx.Client` or `httpx.AsyncClient` |
| `retry_config` | Retry and backoff policy |
| `logging_hooks` | Structured request and response logging callbacks |

## Common Flows

### Magic Links and Password Reset

```python
from spaps_client import SpapsClient

client = SpapsClient(
    base_url="http://localhost:3301",
    api_key="spaps_test_key",
)

try:
    client.auth.send_magic_link(email="user@example.com")
    client.auth.request_password_reset(email="user@example.com")
    client.auth.confirm_password_reset(
        token="reset-token-from-email",
        new_password="Sup3rStrong!",
    )
finally:
    client.close()
```

### Authenticated Stripe Checkout

`create_checkout_session` is the convenience path for authenticated checkout.
Pass the server-managed Stripe `price_id`; the client sends the active Stripe
checkout contract with one line item for that price.

```python
from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_test_key",
)

try:
    client.auth.sign_in_with_password(
        email="user@example.com",
        password="correct-horse-battery-staple",
    )
    checkout = client.payments.create_checkout_session(
        price_id="price_123",
        mode="subscription",
        success_url="https://app.example.test/success",
        cancel_url="https://app.example.test/cancel",
        trial_period_days=14,
    )
    print(checkout.checkout_url)
finally:
    client.close()
```

### Server-Side Usage Control

Use `usage` from a trusted backend with a secret SPAPS key. Browser apps should
call their own backend first; that backend asks SPAPS to authorize the work,
runs the work only on an allow or warning decision, then records the actual
usage with a stable idempotency key.

```python
from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
)

try:
    authorization = client.usage.authorize_usage(
        feature_key="assistant_tokens",
        resource_type="company",
        resource_id="company_123",
        subject_user_id="user_123",
        dimensions={"requests": 1, "input_tokens": 1200},
    )
    if authorization.decision == "blocked":
        message = authorization.reasons[0].message if authorization.reasons else "Usage not authorized"
        raise RuntimeError(message)

    result = run_assistant_job()

    client.usage.record_usage(
        authorization_ref=authorization.authorization_ref,
        idempotency_key=result.job_id,
        feature_key="assistant_tokens",
        resource_type="company",
        resource_id="company_123",
        subject_user_id="user_123",
        dimensions={
            "input_tokens": result.input_tokens,
            "output_tokens": result.output_tokens,
        },
        metadata={"job_id": result.job_id},
    )
finally:
    client.close()
```

For dashboard and policy views, use `client.usage.get_features()`,
`client.usage.get_status(...)`, and `client.usage.get_history(...)` from the
same trusted backend context.

### Marketing Events

Use `client.marketing.emit_event(...)` with a publishable key to record anonymous
attribution touches or experiment exposures. Use a secret key from a trusted
server or agent to read experiment results and the current conservative stop
signal.

```python
from spaps_client import SpapsClient

browser_client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_pub_example",
)

try:
    browser_client.marketing.emit_event(
        anon_id="anon_01HY...",
        event_type="experiment_exposure",
        experiment_id="landing-hero-copy",
        variant_id="treatment",
        dedupe_key="landing-hero-copy:anon_01HY:treatment",
    )
finally:
    browser_client.close()

agent_client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
)

try:
    results = agent_client.marketing.get_experiment_results("landing-hero-copy")
    print(results.decision.recommendation, results.decision.winner_variant_id)
finally:
    agent_client.close()
```

### Permission Checks With Explicit Admin Config

```python
from spaps_client import PermissionChecker

checker = PermissionChecker(customAdmins=["admin@example.com"])
role = checker.getRole("operator@example.com")

if checker.requiresAdmin({"email": "operator@example.com"}):
    raise PermissionError(
        checker.getErrorMessage("admin", role, action="change billing settings")
    )
```

### Issue Reporting Voice Token

Voice issue reporting uses a short-lived SPAPS token for ElevenLabs Scribe. Keep the ElevenLabs API key on the SPAPS server.

```python
from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_pub_example",
)

try:
    client.set_tokens(access_token="end-user-access-token")
    voice_token = client.issue_reporting.create_voice_token()
    print(voice_token.provider, voice_token.model_id)
finally:
    client.close()
```

### Issue Reporting Screenshot Attachments

Screenshots are uploaded as private pending hosted assets first. Create, update, or reply calls then attach those IDs; raw image bytes, data URLs, and base64 payloads should not be stored in issue notes or target metadata.

```python
from pathlib import Path

from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_pub_example",
)

try:
    client.set_tokens(access_token="end-user-access-token")
    attachment = client.issue_reporting.upload_issue_report_attachment(
        file=Path("protocol-save-failure.png").read_bytes(),
        filename="protocol-save-failure.png",
        mime_type="image/png",
    )

    issue = client.issue_reporting.create_issue_report(
        target={
            "component_key": "patient_protocol_widget",
            "component_label": "Patient Protocol Widget",
            "page_url": "/patients/123/protocol",
            "surface_ref": "daily-log",
            "metadata": {"section": "daily log"},
        },
        note="The save action silently fails after I edit today's protocol note.",
        reporter_role_hint="practitioner",
        attachment_ids=[attachment.id],
    )

    access = client.issue_reporting.get_issue_report_attachment_access(
        attachment_id=attachment.id,
    )
    print(issue.id, access.expires_in_seconds)
finally:
    client.close()
```

SPAPS accepts PNG, JPEG, and WebP screenshots up to 10 MiB each, with at most 5 retained screenshots per report. The hosted object remains private; callers fetch a short-lived access URL after normal issue-reporting authorization succeeds. SPAPS does not redact screenshot contents, so host apps should warn users when a capture may include sensitive data.

### Skill Evals

Skill eval helpers wrap the SPAPS blind review endpoints for agent-skill logs. Paid case creation accepts a `PAYMENT-SIGNATURE` through `payment_signature`. Reviewers submit `valuable` and `not_valuable` marks, and submitters read those marks through an insight inbox before applying skill changes.

```python
from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_pub_example",
)

try:
    created = client.skill_evals.create_case(
        {
            "title": "Docs skill comparison",
            "task_claim": "Compare both implementations.",
            "success_criteria": ["Finds repo boundaries"],
            "candidates": [
                {
                    "candidate_id": "A",
                    "output_ref": "spaps-artifact://case/a",
                    "evidence_summary": "Validation passed",
                    "artifact_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                    "artifact_mime": "text/markdown",
                    "jsonl_log_ref": "spaps-artifact://logs/a.jsonl",
                    "jsonl_log_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                    "skill_ref": "skill://docs-review",
                    "skill_version_ref": "skill://docs-review/v2.0",
                    "skill_version_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
                    "model_id": "openai/gpt-5.4",
                    "effort_level": "medium",
                    "provenance_ref": "skill://private/a",
                },
                {
                    "candidate_id": "B",
                    "output_ref": "spaps-artifact://case/b",
                    "evidence_summary": "Validation passed",
                    "artifact_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
                    "artifact_mime": "text/markdown",
                    "jsonl_log_ref": "spaps-artifact://logs/b.jsonl",
                    "jsonl_log_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
                    "skill_ref": "skill://docs-review",
                    "skill_version_ref": "skill://docs-review/v2.1",
                    "skill_version_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
                    "model_id": "openai/gpt-5.4",
                    "effort_level": "medium",
                    "provenance_ref": "skill://private/b",
                },
            ],
            "case_policy": {
                "access_mode": "team_private",
                "allowed_model_efforts": [
                    {"model_id": "openai/gpt-5.4", "effort_level": "medium"}
                ],
                "participant_allowlist": ["reviewer-actor-id"],
            },
            "idempotency_key": "eval-create-001",
        },
        payment_signature=signed_payment,
    )
    room = client.skill_evals.get_review_room(created["case_id"])
    print(room["reviewer_state"])
    review = client.skill_evals.submit_review(
        created["case_id"],
        {
            "review_marks": [
                {
                    "candidate_id": "B",
                    "kind": "valuable",
                    "note": "B checks the configured docs path before recommending an edit.",
                    "reason_code": "prevents_wrong_repo_patch",
                    "confidence": "high",
                    "criterion": "Finds repo boundaries",
                }
            ]
        },
    )
    inbox = client.skill_evals.get_insights(created["case_id"])
    print(inbox["valuable"][0]["jsonl_log_ref"], review["review_mark_counts"])
    client.skill_evals.respond_to_review(
        created["case_id"],
        inbox["valuable"][0]["source_review_id"],
        {
            "response": "applied",
            "reason": "Updated the skill from the concrete log-backed insight.",
            "applied_insight_ref": inbox["valuable"][0]["insight_ref"],
            "skill_change_ref": "skill://docs-review/v2.1",
            "skill_version_before": "skill://docs-review/v2.0",
            "skill_version_after": "skill://docs-review/v2.1",
            "jsonl_log_ref": "spaps-artifact://logs/apply.jsonl",
            "jsonl_log_hash": (
                "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
            ),
            "model_id": "openai/gpt-5.4",
            "effort_level": "medium",
        },
    )
finally:
    client.close()
```

### Webhook Verification

```python
from spaps_client import verify_spaps_webhook

payload = verify_spaps_webhook(
    body=request_body_bytes,
    signature=request_headers["X-SPAPS-Signature"],
    secret="whsec_example",
)

print(payload.type)
```

## Validation

From the repository root:

```bash
npm run lint:python-client
npm run typecheck:python-client
npm run test:python-client
```

For local package work:

```bash
cd packages/python-client
pip install -e '.[dev]'
```

## Troubleshooting

### `ValueError: Access token not found`

Authenticate first with `client.auth...` helpers, or seed tokens with your configured token storage.

### `401` or `403` responses

Check the API key, access token, and endpoint role requirements for the target environment.

### Hosted examples fail against localhost

Set `base_url="http://localhost:3301"` and use a local development key or test credentials.

### I need more control over HTTP behavior

Inject a custom `httpx` client or configure `RetryConfig` and logging hooks.

### Which client should I start with?

Use `SpapsClient` unless your service is already async end to end.

## Limitations

- Some specialty integrations live as standalone helpers instead of methods on the main client.
- Endpoint coverage tracks the active SPAPS backend surface; new backend features may appear here incrementally.
- You still need to provide environment-specific API keys, tokens, and deployment URLs.

## FAQ

### Is the package name different from the import path?

Yes. Install `spaps`; import from `spaps_client`.

### Does it support async codebases?

Yes. Use `AsyncSpapsClient` and the async helper classes.

### Is webhook verification included?

Yes. Use `verify_spaps_webhook`.

### Can I replace the default token storage?

Yes. Pass a custom `token_storage` implementation.

### Does it retry requests automatically?

It can. Pass `RetryConfig` if you want retry and backoff behavior.

## Metadata

- `package_name`: `spaps`
- `latest_version`: `0.5.0`
- `minimum_runtime`: `Python >=3.9`
- `api_base_url`: `https://api.sweetpotato.dev`

## About Contributions

> *About Contributions:* Please don't take this the wrong way, but I do not accept outside contributions for any of my projects. I simply don't have the mental bandwidth to review anything, and it's my name on the thing, so I'm responsible for any problems it causes; thus, the risk-reward is highly asymmetric from my perspective. I'd also have to worry about other "stakeholders," which seems unwise for tools I mostly make for myself for free. Feel free to submit issues, and even PRs if you want to illustrate a proposed fix, but know I won't merge them directly. Instead, I'll have Claude or Codex review submissions via `gh` and independently decide whether and how to address them. Bug reports in particular are welcome. Sorry if this offends, but I want to avoid wasted time and hurt feelings. I understand this isn't in sync with the prevailing open-source ethos that seeks community contributions, but it's the only way I can move at this velocity and keep my sanity.

## License

MIT
