Metadata-Version: 2.4
Name: alternator-client
Version: 2.0.0
Summary: Client-side load balancing for ScyllaDB Alternator
Author-email: ScyllaDB <info@scylladb.com>
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/scylladb/alternator-client-python
Project-URL: Documentation, https://github.com/scylladb/alternator-client-python#readme
Project-URL: Repository, https://github.com/scylladb/alternator-client-python
Project-URL: Issues, https://github.com/scylladb/alternator-client-python/issues
Keywords: scylladb,alternator,dynamodb,load-balancing,boto3
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Database
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: boto3>=1.43.64
Requires-Dist: botocore>=1.43.64
Provides-Extra: async
Requires-Dist: aiobotocore>=3.9.1; extra == "async"
Requires-Dist: aiohttp>=3.14.3; extra == "async"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
Requires-Dist: pytest-cov>=4.0; extra == "dev"
Requires-Dist: pytest-timeout>=2.0; extra == "dev"
Requires-Dist: hypothesis>=6.0; extra == "dev"
Requires-Dist: mypy>=1.0; extra == "dev"
Requires-Dist: ruff>=0.16.0; extra == "dev"
Requires-Dist: types-aiobotocore[dynamodb]>=3.9.1; extra == "dev"
Requires-Dist: boto3-stubs[dynamodb]>=1.43.64; extra == "dev"
Dynamic: license-file

# Alternator Load Balancing Client for Python

A Python library that provides client-side load balancing for [ScyllaDB Alternator](https://docs.scylladb.com/stable/alternator/), wrapping boto3/aiobotocore to transparently distribute requests across cluster nodes.

## Features

- **Automatic Load Balancing**: Distributes requests across all available Alternator nodes using randomized request-scoped plans
- **Node Discovery**: Automatically discovers cluster topology via the `/localnodes` endpoint
- **Topology Awareness**: Route requests to specific datacenters or racks
- **Key Affinity Routing**: Optimizes LWT (Lightweight Transaction) operations by routing requests for the same partition key to the same node
- **Request Compression**: Optional gzip request compression to reduce bandwidth
- **Response Compression**: Optional gzip/deflate response decompression
- **Header Optimization**: Filters unnecessary headers to reduce request overhead
- **TLS Support**: Full TLS/SSL support with custom CA certificates
- **Async Support**: Full async/await support via aiobotocore

See the
[capability matrix](https://github.com/scylladb/alternator-client-python/blob/main/docs/CAPABILITY_MATRIX.md)
for the current support status and planned follow-up work.

## Installation

```bash
# Basic installation (sync client only)
pip install alternator-client

# With async support
pip install alternator-client[async]
```

> **Note:** The PyPI package name is `alternator-client`, but the Python import remains `alternator`.

## Quick Start

### Which API Should I Use?

Use `alternator.client(...)` for the common synchronous case where a context
manager can own the SDK client and background node refresh.

Use `create_client` / `close_client` when the SDK client must be created in one
place and closed elsewhere. Use `create_resource` / `close_resource` for the
boto3 table-oriented resource interface.

Use `Helper` or `AsyncHelper` when one object should own client/resource
lifecycle and expose topology diagnostics such as node refresh, node inspection,
routing validation, and partition-key cache inspection.

### Synchronous Client

For the common case, use the top-level `alternator.client` context manager.
Seeds are host names or IP addresses only; use `port` for the single Alternator
port.

```python
import alternator

with alternator.client(
    seeds=["192.168.1.1", "192.168.1.2"],
    port=8000,
) as client:
    response = client.list_tables()
    print(response["TableNames"])
```

```python
from alternator import Config, AlternatorClient

# Configure the client
config = Config(
    seed_hosts=["192.168.1.1", "192.168.1.2"],
    port=8000,
)

# Use as a context manager (recommended)
with AlternatorClient(config) as client:
    # Use like a normal boto3 DynamoDB client
    response = client.list_tables()
    print(response["TableNames"])

    # Put an item
    client.put_item(
        TableName="my_table",
        Item={
            "pk": {"S": "user123"},
            "data": {"S": "Hello, World!"},
        }
    )
```

### Asynchronous Client

```python
import asyncio
from alternator import Config
from alternator.async_client import AsyncAlternatorClient

async def main():
    config = Config(
        seed_hosts=["192.168.1.1"],
        port=8000,
    )

    async with AsyncAlternatorClient(config) as client:
        # Use like a normal aiobotocore DynamoDB client
        response = await client.list_tables()
        print(response["TableNames"])

asyncio.run(main())
```

### Helper Facade

Use `Helper` when you need explicit lifecycle control or diagnostics in addition
to standard boto3 clients and resources.

```python
from alternator import Config, Helper

config = Config(seed_hosts=["192.168.1.1", "192.168.1.2"], port=8000)

with Helper(config) as helper:
    client = helper.client()
    resource = helper.resource()

    helper.update_live_nodes()
    print(helper.get_nodes())
    print(helper.next_node())

    client.list_tables()
    resource.Table("my_table").get_item(Key={"pk": "user123"})
```

Async code can use `AsyncHelper`:

```python
from alternator import Config
from alternator.async_client import AsyncHelper

config = Config(seed_hosts=["192.168.1.1"], port=8000)

async with AsyncHelper(config) as helper:
    client = await helper.client()
    await helper.update_live_nodes()
    print(helper.get_nodes())
    await client.list_tables()
```

`get_active_nodes()` currently returns the live-node list, and
`get_quarantined_nodes()` returns an empty list because node health and
quarantine behavior are intentionally deferred.

## Configuration

### Basic Configuration

```python
from alternator import Config

config = Config(
    seed_hosts=["node1.example.com", "node2.example.com"],
    port=8000,
    scheme="http",  # or "https" for TLS
)
```

> **2.0 compatibility:** `AlternatorConfig` and `TlsConfig` remain available for
> existing callers, but are deprecated. Prefer `Config` and `TLS` for new code.
> `AlternatorConfigBuilder.build()` now returns `Config`, rather than the
> deprecated `AlternatorConfig` subclass. See the
> [2.0.0 release notes](https://github.com/scylladb/alternator-client-python/blob/main/docs/RELEASE_NOTES.md)
> for breaking changes and migration guidance.

### Using the Builder Pattern

```python
from alternator import (
    AlternatorConfigBuilder,
    CompressionAlgorithm,
    KeyRouteAffinityMode,
    ResponseCompression,
    TLS,
)

config = (
    AlternatorConfigBuilder()
    .with_seeds("node1.example.com", "node2.example.com")
    .with_port(8000)
    .with_https(TLS.system_default())
    .with_datacenter("us-east-1")
    .with_compression(CompressionAlgorithm.GZIP, min_size=1024)
    .with_response_compression(ResponseCompression.GZIP)
    .with_key_affinity(KeyRouteAffinityMode.RMW)
    .with_refresh_intervals(active_ms=1000, idle_ms=60000)
    .build()
)
```

### Configuration Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `seed_hosts` | `Sequence[str]` | (required) | Initial nodes for cluster discovery |
| `port` | `int` | (required) | Alternator port |
| `scheme` | `str` | `"http"` | Protocol scheme (`"http"` or `"https"`) |
| `routing_scope` | `RoutingScope` | `ClusterScope()` | Topology-aware routing |
| `compression` | `CompressionAlgorithm` | `NONE` | Request compression |
| `min_compression_size_bytes` | `int` | `1024` | Minimum body size to compress |
| `gzip_level` | `int` | `9` | gzip compression level, `0` through `9` |
| `response_compression` | `Sequence[ResponseCompression]` | empty | Accepted response compression encodings |
| `optimize_headers` | `bool` | `False` | Enable header filtering |
| `headers_whitelist` | `frozenset[str]` | `None` | Additional headers to keep |
| `header_whitelist_callback` | callable | `None` | Callback for dynamic header whitelist additions |
| `tls` | `TLS` | system default | TLS trust, client certificates, and key logging |
| `key_affinity` | `KeyRouteAffinityConfig` | `NONE` | Key-based routing |
| `retries` | `RetryConfig` | standard, 3 attempts | SDK retry behavior |
| `max_pool_connections` | `int` | `200` | Max connections per host |
| `timeouts` | `TimeoutConfig` | discovery 5s, connect 5s, read 30s | Discovery and SDK per-attempt timeouts |
| `aws_region` | `str` | `"us-east-1"` | Region placeholder required by the SDK |
| `user_agent` | str, callable, or `None` | `alternator-client-python/<version>` | Final User-Agent; `None` omits the wire header |
| `active_refresh_interval_ms` | `int` | `1000` | Node refresh interval when active |
| `idle_refresh_interval_ms` | `int` | `60000` | Node refresh interval when idle |

## Authentication

Authentication is disabled by default. Alternator authentication in this client
supports static credentials only; AWS SDK environment, profile, and provider-chain
credentials are not used for Alternator auth.

```python
from alternator import Auth, AlternatorClient, Config

config = Config(seed_hosts=["node1"], port=8000)

# Default: unsigned requests
with AlternatorClient(config, auth=Auth.disabled()) as client:
    client.list_tables()

# Signed requests with static Alternator credentials
with AlternatorClient(
    config,
    auth=Auth.static_credentials("alternator", "secret"),
) as client:
    client.list_tables()
```

Passing raw boto credential kwargs such as `aws_access_key_id` still works for
compatibility, but is deprecated. Prefer `auth=Auth.static_credentials(...)`.

## Comparing with a Regular AWS SDK Client

An Alternator client is a boto3 DynamoDB client configured with ScyllaDB
Alternator node discovery and load balancing. A regular AWS SDK client uses the
normal AWS DynamoDB regional endpoint and AWS SDK credential chain.

```python
import boto3
import alternator

with alternator.client(
    seeds=["node1.example.com", "node2.example.com"],
    port=8000,
) as alternator_client:
    aws_client = boto3.client("dynamodb", region_name="us-east-1")

    print("Alternator endpoint:", alternator_client.meta.endpoint_url)
    print("AWS endpoint:", aws_client.meta.endpoint_url)

    print("Alternator tables:", alternator_client.list_tables()["TableNames"])
    # Requires normal AWS credentials:
    # print("AWS tables:", aws_client.list_tables()["TableNames"])
```

See `examples/compare_aws_sdk.py` for a runnable version. The example keeps
Alternator seeds host-only and uses one `port` setting for all seeds.

## Routing Scopes

Control which nodes receive your requests based on topology:

```python
from alternator import Config, ClusterScope, DatacenterScope, RackScope

# Route to any node in the cluster (default)
config = Config(
    seed_hosts=["node1"],
    port=8000,
    routing_scope=ClusterScope(),
)

# Route only to nodes in a specific datacenter
config = Config(
    seed_hosts=["node1"],
    port=8000,
    routing_scope=DatacenterScope(datacenter="us-east-1"),
)

# Route only to nodes in a specific rack
config = Config(
    seed_hosts=["node1"],
    port=8000,
    routing_scope=RackScope(datacenter="us-east-1", rack="rack1"),
)
```

`ClusterScope` queries every configured seed host and combines the returned
`/localnodes` results. Because ScyllaDB's optionless `/localnodes` endpoint
returns nodes from the contacted seed's local datacenter, cluster-wide routing
spans multiple datacenters only when the configuration includes at least one
reachable seed from each datacenter.

Default constructors stay constrained to their requested scope:

- `DatacenterScope("dc1")` tries only datacenter `dc1`
- `RackScope("dc1", "rack1")` tries only rack `rack1` in datacenter `dc1`

Use the named `fallback` argument when a broader fallback chain is desired:

```python
from alternator import ClusterScope, DatacenterScope, RackScope

cluster_only = ClusterScope()
datacenter_only = DatacenterScope("dc1")
datacenter_then_cluster = DatacenterScope("dc1", fallback=ClusterScope())
rack_only = RackScope("dc1", "rack1")
rack_then_datacenter = RackScope(
    "dc1",
    "rack1",
    fallback=DatacenterScope("dc1", fallback=None),
)
rack_then_datacenter_then_cluster = RackScope(
    "dc1",
    "rack1",
    fallback=DatacenterScope("dc1", fallback=ClusterScope()),
)
```

`Helper.check_rack_and_datacenter_set_correctly()` validates the configured
scope by querying `/localnodes` with the configured datacenter and rack filters
without replacing the helper's current live-node list.
`AsyncHelper` exposes the same validation methods as awaitable methods.

## Key Affinity (LWT Optimization)

For Lightweight Transactions (conditional writes), routing requests for the same partition key to the same node can improve performance:

```python
from alternator import (
    AlternatorConfigBuilder,
    KeyRouteAffinityMode,
)

config = (
    AlternatorConfigBuilder()
    .with_seeds("node1")
    .with_port(8000)
    .with_key_affinity(
        mode=KeyRouteAffinityMode.RMW,  # Only for read-modify-write ops
        table_pk_map={"my_table": "pk"},  # Optional: preload PK names
    )
    .build()
)
```

### Affinity Modes

| Mode | Description |
|------|-------------|
| `NONE` | Disabled (default randomized routing) |
| `RMW` | Only for write operations that require a read-before-write path |
| `ANY_WRITE` | For all write operations (`PutItem`, `UpdateItem`, `DeleteItem`, `BatchWriteItem`) |

`RMW` mode applies affinity to conditional `PutItem`/`DeleteItem`, non-`NONE`
returns, and `UpdateItem` requests that need prior item state, including
non-empty update or condition expressions, non-empty legacy `Expected`, selected
`ReturnValues`, `ADD`, and value-bearing `DELETE` attribute updates.
`BatchWriteItem` does not use affinity in `RMW` mode.

`ANY_WRITE` mode applies affinity to single-item writes using the request
partition key. For `BatchWriteItem`, each valid put/delete votes for its
preferred node. The request orders voted nodes by descending vote count and
then address, followed by remaining nodes. Missing partition-key metadata and
unsupported key values are skipped; absence of usable votes falls back to
normal routing.

## TLS Configuration

```python
from alternator import TLS, TlsSessionCacheConfig
from pathlib import Path

# Use system CA certificates (default)
tls = TLS.system_default()

# Use custom CA certificate
tls = TLS.with_custom_ca(Path("/path/to/ca.pem"))

# Trust all certificates (INSECURE - dev only)
tls = TLS.trust_all()

# Mutual TLS with separate certificate and key files
tls = TLS(
    custom_ca_cert_paths=[Path("/path/to/ca.pem")],
    client_cert_path=Path("/path/to/client.crt"),
    client_key_path=Path("/path/to/client.key"),
)

# Mutual TLS with a combined certificate/key PEM file
tls = TLS(
    custom_ca_cert_paths=[Path("/path/to/ca.pem")],
    client_cert_path=Path("/path/to/client-combined.pem"),
)

# Debug TLS traffic with a key log file
tls = TLS(
    custom_ca_cert_paths=[Path("/path/to/ca.pem")],
    key_log_file_path=Path("/secure/tmp/alternator-tls.keys"),
)

# Full configuration
tls = TLS(
    custom_ca_cert_paths=[Path("/path/to/ca.pem")],
    trust_system_ca_certs=True,
    verify_hostname=True,
    session_cache=TlsSessionCacheConfig(
        enabled=True,
        cache_size=1024,
        timeout_seconds=86400,
    ),
    client_cert_path=Path("/path/to/client.crt"),
    client_key_path=Path("/path/to/client.key"),
    key_log_file_path=Path("/secure/tmp/alternator-tls.keys"),
)
```

Client certificate settings are loaded into the SSL context used for
`/localnodes` discovery and passed to the SDK as `client_cert` for HTTPS
DynamoDB API calls. If you configure custom server CA certificates, continue to
pass the matching SDK `verify` argument or an equivalent SDK setting for API
calls.

TLS key logs contain traffic decryption material. Store them only in protected
temporary locations, delete them after debugging, and never commit them. Key log
support depends on Python/OpenSSL exposing `SSLContext.keylog_filename`; runtimes
without that attribute ignore `key_log_file_path`.

## Request And Response Compression

Enable gzip compression for large request bodies:

> **Note:** Gzip request compression requires **ScyllaDB 2026.1.0 or later**. HTTP response compression requires an Alternator build that includes ScyllaDB core response-compression support from `scylladb/scylladb#27454`.

```python
from alternator import (
    AlternatorConfigBuilder,
    CompressionAlgorithm,
    ResponseCompression,
)

config = (
    AlternatorConfigBuilder()
    .with_seeds("node1")
    .with_port(8000)
    .with_compression(
        CompressionAlgorithm.GZIP,
        min_size=1024,  # Only compress bodies >= 1KB
        gzip_level=6,   # Python gzip level 0-9; default is 9
    )
    .with_response_compression(
        ResponseCompression.GZIP,
        ResponseCompression.DEFLATE,
    )
    .build()
)
```

Compression uses Python's `gzip.compress` implementation. Levels `0` through
`9` are accepted: lower levels spend less CPU and usually produce larger bodies;
higher levels spend more CPU and usually produce smaller bodies. The client only
sends compressed bodies when the compressed payload is smaller than the original
payload.

Response compression is disabled by default. When enabled, the client sends
`Accept-Encoding` with the configured encodings and decodes `Content-Encoding:
gzip` or `Content-Encoding: deflate` responses before boto3/aiobotocore parses the
DynamoDB JSON body. Use `.without_response_compression()` to disable it again in
builder chains.

## Header Optimization

Header optimization remains opt-in. Required protocol, compression, and auth
headers are preserved automatically. Use `whitelist` for static additions and
`whitelist_callback` when the allowed headers depend on configuration or auth
state:

```python
from alternator import AlternatorConfigBuilder, HeaderWhitelistContext

def extra_headers(context: HeaderWhitelistContext) -> set[str]:
    if context.auth_enabled:
        return {"X-Service-Trace"}
    return {"X-Anonymous-Trace"}

config = (
    AlternatorConfigBuilder()
    .with_seeds("node1")
    .with_port(8000)
    .with_header_optimization(
        whitelist={"X-Static-Header"},
        whitelist_callback=extra_headers,
    )
    .build()
)
```

The callback returns additional headers to keep. It cannot remove the required
headers exposed in `context.required_headers`.

## Error Handling

```python
from alternator import (
    AlternatorClient,
    Config,
    AlternatorError,
    NoNodesAvailableError,
    ConfigurationError,
)

try:
    config = Config(seed_hosts=[], port=8000)
except ConfigurationError as e:
    print(f"Invalid configuration: {e}")

try:
    with AlternatorClient(config) as client:
        client.list_tables()
except NoNodesAvailableError as e:
    print(f"No nodes available: {e}")
except AlternatorError as e:
    print(f"Alternator error: {e}")
```

## Logging

The library uses Python's standard logging module with the logger name `alternator`:

```python
import logging

# Enable debug logging
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("alternator").setLevel(logging.DEBUG)
```

Log levels:
- `INFO`: Node discovery events
- `WARNING`: Fallback events, connection issues
- `DEBUG`: Detailed routing decisions, node lists
- `ERROR`: Failed operations

## DynamoDB Resource Interface

For table-oriented operations, use `AlternatorResource` which wraps boto3's DynamoDB resource:

```python
from alternator import Config, AlternatorResource

config = Config(seed_hosts=["192.168.1.1"], port=8000)

with AlternatorResource(config) as resource:
    table = resource.Table("my_table")
    table.put_item(Item={"pk": "user123", "data": "hello"})
    response = table.get_item(Key={"pk": "user123"})
```

You can also use the factory function:

```python
from alternator import create_resource, close_resource, Config

config = Config(seed_hosts=["node1"], port=8000)
resource = create_resource(config)

try:
    table = resource.Table("my_table")
    table.scan()
finally:
    close_resource(resource)
```

## Manual Resource Management

If you prefer not to use context managers:

```python
from alternator import create_client, close_client, Config

config = Config(seed_hosts=["node1"], port=8000)
client = create_client(config)

try:
    client.list_tables()
finally:
    close_client(client)  # Stop discovery and close the underlying SDK session
```

Async equivalent:

```python
from alternator import Config
from alternator.async_client import create_async_client, close_async_client

config = Config(seed_hosts=["node1"], port=8000)
client = await create_async_client(config)

try:
    await client.list_tables()
finally:
    await close_async_client(client)
```

## Transport Configuration

`TimeoutConfig.discovery_seconds` applies only to `/localnodes` discovery
requests. `TimeoutConfig.connect_seconds` and `TimeoutConfig.read_seconds` are
passed to botocore/aiobotocore as per-attempt SDK connect and read timeouts;
they are not whole-operation deadlines. Use application-level cancellation or
your own deadline wrapper for end-to-end call deadlines.

`RetryConfig`, `max_pool_connections`, `aws_region`, and SDK timeouts are passed
to the generated SDK config. `aws_region` is a placeholder required by the SDK;
Alternator request routing still uses discovered Alternator endpoints.

```python
from alternator import Config, RetryConfig, RetryMode, TimeoutConfig

config = Config(
    seed_hosts=["node1", "node2"],
    port=8000,
    retries=RetryConfig(max_attempts=4, mode=RetryMode.STANDARD),
    max_pool_connections=300,
    timeouts=TimeoutConfig(
        discovery_seconds=3.0,
        connect_seconds=2.0,
        read_seconds=10.0,
    ),
)
```

By default, Alternator sends `alternator-client-python/<version>` as the final
wire `User-Agent` header. Pass `None` to omit the header:

```python
from alternator import Config, create_client

config = Config(
    seed_hosts=["node1", "node2"],
    port=8000,
    user_agent=None,
)
client = create_client(config)
```

Pass a string to `user_agent` when you need to set a final value:

```python
config = Config(
    seed_hosts=["node1", "node2"],
    port=8000,
    user_agent="orders-service/1.0",
)
client = create_client(config)
```

Pass a callback when you need to wrap or add to the default
`alternator-client-python/<version>` identity:

```python
config = Config(
    seed_hosts=["node1", "node2"],
    port=8000,
    user_agent=lambda default: f"orders-service {default}",
)
client = create_client(config)
```

The client still owns the SDK config object, endpoint routing, and the final
wire `User-Agent` header. Use typed Alternator config fields for SDK transport
settings: `RetryConfig` for retry behavior, `TimeoutConfig` for connect/read
timeouts, `max_pool_connections` for pool sizing, `aws_region` for the SDK
region placeholder, and `TLS` for client certificates. Python botocore does not
expose direct knobs for max idle connections, max idle connections per host, or
idle connection timeout; tune `max_pool_connections`, retries, and timeouts
instead.

## Production Recommendations

- **Connection pool sizing**: The default `max_pool_connections=200` works for most workloads. Increase if you see connection pool exhaustion warnings under high concurrency.
- **Refresh intervals**: Default active refresh (1s) is appropriate for dynamic clusters. For stable clusters, increase `active_refresh_interval_ms` to reduce discovery overhead.
- **Timeouts**: Default `TimeoutConfig.discovery_seconds=5.0`, `connect_seconds=5.0`, and `read_seconds=30.0` are conservative. Tune based on your network latency and query complexity.
- **Monitoring**: Enable `INFO`-level logging for the `alternator` logger to track node discovery events. Use `DEBUG` for detailed routing decisions during troubleshooting.
- **Seed hosts**: Configure at least 2-3 seed hosts for redundancy in case one seed is temporarily unavailable during startup.

## Thread Safety

Sync clients created by `create_client` / `AlternatorClient` are thread-safe: the underlying node selection, round-robin counter, and node list updates are all protected by locks. You can safely share a single client across multiple threads.

Async clients created by `create_async_client` / `AsyncAlternatorClient` are safe to use from multiple concurrent coroutines within the same event loop. Do not share an async client across different event loops.

## Known Limitations

- **Request Compression**: Gzip request compression requires ScyllaDB 2026.1.0+.
- **Response Compression**: Response gzip/deflate decoding requires an Alternator build that includes `scylladb/scylladb#27454` and must be enabled explicitly with `with_response_compression(...)`.
- **Gzip Compression Levels**: Python's gzip module supports levels `0` through `9`; this client does not expose alternative compression algorithms or custom compressor objects.
- **TLS Session Cache Settings**: The `cache_size` and `timeout_seconds` parameters in `TlsSessionCacheConfig` are not currently used by Python's `ssl` module. Only the `enabled` flag controls session ticket behavior.
- **TLS Key Logs**: Key log file support depends on Python/OpenSSL runtime support for `SSLContext.keylog_filename` and should only be used in protected debugging environments.
- **mTLS Integration Fixtures**: The local Scylla fixture in this repository does not require client certificate authentication, so automated tests cover configuration propagation and SSL context setup rather than a full mutual-TLS handshake.
- **Key Affinity Discovery**: For sync and async clients, partition key auto-discovery happens in the background. The first request for an unknown table uses normal routing while discovery runs; subsequent requests use affinity. Preloading via `table_pk_map` avoids this initial miss.
- **Batch Operations**: `BatchWriteItem` key affinity in `ANY_WRITE` mode uses preferred-node voting across eligible put/delete entries. Tied nodes use address order; missing partition-key metadata and unsupported key values are skipped. No eligible votes cause normal-routing fallback; no active nodes fail locally. Batches are not split by affinity target.
- **Node Health**: Node health, quarantine behavior, decommission handling, and dead-node handling are planning-only. `get_quarantined_nodes()` returns an empty list until a future implementation is explicitly added.

## Examples

- `examples/sync_demo.py`: synchronous client lifecycle and basic operations
- `examples/async_demo.py`: async client lifecycle and concurrent operations
- `examples/compare_aws_sdk.py`: Alternator client setup compared with a regular AWS SDK DynamoDB client
- `examples/capability_configuration.py`: helper lifecycle, explicit routing fallback, static auth, timeouts/retries, mTLS, compression/header optimization, and key affinity configuration recipes

## Release Notes

See the
[release notes](https://github.com/scylladb/alternator-client-python/blob/main/docs/RELEASE_NOTES.md)
for the changes and migration steps in 2.0.0.

## Development

```bash
# Clone the repository
git clone https://github.com/scylladb/alternator-client-python.git
cd alternator-client-python

# Install in development mode
make install

# Run tests
make test-unit

# Run linting
make lint

# Run mypy type checks
make typecheck

# Start local Scylla cluster for integration tests
make scylla-start
make test-integration
make scylla-stop
```

## License

Apache License 2.0

## Contributing

Contributions are welcome! Please read the contributing guidelines before submitting a pull request.
