# Táillí License Client for Python

A friendly Python client for the Táillí License Manager. Manage seats and API keys for offline/air-gapped deployments with minimal code.

## Installation

```bash
pip install tailli-license
```

Or install from source:

```bash
cd license-manager/clients/python
pip install -e .
```

## Quick Start

```python
from tailli_license import LicenseClient

# Connect to your License Manager
client = LicenseClient(
    base_url="http://localhost:8787",
    product_id="my-product"
)

# Use a seat with automatic cleanup
with client.seat() as seat:
    print(f"Allocated seat: {seat.seat_id}")
    # Do your licensed work here
    run_my_application()
# Seat automatically released when done
```

## Features

- **Simple context manager** - Seats are automatically released, even on exceptions
- **Auto-refresh** - Tokens are refreshed in the background, no manual work needed
- **Zero dependencies** - Uses only Python standard library
- **Type hints** - Full type annotations for IDE support
- **Friendly errors** - Clear exceptions for common issues (seat limit, expired license)

## Usage Examples

### Check License Status

```python
from tailli_license import LicenseClient

client = LicenseClient("http://localhost:8787", product_id="video-processor")

status = client.status()
print(f"Product: {status['productId']}")
print(f"Seats: {status['seatsInUse']}/{status['seats']} in use")
print(f"Expires: {status['expiry']}")
print(f"Features: {', '.join(status['features'])}")
```

### Use a Seat (Recommended)

The context manager is the cleanest way to use seats:

```python
from tailli_license import LicenseClient, SeatLimitError, LicenseExpiredError

client = LicenseClient("http://localhost:8787", product_id="video-processor")

try:
    with client.seat() as seat:
        print(f"Got seat {seat.seat_id}")

        # Your licensed code here
        process_videos()

except SeatLimitError:
    print("All seats are in use. Please try again later.")
except LicenseExpiredError:
    print("Your license has expired. Please contact support.")
```

### Add Context for Auditing

Track who's using seats for compliance and debugging:

```python
with client.seat(context={"user": "alice", "workstation": "lab-pc-42"}) as seat:
    run_analysis()
```

### Manual Seat Management

For more control, manage seats directly:

```python
# Allocate a seat
seat = client.allocate(context={"user": "bob"})

try:
    # Do work...
    for batch in data_batches:
        process(batch)
finally:
    # Always release when done
    seat.release()
```

### Disable Auto-Refresh

For short operations, you might not need background refresh:

```python
with client.seat(auto_refresh=False) as seat:
    quick_operation()  # Done in < 1 minute
```

### Check Feature Entitlements

See what features your license enables:

```python
entitlements = client.get_entitlements()

if "ai-enhancement" in entitlements["features"]:
    enable_ai_features()

if "batch-processing" in entitlements["features"]:
    enable_batch_mode()

print(f"API keys: {entitlements['keysIssued']}/{entitlements['maxKeys']} used")
```

### Verify User API Keys

Validate JWTs issued by the License Manager:

```python
try:
    payload = client.verify_key(user_provided_token)
    print(f"Valid token for product: {payload['productId']}")
    print(f"Features: {payload['features']}")
except LicenseError:
    print("Invalid or expired token")
```

## Error Handling

The client provides specific exceptions for common scenarios:

```python
from tailli_license import (
    LicenseClient,
    LicenseError,      # Base class for all errors
    SeatLimitError,    # All seats are in use
    LicenseExpiredError,  # License has expired
    ConnectionError,   # Can't reach License Manager
)

client = LicenseClient("http://localhost:8787", product_id="my-product")

try:
    with client.seat() as seat:
        do_work()
except SeatLimitError:
    # All seats taken - maybe queue the request or notify user
    queue_for_later()
except LicenseExpiredError:
    # License expired - notify admin
    notify_admin("License expired!")
except ConnectionError:
    # Can't reach LM - maybe it's down or network issue
    use_cached_state_or_fail_gracefully()
except LicenseError as e:
    # Other license-related errors
    log_error(f"License error: {e}")
```

## Configuration

### Client Options

```python
client = LicenseClient(
    base_url="http://localhost:8787",  # License Manager URL
    product_id="my-product",            # Your product ID
    timeout=30.0,                       # Request timeout in seconds
)
```

### Seat Options

```python
with client.seat(
    context={"user": "alice"},  # Metadata for auditing
    auto_refresh=True,          # Keep token fresh (default: True)
) as seat:
    pass
```

### Logging

Enable debug logging to see what's happening:

```python
import logging

logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger("tailli_license")
logger.setLevel(logging.DEBUG)
```

## Thread Safety

The `LicenseClient` is thread-safe. You can share one client across threads:

```python
from concurrent.futures import ThreadPoolExecutor

client = LicenseClient("http://localhost:8787", product_id="my-product")

def worker(task_id):
    with client.seat(context={"task": task_id}) as seat:
        process_task(task_id)

with ThreadPoolExecutor(max_workers=4) as executor:
    executor.map(worker, range(10))
```

Each `seat()` call gets its own seat allocation.

## API Reference

### LicenseClient

| Method | Description |
|--------|-------------|
| `status()` | Get license status (seats, expiry, features) |
| `allocate(context, auto_refresh)` | Allocate a seat, returns `Seat` object |
| `seat(context, auto_refresh)` | Context manager for seat allocation |
| `verify_key(token)` | Verify a user API key (JWT) |
| `get_entitlements()` | Get feature entitlements |

### Seat

| Method/Attribute | Description |
|-----------------|-------------|
| `seat_id` | Unique identifier for this seat |
| `token` | Current authentication token |
| `expires_at` | Token expiration time |
| `release()` | Release the seat back to the pool |
| `refresh()` | Manually refresh the token |

## License

MIT License - see the main Táillí repository for details.
