# Design: First-Class Python API

**Issue:** #90
**Date:** 2026-02-07
**Status:** Approved

## Goal

Extract command execution logic from the CLI layer into a public Python API.
The CLI becomes a thin facade over the library. Programmatic use no longer
requires CLI workarounds (CliRunner, `_plugin_groups`, output parsing).

## Public Interface

```python
from graftpunk import GraftpunkClient

# Attribute access — primary interface
bek = GraftpunkClient("bek")
cart = bek.cart.get()
invoices = bek.invoice.list(status="open")
detail = bek.account.detail(account_number="123")

# Top-level commands (no group)
bek.login()

# String dispatch — escape hatch for computed command names
cmd_group, cmd_name = "invoice", "list"
result = bek.execute(cmd_group, cmd_name, status="open")

# Context manager
with GraftpunkClient("bek") as bek:
    cart = bek.cart.get()

# One-liner
items = GraftpunkClient("bek").cart.get().data
```

All methods return `CommandResult`. No separate `run()` function — the client
is cheap to create and covers both one-shot and multi-call use cases.

## Design Decisions

| Decision | Choice | Rationale |
|----------|--------|-----------|
| Return type | `CommandResult` | Already exists, handlers know it, unused CLI fields (`format_hint`) are harmless `None` |
| Session persistence | Auto-persist when dirty | Matches CLI behavior, scripts just work |
| Mid-level API | `GraftpunkClient` object | Natural home for per-plugin state (session, tokens, rate limits) |
| Interface style | Attribute access + string dispatch | Pythonic primary, escape hatch for computed names |
| `run()` function | Dropped | Client is already a one-liner, `run()` adds nothing |
| Module location | `src/graftpunk/client.py`, exported from top-level | `from graftpunk import GraftpunkClient` |

## Architecture

### New Classes (`src/graftpunk/client.py`)

```python
class GraftpunkClient:
    """Stateful client for a single plugin."""

    def __init__(self, plugin_name: str):
        self._plugin = get_plugin(plugin_name)
        self._session = None      # Lazy-loaded on first command
        self._commands = {}       # name → CommandSpec (top-level)
        self._groups = {}         # group_name → {name → CommandSpec}

    def __getattr__(self, name: str):
        # Top-level command → _CommandCallable
        # Command group → _GroupProxy
        # Neither → AttributeError

    def execute(self, *args: str, **kwargs: Any) -> CommandResult:
        # String dispatch: execute("invoice", "list", status="open")
        # or: execute("login")

    def close(self) -> None:
        # Persist session if dirty, plugin teardown

    def __enter__(self) / __exit__(self):
        # Context manager

    def _execute_command(self, spec: CommandSpec, **kwargs) -> CommandResult:
        # The extracted pipeline (see below)


class _GroupProxy:
    """A command group (e.g., client.invoice)."""

    def __getattr__(self, name: str) -> _CommandCallable:
        # Command in group → _CommandCallable
        # Not found → AttributeError


class _CommandCallable:
    """A bound command ready to execute."""

    def __call__(self, **kwargs: Any) -> CommandResult:
        # Delegates to client._execute_command(spec, **kwargs)
```

### Extracted Pipeline (`_execute_command`)

This is the logic currently in the CLI callback, unwired from Click:

```
_execute_command(spec, **kwargs) → CommandResult:
    1. Lazy-load session (first call only)
    2. Inject tokens (if plugin has token_config)
    3. Build CommandContext
    4. Execute with rate limiting + retries (_execute_with_limits)
    5. Handle 403 token retry (clear cache, re-extract, retry once)
    6. Normalize return → CommandResult
    7. Auto-persist session if dirty
```

### Plugin Discovery Decoupling

New shared function:

```python
# src/graftpunk/plugins/__init__.py
def get_plugin(name: str) -> SitePlugin:
    """Look up a plugin by site_name. Cached. Raises PluginError."""

def discover_all_plugins() -> list[SitePlugin]:
    """Run all three discovery sources. Cached."""
```

- Entry points, YAML, Python files — all three sources, same as today
- Results cached after first call
- No Click/Typer side effects

CLI registration becomes a consumer:

```python
def register_plugin_commands(app):
    for plugin in discover_all_plugins():
        # Wire up Click/Typer commands (CLI-only concern)
```

### CLI After Extraction

The callback shrinks to format + error display:

```python
def callback(**kwargs):
    output_format = kwargs.pop("output_format", "json")
    try:
        result = client._execute_command(spec, **kwargs)
        format_output(result, output_format, console)
    except CommandError as e:
        console.print(str(e))
        raise SystemExit(1)
```

## Error Handling

The client raises typed exceptions. The CLI catches and formats them.

```python
# Library callers get real exceptions
try:
    result = bek.invoice.list()
except SessionNotFoundError:
    ...  # No session — need to login first
except CommandError as e:
    ...  # Plugin-raised error
except GraftpunkError:
    ...  # Catch-all
```

Exception hierarchy already exists. Network errors (retries exhausted) bubble
as `requests.RequestException`. No new exception types needed.

## File Changes

### New
- `src/graftpunk/client.py` — `GraftpunkClient`, `_GroupProxy`, `_CommandCallable`

### Modified
- `src/graftpunk/__init__.py` — export `GraftpunkClient`
- `src/graftpunk/plugins/__init__.py` — add `get_plugin`, `discover_all_plugins`
- `src/graftpunk/plugins/plugin_commands.py` — extract pipeline from callback

### Unchanged
- `src/graftpunk/cli/main.py` — still calls `register_plugin_commands()`
- Plugin files — handlers work the same way
- `_execute_with_limits()` — already standalone
