# licenseos

Official Python SDK for [LicensesOS](https://licenseos.com) -- software license management, validation, activation, and usage metering.

## Installation

```bash
pip install licenseos
```

Requires Python 3.9 or higher. One runtime dependency (`httpx`).

For offline validation support (Ed25519 token verification):

```bash
pip install licenseos[offline]
```

## Quick Start

```python
from licenseos import LicensesOSClient

client = LicensesOSClient("your-api-key")

result = client.validate("XXXX-XXXX-XXXX-XXXX")

if result.is_valid():
    print("License is valid")
else:
    print("Invalid:", result.error["message"] if result.error else "Unknown error")
```

Or use the client as a context manager:

```python
with LicensesOSClient("your-api-key") as client:
    result = client.validate("XXXX-XXXX-XXXX-XXXX")
```

## Configuration

```python
client = LicensesOSClient(
    "your-api-key",
    base_url="https://api.licenseos.com",  # default
    timeout=30.0,                           # seconds, default 30
)
```

| Option     | Type    | Default                     | Description              |
|------------|---------|-----------------------------|--------------------------|
| `base_url` | `str`   | `https://api.licenseos.com` | API base URL             |
| `timeout`  | `float` | `30.0`                      | Request timeout in seconds |

## License Validation

### Basic Validation

```python
result = client.validate("XXXX-XXXX-XXXX-XXXX")

if result.is_valid():
    print("Status:", result.license["status"])
```

### Validation with Identifier

Pass a domain or device identifier to check activation status:

```python
result = client.validate("XXXX-XXXX-XXXX-XXXX", "example.com")

if result.is_valid():
    print("Activated:", result.activated)
    print("Remaining activations:", result.get_remaining_activations())
```

### Validation with Metadata

```python
result = client.validate("XXXX-XXXX-XXXX-XXXX", "example.com", {
    "app_version": "2.1.0",
    "os": "linux",
})
```

### Checking Entitlements

```python
if result.is_valid():
    if result.has_entitlement("premium_features"):
        enable_premium()

    max_seats = result.get_entitlement("max_seats", 1)
    print("Max seats:", max_seats)
```

### Checking Status

```python
result.is_active()    # status == "active"
result.is_expired()   # license is expired
result.is_revoked()   # status == "revoked"
```

### ValidationResult Properties

| Property       | Type                   | Description                              |
|----------------|------------------------|------------------------------------------|
| `valid`        | `bool`                 | Whether the license is valid             |
| `license`      | `dict \| None`         | License details                          |
| `activated`    | `bool \| None`         | Whether the identifier is activated      |
| `activation`   | `dict \| None`         | Activation details if activated          |
| `offline_token`| `str \| None`          | Offline validation token (if policy allows) |
| `error`        | `dict \| None`         | Error details if validation failed       |

| Method                        | Returns       | Description                              |
|-------------------------------|---------------|------------------------------------------|
| `is_valid()`                  | `bool`        | Whether the license is valid             |
| `is_active()`                 | `bool`        | Whether the status is `active`           |
| `is_expired()`                | `bool`        | Whether the license is expired           |
| `is_revoked()`                | `bool`        | Whether the license is revoked           |
| `has_entitlement(key)`        | `bool`        | Check if an entitlement key exists       |
| `get_entitlement(key, default)` | `Any`       | Get an entitlement value with default    |
| `get_remaining_activations()` | `int \| None` | Remaining activations, None if unlimited |
| `to_dict()`                   | `dict`        | Serialize to a plain dictionary          |

## Activation Management

### Activate a License

```python
result = client.activate(
    "XXXX-XXXX-XXXX-XXXX",
    "example.com",
    label="Production Server",
    metadata={"server_id": "srv-01"},
)

print("Activation ID:", result.activation["id"])
print("Remaining:", result.get_remaining_activations())
```

### Deactivate a License

```python
result = client.deactivate("XXXX-XXXX-XXXX-XXXX", "example.com")

if result.is_deactivated():
    print("Successfully deactivated")
```

### List Activations

```python
result = client.list_activations("XXXX-XXXX-XXXX-XXXX")

print("Total:", result.count)
print("Limit:", result.limit)
print("Active:", result.get_active_count())

for activation in result.activations:
    print(f"{activation['identifier']} - {activation['status']}")

# Check if a specific identifier is activated
if result.has_activation("example.com"):
    print("example.com is activated")
```

## Heartbeat Check-ins

Send periodic heartbeat signals to confirm an activation is still alive:

```python
result = client.heartbeat("XXXX-XXXX-XXXX-XXXX", "example.com")

print("Next check-in at:", result.next_check_in_at)
print("Seconds until next:", result.get_seconds_until_next_check_in())
```

### Automatic Heartbeat Loop

```python
import time
import threading

def start_heartbeat(client, license_key, identifier, interval=300):
    def loop():
        while True:
            try:
                result = client.heartbeat(license_key, identifier)
                print("Heartbeat OK, next at:", result.next_check_in_at)
            except Exception as e:
                print("Heartbeat failed:", e)
            time.sleep(interval)

    thread = threading.Thread(target=loop, daemon=True)
    thread.start()
```

### HeartbeatResult Properties

| Property          | Type   | Description                          |
|-------------------|--------|--------------------------------------|
| `activation`      | `dict` | The activation details               |
| `next_check_in_at`| `str`  | ISO 8601 timestamp of next deadline  |

| Method                              | Returns    | Description                           |
|-------------------------------------|------------|---------------------------------------|
| `get_next_check_in()`               | `datetime` | Next check-in as a datetime object    |
| `get_seconds_until_next_check_in()` | `float`    | Seconds remaining until next check-in |

## Usage Metering

Track and enforce API call counts, export quotas, or any usage-based limit.

### Increment Usage

```python
result = client.increment_usage("XXXX-XXXX-XXXX-XXXX")

print("Current uses:", result.usage["uses_count"])
print("Max uses:", result.usage["max_uses"])
print("Remaining:", result.usage["uses_remaining"])
```

### Increment by a Specific Amount

```python
result = client.increment_usage("XXXX-XXXX-XXXX-XXXX", 5)
```

### Check Usage Limits

```python
if result.is_limit_reached():
    print("Usage limit reached")

pct = result.get_usage_percentage()
if pct is not None and pct > 80:
    print(f"Warning: {pct:.0f}% of usage limit consumed")

reset_date = result.get_reset_date()
if reset_date:
    print("Usage resets at:", reset_date.isoformat())
```

### UsageResult Properties

| Property | Type   | Description                      |
|----------|--------|----------------------------------|
| `usage`  | `dict` | Usage counts, limits, and resets |

The `usage` dictionary contains:

| Field                  | Type          | Description                           |
|------------------------|---------------|---------------------------------------|
| `uses_count`           | `int`         | Current usage count                   |
| `max_uses`             | `int \| None` | Maximum allowed uses, None if unlimited |
| `uses_remaining`       | `int \| None` | Remaining uses, None if unlimited     |
| `usage_reset_interval` | `str \| None` | Reset interval (`monthly`, `yearly`)  |
| `uses_reset_at`        | `str \| None` | Next reset timestamp (ISO 8601)       |

| Method                | Returns          | Description                            |
|-----------------------|------------------|----------------------------------------|
| `is_limit_reached()`  | `bool`           | Whether the usage limit has been hit   |
| `get_usage_percentage()` | `float \| None` | Usage as a percentage of the limit   |
| `get_reset_date()`    | `datetime \| None` | Next usage reset as a datetime object |

## Offline Validation

When a policy has offline validation enabled, the `validate()` response includes a signed token. You can verify this token locally without contacting the API server.

Requires the `cryptography` package:

```bash
pip install licenseos[offline]
```

### Step 1: Obtain an Offline Token

```python
result = client.validate("XXXX-XXXX-XXXX-XXXX", "example.com")

if result.is_valid() and result.offline_token:
    # Persist the token (e.g., to a file or database)
    with open("license.token", "w") as f:
        f.write(result.offline_token)
```

### Step 2: Validate Offline

```python
from licenseos import OfflineValidator

# Your app's Ed25519 public key (base64-encoded, from the signing-key endpoint)
validator = OfflineValidator("base64-encoded-public-key")

with open("license.token") as f:
    token = f.read()

result = validator.validate(token)

if result.is_valid():
    print("License valid offline")
    print("Status:", result.status)
    print("Entitlements:", result.entitlements)
    print("Expires in:", result.remaining_seconds(), "seconds")
elif result.is_expired():
    print("Token expired -- re-validate online for a fresh token")
else:
    print("Invalid token:", result.error_code)
```

### OfflineValidationResult Properties

| Property              | Type            | Description                         |
|-----------------------|-----------------|-------------------------------------|
| `valid`               | `bool`          | Whether the token is valid          |
| `status`              | `str`           | License status (`active`, etc.)     |
| `error_code`          | `str \| None`   | Error code if invalid               |
| `license_id`          | `str \| None`   | License ID                          |
| `entitlements`        | `dict \| None`  | License entitlements                |
| `expires_at`          | `str \| None`   | License expiration (ISO 8601)       |
| `activation_limit`    | `int \| None`   | Maximum activations                 |
| `activation_count`    | `int`           | Current activation count            |
| `identifier`          | `str \| None`   | Device/domain identifier            |
| `activated`           | `bool`          | Whether identifier is activated     |
| `issued_at`           | `int`           | Token issue timestamp (Unix)        |
| `expires_at_timestamp`| `int`           | Token expiry timestamp (Unix)       |
| `ttl`                 | `int`           | Token TTL in seconds                |
| `uses_count`          | `int`           | Current usage count                 |
| `max_uses`            | `int \| None`   | Maximum allowed uses                |

| Method              | Returns | Description                              |
|---------------------|---------|------------------------------------------|
| `is_valid()`        | `bool`  | Whether the token is valid               |
| `is_expired()`      | `bool`  | Whether the token has expired            |
| `remaining_seconds()` | `int` | Seconds remaining before token expiry    |

## Machine Fingerprinting

Generate a unique machine identifier for device-locked licenses. Uses standard library only (no extra dependencies).

```python
from licenseos import get_machine_id

machine_id = get_machine_id()
result = client.validate("XXXX-XXXX-XXXX-XXXX", machine_id)
```

The machine ID is a SHA-256 hash of the MAC address and hostname.

## Error Handling

The SDK raises two error types, both extending `LicensesOSError`:

- **`ApiError`** -- The API returned a non-2xx response. Has `code` (machine-readable string like `LICENSE_NOT_FOUND`) and `status_code` (HTTP status).
- **`NetworkError`** -- A network-level failure occurred (timeout, DNS, connection refused). Has an optional `cause` with the underlying error.

```python
from licenseos import ApiError, NetworkError

try:
    result = client.activate("XXXX-XXXX-XXXX-XXXX", "example.com")
except ApiError as e:
    print(f"API error [{e.code}]: {e} (HTTP {e.status_code})")

    if e.code == "LICENSE_NOT_FOUND":
        print("The license key does not exist")
    elif e.code == "ACTIVATION_LIMIT_REACHED":
        print("No more activations available")
    elif e.code == "LICENSE_SUSPENDED":
        print("This license has been suspended")
except NetworkError as e:
    print("Network error:", e)
    # Fall back to cached state or offline validation
```

> **Note:** The `validate()` method does **not** raise for invalid licenses. An invalid license returns a `ValidationResult` with `valid=False` and an `error` dict. `ApiError` is only raised for actual API errors (authentication failure, server error, etc.).

## Domain Normalization

The SDK automatically normalizes domain/URL identifiers before sending them to the API. The static method is also available for direct use:

```python
LicensesOSClient.normalize_domain("https://WWW.Example.Com:8080/path")
# Returns: "example.com"

LicensesOSClient.normalize_domain("HTTP://blog.example.com/")
# Returns: "blog.example.com"
```

Normalization rules:
- Lowercases and strips whitespace
- Strips `http://` and `https://` protocol prefixes
- Strips paths, ports, and `www.` prefix
- IDNA-encodes internationalized domain names

## Grace Period / Cached State

For resilience when the API is unreachable, cache the validation status and timestamp, then use `should_allow_premium()` to decide if the cached result is still trustworthy:

```python
import time

# After a successful validation
cached_state = {
    "status": result.license["status"] if result.license else None,
    "cached_at": int(time.time()),
}
save_to_storage(cached_state)

# Later, when the API is unreachable
state = load_from_storage()
if LicensesOSClient.should_allow_premium(state):
    enable_premium()   # Cache is fresh enough
else:
    disable_premium()  # Cache is stale
```

The default grace period is 48 hours beyond the 12-hour cache TTL (60 hours total). Customize it:

```python
# Custom grace period: 24 hours (in seconds)
LicensesOSClient.should_allow_premium(state, 86_400)

# No grace period -- strict 12-hour cache TTL only
LicensesOSClient.should_allow_premium(state, 0)
```

## Type Safety

The SDK ships with full type annotations compatible with `mypy` (strict mode) and editor auto-completion. All TypedDict definitions are available for import:

```python
from licenseos.types import (
    ValidationResponseData,
    ValidationLicenseData,
    ActivationDetailData,
    HeartbeatResponseData,
    UsageData,
    OfflineTokenPayload,
)
```

## License

MIT
