Metadata-Version: 2.4
Name: permitcore
Version: 1.0.0
Summary: Official Python SDK for PermitCore license management
Author-email: PermitCore <sdk@permitcore.dev>
License: MIT
Project-URL: Homepage, https://permitcore.dev
Project-URL: Repository, https://github.com/permitCore-spec/PermitCore/tree/main/SDKs/python
Keywords: license,licensing,sdk,permitcore,activation
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
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
Requires-Python: >=3.8
Description-Content-Type: text/markdown
Provides-Extra: offline
Requires-Dist: cryptography>=41; extra == "offline"
Provides-Extra: test
Requires-Dist: pytest>=7; extra == "test"
Requires-Dist: cryptography>=41; extra == "test"

# PermitCore Python SDK

Official Python client for [PermitCore](https://permitcore.dev) license management.

**Requirements:** Python 3.8+, zero required dependencies. The optional `cryptography` package
(`pip install permitcore[offline]`) is only needed for offline license token verification.

---

## Installation

```bash
pip install permitcore
```

Or, for offline license token verification support:

```bash
pip install permitcore[offline]
```

---

## Quick start

```python
from permitcore import PermitCoreClient

client = PermitCoreClient("https://your-instance.com")
result = client.validate("PERMIT-XXXX-XXXX-XXXX-XXXX")

if result.is_valid:
    print(f"Valid! Product: {result.product_name}")
    if result.has_feature("export"):
        enable_export()
```

---

## Validate

```python
result = client.validate(license_key, version="2.3.1")  # version is optional

# result.is_valid                  bool
# result.product_name              Optional[str]
# result.remaining_activations     Optional[int]
# result.expires_at                Optional[str]  (ISO 8601)
# result.features                  Optional[List[str]]
# result.custom_fields             Optional[Dict[str, str]]
# result.is_trial                  bool
# result.trial_days_remaining      Optional[int]
# result.node_locked                bool
# result.offline_grace_days        Optional[int]
# result.min_version               Optional[str]
# result.max_version               Optional[str]
# result.vendor_warning            Optional[str]
# result.message                   Optional[str]
# result.is_offline                bool  (true when served from local cache)
```

`validate()` never consumes an activation slot. It falls back to the local disk cache when the
server is unreachable, as long as the license has `offline_grace_days` configured. Passing
`version` lets the server enforce `min_version`/`max_version` restrictions on the license.

---

## Activate

```python
result = client.activate(
    license_key,
    device_id=None,       # auto-generated HWID when omitted
    device_name="Production Server #1",
    version="2.3.1",      # optional
)

if not result.is_valid:
    raise RuntimeError(f"Activation failed: {result.message}")
```

Call `activate()` **once** per installation. Use `validate()` on every subsequent launch.

---

## Meter (usage events)

```python
# Record a single API call
recorded = client.meter(license_key, "api_call")

# Record bulk usage with metadata
recorded = client.meter(license_key, "export", quantity=5, meta={"format": "pdf", "pages": 12})
```

Returns `True` if the event was recorded on the server, `False` on any failure (network error, or
the server rejecting the event).

---

## Floating licenses

```python
# Check out a seat at session start
session = client.checkout(license_key)
if not session.success:
    raise RuntimeError(f"No seats available: {session.message}")

token = session.session_token

# Heartbeat every 4-5 minutes to keep the seat alive
client.heartbeat(token)

# Release the seat when done
client.checkin(token)
```

---

## Offline license tokens

An offline activation token (`pc_offline_v1.<payload>.<signature>`) lets your app verify a
license with **zero network calls**, using ECDSA P-256 signature verification against your
tenant's public key (`GET /api/v1/{tenantSlug}/public-key`). Useful for air-gapped or
intermittently-connected deployments. Requires the optional `cryptography` package
(`pip install permitcore[offline]`) — a plain `pip install permitcore` install raises
`OfflineVerificationUnavailable` if you call these without it.

```python
# Pure local verification — no network call. Never throws (except OfflineVerificationUnavailable
# if 'cryptography' isn't installed).
result = PermitCoreClient.verify_offline_token(token, public_key_base64)

if result.is_valid:
    print(f"Valid! Product: {result.payload.product_name}")
else:
    print(f"Invalid: {result.message}")
```

```python
# Verify + bind to this device + persist locally (call once, e.g. at install time)
result = PermitCoreClient.activate_offline(token, public_key_base64, device_id)

# On every later launch — no token needed, reads the local cache, still no network call
result = PermitCoreClient.validate_offline(device_id)
```

```python
# Optional: ask the server to verify the token AND check its revocation status (requires network)
result = client.verify_offline_online(token)
```

All four methods return an `OfflineTokenResult(is_valid, message, payload)`. `payload` (an
`OfflineTokenPayload`) carries `token_id`, `tenant_slug`, `kid`, `tenant_id`, `license_id`,
`license_key_hash`, `device_id`, `device_name`, `product_name`, `max_activations`, `issued_at`,
`expires_at`. `kid` identifies which of the tenant's signing keys produced the token (`None` on
tokens issued before key versioning existed) — informational only, `verify_offline_token()` still
verifies against whatever `public_key_base64` you pass it. `verify_offline_token()` and
`validate_offline()` never throw for malformed,
tampered, expired, or missing input — they just return `is_valid=False` with a descriptive
`message`.

`activate_offline()`'s local cache is stored under the user's home directory as
`.permitcore_offline_<hash>` (same convention as the `validate()`/`activate()` cache, keyed by
device ID instead of license key).

---

## Version enforcement

```python
result = client.validate(license_key)

my_version = "2.3.0"
if result.min_version and my_version < result.min_version:
    raise RuntimeError(f"Please update to version {result.min_version} or newer.")
if result.max_version and my_version > result.max_version:
    raise RuntimeError(f"This build ({my_version}) is not licensed for versions above {result.max_version}.")
```

Pass `version=my_version` to `validate()`/`activate()` to also have the *server* enforce this —
otherwise only client-side comparison happens.

---

## Offline grace pattern

```python
result = client.validate(license_key)  # falls back to cache automatically

if not result.is_valid:
    raise RuntimeError(f"License invalid: {result.message}")

if result.is_offline:
    # Server unreachable — running on cached result
    show_notice("Running in offline mode. Connect to the internet to refresh your license.")
```

The cache is stored under the user's home directory as `.permitcore_cache_<hash>`. It expires
after `offline_grace_days` days.

---

## Constructor options

```python
client = PermitCoreClient(
    base_url="https://your-instance.com",
    enable_offline_cache=True,  # default — set False to always require network
    timeout=5,                  # HTTP timeout in seconds
)
```

---

## LicenseResult reference

| Field | Type | Description |
|---|---|---|
| `is_valid` | `bool` | True if the license is active and valid |
| `product_name` | `Optional[str]` | Product the license belongs to |
| `remaining_activations` | `Optional[int]` | Slots left before MaxActivations is reached |
| `expires_at` | `Optional[str]` | Expiry date (ISO 8601 UTC), None if perpetual |
| `features` | `Optional[List[str]]` | Feature flag list, e.g. `["export", "api"]` |
| `custom_fields` | `Optional[Dict[str, str]]` | Arbitrary key/value metadata set on the license |
| `is_trial` | `bool` | True for trial licenses |
| `trial_days_remaining` | `Optional[int]` | Days until trial expires |
| `node_locked` | `bool` | True if bound to a specific device |
| `offline_grace_days` | `Optional[int]` | How many days the cache is valid |
| `min_version` / `max_version` | `Optional[str]` | Version enforcement bounds |
| `vendor_warning` | `Optional[str]` | Non-fatal message from the vendor |
| `message` | `Optional[str]` | Reason when `is_valid = False` |
| `is_offline` | `bool` | True when result came from local cache |

---

## Development

```bash
pip install -e ".[test]"   # installs pytest + cryptography for the test run
pytest
```

`tests/test_vectors.py` runs this SDK's `verify_offline_token`/`verify_grace_cache_token` against
the shared, language-agnostic cross-SDK protocol vectors in `../../test-vectors/vectors.json`
(fixed ECDSA P-256/SHA-256 tokens every PermitCore SDK verifies identically — see that file's own
`schemaNote`) and checks the `validate`/`activate` request bodies this SDK builds match the shared
`requestShapes` key sets exactly.

`has_feature(feature: str) -> bool` — case-insensitive feature check.
