Metadata-Version: 2.4
Name: stashkat
Version: 0.1.5
Summary: Pluggable S3 storage and fetcher for CoreKat
Author-email: velocikat <velocikat@lza.sh>
Requires-Python: >=3.12
Requires-Dist: aioboto3>=12.0
Requires-Dist: corekat[cli]>=0.1.0
Provides-Extra: all
Requires-Dist: corekat[sentry]; extra == 'all'
Provides-Extra: sentry
Requires-Dist: corekat[sentry]; extra == 'sentry'
Description-Content-Type: text/markdown


# StashKat

`stashkat` provides a pluggable S3 storage backend for `corekat`. It implements the `Fetcher` protocol, allowing `corekat`'s `DownloadClient` to handle `s3://` URLs. It also provides a CLI for direct S3 operations.

## Features

- **S3 Fetcher for CoreKat**: Enables `corekat.client.filedl.DownloadClient` to download from S3.
- **Async-first S3 Client**: A high-level, asynchronous client for interacting with S3-compatible object storage.
- **Command-Line Interface**: `stashkat` CLI for uploading, downloading, and copying files from S3.

## Installation

```bash
uv pip install stashkat
```

## Integration with CoreKat

When `stashkat` is installed in the same environment as `corekat`, its S3 fetcher is automatically registered upon initialization.

### Programmatic Usage with DownloadClient

```python
import asyncio
from corekat.config import config
from corekat.client.filedl import DownloadClient
from stashkat.init import init as stashkat_init

async def main():
    # Load config (which should include S3 settings)
    conf = config()
    # Initialize stashkat to register S3 fetcher
    stashkat_init(conf)

    downloader = DownloadClient()

    # Download from S3 using corekat's DownloadClient
    file_info = await downloader.download(
        source="s3://my-bucket/my-file.txt",
        dest_dir="/tmp"
    )
    print(f"Downloaded {file_info.path}")

if __name__ == "__main__":
    asyncio.run(main())
```

### Direct S3Client Usage

```python
import asyncio
from stashkat.config import config
from stashkat.s3 import S3Client
from stashkat.models import StoragePath

async def main():
    conf = config()
    client = S3Client(conf.s3)
    
    # Upload file
    storage_path = await client.upload_file("local/file.txt", dest="uploads/file.txt")
    print(f"Uploaded to: {storage_path.url}")
    
    # Download file
    remote = StoragePath.model_validate("s3://my-bucket/uploads/file.txt")
    await client.download_file(remote, dest="/tmp/downloaded.txt")
    
    # List files
    files = await client.list(prefix="uploads/")
    for file in files:
        print(f"Found: {file.url}")

if __name__ == "__main__":
    asyncio.run(main())
```

## Errors & retries

Every backend translates its provider-specific failures into a small, portable
exception hierarchy (in `stashkat.exceptions`), so you catch the same types
regardless of whether S3, the filesystem, or a future backend is behind the
`StashStorage` interface. All inherit `corekat`'s `AppException`, so an error
that reaches a `servekat` route maps to a sensible HTTP status automatically.

| Exception | Raised when | HTTP | `retryable` |
|---|---|---|---|
| `StashError` | base class / uncategorised failure | 500 | `False` |
| `StashConfigError` | misconfig, missing/partial creds, scheme mismatch (also a `ValueError`) | 500 | `False` |
| `StashNotFoundError` | object / key / bucket does not exist | 404 | `False` |
| `StashAuthError` | bad credentials / access denied | 403 | `False` |
| `StashTransientError` | throttling, 5xx, connection/timeout — safe to retry | 503 | `True` |

```python
from stashkat import StashNotFoundError, StashTransientError, get_storage

storage = get_storage(conf, scheme="s3")
try:
    await storage.download_file(remote, dest="/tmp/out.txt")
except StashNotFoundError:
    ...  # 404 to the user — the object isn't there
except StashTransientError as e:
    assert e.retryable  # back off and retry
```

Branch on `err.retryable` to drive backoff/retry without importing `botocore` or
knowing which backend produced the error. Credentials are **never** validated
eagerly at client construction — an empty credential pair is a valid
configuration (resolved from the environment / IAM role / instance-profile
chain), and a broken pair surfaces lazily as a `StashConfigError` on first use.

`retry_on_transient` wraps that contract in a ready-made backoff loop — it retries
**only** `StashTransientError` and re-raises everything else immediately:

```python
from stashkat import retry_on_transient

stat = await retry_on_transient(lambda: storage.stat(remote), attempts=3)
```

## Metadata, existence & presigned URLs

Beyond the read/write/copy basics, every backend exposes the operations apps
otherwise drop down to raw `boto3` for:

```python
storage = get_storage(conf, scheme="s3")

# Cheap existence check + metadata (no download):
if await storage.exists(remote):
    info = await storage.stat(remote)      # -> ObjectStat
    print(info.size, info.content_type, info.etag, info.last_modified)

# Record a content type on upload (served back by stat / on GET):
await storage.upload_file(file_obj, dest="docs/report.pdf", content_type="application/pdf")

# Presigned URLs — let the browser talk to object storage directly (S3 only):
get_url = await storage.presigned_get_url(remote, expires_in=300)
put = await storage.presigned_put_url(dest, content_type="image/png", expires_in=300)
```

`presigned_get_url` / `presigned_put_url` are pure local SigV4 crypto (no network
I/O). The filesystem backend has no presign concept, so it raises
`StashConfigError`; `exists` / `stat` work on both backends (the filesystem
backend guesses `content_type` from the extension and has no `etag`).

For **synchronous, high-volume** signing (e.g. a sync view-builder that signs
many URLs per request), use `S3Presigner` — a plain sync class that reuses one
botocore client instead of building an async client per call:

```python
from stashkat import S3Presigner

presigner = S3Presigner(conf.s3)          # built from the same S3ConfigSchema
if presigner.available:                   # explicit credentials present?
    get_url = presigner.get_url("s3://bucket/key.jpg", expires_in=300)
    put_url = presigner.put_url("uploads/x.png", content_type="image/png")
```


## CLI Usage

### Configuration

The CLI requires S3 configuration to be present in a `config.yaml` or `localconfig.yaml` file, or via environment variables (prefixed with `STASHKAT_`).

Example `config.yaml`:
```yaml
s3:
  bucket: "my-default-bucket"
  region: "us-east-1"
  access_key: "..." # Or use environment variables
  secret_key: "..." # Or use environment variables
```

### Commands

**Upload a file:**
```bash
stashkat s3 upload /path/to/local/file.txt
```

**Upload with a specific destination key:**
```bash
stashkat s3 upload /path/to/local/file.txt --dest "archive/file.txt"
```

**Download a file:**
```bash
stashkat s3 download s3://my-bucket/archive/file.txt /path/to/save/location.txt
```

**Copy a file:**
```bash
stashkat s3 copy s3://my-bucket/source.txt s3://my-bucket/destination.txt
```
