# Concentriq LS Python Client

Python client library for Proscia Concentriq Life Sciences API.

[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

## Installation

```bash
pip install concentriq-ls-client
```

Or with uv:

```bash
uv add concentriq-ls-client
```

## Quick Start

```python
from concentriq import ConcentriqClient
from concentriq.auth import ApiKeyAuth

client = ConcentriqClient(
    base_url="https://app.concentriq.com",
    auth=ApiKeyAuth(api_key="your-api-key")
)

# List repositories sorted by image count
repos = client.v1.image_sets.list(
    page=1, rows_per_page=10, sort_by="imageCount", descending=True
)

for repo in repos:
    print(f"{repo['name']}: {repo['imageCount']} images")
```

## Authentication

Four authentication methods are supported:

```python
from concentriq.auth import ApiKeyAuth, BasicAuth, JWTAuth, SessionAuth

# API Key (most common)
auth = ApiKeyAuth(api_key="your-api-key")

# Basic Auth
auth = BasicAuth(username="user", password="pass")

# JWT Bearer Token
auth = JWTAuth(token="your-jwt-token")

# Session Cookie
auth = SessionAuth(session_cookie="connect.sid=...")
```

## Image Upload

Upload image files with automatic single-part or concurrent multipart upload:

```python
from concentriq.upload import upload_image

# Upload from file path (auto-detects name and size)
image = upload_image(client, "/path/to/slide.svs", image_set_id=100)
print(f"Created image: {image['id']}")

# Upload from file object
with open("slide.svs", "rb") as f:
    image = upload_image(
        client, f, image_set_id=100,
        file_name="slide.svs", file_size=os.path.getsize("slide.svs")
    )

# Tune concurrency and part size
image = upload_image(
    client, "/path/to/large_slide.svs",
    image_set_id=100,
    part_size=50 * 1024 * 1024,   # 50 MB parts (default: 15 MB)
    max_concurrency=8,             # parallel uploads (default: 4)
)
```

Files smaller than `part_size` use a single presigned PUT. Larger files use S3 multipart upload with concurrent part uploads (3-4x faster than sequential). Multipart uploads are automatically aborted on failure.

## Pagination and Sorting

Use server-side sorting and pagination — avoid fetching everything client-side:

```python
from concentriq.models.v1.filters import ImageFilters

# Top 10 repos by image count
repos = client.v1.image_sets.list(
    page=1, rows_per_page=10, sort_by="imageCount", descending=True
)

# Most recent images in a repository
images = client.v1.images.list(
    page=1, rows_per_page=20, sort_by="created", descending=True,
    filters=ImageFilters(imageSetId=[123])
)

# List responses include pagination metadata
repos = client.v1.image_sets.list(page=1, rows_per_page=10)
total = repos.meta["pagination"]["totalRows"]    # total matching rows
returned = repos.meta["pagination"]["rowsReturned"]  # rows in this page
print(f"Showing {returned} of {total} repositories")

# Iterate all pages (only when you truly need everything)
page = 1
all_images = []
while True:
    batch = client.v1.images.list(
        page=page, rows_per_page=1000,
        filters=ImageFilters(imageSetId=[123])
    )
    all_images.extend(batch)
    if len(batch) < 1000:
        break
    page += 1
print(f"Fetched {len(all_images)} images")
```

## Metadata

### Reading and Writing Values

```python
from concentriq.models.v1.filters import MetadataValueFilters

# Look up a field by name (cached — only fetches from API once)
field = client.v1.metadata_fields.get_by_name("Patient ID")
field = client.v1.metadata_fields.get_by_name("Status", resource_type="image")

# Read metadata values for an image
values = client.v1.metadata_values.list(
    filters=MetadataValueFilters(imageId=[456])
)

# Update metadata values (batch)
client.v1.metadata_values.update([
    {"fieldId": 1, "resourceId": 456, "content": "PAT-2025-001"},
    {"fieldId": 2, "resourceId": 456, "content": 42},
    {"fieldId": 3, "resourceId": 456, "content": True},
    {"fieldId": 4, "resourceId": 456, "content": "03/17/2026"},  # dates: MM/DD/YYYY
])
```

### Dropdown Fields

Dropdown metadata fields store numeric option IDs, not text. Use `DropdownResolver` to translate:

```python
from concentriq.metadata import DropdownResolver

field = client.v1.metadata_fields.get_by_name("Status", resource_type="image")
resolver = DropdownResolver(field)

resolver.to_id("Active")    # → 10 (for writing)
resolver.to_value(10)        # → "Active" (for reading)
resolver.values              # ["Active", "Inactive", "Pending"]

# Use in updates
client.v1.metadata_values.update([
    {"fieldId": field["id"], "resourceId": 456, "content": resolver.to_id("Active")}
])
```

## Annotation XML Export/Import

```python
# Export all annotations for an image as Aperio XML
xml_bytes = client.v1.images.export_annotations_xml(456)
with open("annotations.xml", "wb") as f:
    f.write(xml_bytes)

# Export a subset of annotations
xml_bytes = client.v1.images.export_annotations_xml(456, annotation_ids=[100, 101])

# Import annotations from XML (supports Aperio XML, HALO XML, NDPA)
result = client.v1.images.import_annotations(456, "annotations.xml")
print(f"Imported {result['importedAnnotationsCount']} annotations")

# Preview missing annotation classes before importing
result = client.v1.images.import_annotations(
    456, "annotations.xml", require_confirmation=True
)
if result["requireConfirmation"]:
    print(f"Missing classes: {result['nonExistingAnnotationClassNames']}")
```

## Error Handling

```python
from concentriq.exceptions import (
    AuthenticationError,  # 401
    AuthorizationError,   # 403
    NotFoundError,        # 404
    ValidationError,      # 400/422
    ServerError,          # 500+
    APIError,             # base class for all API errors
)

try:
    image_set = client.v1.image_sets.get(id=999)
except NotFoundError:
    print("Not found")
except AuthorizationError:
    print("No permission")
except APIError as e:
    print(f"API error: {e}")
```

## API Coverage

### V1 API (`/api/*`)
- **ImageSets** — CRUD, pagination, sorting, filters
- **Images** — CRUD, download, annotation XML export/import, upload
- **Folders** — Full CRUD
- **Annotations** — Full CRUD
- **Metadata** — Fields (cached lookup by name), values (batch update), DropdownResolver
- **Users**, **Organizations**, **Templates**, **Workflows**, **Orders**, **Attachments**

### V2 API (`/api/v2/*`)
- **UserGroups** — CRUD with permissions
- **Modules** — External module configuration
- **SavedDisplaySettings** — Fluorescence display settings

### V3 API (`/api/v3/*`)
- **Auth** — JWT tokens, API keys, whoami
- **Studies** — Full CRUD + fields/users/statistics
- **ImageSets** — Seed generation, blinding
- **Files** — Multipart upload infrastructure
- **AnnotationClasses**, **ChannelGroups**, **AppConfig**, **Users**, **AuditLogs**

## Requirements

- Python 3.10+
- httpx >= 0.27.0
- pydantic >= 2.0.0

## License

Copyright 2025 Proscia Inc. Licensed under the MIT License.
