diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3973c48..46624df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [main, master] pull_request: - branches: [main, master] jobs: lint: @@ -62,8 +61,5 @@ jobs: - name: Install dependencies run: make install - - name: Start 3-node Scylla cluster with TLS - run: make scylla-start - - name: Run integration tests run: make test-integration diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9ea62c..e48bd71 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,8 +146,6 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 - with: - skip-existing: true create-release: name: Create GitHub Release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5d76081 --- /dev/null +++ b/LICENSE @@ -0,0 +1,182 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, "control" means (i) the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, +made available under the License, as indicated by a copyright notice that is +included in or attached to the work (an example is provided in the Appendix +below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work by +the copyright owner or by an individual or Legal Entity authorized to submit on +behalf of the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent to the +Licensor or its representatives, including but not limited to communication on +electronic mailing lists, source code control systems, and issue tracking +systems that are managed by, or on behalf of, the Licensor for the purpose of +discussing and improving the Work, but excluding communication that is +conspicuously marked or otherwise designated in writing by the copyright owner +as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable copyright license to +reproduce, prepare Derivative Works of, publicly display, publicly perform, +sublicense, and distribute the Work and such Derivative Works in Source or +Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, +each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable (except as stated in this section) patent +license to make, have made, use, offer to sell, sell, import, and otherwise +transfer the Work, where such license applies only to those patent claims +licensable by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) with the Work to +which such Contribution(s) was submitted. If You institute patent litigation +against any entity (including a cross-claim or counterclaim in a lawsuit) +alleging that the Work or a Contribution incorporated within the Work +constitutes direct or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate as of the date +such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or +Derivative Works thereof in any medium, with or without modifications, and in +Source or Object form, provided that You meet the following conditions: + +(a) You must give any other recipients of the Work or Derivative Works a copy of +this License; and + +(b) You must cause any modified files to carry prominent notices stating that +You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works that You +distribute, all copyright, patent, trademark, and attribution notices from the +Source form of the Work, excluding those notices that do not pertain to any part +of the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its distribution, then +any Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the Derivative +Works; within the Source form or documentation, if provided along with the +Derivative Works; or within a display generated by the Derivative Works, if and +wherever such third-party notices normally appear. The contents of the NOTICE +file are for informational purposes only and do not modify the License. You may +add Your own attribution notices within Derivative Works that You distribute, +alongside or as an addendum to the NOTICE text from the Work, provided that such +additional attribution notices cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any +Contribution intentionally submitted for inclusion in the Work by You to the +Licensor shall be under the terms and conditions of this License, without any +additional terms or conditions. Notwithstanding the above, nothing herein shall +supersede or modify the terms of any separate license agreement you may have +executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, +trademarks, service marks, or product names of the Licensor, except as required +for reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in +writing, Licensor provides the Work (and each Contributor provides its +Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied, including, without limitation, any warranties or +conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any risks +associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in +tort (including negligence), contract, or otherwise, unless required by +applicable law (such as deliberate and grossly negligent acts) or agreed to in +writing, shall any Contributor be liable to You for damages, including any +direct, indirect, special, incidental, or consequential damages of any character +arising as a result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, work stoppage, +computer failure or malfunction, or any and all other commercial damages or +losses), even if such Contributor has been advised of the possibility of such +damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or +Derivative Works thereof, You may choose to offer, and charge a fee for, +acceptance of support, warranty, indemnity, or other liability obligations +and/or rights consistent with this License. However, in accepting such +obligations, You may act only on Your own behalf and on Your sole responsibility, +not on behalf of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability incurred by, or +claims asserted against, such Contributor by reason of your accepting any such +warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets "[]" replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same "printed page" as the copyright notice for easier identification within +third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..13f09b2 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,8 @@ +include LICENSE +include README.md +include RELEASING.md +include Makefile +include docker-compose.yml +include renovate.json +recursive-include examples *.py +recursive-include tests *.py *.yaml diff --git a/README.md b/README.md index b2da312..9f8d5e9 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,6 @@ A Python library that provides client-side load balancing for [ScyllaDB Alternat - **TLS Support**: Full TLS/SSL support with custom CA certificates - **Async Support**: Full async/await support via aioboto3 -See [docs/CAPABILITY_MATRIX.md](docs/CAPABILITY_MATRIX.md) for the current -capability matrix and planned follow-up work. - ## Installation ```bash @@ -145,10 +142,6 @@ async with AsyncSession(config) as session: await client.list_tables() ``` -`active_nodes` currently returns the live-node list, and -`quarantined_nodes` returns an empty list because node health and -quarantine behavior are intentionally deferred. - ## Configuration ### Basic Configuration @@ -163,33 +156,37 @@ config = Config( ) ``` -> **Compatibility:** `AlternatorConfig` and `TlsConfig` remain available for -> existing callers, but are deprecated. Prefer `Config` and `TLS` for new code. -> See [docs/COMPATIBILITY_AND_RELEASE.md](docs/COMPATIBILITY_AND_RELEASE.md) -> for compatibility and versioning decisions. - -### Using the Builder Pattern +### Composed Configuration ```python from alternator import ( - AlternatorConfigBuilder, CompressionAlgorithm, + Config, + DatacenterScope, + KeyRouteAffinityConfig, KeyRouteAffinityMode, + NodeListPollingConfig, + RequestCompressionConfig, 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() +config = Config( + seed_hosts=["node1.example.com", "node2.example.com"], + port=8000, + scheme="https", + tls=TLS.system_default(), + routing_scope=DatacenterScope("us-east-1"), + request_compression=RequestCompressionConfig( + algorithm=CompressionAlgorithm.GZIP, + min_size_bytes=1024, + ), + response_compression=(ResponseCompression.GZIP,), + key_affinity=KeyRouteAffinityConfig(mode=KeyRouteAffinityMode.RMW), + node_list_polling=NodeListPollingConfig( + active_interval_ms=1000, + idle_interval_ms=60000, + ), ) ``` @@ -201,13 +198,9 @@ config = ( | `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` | +| `request_compression` | `RequestCompressionConfig` | disabled; 1 KiB threshold, level 9 when enabled | Request gzip settings | | `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 | +| `header_optimization` | `HeaderOptimizationConfig` | disabled | Header filtering settings | | `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 | @@ -215,8 +208,7 @@ config = ( | `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/` | 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 | +| `node_list_polling` | `NodeListPollingConfig` | active 1s, idle 60s | Node refresh intervals | ## Authentication @@ -247,8 +239,9 @@ with alternator.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(...)`. +You can also pass boto-style credential kwargs such as `aws_access_key_id`. +Prefer `auth=Auth.static_credentials(...)` when you want the authentication +choice to be explicit in Alternator code. ## Comparing with a Regular AWS SDK Client @@ -341,7 +334,7 @@ rack_then_datacenter_then_cluster = RackScope( `Session.validate_scope()` validates the configured scope by querying `/localnodes` with the configured datacenter and rack filters -without replacing the helper's current live-node list. +without replacing the session's current live-node list. `AsyncSession` exposes the same validation methods as awaitable methods. ## Key Affinity (LWT Optimization) @@ -350,19 +343,18 @@ For Lightweight Transactions (conditional writes), routing requests for the same ```python from alternator import ( - AlternatorConfigBuilder, + Config, + KeyRouteAffinityConfig, KeyRouteAffinityMode, ) -config = ( - AlternatorConfigBuilder() - .with_seeds("node1") - .with_port(8000) - .with_key_affinity( +config = Config( + seed_hosts=["node1"], + port=8000, + key_affinity=KeyRouteAffinityConfig( mode=KeyRouteAffinityMode.RMW, # Only for read-modify-write ops - table_pk_map={"my_table": "pk"}, # Optional: preload PK names - ) - .build() + table_pk_attributes={"my_table": "pk"}, # Optional: preload PK names + ), ) ``` @@ -389,7 +381,7 @@ key values, no active nodes, no votes, or tied votes fall back to normal routing ## TLS Configuration ```python -from alternator import TLS, TlsSessionCacheConfig +from alternator import TLS from pathlib import Path # Use system CA certificates (default) @@ -425,11 +417,7 @@ 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, - ), + session_tickets_enabled=True, 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"), @@ -455,25 +443,24 @@ Enable gzip compression for large request bodies: ```python from alternator import ( - AlternatorConfigBuilder, CompressionAlgorithm, + Config, + RequestCompressionConfig, ResponseCompression, ) -config = ( - AlternatorConfigBuilder() - .with_seeds("node1") - .with_port(8000) - .with_compression( - CompressionAlgorithm.GZIP, - min_size=1024, # Only compress bodies >= 1KB +config = Config( + seed_hosts=["node1"], + port=8000, + request_compression=RequestCompressionConfig( + algorithm=CompressionAlgorithm.GZIP, + min_size_bytes=1024, # Only compress bodies >= 1KB gzip_level=6, # Python gzip level 0-9; default is 9 - ) - .with_response_compression( + ), + response_compression=( ResponseCompression.GZIP, ResponseCompression.DEFLATE, - ) - .build() + ), ) ``` @@ -486,8 +473,7 @@ 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/aioboto3 parses the -DynamoDB JSON body. Use `.without_response_compression()` to disable it again in -builder chains. +DynamoDB JSON body. ## Header Optimization @@ -497,22 +483,21 @@ headers are preserved automatically. Use `whitelist` for static additions and state: ```python -from alternator import AlternatorConfigBuilder, HeaderWhitelistContext +from alternator import Config, HeaderOptimizationConfig, 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"}, +config = Config( + seed_hosts=["node1"], + port=8000, + header_optimization=HeaderOptimizationConfig( + enabled=True, + whitelist=frozenset({"X-Static-Header"}), whitelist_callback=extra_headers, - ) - .build() + ), ) ``` @@ -803,7 +788,7 @@ 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. +- **Refresh intervals**: Default active refresh (1s) is appropriate for dynamic clusters. For stable clusters, set `node_list_polling=NodeListPollingConfig(active_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. @@ -817,27 +802,21 @@ Async clients created by `AsyncSession.client("dynamodb")` are safe to use from ## 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(...)`. +- **Response Compression**: Response gzip/deflate decoding requires an Alternator build that includes `scylladb/scylladb#27454` and must be enabled explicitly with `Config.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 Session Tickets**: `TLS.session_tickets_enabled` controls session ticket behavior. Python's `ssl` module does not expose direct cache size or timeout controls. - **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. -- **Async Key Affinity**: For async clients, partition key auto-discovery happens asynchronously. The first request for an unknown table will use round-robin routing while discovery runs in the background. Subsequent requests will use affinity. Preloading via `table_pk_map` avoids this initial miss. +- **Async Key Affinity**: For async clients, partition key auto-discovery happens asynchronously. The first request for an unknown table will use round-robin routing while discovery runs in the background. Subsequent requests will use affinity. Preloading `KeyRouteAffinityConfig.table_pk_attributes` avoids this initial miss. - **Batch Operations**: `BatchWriteItem` key affinity in `ANY_WRITE` mode uses preferred-node voting across eligible put/delete entries. Ties, missing partition-key metadata, unsupported key values, no active nodes, or no eligible votes fall back to normal routing. Batches are not split by affinity target. -- **Node Health**: Node health, quarantine behavior, decommission handling, and dead-node handling are planning-only. `Session.quarantined_nodes` returns an empty list until a future implementation is explicitly added. +- **Node Health**: Node health, quarantine behavior, decommission handling, and dead-node handling are planning-only. ## 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 [docs/RELEASE_NOTES.md](docs/RELEASE_NOTES.md) for capability release-note -guidance covering additive APIs, deprecations, behavior notes, migration steps, -and versioning expectations. +- `examples/capability_configuration.py`: session lifecycle, explicit routing fallback, static auth, timeouts/retries, mTLS, compression/header optimization, and key affinity configuration recipes ## Development diff --git a/RELEASING.md b/RELEASING.md index 790d564..eb69af6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -6,24 +6,22 @@ This document describes how to release a new version of the Alternator Load Bala 1. You must have write access to the repository 2. The `pypi` environment must be configured in GitHub with PyPI trusted publishing -3. (Optional) GPG signing keys must be configured for signed releases +3. GPG signing keys must be configured in GitHub Secrets; the release workflow + signs package artifacts, checksums, and tags ## Creating a Release -### Compatibility Decision Gate +### API Gate -Before choosing the next version, review -[docs/COMPATIBILITY_AND_RELEASE.md](docs/COMPATIBILITY_AND_RELEASE.md). - -Use a minor release for additive compatible APIs and opt-in capabilities. Use a -major release for changed defaults, removed deprecated names, removed legacy -credential kwargs, changed default routing fallback, changed default auth, or -default-enabled node health/quarantine behavior. +Before choosing the next version, review the public API exported by +`alternator.__all__`, the direct import modules documented in the README, and +the examples. Release notes for capability work should call out auth defaults, routing -fallback behavior, deprecated-name migration, key-affinity behavior, and whether -node health remains planning-only. Use -[docs/RELEASE_NOTES.md](docs/RELEASE_NOTES.md) as the release-note source. +fallback behavior, key-affinity behavior, and whether node health remains +planning-only. Future changes that remove public names, change defaults, change +auth behavior, or default-enable node health/quarantine behavior should be +treated as breaking changes. ### 1. Update Version diff --git a/alternator/__init__.py b/alternator/__init__.py index 852a769..e41bd90 100644 --- a/alternator/__init__.py +++ b/alternator/__init__.py @@ -34,18 +34,17 @@ Configuration ------------- -Use ``Config`` for direct configuration or ``AlternatorConfigBuilder`` -for a fluent builder pattern:: - - from alternator import AlternatorConfigBuilder, CompressionAlgorithm - - config = ( - AlternatorConfigBuilder() - .with_seeds("node1", "node2") - .with_port(8000) - .with_https() - .with_compression(CompressionAlgorithm.GZIP) - .build() +Use ``Config`` for direct configuration:: + + from alternator import Config, CompressionAlgorithm, RequestCompressionConfig + + config = Config( + seed_hosts=["node1", "node2"], + port=8000, + scheme="https", + request_compression=RequestCompressionConfig( + algorithm=CompressionAlgorithm.GZIP, + ), ) Key Classes @@ -56,7 +55,6 @@ - ``AsyncSession``: Async lifecycle and diagnostics facade - ``Config``: Main configuration dataclass - ``Auth``: Explicit disabled/static-credentials auth settings -- ``AlternatorConfigBuilder``: Fluent builder for configuration - ``TLS``: TLS/SSL configuration - ``ClusterScope``, ``DatacenterScope``, ``RackScope``: Routing scope controls @@ -86,8 +84,6 @@ ) from alternator.config import ( TLS, - AlternatorConfig, - AlternatorConfigBuilder, Auth, CompressionAlgorithm, Config, @@ -102,8 +98,6 @@ RetryConfig, RetryMode, TimeoutConfig, - TlsConfig, - TlsSessionCacheConfig, UserAgent, UserAgentCustomizer, ) @@ -133,8 +127,6 @@ "Auth", "Config", "TLS", - "AlternatorConfig", - "AlternatorConfigBuilder", "CompressionAlgorithm", "HeaderOptimizationConfig", "HeaderWhitelistCallback", @@ -147,8 +139,6 @@ "RetryConfig", "RetryMode", "TimeoutConfig", - "TlsConfig", - "TlsSessionCacheConfig", "UserAgent", "UserAgentCustomizer", # Exceptions diff --git a/alternator/async_client.py b/alternator/async_client.py index 382943f..9f9d2a3 100644 --- a/alternator/async_client.py +++ b/alternator/async_client.py @@ -22,18 +22,18 @@ create_ssl_context, ) from alternator.client import ( - DEFAULT_PORT, + _DEFAULT_PORT, _config_from_client_args, _validate_service_name, ) -from alternator.config import KeyRouteAffinityMode, build_sdk_config_kwargs +from alternator.config import KeyRouteAffinityMode, _build_sdk_config_kwargs from alternator.core.auth import apply_auth from alternator.core.handlers import _register_alternator_handlers from alternator.core.key_affinity import ( select_affinity_node, ) from alternator.core.live_nodes import AsyncLiveNodesManager, NodeList -from alternator.vector import enable_vector_support +from alternator.vector import _enable_vector_support if TYPE_CHECKING: from types_aiobotocore_dynamodb import DynamoDBClient as AsyncDynamoDBClient @@ -42,9 +42,10 @@ logger = logging.getLogger("alternator") _AUTH_UNSET = object() +__all__ = ["AsyncSession"] -class AsyncPartitionKeyCache: +class _AsyncPartitionKeyCache: """ Async version of partition key cache for table -> pk name mapping. @@ -192,7 +193,7 @@ async def clear(self) -> None: async with self._lock: self._cache.clear() - def preload(self, table_pk_map: dict[str, str]) -> None: + def preload(self, table_pk_attributes: dict[str, str]) -> None: """ Preload cache with known table -> pk mappings. @@ -200,14 +201,14 @@ def preload(self, table_pk_map: dict[str, str]) -> None: before concurrent access begins. Args: - table_pk_map: Mapping of table name to partition key name + table_pk_attributes: Mapping of table name to partition key name """ - self._cache.update(table_pk_map) + self._cache.update(table_pk_attributes) def _create_async_affinity_node_computer( config: Config, - pk_cache: AsyncPartitionKeyCache | None, + pk_cache: _AsyncPartitionKeyCache | None, ) -> Callable[[str, dict[str, Any], NodeList], str | None] | None: """ Create a function that selects the preferred key-affinity node. @@ -318,7 +319,7 @@ def _create_aio_config(config: Config, *, auth_enabled: bool) -> object: from botocore import UNSIGNED - kwargs = build_sdk_config_kwargs(config) + kwargs = _build_sdk_config_kwargs(config) kwargs.pop("signature_version", None) if not auth_enabled: kwargs["signature_version"] = UNSIGNED @@ -367,9 +368,9 @@ async def _create_async_client_with_manager( try: # Set up partition key cache if affinity is enabled - pk_cache: AsyncPartitionKeyCache | None = None + pk_cache: _AsyncPartitionKeyCache | None = None if config.key_affinity.mode != KeyRouteAffinityMode.NONE: - pk_cache = AsyncPartitionKeyCache(client) + pk_cache = _AsyncPartitionKeyCache(client) if config.key_affinity.table_pk_attributes: pk_cache.preload(dict(config.key_affinity.table_pk_attributes)) setattr(client, PK_CACHE_ATTR, pk_cache) @@ -389,7 +390,7 @@ async def _create_async_client_with_manager( setattr(client, MANAGER_OWNS_ATTR, owns_manager) # Enable Alternator vector search extensions - enable_vector_support(client) + _enable_vector_support(client) except Exception: await client_ctx.__aexit__(None, None, None) raise @@ -429,7 +430,7 @@ def __init__( cluster_config: Config | None = None, *, seeds: Sequence[str] | None = None, - port: int = DEFAULT_PORT, + port: int = _DEFAULT_PORT, scheme: Literal["http", "https"] = "http", auth: Auth | None = None, **boto_kwargs: Any, # noqa: ANN401 -- aioboto3 kwargs are untyped @@ -524,16 +525,6 @@ def nodes(self) -> list[str]: return [] return list(self._manager.nodes.nodes) - @property - def active_nodes(self) -> list[str]: - """Return active nodes; currently this is the live-node list.""" - return self.nodes - - @property - def quarantined_nodes(self) -> list[str]: - """Return quarantined nodes; node quarantine is not implemented.""" - return [] - async def validate_scope(self) -> bool: """Validate configured rack/datacenter scope without changing state.""" manager = self._manager diff --git a/alternator/client.py b/alternator/client.py index cbb22ee..b755e4f 100644 --- a/alternator/client.py +++ b/alternator/client.py @@ -16,7 +16,7 @@ from alternator._constants import MANAGER_ATTR, MANAGER_OWNS_ATTR, PK_CACHE_ATTR from alternator._http import create_ssl_context, create_sync_http_fetcher -from alternator.config import Config, KeyRouteAffinityMode, build_sdk_config_kwargs +from alternator.config import Config, KeyRouteAffinityMode, _build_sdk_config_kwargs from alternator.core.auth import apply_auth from alternator.core.handlers import _register_alternator_handlers from alternator.core.key_affinity import ( @@ -25,7 +25,7 @@ ) from alternator.core.live_nodes import NodeList, SyncLiveNodesManager from alternator.exceptions import ConfigurationError -from alternator.vector import enable_vector_support +from alternator.vector import _enable_vector_support if TYPE_CHECKING: from mypy_boto3_dynamodb import DynamoDBClient @@ -34,7 +34,8 @@ from alternator.config import Auth logger = logging.getLogger("alternator") -DEFAULT_PORT = 8000 +_DEFAULT_PORT = 8000 +__all__ = ["Session", "client", "resource"] # Registry of active managers for cleanup on exit _active_managers: weakref.WeakValueDictionary[int, SyncLiveNodesManager] = ( @@ -157,7 +158,7 @@ def _create_boto_config(config: Config, *, auth_enabled: bool) -> BotoConfig: """ from botocore import UNSIGNED - kwargs = build_sdk_config_kwargs(config) + kwargs = _build_sdk_config_kwargs(config) kwargs.pop("signature_version", None) if not auth_enabled: kwargs["signature_version"] = UNSIGNED @@ -255,7 +256,7 @@ def _create_client_with_manager( setattr(client, MANAGER_OWNS_ATTR, owns_manager) # Enable Alternator vector search extensions before registering finalizers. - enable_vector_support(client) + _enable_vector_support(client) if owns_manager: _register_manager(manager, client) @@ -323,7 +324,7 @@ def _config_from_client_args( ) -> Config: """Build or validate an Alternator config from boto3-style factory args.""" if cluster_config is not None: - if seeds is not None or port != DEFAULT_PORT or scheme != "http": + if seeds is not None or port != _DEFAULT_PORT or scheme != "http": raise ConfigurationError( "Do not combine cluster_config with seeds, port, or scheme" ) @@ -345,7 +346,7 @@ def client( *, cluster_config: Config | None = None, seeds: Sequence[str] | None = None, - port: int = DEFAULT_PORT, + port: int = _DEFAULT_PORT, scheme: Literal["http", "https"] = "http", auth: Auth | None = None, **boto_kwargs: Any, # noqa: ANN401 -- boto3 kwargs are untyped @@ -372,7 +373,7 @@ def resource( *, cluster_config: Config | None = None, seeds: Sequence[str] | None = None, - port: int = DEFAULT_PORT, + port: int = _DEFAULT_PORT, scheme: Literal["http", "https"] = "http", auth: Auth | None = None, **boto_kwargs: Any, # noqa: ANN401 -- boto3 kwargs are untyped @@ -459,7 +460,7 @@ def _create_resource_with_manager( setattr(resource, MANAGER_OWNS_ATTR, owns_manager) # Enable Alternator vector search extensions before registering finalizers. - enable_vector_support(resource) + _enable_vector_support(resource) if owns_manager: _register_manager(manager, resource) @@ -509,7 +510,7 @@ def __init__( cluster_config: Config | None = None, *, seeds: Sequence[str] | None = None, - port: int = DEFAULT_PORT, + port: int = _DEFAULT_PORT, scheme: Literal["http", "https"] = "http", auth: Auth | None = None, **boto_kwargs: Any, # noqa: ANN401 -- boto3 kwargs are untyped @@ -629,16 +630,6 @@ def nodes(self) -> list[str]: return [] return list(self._manager.nodes.nodes) - @property - def active_nodes(self) -> list[str]: - """Return active nodes; currently this is the live-node list.""" - return self.nodes - - @property - def quarantined_nodes(self) -> list[str]: - """Return quarantined nodes; node quarantine is not implemented.""" - return [] - def validate_scope(self) -> bool: """Return whether the configured rack/datacenter scope is complete.""" manager = self._manager diff --git a/alternator/config.py b/alternator/config.py index fc98a8a..799b3fc 100644 --- a/alternator/config.py +++ b/alternator/config.py @@ -17,10 +17,30 @@ logger = logging.getLogger("alternator") -SDKClientCert = str | tuple[str, str] +_SDKClientCert = str | tuple[str, str] UserAgentCustomizer = Callable[[str], str] UserAgent = str | UserAgentCustomizer -DEFAULT_USER_AGENT = f"alternator-client-python/{__version__}" +_DEFAULT_USER_AGENT = f"alternator-client-python/{__version__}" + +__all__ = [ + "Auth", + "CompressionAlgorithm", + "Config", + "HeaderOptimizationConfig", + "HeaderWhitelistCallback", + "HeaderWhitelistContext", + "KeyRouteAffinityConfig", + "KeyRouteAffinityMode", + "NodeListPollingConfig", + "RequestCompressionConfig", + "ResponseCompression", + "RetryConfig", + "RetryMode", + "TLS", + "TimeoutConfig", + "UserAgent", + "UserAgentCustomizer", +] class CompressionAlgorithm(Enum): @@ -45,24 +65,6 @@ class KeyRouteAffinityMode(Enum): ANY_WRITE = auto() # All write operations -@dataclass(frozen=True) -class TlsSessionCacheConfig: - """ - TLS session cache settings for connection reuse. - - Note: - Python's ``ssl`` module does not expose direct control over session - cache size or timeout. Only the ``enabled`` flag is effective, controlling - whether session tickets (OP_NO_TICKET) are enabled or disabled. - The ``cache_size`` and ``timeout_seconds`` parameters are reserved for - future use with custom implementations or alternative TLS backends. - """ - - enabled: bool = True - cache_size: int = 1024 # Reserved for future use - timeout_seconds: int = 86400 # Reserved for future use (24 hours) - - @dataclass(frozen=True) class TLS: """TLS/SSL configuration for HTTPS connections.""" @@ -75,8 +77,8 @@ class TLS: # Verification settings verify_hostname: bool = True - # Session caching - session_cache: TlsSessionCacheConfig = field(default_factory=TlsSessionCacheConfig) + # TLS session tickets + session_tickets_enabled: bool = True # Client certificate authentication client_cert_path: Path | None = None @@ -91,7 +93,7 @@ def __post_init__(self) -> None: raise ConfigurationError("client_key_path requires client_cert_path") @property - def sdk_client_cert(self) -> SDKClientCert | None: + def sdk_client_cert(self) -> _SDKClientCert | None: """Return the botocore client_cert value for this TLS config.""" if self.client_cert_path is None: return None @@ -141,19 +143,6 @@ def with_custom_ca(cls, *cert_paths: Path) -> TLS: return cls(custom_ca_cert_paths=tuple(cert_paths)) -@dataclass(frozen=True) -class TlsConfig(TLS): - """Deprecated compatibility name for :class:`TLS`.""" - - def __post_init__(self) -> None: - super().__post_init__() - warnings.warn( - "TlsConfig is deprecated; use TLS instead.", - DeprecationWarning, - stacklevel=2, - ) - - @dataclass(frozen=True) class RequestCompressionConfig: """Configuration for request compression.""" @@ -401,7 +390,7 @@ class Config: # SDK settings aws_region: str = "us-east-1" - user_agent: UserAgent | None = DEFAULT_USER_AGENT + user_agent: UserAgent | None = _DEFAULT_USER_AGENT def __post_init__(self) -> None: """Validate configuration values.""" @@ -426,229 +415,7 @@ def __post_init__(self) -> None: ) -@dataclass(frozen=True) -class AlternatorConfig(Config): - """Deprecated compatibility name for :class:`Config`.""" - - def __post_init__(self) -> None: - warnings.warn( - "AlternatorConfig is deprecated; use Config instead.", - DeprecationWarning, - stacklevel=2, - ) - super().__post_init__() - - -class AlternatorConfigBuilder: - """ - Fluent builder for Config. - - Example: - config = ( - AlternatorConfigBuilder() - .with_seeds("192.168.1.1", "192.168.1.2") - .with_port(8000) - .with_https() - .with_datacenter("dc1") - .with_compression(CompressionAlgorithm.GZIP) - .build() - ) - """ - - def __init__(self) -> None: - self._seed_hosts: list[str] = [] - self._port: int = 8000 - self._scheme: Literal["http", "https"] = "http" - self._routing_scope: RoutingScope | None = None - self._request_compression = RequestCompressionConfig() - self._response_compression: tuple[ResponseCompression, ...] = () - self._header_optimization = HeaderOptimizationConfig() - self._tls = TLS.system_default() - self._key_affinity = KeyRouteAffinityConfig() - self._retries = RetryConfig() - self._max_pool_connections = 200 - self._node_list_polling = NodeListPollingConfig() - self._timeouts = TimeoutConfig() - self._aws_region = "us-east-1" - self._user_agent: UserAgent | None = DEFAULT_USER_AGENT - - def with_seeds(self, *hosts: str) -> AlternatorConfigBuilder: - """Add seed hosts for node discovery.""" - self._seed_hosts.extend(hosts) - return self - - def with_port(self, port: int) -> AlternatorConfigBuilder: - """Set the Alternator port.""" - self._port = port - return self - - def with_http(self) -> AlternatorConfigBuilder: - """Use HTTP scheme (default).""" - self._scheme = "http" - return self - - def with_https(self, tls_config: TLS | None = None) -> AlternatorConfigBuilder: - """Use HTTPS scheme with optional TLS configuration.""" - self._scheme = "https" - if tls_config: - self._tls = tls_config - return self - - def with_cluster_scope(self) -> AlternatorConfigBuilder: - """Route to any node in the cluster (default).""" - from alternator.core.routing_scope import ClusterScope - - self._routing_scope = ClusterScope() - return self - - def with_datacenter(self, dc: str) -> AlternatorConfigBuilder: - """Route to nodes in a specific datacenter.""" - from alternator.core.routing_scope import DatacenterScope - - self._routing_scope = DatacenterScope(datacenter=dc) - return self - - def with_rack(self, dc: str, rack: str) -> AlternatorConfigBuilder: - """Route to nodes in a specific rack within a datacenter.""" - from alternator.core.routing_scope import RackScope - - self._routing_scope = RackScope(datacenter=dc, rack=rack) - return self - - def with_compression( - self, - algorithm: CompressionAlgorithm, - min_size: int = 1024, - gzip_level: int = 9, - ) -> AlternatorConfigBuilder: - """Enable request compression.""" - self._request_compression = RequestCompressionConfig( - algorithm=algorithm, - min_size_bytes=min_size, - gzip_level=gzip_level, - ) - return self - - def with_response_compression( - self, - encoding: ResponseCompression, - *additional_encodings: ResponseCompression, - ) -> AlternatorConfigBuilder: - """Enable response compression for the accepted encodings.""" - encodings = (encoding, *additional_encodings) - self._response_compression = _normalize_response_compression(encodings) - return self - - def without_response_compression(self) -> AlternatorConfigBuilder: - """Disable response compression.""" - self._response_compression = () - return self - - def with_header_optimization( - self, - whitelist: frozenset[str] | set[str] | None = None, - whitelist_callback: HeaderWhitelistCallback | None = None, - ) -> AlternatorConfigBuilder: - """Enable header optimization (filtering). - - When enabled, non-essential HTTP headers are stripped from requests - to reduce bandwidth. Authentication headers (Authorization, - X-Amz-Date, X-Amz-Security-Token) are preserved when explicit - static credentials are passed with ``auth=Auth.static_credentials(...)``. - """ - self._header_optimization = HeaderOptimizationConfig( - enabled=True, - whitelist=frozenset(whitelist) if whitelist is not None else None, - whitelist_callback=whitelist_callback, - ) - return self - - def with_key_affinity( - self, - mode: KeyRouteAffinityMode, - table_pk_map: Mapping[str, str] | None = None, - ) -> AlternatorConfigBuilder: - """Enable key-based routing affinity for LWT optimization.""" - self._key_affinity = KeyRouteAffinityConfig( - mode=mode, - table_pk_attributes=table_pk_map or {}, - ) - return self - - def with_refresh_intervals( - self, active_ms: int = 1000, idle_ms: int = 60000 - ) -> AlternatorConfigBuilder: - """Set node refresh intervals.""" - self._node_list_polling = NodeListPollingConfig( - active_interval_ms=active_ms, idle_interval_ms=idle_ms - ) - return self - - def with_retries( - self, - max_attempts: int = 3, - mode: RetryMode = RetryMode.STANDARD, - ) -> AlternatorConfigBuilder: - """Set retry configuration for DynamoDB operations.""" - self._retries = RetryConfig(max_attempts=max_attempts, mode=mode) - return self - - def with_pool_connections(self, max_connections: int) -> AlternatorConfigBuilder: - """Set maximum pool connections per host.""" - self._max_pool_connections = max_connections - return self - - def with_timeouts( - self, - discovery_seconds: float = 5.0, - connect_seconds: float = 5.0, - read_seconds: float = 30.0, - ) -> AlternatorConfigBuilder: - """Set timeout values for discovery and operations.""" - self._timeouts = TimeoutConfig( - discovery_seconds=discovery_seconds, - connect_seconds=connect_seconds, - read_seconds=read_seconds, - ) - return self - - def with_aws_region(self, region_name: str) -> AlternatorConfigBuilder: - """Set the AWS region placeholder passed to the SDK client.""" - self._aws_region = region_name - return self - - def with_user_agent( - self, - user_agent: UserAgent | None, - ) -> AlternatorConfigBuilder: - """Set a literal or callback-built Alternator User-Agent value.""" - self._user_agent = user_agent - return self - - def build(self) -> Config: - """Build the configuration object.""" - from alternator.core.routing_scope import ClusterScope - - return Config( - seed_hosts=tuple(self._seed_hosts), - port=self._port, - scheme=self._scheme, - routing_scope=self._routing_scope or ClusterScope(), - request_compression=self._request_compression, - response_compression=self._response_compression, - header_optimization=self._header_optimization, - tls=self._tls, - key_affinity=self._key_affinity, - retries=self._retries, - max_pool_connections=self._max_pool_connections, - node_list_polling=self._node_list_polling, - timeouts=self._timeouts, - aws_region=self._aws_region, - user_agent=self._user_agent, - ) - - -def build_sdk_config_kwargs(config: Config) -> dict[str, Any]: +def _build_sdk_config_kwargs(config: Config) -> dict[str, Any]: """Build SDK config kwargs shared by sync and async clients.""" kwargs: dict[str, Any] = { "retries": { @@ -671,7 +438,7 @@ def build_sdk_config_kwargs(config: Config) -> dict[str, Any]: def _resolve_user_agent(user_agent: UserAgent) -> str: """Return the User-Agent produced from the default Alternator identity.""" resolved = ( - user_agent if isinstance(user_agent, str) else user_agent(DEFAULT_USER_AGENT) + user_agent if isinstance(user_agent, str) else user_agent(_DEFAULT_USER_AGENT) ) if not isinstance(resolved, str) or not resolved: raise ConfigurationError( diff --git a/alternator/core/__init__.py b/alternator/core/__init__.py index ea22462..74e5de4 100644 --- a/alternator/core/__init__.py +++ b/alternator/core/__init__.py @@ -1,27 +1,3 @@ -"""Core modules for Alternator load balancing.""" +"""Internal implementation modules for Alternator clients.""" -from alternator.core.live_nodes import ( - LiveNodesManagerCore, - NodeList, - RoundRobinSelector, - SyncLiveNodesManager, -) -from alternator.core.routing_scope import ( - ClusterScope, - DatacenterScope, - RackScope, - RoutingScope, -) -from alternator.exceptions import NoNodesAvailableError - -__all__ = [ - "ClusterScope", - "DatacenterScope", - "LiveNodesManagerCore", - "NodeList", - "NoNodesAvailableError", - "RackScope", - "RoutingScope", - "RoundRobinSelector", - "SyncLiveNodesManager", -] +__all__: list[str] = [] diff --git a/alternator/core/auth.py b/alternator/core/auth.py index 8aa5614..0de495b 100644 --- a/alternator/core/auth.py +++ b/alternator/core/auth.py @@ -2,13 +2,12 @@ from __future__ import annotations -import warnings from typing import Any from alternator.config import Auth from alternator.exceptions import ConfigurationError -_CREDENTIAL_KWARGS = frozenset( +_BOTO_CREDENTIAL_KWARGS = frozenset( { "aws_access_key_id", "aws_secret_access_key", @@ -24,19 +23,13 @@ def apply_auth(auth: Auth | None, boto_kwargs: dict[str, Any]) -> bool: Returns: Whether request signing should be enabled. """ - legacy_credential_keys = _CREDENTIAL_KWARGS.intersection(boto_kwargs) - if legacy_credential_keys: + boto_credential_keys = _BOTO_CREDENTIAL_KWARGS.intersection(boto_kwargs) + if boto_credential_keys: if auth is not None: raise ConfigurationError( "Do not combine auth=... with raw boto credential kwargs; " "use Auth.static_credentials(...) instead" ) - warnings.warn( - "Passing raw boto credential kwargs is deprecated; " - "use auth=Auth.static_credentials(...) instead.", - DeprecationWarning, - stacklevel=3, - ) return "aws_access_key_id" in boto_kwargs resolved_auth = auth or Auth.disabled() diff --git a/alternator/core/handlers.py b/alternator/core/handlers.py index 75b9a8b..aeb0560 100644 --- a/alternator/core/handlers.py +++ b/alternator/core/handlers.py @@ -11,7 +11,7 @@ from botocore.awsrequest import AWSPreparedRequest, AWSRequest from botocore.hooks import BaseEventHooks -from alternator.config import DEFAULT_USER_AGENT, CompressionAlgorithm +from alternator.config import _DEFAULT_USER_AGENT, CompressionAlgorithm from alternator.core.compression import ( create_compression_handler, create_response_compression_decode_handler, @@ -53,7 +53,7 @@ def _register_alternator_handlers( | None = None, *, auth_enabled: bool = False, - user_agent: str | None = DEFAULT_USER_AGENT, + user_agent: str | None = _DEFAULT_USER_AGENT, ) -> None: """ Register all Alternator event handlers on a boto3/aioboto3 client. diff --git a/alternator/core/key_affinity.py b/alternator/core/key_affinity.py index 6108733..3b55f7a 100644 --- a/alternator/core/key_affinity.py +++ b/alternator/core/key_affinity.py @@ -535,12 +535,12 @@ def clear(self) -> None: with self._lock: self._cache.clear() - def preload(self, table_pk_map: dict[str, str]) -> None: + def preload(self, table_pk_attributes: dict[str, str]) -> None: """ Preload cache with known table -> pk mappings. Args: - table_pk_map: Mapping of table name to partition key name + table_pk_attributes: Mapping of table name to partition key name """ with self._lock: - self._cache.update(table_pk_map) + self._cache.update(table_pk_attributes) diff --git a/alternator/core/live_nodes.py b/alternator/core/live_nodes.py index 65de23a..70d2be9 100644 --- a/alternator/core/live_nodes.py +++ b/alternator/core/live_nodes.py @@ -20,7 +20,7 @@ logger = logging.getLogger("alternator") -# Re-export for backwards compatibility +# Module exports. __all__ = [ "NoNodesAvailableError", "NodeList", diff --git a/alternator/core/routing_scope.py b/alternator/core/routing_scope.py index c2a241d..5fd2796 100644 --- a/alternator/core/routing_scope.py +++ b/alternator/core/routing_scope.py @@ -6,6 +6,13 @@ from dataclasses import dataclass from urllib.parse import quote +__all__ = [ + "ClusterScope", + "DatacenterScope", + "RackScope", + "RoutingScope", +] + class RoutingScope(ABC): """Base class for topology-aware routing scopes.""" diff --git a/alternator/core/tls.py b/alternator/core/tls.py index 1406a5e..0d1ca42 100644 --- a/alternator/core/tls.py +++ b/alternator/core/tls.py @@ -51,11 +51,8 @@ def create_ssl_context(tls_config: TLS) -> ssl.SSLContext: if tls_config.key_log_file_path is not None and hasattr(context, "keylog_filename"): context.keylog_filename = str(tls_config.key_log_file_path) - # Configure session caching - # Note: Python's ssl module only exposes session ticket control (OP_NO_TICKET). - # The cache_size and timeout_seconds settings in TlsSessionCacheConfig are - # reserved for future use with custom implementations or alternative TLS backends. - if tls_config.session_cache.enabled: + # Configure session ticket reuse. + if tls_config.session_tickets_enabled: # Enable session tickets for session reuse context.options &= ~ssl.OP_NO_TICKET else: diff --git a/alternator/exceptions.py b/alternator/exceptions.py index 4884f4a..1fb9947 100644 --- a/alternator/exceptions.py +++ b/alternator/exceptions.py @@ -2,6 +2,8 @@ from __future__ import annotations +__all__ = ["AlternatorError", "ConfigurationError", "NoNodesAvailableError"] + class AlternatorError(Exception): """Base exception for all Alternator client errors.""" @@ -73,8 +75,8 @@ class ConfigurationError(AlternatorError, ValueError): - Configuration values are invalid - Conflicting configuration options are specified - Inherits from both ``AlternatorError`` and ``ValueError`` for backward - compatibility with code that catches ``ValueError`` from config validation. + Inherits from both ``AlternatorError`` and ``ValueError`` so callers can + catch either the package-specific base or the standard validation base. Troubleshooting: - Verify all required fields are provided diff --git a/alternator/vector.py b/alternator/vector.py index 0742795..c9d3cb1 100644 --- a/alternator/vector.py +++ b/alternator/vector.py @@ -1,8 +1,7 @@ """Vector search support for ScyllaDB Alternator. -This module provides the :class:`Vector` type and the -:func:`enable_vector_support` function that add ScyllaDB Alternator's -vector search extensions to a boto3 DynamoDB client or resource. +This module provides the :class:`Vector` type for ScyllaDB Alternator vector +search extensions. Vector search is a ScyllaDB Alternator extension that does not exist in the standard AWS DynamoDB API. Because boto3 / botocore is generated from the @@ -20,7 +19,7 @@ DynamoDB Resource interface automatically converts :class:`Vector` instances to the ``FLOAT32VECTOR`` wire format and back. -:func:`enable_vector_support` is called automatically by +Vector support is enabled automatically by :func:`~alternator.client`, :func:`~alternator.resource`, and :class:`~alternator.async_client.AsyncSession`, so users of the Alternator client library do not need to call it manually. @@ -81,6 +80,8 @@ import boto3.dynamodb.types +__all__ = ["Vector"] + # --------------------------------------------------------------------------- # New botocore shapes that describe Alternator's vector search extensions. # These are injected into the DynamoDB service model at runtime. @@ -169,7 +170,7 @@ class Vector(list[float]): """An optimized vector type for ScyllaDB Alternator vector search. Subclasses :class:`list` so it can be used everywhere a regular Python - list is expected. When :func:`enable_vector_support` has been called, + list is expected. When vector support has been enabled automatically, the patched :class:`~boto3.dynamodb.types.TypeSerializer` will encode a ``Vector`` value as ``{"FLOAT32VECTOR": [...]}`` on the wire instead of the standard DynamoDB list encoding ``{"L": [{"N": "..."}, ...]}``. @@ -210,7 +211,7 @@ def _deserialize_float32vector(self, value: Any) -> Vector: # noqa: ANN401 -- v return Vector(value) -def enable_vector_support(client_or_resource: Any) -> None: # noqa: ANN401 -- accepts either a boto3 DynamoDB client or resource instance +def _enable_vector_support(client_or_resource: Any) -> None: # noqa: ANN401 -- accepts either a boto3 DynamoDB client or resource instance """Enable ScyllaDB Alternator vector search on a boto3 DynamoDB client or resource. Patches botocore's in-memory DynamoDB service model to accept and return diff --git a/docs/CAPABILITY_MATRIX.md b/docs/CAPABILITY_MATRIX.md deleted file mode 100644 index c4c96bb..0000000 --- a/docs/CAPABILITY_MATRIX.md +++ /dev/null @@ -1,44 +0,0 @@ -# Alternator Client Capability Matrix - -This matrix tracks current Python Alternator client capabilities, planned work, -and intentionally deferred behavior. - -| Capability | Status | Tracking | Notes | -| --- | --- | --- | --- | -| Sync DynamoDB client | Supported | Existing API | `alternator.client("dynamodb", ...)` and `Session.client("dynamodb")` return standard boto3 clients. | -| DynamoDB resource | Supported | Existing API | `alternator.resource("dynamodb", ...)` and `Session.resource("dynamodb")` wrap boto3 resource usage. | -| Async DynamoDB client | Supported | Existing API | `AsyncSession.client("dynamodb")` uses aioboto3. | -| Host-only seeds with one shared port | Supported | Existing API | Seeds must not include ports; one port applies to all nodes. | -| Node discovery | Supported | Existing API | `/localnodes` refresh updates the live node list. `ClusterScope` combines results from all configured seeds, so multi-DC routing requires at least one reachable seed from each datacenter. | -| Routing scopes | Supported | [#35](https://github.com/scylladb/alternator-client-python/issues/35) | Cluster, datacenter, and rack scopes support explicit fallback chains and scoped validation helpers. | -| Request query plans | Supported | Existing API | Requests use stable seeded node ordering for retries. | -| Auth | Supported | Existing API | Disabled by default; explicit static credentials enable signing. | -| Request compression | Supported | [#37](https://github.com/scylladb/alternator-client-python/issues/37) | Gzip request compression supports threshold and compression-level configuration. | -| Response compression | Supported | [#65](https://github.com/scylladb/alternator-client-python/issues/65) | Gzip and deflate response decoding is disabled by default and enabled with `with_response_compression(...)`. | -| Header optimization | Supported | [#37](https://github.com/scylladb/alternator-client-python/issues/37) | Required headers are preserved, with static and callback-computed whitelist additions. | -| User-Agent replacement | Supported | [#61](https://github.com/scylladb/alternator-client-python/issues/61) | Requests do not preserve boto3/botocore user-agent tokens. By default, requests send the Alternator Python client identity; `Config.user_agent=None` removes the wire header; a string sets the final value; a callback can wrap/add to the default identity. | -| TLS server trust | Supported | Existing API | System CA, custom CA, hostname verification, and trust-all mode exist. | -| TLS client certificates | Supported | [#38](https://github.com/scylladb/alternator-client-python/issues/38) | mTLS certificate/key paths are loaded into discovery SSL contexts and SDK `client_cert` config. | -| TLS key log file | Supported | [#38](https://github.com/scylladb/alternator-client-python/issues/38) | Debug-only key log file paths are applied to SSL contexts when supported by the runtime. | -| Transport and SDK config knobs | Supported | [#34](https://github.com/scylladb/alternator-client-python/issues/34), [#89](https://github.com/scylladb/alternator-client-python/issues/89) | Retry, pool, connect/read timeout, region, TLS client certificates, and User-Agent settings have typed Alternator config fields. The client does not expose raw SDK config mutation. | -| Key route affinity | Supported | [#23](https://github.com/scylladb/alternator-client-python/issues/23) | RMW detection, single-write affinity, and BatchWriteItem preferred-node voting are implemented with fallback on missing or ambiguous routing data. | -| Session lifecycle facade | Supported | [#33](https://github.com/scylladb/alternator-client-python/issues/33) | `Session` and `AsyncSession` expose lifecycle, node inspection, topology checks, and partition-key diagnostics. | -| Compatibility and release decisions | Supported | [#39](https://github.com/scylladb/alternator-client-python/issues/39) | Decision record lives in [docs/COMPATIBILITY_AND_RELEASE.md](COMPATIBILITY_AND_RELEASE.md). | -| Node health tracking | Deferred | [#32](https://github.com/scylladb/alternator-client-python/issues/32) | Planning-only. No node health code, tests, config objects, or behavior changes are authorized by this roadmap. | -| Vector search extension | Supported | Existing API | Python client enables ScyllaDB Alternator vector extensions. | -| Capability test harness | Partial | [#36](https://github.com/scylladb/alternator-client-python/issues/36) | Fake Alternator server fixture introduced for deterministic unit tests. | -| Documentation and examples | Supported | [#40](https://github.com/scylladb/alternator-client-python/issues/40) | README, examples, and release-note guidance are aligned with implemented APIs. | - -## Deterministic Test Harness - -Unit tests can use the `fake_alternator_server` fixture to simulate local -Alternator HTTP behavior without a running Scylla cluster. The fixture supports: - -- `/localnodes` JSON responses -- arbitrary HTTP status responses -- request path capture -- deterministic node add/remove scenarios -- transport failure tests by pointing clients at unused ports - -Use integration tests for real Scylla behavior and the fake server for edge -cases that need precise control over responses. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md deleted file mode 100644 index 8a90e33..0000000 --- a/docs/RELEASE_NOTES.md +++ /dev/null @@ -1,80 +0,0 @@ -# Capability Release Notes - -Use this file as the release-note source for the capability roadmap tracked in -[FEATURE_PARITY.md](../FEATURE_PARITY.md). Keep -[docs/COMPATIBILITY_AND_RELEASE.md](COMPATIBILITY_AND_RELEASE.md) as the -authority for compatibility decisions. - -## Next Major Release - -### Additive APIs - -- Added `Session` and `AsyncSession` lifecycle facades for callers that need one - owner for clients/resources plus topology diagnostics. -- Added explicit routing fallback controls with `fallback=...` and - `without_fallback(...)`. -- Added topology validation helpers for configured datacenter/rack scopes. -- Added transport configuration for SDK retries, connect/read timeouts, - connection pool size, region placeholder, TLS client certificates, and - User-Agent customization. -- Added Alternator-specific `User-Agent` control with `Config.user_agent`. - By default, requests send the Alternator Python client identity; callers can - pass `None` to remove the wire header, a string to set a final value, or a - callback to wrap/add to the default identity. -- Added TLS client certificate, client key, and key log file settings. -- Added configurable gzip compression level and callback-based header whitelist - additions. -- Added opt-in gzip and deflate response compression decoding. -- Updated key-affinity routing semantics for read-modify-write operations and - `BatchWriteItem` preferred-node voting. - -### Compatibility Notes - -- Request signing remains disabled by default. -- Alternator request signing continues to use explicit static credentials via - `auth=Auth.static_credentials(...)`. -- Raw SDK credential kwargs remain supported for compatibility, but emit - deprecation warnings. -- The convenience default port remains `8000`; pass `port=...` explicitly if - your deployment uses a different port. -- Datacenter and rack constructors now stay constrained by default. Use - `fallback=...` when broader routing is desired. -- Deprecated names such as `AlternatorConfig` and `TlsConfig` remain available - and continue to warn. Prefer `Config` and `TLS` in new code. -- Node health, quarantine behavior, decommission handling, and dead-node - handling remain planning-only. - -### Behavior Notes - -- `RMW` key-affinity mode applies only to writes that require a prior item - state. `BatchWriteItem` does not use affinity in `RMW` mode. -- `ANY_WRITE` key-affinity mode selects a preferred node for single-item writes. - For `BatchWriteItem`, valid put/delete entries vote for their preferred node. - Tied votes, missing partition-key metadata, unsupported key values, no active - nodes, or no eligible votes fall back to normal routing. -- The client owns the SDK config object, endpoint routing, auth-managed - signature settings, TLS client certificate settings, retries, timeouts, and - final wire `User-Agent` handling. -- TLS key logs contain traffic decryption material and should only be used in - protected debugging environments. - -### Migration Steps - -- Replace `AlternatorConfig` with `Config` and `TlsConfig` with `TLS` in new or - updated code. -- Replace raw SDK credential kwargs with - `auth=Auth.static_credentials(access_key_id, secret_access_key)`. -- Replace implicit topology fallback assumptions with explicit `fallback=...` - when requests may broaden from rack to datacenter or cluster scope. -- Preload partition-key metadata with `table_pk_map` when key affinity should be - active on the first request for a table. -- Review timeout configuration as per-attempt SDK settings, not whole-operation - deadlines. - -### Versioning Expectation - -The capability batch is compatible additive work and should be released as a -minor version unless a separate maintainer decision adds an incompatible change. -Changed defaults, removed deprecated names, removed legacy credential kwargs, -changed routing fallback defaults, changed auth defaults, or default-enabled node -health/quarantine behavior require a major release. diff --git a/examples/capability_configuration.py b/examples/capability_configuration.py index 611bf0d..5a7cae3 100644 --- a/examples/capability_configuration.py +++ b/examples/capability_configuration.py @@ -12,34 +12,37 @@ from alternator import ( TLS, - AlternatorConfigBuilder, Auth, ClusterScope, CompressionAlgorithm, Config, DatacenterScope, + HeaderOptimizationConfig, HeaderWhitelistContext, + KeyRouteAffinityConfig, KeyRouteAffinityMode, RackScope, + RequestCompressionConfig, + RetryConfig, RetryMode, Session, TimeoutConfig, ) -def helper_lifecycle_example() -> None: +def session_lifecycle_example() -> None: """Use Session when one object should own clients and topology diagnostics.""" config = Config( seed_hosts=["node1.example.com", "node2.example.com"], port=8000, ) - with Session(config, auth=Auth.disabled()) as helper: - client = helper.client("dynamodb") - resource = helper.resource("dynamodb") + with Session(config, auth=Auth.disabled()) as session: + client = session.client("dynamodb") + resource = session.resource("dynamodb") - helper.refresh_nodes() - print("nodes:", helper.nodes) + session.refresh_nodes() + print("nodes:", session.nodes) client.list_tables() resource.Table("orders").scan(Limit=1) @@ -74,20 +77,18 @@ def datacenter_only_config() -> Config: def transport_config() -> Config: """Configure retries, per-attempt timeouts, pool size, and User-Agent.""" - return ( - AlternatorConfigBuilder() - .with_seeds("node1.example.com", "node2.example.com") - .with_port(8000) - .with_retries(max_attempts=4, mode=RetryMode.STANDARD) - .with_timeouts( + return Config( + seed_hosts=["node1.example.com", "node2.example.com"], + port=8000, + retries=RetryConfig(max_attempts=4, mode=RetryMode.STANDARD), + timeouts=TimeoutConfig( discovery_seconds=3.0, connect_seconds=2.0, read_seconds=10.0, - ) - .with_pool_connections(300) - .with_aws_region("us-east-1") - .with_user_agent(lambda default: f"orders-service {default}") - .build() + ), + max_pool_connections=300, + aws_region="us-east-1", + user_agent=lambda default: f"orders-service {default}", ) @@ -119,34 +120,31 @@ def extra_headers(context: HeaderWhitelistContext) -> set[str]: def compression_and_header_config() -> Config: """Enable gzip request compression and dynamic header optimization.""" - return ( - AlternatorConfigBuilder() - .with_seeds("node1.example.com") - .with_port(8000) - .with_compression( - CompressionAlgorithm.GZIP, - min_size=1024, + return Config( + seed_hosts=["node1.example.com"], + port=8000, + request_compression=RequestCompressionConfig( + algorithm=CompressionAlgorithm.GZIP, + min_size_bytes=1024, gzip_level=6, - ) - .with_header_optimization( - whitelist={"X-Request-Id"}, + ), + header_optimization=HeaderOptimizationConfig( + enabled=True, + whitelist=frozenset({"X-Request-Id"}), whitelist_callback=extra_headers, - ) - .build() + ), ) def key_affinity_config() -> Config: """Enable write affinity with preloaded partition-key metadata.""" - return ( - AlternatorConfigBuilder() - .with_seeds("node1.example.com", "node2.example.com") - .with_port(8000) - .with_key_affinity( - KeyRouteAffinityMode.ANY_WRITE, - table_pk_map={"orders": "order_id", "customers": "customer_id"}, - ) - .build() + return Config( + seed_hosts=["node1.example.com", "node2.example.com"], + port=8000, + key_affinity=KeyRouteAffinityConfig( + mode=KeyRouteAffinityMode.ANY_WRITE, + table_pk_attributes={"orders": "order_id", "customers": "customer_id"}, + ), ) diff --git a/tests/integration/test_async_client.py b/tests/integration/test_async_client.py index ffe9210..081fff8 100644 --- a/tests/integration/test_async_client.py +++ b/tests/integration/test_async_client.py @@ -11,7 +11,14 @@ import pytest -from alternator import Config +from alternator import ( + CompressionAlgorithm, + Config, + HeaderOptimizationConfig, + KeyRouteAffinityConfig, + KeyRouteAffinityMode, + RequestCompressionConfig, +) from tests.integration import SCYLLA_HOST, SCYLLA_PORT, SKIP_INTEGRATION pytestmark = [ @@ -167,18 +174,15 @@ class TestAsyncKeyAffinity: async def test_async_rmw_affinity(self, table_name: str) -> None: """Test RMW operations use affinity routing with async client.""" - from alternator import AlternatorConfigBuilder, KeyRouteAffinityMode from alternator.async_client import AsyncSession - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_key_affinity( - KeyRouteAffinityMode.RMW, - table_pk_map={table_name: "pk"}, - ) - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + key_affinity=KeyRouteAffinityConfig( + mode=KeyRouteAffinityMode.RMW, + table_pk_attributes={table_name: "pk"}, + ), ) async with AsyncSession(config) as session: @@ -230,18 +234,15 @@ async def increment() -> None: async def test_async_any_write_affinity(self, table_name: str) -> None: """Test ANY_WRITE mode routes all writes through affinity.""" - from alternator import AlternatorConfigBuilder, KeyRouteAffinityMode from alternator.async_client import AsyncSession - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_key_affinity( - KeyRouteAffinityMode.ANY_WRITE, - table_pk_map={table_name: "pk"}, - ) - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + key_affinity=KeyRouteAffinityConfig( + mode=KeyRouteAffinityMode.ANY_WRITE, + table_pk_attributes={table_name: "pk"}, + ), ) async with AsyncSession(config) as session: @@ -293,16 +294,13 @@ async def test_async_any_write_affinity(self, table_name: str) -> None: async def test_async_pk_auto_discovery(self, table_name: str) -> None: """Test async auto-discovery of partition key via DescribeTable.""" - from alternator import AlternatorConfigBuilder, KeyRouteAffinityMode from alternator.async_client import AsyncSession - # Configure affinity WITHOUT pre-defined table_pk_map - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_key_affinity(KeyRouteAffinityMode.ANY_WRITE) - .build() + # Configure affinity without preloaded partition-key attributes. + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + key_affinity=KeyRouteAffinityConfig(mode=KeyRouteAffinityMode.ANY_WRITE), ) async with AsyncSession(config) as session: @@ -352,7 +350,6 @@ async def test_async_compression( skip_if_scylla_version_below: Callable[..., None], ) -> None: """Test compression with async client.""" - from alternator import AlternatorConfigBuilder, CompressionAlgorithm from alternator.async_client import AsyncSession from tests.integration.scylla_version import ScyllaVersion @@ -360,12 +357,13 @@ async def test_async_compression( ScyllaVersion(2026, 1, 0), "gzip request compression" ) - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_compression(CompressionAlgorithm.GZIP, min_size=100) - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + request_compression=RequestCompressionConfig( + algorithm=CompressionAlgorithm.GZIP, + min_size_bytes=100, + ), ) async with AsyncSession(config) as session: @@ -409,15 +407,12 @@ class TestAsyncHeaderOptimization: async def test_async_header_optimization(self, table_name: str) -> None: """Test header optimization with async client.""" - from alternator import AlternatorConfigBuilder from alternator.async_client import AsyncSession - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_header_optimization() - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + header_optimization=HeaderOptimizationConfig(enabled=True), ) async with AsyncSession(config) as session: diff --git a/tests/integration/test_composite_keys.py b/tests/integration/test_composite_keys.py index 48092eb..e73870a 100644 --- a/tests/integration/test_composite_keys.py +++ b/tests/integration/test_composite_keys.py @@ -11,8 +11,8 @@ import pytest from alternator import ( - AlternatorConfigBuilder, Config, + KeyRouteAffinityConfig, KeyRouteAffinityMode, ) from alternator import ( @@ -268,15 +268,13 @@ class TestCompositeKeyAffinity: def test_affinity_uses_hash_key_only(self, table_name: str) -> None: """Test that affinity routing works with composite keys.""" - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_key_affinity( - KeyRouteAffinityMode.ANY_WRITE, - table_pk_map={table_name: "pk"}, - ) - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + key_affinity=KeyRouteAffinityConfig( + mode=KeyRouteAffinityMode.ANY_WRITE, + table_pk_attributes={table_name: "pk"}, + ), ) with alternator_client("dynamodb", cluster_config=config) as client: @@ -308,12 +306,10 @@ def test_affinity_uses_hash_key_only(self, table_name: str) -> None: def test_affinity_auto_discover_pk(self, table_name: str) -> None: """Test auto-discovery of partition key for composite key table.""" - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_key_affinity(KeyRouteAffinityMode.ANY_WRITE) - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + key_affinity=KeyRouteAffinityConfig(mode=KeyRouteAffinityMode.ANY_WRITE), ) with alternator_client("dynamodb", cluster_config=config) as client: diff --git a/tests/integration/test_connection_reuse.py b/tests/integration/test_connection_reuse.py index 7946602..f5579bb 100644 --- a/tests/integration/test_connection_reuse.py +++ b/tests/integration/test_connection_reuse.py @@ -16,7 +16,6 @@ from alternator import ( TLS, - AlternatorConfigBuilder, Config, ) from alternator import ( @@ -51,12 +50,11 @@ def https_config() -> Config | None: ca_path = Path(__file__).resolve().parents[1] / "scylla" / "db.crt" if not ca_path.exists(): pytest.skip("Self-signed certificate not found (run 'make scylla-start')") - return ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_HTTPS_PORT) - .with_https(TLS.with_custom_ca(ca_path)) - .build() + return Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_HTTPS_PORT, + scheme="https", + tls=TLS.with_custom_ca(ca_path), ) diff --git a/tests/integration/test_header_optimization.py b/tests/integration/test_header_optimization.py index 91ad52a..6629137 100644 --- a/tests/integration/test_header_optimization.py +++ b/tests/integration/test_header_optimization.py @@ -4,7 +4,7 @@ import pytest -from alternator import AlternatorConfigBuilder, Auth +from alternator import Auth, Config, HeaderOptimizationConfig from alternator import client as alternator_client from alternator.async_client import AsyncSession from tests.integration import SCYLLA_HOST, SCYLLA_PORT, SKIP_INTEGRATION @@ -21,12 +21,10 @@ def _user_agent_config(user_agent_label: str) -> object: - return ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_user_agent(user_agent_label) - .build() + return Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + user_agent=user_agent_label, ) @@ -44,13 +42,14 @@ def test_disabled_keeps_user_agent_on_wire(self) -> None: assert request.header("user-agent") == "alternator-unfiltered" def test_filters_wire_headers_and_preserves_auth(self) -> None: - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_header_optimization(whitelist={"X-Keep-Me"}) - .with_user_agent("alternator-filtered") - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + header_optimization=HeaderOptimizationConfig( + enabled=True, + whitelist=frozenset({"X-Keep-Me"}), + ), + user_agent="alternator-filtered", ) with alternator_client( "dynamodb", @@ -74,12 +73,13 @@ class TestAsyncHeaderOptimization: @pytest.mark.asyncio async def test_filters_wire_headers_and_preserves_auth(self) -> None: - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_header_optimization(whitelist={"X-Keep-Me"}) - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + header_optimization=HeaderOptimizationConfig( + enabled=True, + whitelist=frozenset({"X-Keep-Me"}), + ), ) async with AsyncSession( config, diff --git a/tests/integration/test_key_affinity_operations.py b/tests/integration/test_key_affinity_operations.py index 9915c13..0562ee9 100644 --- a/tests/integration/test_key_affinity_operations.py +++ b/tests/integration/test_key_affinity_operations.py @@ -13,8 +13,8 @@ import pytest from alternator import ( - AlternatorConfigBuilder, Config, + KeyRouteAffinityConfig, KeyRouteAffinityMode, ) from alternator import ( @@ -48,29 +48,25 @@ def _create_table(client: Any, table_name: str) -> None: # noqa: ANN401 -- gene def _make_rmw_config(table_name: str) -> Config: """Create config with RMW affinity mode.""" - return ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_key_affinity( - KeyRouteAffinityMode.RMW, - table_pk_map={table_name: "pk"}, - ) - .build() + return Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + key_affinity=KeyRouteAffinityConfig( + mode=KeyRouteAffinityMode.RMW, + table_pk_attributes={table_name: "pk"}, + ), ) def _make_any_write_config(table_name: str) -> Config: """Create config with ANY_WRITE affinity mode.""" - return ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_key_affinity( - KeyRouteAffinityMode.ANY_WRITE, - table_pk_map={table_name: "pk"}, - ) - .build() + return Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + key_affinity=KeyRouteAffinityConfig( + mode=KeyRouteAffinityMode.ANY_WRITE, + table_pk_attributes={table_name: "pk"}, + ), ) diff --git a/tests/integration/test_live_nodes.py b/tests/integration/test_live_nodes.py index bb2e909..db6296a 100644 --- a/tests/integration/test_live_nodes.py +++ b/tests/integration/test_live_nodes.py @@ -1,10 +1,16 @@ -"""Integration tests for live-node discovery through public helpers.""" +"""Integration tests for live-node discovery through public sessions.""" from __future__ import annotations import pytest -from alternator import AlternatorConfigBuilder, Auth, KeyRouteAffinityMode, Session +from alternator import ( + Auth, + Config, + KeyRouteAffinityConfig, + KeyRouteAffinityMode, + Session, +) from alternator.async_client import AsyncSession from tests.integration import SCYLLA_HOST, SCYLLA_PORT, SKIP_INTEGRATION @@ -15,51 +21,45 @@ def _affinity_config() -> object: - return ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_key_affinity( - KeyRouteAffinityMode.ANY_WRITE, - table_pk_map={"preconfigured_table": "pk"}, - ) - .build() + return Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + key_affinity=KeyRouteAffinityConfig( + mode=KeyRouteAffinityMode.ANY_WRITE, + table_pk_attributes={"preconfigured_table": "pk"}, + ), ) -class TestLiveNodeHelperDiagnostics: - """Verify sync helper diagnostics and shared-client lifecycle.""" +class TestLiveNodeSessionDiagnostics: + """Verify sync session diagnostics and shared-client lifecycle.""" - def test_helper_exposes_nodes_clients_and_partition_keys(self) -> None: - with Session(_affinity_config(), auth=Auth.disabled()) as helper: - assert helper.refresh_nodes() - nodes = helper.nodes + def test_session_exposes_nodes_clients_and_partition_keys(self) -> None: + with Session(_affinity_config(), auth=Auth.disabled()) as session: + assert session.refresh_nodes() + nodes = session.nodes assert nodes - assert helper.active_nodes == nodes - assert helper.quarantined_nodes == [] - assert helper.supports_topology_filters() - assert helper.partition_key_for("preconfigured_table") == "pk" + assert session.supports_topology_filters() + assert session.partition_key_for("preconfigured_table") == "pk" - client = helper.client("dynamodb") + client = session.client("dynamodb") assert "TableNames" in client.list_tables() - resource = helper.resource("dynamodb") + resource = session.resource("dynamodb") assert "TableNames" in resource.meta.client.list_tables() -class TestAsyncLiveNodeHelperDiagnostics: - """Verify async helper diagnostics and shared-client lifecycle.""" +class TestAsyncLiveNodeSessionDiagnostics: + """Verify async session diagnostics and shared-client lifecycle.""" @pytest.mark.asyncio - async def test_helper_exposes_nodes_clients_and_partition_keys(self) -> None: - async with AsyncSession(_affinity_config(), auth=Auth.disabled()) as helper: - assert await helper.refresh_nodes() - nodes = helper.nodes + async def test_session_exposes_nodes_clients_and_partition_keys(self) -> None: + async with AsyncSession(_affinity_config(), auth=Auth.disabled()) as session: + assert await session.refresh_nodes() + nodes = session.nodes assert nodes - assert helper.active_nodes == nodes - assert helper.quarantined_nodes == [] - assert await helper.supports_topology_filters() - assert await helper.partition_key_for("preconfigured_table") == "pk" + assert await session.supports_topology_filters() + assert await session.partition_key_for("preconfigured_table") == "pk" - client = await helper.client("dynamodb") + client = await session.client("dynamodb") assert "TableNames" in await client.list_tables() diff --git a/tests/integration/test_node_health.py b/tests/integration/test_node_discovery_resilience.py similarity index 77% rename from tests/integration/test_node_health.py rename to tests/integration/test_node_discovery_resilience.py index 04f494c..4248b35 100644 --- a/tests/integration/test_node_health.py +++ b/tests/integration/test_node_discovery_resilience.py @@ -1,8 +1,7 @@ -"""Integration tests for node health tracking behavior. +"""Integration tests for node discovery and retry resilience. -Tests verify node discovery, node list refresh, and behavior when nodes -become unavailable or recover. These tests exercise the current node -management capabilities. +Tests verify node discovery, node list refresh, retry settings, and concurrent +request handling with the current round-robin routing behavior. These tests require a running Scylla cluster with Alternator enabled. Start a local cluster with: make scylla-start @@ -13,8 +12,8 @@ import pytest from alternator import ( - AlternatorConfigBuilder, Config, + RetryConfig, ) from alternator import ( client as alternator_client, @@ -66,10 +65,10 @@ def test_seed_host_used_for_initial_discovery(self) -> None: assert "TableNames" in response -class TestNodeRecoveryDetection: - """Test that the client detects when nodes recover via periodic refresh.""" +class TestPeriodicRefresh: + """Test that periodic refresh keeps operations working.""" - def test_continuous_operations_after_recovery(self, config: Config) -> None: + def test_continuous_operations_after_refresh(self, config: Config) -> None: """Test that operations continue to work after the node list refreshes. This simulates the background refresh cycle by making requests @@ -83,18 +82,12 @@ def test_continuous_operations_after_recovery(self, config: Config) -> None: time.sleep(0.1) -class TestActiveVsQuarantinedNodeTracking: - """Test tracking of active node status. +class TestDiscoveredNodeRequests: + """Test request behavior with discovered nodes.""" - Note: The Python client currently uses round-robin with periodic - refresh rather than explicit quarantine. These tests verify the - current behavior of node list management. - """ - - def test_all_nodes_active_after_startup(self, config: Config) -> None: - """Test that all discovered nodes are active after client startup.""" + def test_requests_succeed_after_startup(self, config: Config) -> None: + """Test that requests succeed after node discovery.""" with alternator_client("dynamodb", cluster_config=config) as client: - # Multiple requests should succeed, indicating active nodes success_count = 0 for _ in range(50): try: @@ -104,7 +97,6 @@ def test_all_nodes_active_after_startup(self, config: Config) -> None: except Exception: pass - # All requests should succeed when all nodes are healthy assert success_count == 50 def test_node_list_refreshed_periodically(self, config: Config) -> None: @@ -123,25 +115,19 @@ def test_node_list_refreshed_periodically(self, config: Config) -> None: assert "TableNames" in response -class TestQuarantineAndRelease: - """Test quarantine and release behavior. - - Note: The Python client doesn't yet implement explicit quarantine/release. - These tests verify the retry behavior which provides similar resilience. - """ +class TestRetryAndSeedFallback: + """Test retry settings and seed fallback behavior.""" def test_requests_retry_on_temporary_failure(self) -> None: """Test that requests succeed even with retries enabled. - The client's retry mechanism provides resilience similar to - quarantine by automatically retrying failed requests on other nodes. + The client's retry mechanism automatically retries failed requests on + other nodes. """ - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_retries(max_attempts=3) - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + retries=RetryConfig(max_attempts=3), ) with alternator_client("dynamodb", cluster_config=config) as client: @@ -166,7 +152,7 @@ def test_multiple_seed_hosts_fallback(self) -> None: assert "TableNames" in response -class TestQuarantineReleaseConcurrency: +class TestRefreshConcurrency: """Test concurrent request handling with node management. Note: Tests verify that concurrent requests work correctly with diff --git a/tests/integration/test_request_compression.py b/tests/integration/test_request_compression.py index 01ea43d..30109c7 100644 --- a/tests/integration/test_request_compression.py +++ b/tests/integration/test_request_compression.py @@ -10,9 +10,11 @@ from botocore.exceptions import ClientError from alternator import ( - AlternatorConfigBuilder, Auth, CompressionAlgorithm, + Config, + HeaderOptimizationConfig, + RequestCompressionConfig, ) from alternator import ( client as alternator_client, @@ -36,12 +38,13 @@ def _large_item_payload() -> str: def _gzip_config(min_size: int = 100) -> object: - return ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_compression(CompressionAlgorithm.GZIP, min_size=min_size) - .build() + return Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + request_compression=RequestCompressionConfig( + algorithm=CompressionAlgorithm.GZIP, + min_size_bytes=min_size, + ), ) @@ -93,13 +96,17 @@ def test_headers_and_compression_share_wire_whitelist( ScyllaVersion(2026, 1, 0), "gzip request compression" ) - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_compression(CompressionAlgorithm.GZIP, min_size=100) - .with_header_optimization(whitelist={"X-Keep-Me"}) - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + request_compression=RequestCompressionConfig( + algorithm=CompressionAlgorithm.GZIP, + min_size_bytes=100, + ), + header_optimization=HeaderOptimizationConfig( + enabled=True, + whitelist=frozenset({"X-Keep-Me"}), + ), ) with alternator_client( "dynamodb", diff --git a/tests/integration/test_response_compression.py b/tests/integration/test_response_compression.py index 7769a49..d7d9f75 100644 --- a/tests/integration/test_response_compression.py +++ b/tests/integration/test_response_compression.py @@ -8,7 +8,6 @@ import pytest from alternator import ( - AlternatorConfigBuilder, Config, ResponseCompression, ) @@ -34,12 +33,10 @@ def _large_response_payload() -> str: def _response_compression_config(encoding: ResponseCompression) -> Config: - return ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_response_compression(encoding) - .build() + return Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + response_compression=(encoding,), ) diff --git a/tests/integration/test_sync_client.py b/tests/integration/test_sync_client.py index 68fc338..65b91ad 100644 --- a/tests/integration/test_sync_client.py +++ b/tests/integration/test_sync_client.py @@ -11,11 +11,13 @@ import pytest from alternator import ( - AlternatorConfigBuilder, Auth, CompressionAlgorithm, Config, + HeaderOptimizationConfig, + KeyRouteAffinityConfig, KeyRouteAffinityMode, + RequestCompressionConfig, ) from alternator import ( client as alternator_client, @@ -151,12 +153,13 @@ def test_compression_with_large_item( ScyllaVersion(2026, 1, 0), "gzip request compression" ) - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_compression(CompressionAlgorithm.GZIP, min_size=100) - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + request_compression=RequestCompressionConfig( + algorithm=CompressionAlgorithm.GZIP, + min_size_bytes=100, + ), ) with alternator_client("dynamodb", cluster_config=config) as client: @@ -195,15 +198,13 @@ class TestKeyAffinity: def test_rmw_affinity(self, table_name: str) -> None: """Test RMW operations use affinity routing.""" - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_key_affinity( - KeyRouteAffinityMode.RMW, - table_pk_map={table_name: "pk"}, - ) - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + key_affinity=KeyRouteAffinityConfig( + mode=KeyRouteAffinityMode.RMW, + table_pk_attributes={table_name: "pk"}, + ), ) with alternator_client("dynamodb", cluster_config=config) as client: @@ -283,15 +284,13 @@ def test_different_keys_route_differently(self, table_name: str) -> None: This test verifies that the affinity routing actually uses the key for node selection, not just round-robin. """ - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_key_affinity( - KeyRouteAffinityMode.ANY_WRITE, - table_pk_map={table_name: "pk"}, - ) - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + key_affinity=KeyRouteAffinityConfig( + mode=KeyRouteAffinityMode.ANY_WRITE, + table_pk_attributes={table_name: "pk"}, + ), ) with alternator_client("dynamodb", cluster_config=config) as client: @@ -333,14 +332,12 @@ class TestPartitionKeyAutoDiscovery: def test_auto_discover_pk_name(self, table_name: str) -> None: """Test that partition key name is auto-discovered via DescribeTable.""" - # Configure affinity WITHOUT pre-defined table_pk_map - # The client should discover the PK name automatically - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_key_affinity(KeyRouteAffinityMode.ANY_WRITE) - .build() + # Configure affinity without preloaded partition-key attributes. + # The client should discover the PK name automatically. + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + key_affinity=KeyRouteAffinityConfig(mode=KeyRouteAffinityMode.ANY_WRITE), ) with alternator_client("dynamodb", cluster_config=config) as client: @@ -396,12 +393,10 @@ class TestHeaderOptimization: def test_header_optimized_requests_work(self, table_name: str) -> None: """Test that requests with header optimization work correctly.""" - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_header_optimization() - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + header_optimization=HeaderOptimizationConfig(enabled=True), ) with alternator_client("dynamodb", cluster_config=config) as client: @@ -449,12 +444,13 @@ def test_header_optimized_requests_work(self, table_name: str) -> None: def test_header_optimization_with_custom_whitelist(self, table_name: str) -> None: """Test header optimization with custom whitelist.""" - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_header_optimization(whitelist={"X-Custom-Header"}) - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + header_optimization=HeaderOptimizationConfig( + enabled=True, + whitelist=frozenset({"X-Custom-Header"}), + ), ) with alternator_client("dynamodb", cluster_config=config) as client: @@ -464,12 +460,10 @@ def test_header_optimization_with_custom_whitelist(self, table_name: str) -> Non def test_with_credentials_and_header_optimization(self) -> None: """Test header optimization keeps auth headers when credentials are provided.""" - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_header_optimization() - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + header_optimization=HeaderOptimizationConfig(enabled=True), ) with alternator_client( @@ -492,12 +486,10 @@ def test_datacenter_scope_works(self) -> None: """ from alternator import DatacenterScope - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_datacenter("datacenter1") # Common default DC name - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + routing_scope=DatacenterScope("datacenter1"), ) # Verify the config has the correct scope @@ -555,12 +547,11 @@ def test_tls_custom_ca(self) -> None: if not ca_path.exists(): pytest.skip("Self-signed certificate not found (run 'make scylla-start')") - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_HTTPS_PORT) - .with_https(TLS.with_custom_ca(ca_path)) - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_HTTPS_PORT, + scheme="https", + tls=TLS.with_custom_ca(ca_path), ) with alternator_client( @@ -578,12 +569,11 @@ def test_tls_trust_all(self) -> None: """ from alternator import TLS - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_HTTPS_PORT) - .with_https(TLS.trust_all()) - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_HTTPS_PORT, + scheme="https", + tls=TLS.trust_all(), ) with alternator_client( @@ -601,12 +591,11 @@ def test_tls_system_default_rejects_self_signed(self) -> None: from alternator import TLS - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_HTTPS_PORT) - .with_https(TLS.system_default()) - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_HTTPS_PORT, + scheme="https", + tls=TLS.system_default(), ) with ( diff --git a/tests/integration/test_tls.py b/tests/integration/test_tls.py index f2f4ad8..77515d0 100644 --- a/tests/integration/test_tls.py +++ b/tests/integration/test_tls.py @@ -1,6 +1,6 @@ """Integration tests for TLS configuration. -Tests TLS session caching for both ALN (node discovery) and DynamoDB API requests, +Tests TLS session tickets for both ALN (node discovery) and DynamoDB API requests, and SSLKEYLOGFILE support. These tests require a running Scylla cluster with Alternator HTTPS enabled. @@ -17,12 +17,11 @@ from alternator import ( TLS, - AlternatorConfigBuilder, + Config, ) from alternator import ( client as alternator_client, ) -from alternator.config import TlsSessionCacheConfig from tests.integration import SCYLLA_HOST, SCYLLA_HTTPS_PORT, SKIP_INTEGRATION pytestmark = [ @@ -46,45 +45,39 @@ def table_name() -> str: return f"test_tls_{uuid.uuid4().hex[:8]}" -class TestTlsSessionCacheAln: - """Test TLS session cache for ALN (node discovery) requests.""" +class TestTlsSessionTicketsAln: + """Test TLS session tickets for ALN (node discovery) requests.""" - def test_session_cache_enabled_for_aln(self, ca_path: Path) -> None: - """Test that TLS session caching works for /localnodes discovery.""" - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_HTTPS_PORT) - .with_https( - TLS( - custom_ca_cert_paths=(ca_path,), - session_cache=TlsSessionCacheConfig(enabled=True), - ) - ) - .build() + def test_session_tickets_enabled_for_aln(self, ca_path: Path) -> None: + """Test that TLS session tickets work for /localnodes discovery.""" + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_HTTPS_PORT, + scheme="https", + tls=TLS( + custom_ca_cert_paths=(ca_path,), + session_tickets_enabled=True, + ), ) with alternator_client( "dynamodb", cluster_config=config, verify=str(ca_path) ) as client: - # Multiple requests should benefit from session caching + # Multiple requests should work with session tickets enabled. for _ in range(10): response = client.list_tables() assert "TableNames" in response - def test_session_cache_disabled_for_aln(self, ca_path: Path) -> None: - """Test that disabling TLS session cache still works for ALN requests.""" - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_HTTPS_PORT) - .with_https( - TLS( - custom_ca_cert_paths=(ca_path,), - session_cache=TlsSessionCacheConfig(enabled=False), - ) - ) - .build() + def test_session_tickets_disabled_for_aln(self, ca_path: Path) -> None: + """Test disabling TLS session tickets still works for ALN requests.""" + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_HTTPS_PORT, + scheme="https", + tls=TLS( + custom_ca_cert_paths=(ca_path,), + session_tickets_enabled=False, + ), ) with alternator_client( @@ -95,24 +88,21 @@ def test_session_cache_disabled_for_aln(self, ca_path: Path) -> None: assert "TableNames" in response -class TestTlsSessionCacheDynamoDbApi: - """Test TLS session cache for DynamoDB API requests.""" +class TestTlsSessionTicketsDynamoDbApi: + """Test TLS session tickets for DynamoDB API requests.""" - def test_session_cache_enabled_for_api( + def test_session_tickets_enabled_for_api( self, ca_path: Path, table_name: str ) -> None: - """Test session caching with DynamoDB API operations over HTTPS.""" - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_HTTPS_PORT) - .with_https( - TLS( - custom_ca_cert_paths=(ca_path,), - session_cache=TlsSessionCacheConfig(enabled=True), - ) - ) - .build() + """Test session tickets with DynamoDB API operations over HTTPS.""" + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_HTTPS_PORT, + scheme="https", + tls=TLS( + custom_ca_cert_paths=(ca_path,), + session_tickets_enabled=True, + ), ) with alternator_client( @@ -128,7 +118,7 @@ def test_session_cache_enabled_for_api( waiter.wait(TableName=table_name) try: - # Multiple CRUD operations over HTTPS with session cache + # Multiple CRUD operations over HTTPS with session tickets for i in range(5): client.put_item( TableName=table_name, @@ -144,21 +134,18 @@ def test_session_cache_enabled_for_api( finally: client.delete_table(TableName=table_name) - def test_session_cache_disabled_for_api( + def test_session_tickets_disabled_for_api( self, ca_path: Path, table_name: str ) -> None: - """Test DynamoDB API operations over HTTPS without session cache.""" - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_HTTPS_PORT) - .with_https( - TLS( - custom_ca_cert_paths=(ca_path,), - session_cache=TlsSessionCacheConfig(enabled=False), - ) - ) - .build() + """Test DynamoDB API operations over HTTPS without session tickets.""" + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_HTTPS_PORT, + scheme="https", + tls=TLS( + custom_ca_cert_paths=(ca_path,), + session_tickets_enabled=False, + ), ) with alternator_client( @@ -210,12 +197,11 @@ def test_keylog_file_for_aln_requests(self, ca_path: Path) -> None: os.environ["SSLKEYLOGFILE"] = keylog_path try: - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_HTTPS_PORT) - .with_https(TLS.with_custom_ca(ca_path)) - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_HTTPS_PORT, + scheme="https", + tls=TLS.with_custom_ca(ca_path), ) with alternator_client( @@ -251,12 +237,11 @@ def test_keylog_file_for_dynamodb_api_requests(self, ca_path: Path) -> None: os.environ["SSLKEYLOGFILE"] = keylog_path try: - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_HTTPS_PORT) - .with_https(TLS.with_custom_ca(ca_path)) - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_HTTPS_PORT, + scheme="https", + tls=TLS.with_custom_ca(ca_path), ) with alternator_client( @@ -277,17 +262,17 @@ def test_keylog_file_for_dynamodb_api_requests(self, ca_path: Path) -> None: os.unlink(keylog_path) -class TestTlsSessionCacheConfig: - """Test that TLS session cache configuration values are applied correctly.""" +class TestTlsSessionTickets: + """Test that TLS session ticket configuration values are applied correctly.""" - def test_session_cache_context_options(self, ca_path: Path) -> None: + def test_session_ticket_context_options(self, ca_path: Path) -> None: """Verify SSL context has correct session ticket settings.""" from alternator.core.tls import create_ssl_context # Enabled tls_enabled = TLS( custom_ca_cert_paths=(ca_path,), - session_cache=TlsSessionCacheConfig(enabled=True), + session_tickets_enabled=True, ) ctx_enabled = create_ssl_context(tls_enabled) assert not (ctx_enabled.options & ssl.OP_NO_TICKET) @@ -295,7 +280,7 @@ def test_session_cache_context_options(self, ca_path: Path) -> None: # Disabled tls_disabled = TLS( custom_ca_cert_paths=(ca_path,), - session_cache=TlsSessionCacheConfig(enabled=False), + session_tickets_enabled=False, ) ctx_disabled = create_ssl_context(tls_disabled) assert ctx_disabled.options & ssl.OP_NO_TICKET diff --git a/tests/integration/test_wrong_dc_rack.py b/tests/integration/test_wrong_dc_rack.py index 8b32d9b..11f3251 100644 --- a/tests/integration/test_wrong_dc_rack.py +++ b/tests/integration/test_wrong_dc_rack.py @@ -10,7 +10,6 @@ import pytest from alternator import ( - AlternatorConfigBuilder, ClusterScope, Config, DatacenterScope, @@ -165,12 +164,7 @@ def test_datacenter_scope_with_special_characters(self) -> None: def test_cluster_scope_returns_nodes(self) -> None: """Verify cluster scope returns nodes from the running cluster.""" - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .build() - ) + config = Config(seed_hosts=[SCYLLA_HOST], port=SCYLLA_PORT) with alternator_client("dynamodb", cluster_config=config) as client: response = client.list_tables() @@ -181,19 +175,17 @@ def test_correct_datacenter_works(self) -> None: The default Scylla docker-compose uses 'datacenter1'. """ - config = ( - AlternatorConfigBuilder() - .with_seeds(SCYLLA_HOST) - .with_port(SCYLLA_PORT) - .with_datacenter("datacenter1") - .build() + config = Config( + seed_hosts=[SCYLLA_HOST], + port=SCYLLA_PORT, + routing_scope=DatacenterScope("datacenter1"), ) with alternator_client("dynamodb", cluster_config=config) as client: response = client.list_tables() assert "TableNames" in response - def test_helper_validates_correct_datacenter(self) -> None: + def test_session_validates_correct_datacenter(self) -> None: """Session validation succeeds for a known datacenter.""" config = Config( seed_hosts=[SCYLLA_HOST], @@ -203,7 +195,7 @@ def test_helper_validates_correct_datacenter(self) -> None: assert Session(config).validate_scope() - def test_helper_validation_rejects_wrong_datacenter(self) -> None: + def test_session_validation_rejects_wrong_datacenter(self) -> None: """Session validation raises a clear error for an absent datacenter.""" config = Config( seed_hosts=[SCYLLA_HOST], diff --git a/tests/unit/test_async_manager.py b/tests/unit/test_async_manager.py index 45c47ea..e00cda7 100644 --- a/tests/unit/test_async_manager.py +++ b/tests/unit/test_async_manager.py @@ -1,4 +1,4 @@ -"""Tests for AsyncLiveNodesManager and AsyncPartitionKeyCache.""" +"""Tests for AsyncLiveNodesManager and async partition-key cache.""" import asyncio from typing import Any, cast @@ -6,7 +6,7 @@ import pytest -from alternator.async_client import AsyncPartitionKeyCache +from alternator.async_client import _AsyncPartitionKeyCache from alternator.config import Config from alternator.core.live_nodes import AsyncLiveNodesManager, NoNodesAvailableError from alternator.core.routing_scope import ClusterScope, DatacenterScope, RackScope @@ -297,13 +297,13 @@ async def mock_fetch(url: str) -> list[str]: class TestAsyncPartitionKeyCache: - """Tests for AsyncPartitionKeyCache.""" + """Tests for async partition-key cache.""" @pytest.mark.asyncio async def test_preload_returns_cached_value(self) -> None: """Test that preloaded values are returned without fetch.""" client = MagicMock() - cache = AsyncPartitionKeyCache(client) + cache = _AsyncPartitionKeyCache(client) cache.preload({"my_table": "pk"}) @@ -325,7 +325,7 @@ async def test_fetch_and_cache(self) -> None: } } - cache = AsyncPartitionKeyCache(client) + cache = _AsyncPartitionKeyCache(client) # First call should fetch result = await cache.get_pk_name("test_table") @@ -344,7 +344,7 @@ async def test_fetch_returns_none_on_error(self) -> None: client = AsyncMock() client.describe_table.side_effect = Exception("Network error") - cache = AsyncPartitionKeyCache(client) + cache = _AsyncPartitionKeyCache(client) result = await cache.get_pk_name("test_table") assert result is None @@ -361,7 +361,7 @@ async def test_fetch_returns_none_when_no_hash_key(self) -> None: } } - cache = AsyncPartitionKeyCache(client) + cache = _AsyncPartitionKeyCache(client) result = await cache.get_pk_name("test_table") assert result is None @@ -374,7 +374,7 @@ async def test_clear_removes_all_cached(self) -> None: "Table": {"KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}]} } - cache = AsyncPartitionKeyCache(client) + cache = _AsyncPartitionKeyCache(client) cache.preload({"table1": "pk1", "table2": "pk2"}) await cache.clear() @@ -402,7 +402,7 @@ async def slow_describe_table(TableName: str) -> dict[str, Any]: client = AsyncMock() client.describe_table = slow_describe_table - cache = AsyncPartitionKeyCache(client) + cache = _AsyncPartitionKeyCache(client) # Launch multiple concurrent requests tasks = [asyncio.create_task(cache.get_pk_name("test_table")) for _ in range(5)] @@ -423,7 +423,7 @@ async def test_different_tables_fetch_independently(self) -> None: "Table": {"KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}]} } - cache = AsyncPartitionKeyCache(client) + cache = _AsyncPartitionKeyCache(client) await cache.get_pk_name("table1") await cache.get_pk_name("table2") diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 370da5e..b3e379b 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -73,20 +73,17 @@ def test_static_auth_updates_boto_kwargs(self) -> None: assert boto_kwargs["aws_secret_access_key"] == "secret" assert boto_kwargs["region_name"] == "us-east-1" - def test_legacy_boto_credentials_warn_and_enable_auth(self) -> None: - """Raw boto credential kwargs still work but are deprecated.""" + def test_raw_boto_credentials_enable_auth(self) -> None: + """Raw boto credential kwargs enable request signing.""" boto_kwargs: dict[str, object] = { "aws_access_key_id": "alternator", "aws_secret_access_key": "secret", } - with pytest.warns(DeprecationWarning, match="raw boto credential kwargs"): - auth_enabled = apply_auth(None, boto_kwargs) - - assert auth_enabled is True + assert apply_auth(None, boto_kwargs) is True assert boto_kwargs["aws_access_key_id"] == "alternator" - def test_rejects_auth_with_legacy_boto_credentials(self) -> None: + def test_rejects_auth_with_raw_boto_credentials(self) -> None: """Explicit auth cannot be mixed with raw boto credential kwargs.""" boto_kwargs: dict[str, object] = { "aws_access_key_id": "alternator", diff --git a/tests/unit/test_boto_config.py b/tests/unit/test_boto_config.py index 769f0b0..ffb488f 100644 --- a/tests/unit/test_boto_config.py +++ b/tests/unit/test_boto_config.py @@ -9,7 +9,7 @@ from alternator.client import _create_boto_config from alternator.config import ( - DEFAULT_USER_AGENT, + _DEFAULT_USER_AGENT, TLS, Config, RetryConfig, @@ -96,8 +96,8 @@ def test_default_user_agent_propagated(self) -> None: config = self._make_config() boto_config: Any = _create_boto_config(config, auth_enabled=False) - assert boto_config.user_agent == DEFAULT_USER_AGENT - assert boto_config._user_provided_options["user_agent"] == DEFAULT_USER_AGENT + assert boto_config.user_agent == _DEFAULT_USER_AGENT + assert boto_config._user_provided_options["user_agent"] == _DEFAULT_USER_AGENT def test_user_agent_callback_can_append_to_current_value(self) -> None: """Callback can keep the SDK user-agent and add Alternator identity.""" @@ -106,14 +106,14 @@ def test_user_agent_callback_can_append_to_current_value(self) -> None: config = self._make_config(user_agent=lambda default: f"{current} {default}") boto_config: Any = _create_boto_config(config, auth_enabled=False) - assert boto_config.user_agent == f"Boto3/1.0 Botocore/1.0 {DEFAULT_USER_AGENT}" + assert boto_config.user_agent == f"Boto3/1.0 Botocore/1.0 {_DEFAULT_USER_AGENT}" def test_user_agent_callback_can_wrap_default_value(self) -> None: """Callback can place the Alternator identity inside another string.""" config = self._make_config(user_agent=lambda default: f"service-a ({default})") boto_config: Any = _create_boto_config(config, auth_enabled=False) - assert boto_config.user_agent == f"service-a ({DEFAULT_USER_AGENT})" + assert boto_config.user_agent == f"service-a ({_DEFAULT_USER_AGENT})" def test_user_agent_callback_can_replace_default_value(self) -> None: """Callback can replace the Alternator identity completely.""" @@ -208,8 +208,8 @@ def test_async_config_matches_sync_transport_settings(self) -> None: "/path/to/client.crt", "/path/to/client.key", ) - assert aio_config.user_agent == DEFAULT_USER_AGENT - assert aio_config._user_provided_options["user_agent"] == DEFAULT_USER_AGENT + assert aio_config.user_agent == _DEFAULT_USER_AGENT + assert aio_config._user_provided_options["user_agent"] == _DEFAULT_USER_AGENT class TestUnsignedRequestNoAuthHeader: diff --git a/tests/unit/test_client_alias.py b/tests/unit/test_client_alias.py index 4dfb14c..40b16c9 100644 --- a/tests/unit/test_client_alias.py +++ b/tests/unit/test_client_alias.py @@ -4,7 +4,7 @@ import alternator from alternator import Auth, Config -from alternator.client import DEFAULT_PORT, client, resource +from alternator.client import _DEFAULT_PORT, client, resource from alternator.exceptions import ConfigurationError @@ -16,7 +16,7 @@ def test_builds_context_manager_from_seed_keywords(self) -> None: ctx = client("dynamodb", seeds=["node1", "node2"]) assert ctx._config.seed_hosts == ("node1", "node2") - assert ctx._config.port == DEFAULT_PORT + assert ctx._config.port == _DEFAULT_PORT assert ctx._config.scheme == "http" def test_accepts_service_name_and_connection_options(self) -> None: diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index d0d736f..670dd2b 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -7,24 +7,23 @@ from alternator.config import ( TLS, - AlternatorConfig, - AlternatorConfigBuilder, CompressionAlgorithm, Config, + HeaderOptimizationConfig, HeaderWhitelistContext, KeyRouteAffinityConfig, KeyRouteAffinityMode, + NodeListPollingConfig, RequestCompressionConfig, ResponseCompression, RetryConfig, - TlsConfig, - TlsSessionCacheConfig, + TimeoutConfig, ) from alternator.core.routing_scope import ClusterScope, DatacenterScope from alternator.exceptions import ConfigurationError -class TestAlternatorConfig: +class TestConfig: """Tests for Config validation.""" def test_valid_config(self) -> None: @@ -45,7 +44,7 @@ def test_missing_seeds_raises(self) -> None: Config(seed_hosts=[], port=8000) def test_missing_seeds_also_raises_value_error(self) -> None: - """Test backward compat: ConfigurationError is also a ValueError.""" + """ConfigurationError is also a ValueError for callers using either base.""" with pytest.raises(ValueError, match="At least one seed host is required"): Config(seed_hosts=[], port=8000) @@ -99,140 +98,87 @@ def test_default_values(self) -> None: assert isinstance(config.routing_scope, ClusterScope) -class TestDeprecatedConfigNames: - """Tests for deprecated compatibility names.""" - - def test_alternator_config_warns_and_builds_config(self) -> None: - """Test deprecated AlternatorConfig compatibility name.""" - with pytest.warns(DeprecationWarning, match="AlternatorConfig"): - config = AlternatorConfig(seed_hosts=["localhost"], port=8000) - - assert isinstance(config, Config) - assert config.seed_hosts == ["localhost"] - - def test_tls_config_warns_and_builds_tls(self) -> None: - """Test deprecated TlsConfig compatibility name.""" - with pytest.warns(DeprecationWarning, match="TlsConfig"): - tls = TlsConfig() - - assert isinstance(tls, TLS) - assert tls.trust_system_ca_certs is True - - def test_deprecated_tls_factory_warns(self) -> None: - """Test deprecated TlsConfig factory methods remain usable.""" - with pytest.warns(DeprecationWarning, match="TlsConfig"): - tls = TlsConfig.system_default() - - assert isinstance(tls, TLS) - - def test_top_level_preferred_exports(self) -> None: - """Test top-level package exports preferred names.""" - import alternator - - assert alternator.Auth is not None - assert alternator.Config is Config - assert alternator.TLS is TLS - assert alternator.UserAgent is not None - assert alternator.UserAgentCustomizer is not None - - -class TestAlternatorConfigBuilder: - """Tests for AlternatorConfigBuilder.""" +class TestConfigConstruction: + """Tests for direct Config construction of optional settings.""" def test_build_minimal_config(self) -> None: - """Test building a minimal configuration.""" - config = ( - AlternatorConfigBuilder().with_seeds("localhost").with_port(8000).build() - ) + """Test a minimal configuration.""" + config = Config(seed_hosts=("localhost",), port=8000) assert config.seed_hosts == ("localhost",) assert config.port == 8000 assert config.scheme == "http" def test_build_with_multiple_seeds(self) -> None: - """Test building config with multiple seed hosts.""" - config = ( - AlternatorConfigBuilder() - .with_seeds("host1", "host2") - .with_seeds("host3") - .with_port(8000) - .build() - ) + """Test config with multiple seed hosts.""" + config = Config(seed_hosts=("host1", "host2", "host3"), port=8000) assert config.seed_hosts == ("host1", "host2", "host3") def test_build_with_https(self) -> None: - """Test building config with HTTPS.""" - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8443) - .with_https() - .build() - ) + """Test config with HTTPS.""" + config = Config(seed_hosts=("localhost",), port=8443, scheme="https") assert config.scheme == "https" def test_build_with_https_and_tls_config(self) -> None: - """Test building config with HTTPS and custom TLS.""" + """Test config with HTTPS and custom TLS.""" tls = TLS.trust_all() - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8443) - .with_https(tls) - .build() + config = Config( + seed_hosts=("localhost",), + port=8443, + scheme="https", + tls=tls, ) assert config.scheme == "https" assert config.tls.trust_all_certificates is True def test_build_with_datacenter(self) -> None: - """Test building config with datacenter scope.""" - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8000) - .with_datacenter("us-east-1") - .build() + """Test config with datacenter scope.""" + config = Config( + seed_hosts=("localhost",), + port=8000, + routing_scope=DatacenterScope("us-east-1"), ) assert isinstance(config.routing_scope, DatacenterScope) assert config.routing_scope.datacenter == "us-east-1" assert config.routing_scope.fallback is None def test_build_with_compression(self) -> None: - """Test building config with compression.""" - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8000) - .with_compression(CompressionAlgorithm.GZIP, min_size=2048) - .build() + """Test config with compression.""" + config = Config( + seed_hosts=("localhost",), + port=8000, + request_compression=RequestCompressionConfig( + algorithm=CompressionAlgorithm.GZIP, + min_size_bytes=2048, + ), ) assert config.request_compression.algorithm == CompressionAlgorithm.GZIP assert config.request_compression.min_size_bytes == 2048 assert config.request_compression.gzip_level == 9 def test_build_with_compression_level(self) -> None: - """Test building config with a custom gzip compression level.""" - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8000) - .with_compression(CompressionAlgorithm.GZIP, min_size=2048, gzip_level=1) - .build() + """Test config with a custom gzip compression level.""" + config = Config( + seed_hosts=("localhost",), + port=8000, + request_compression=RequestCompressionConfig( + algorithm=CompressionAlgorithm.GZIP, + min_size_bytes=2048, + gzip_level=1, + ), ) assert config.request_compression.algorithm == CompressionAlgorithm.GZIP assert config.request_compression.min_size_bytes == 2048 assert config.request_compression.gzip_level == 1 - def test_build_with_response_compression(self) -> None: - """Test building config with response compression.""" - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8000) - .with_response_compression( + def test_response_compression(self) -> None: + """Test config with response compression.""" + config = Config( + seed_hosts=("localhost",), + port=8000, + response_compression=( ResponseCompression.GZIP, ResponseCompression.DEFLATE, - ) - .build() + ), ) assert config.response_compression == ( ResponseCompression.GZIP, @@ -240,25 +186,22 @@ def test_build_with_response_compression(self) -> None: ) def test_without_response_compression(self) -> None: - """Test disabling response compression.""" - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8000) - .with_response_compression(ResponseCompression.GZIP) - .without_response_compression() - .build() - ) + """Test response compression is disabled by default.""" + config = Config(seed_hosts=("localhost",), port=8000) assert config.response_compression == () - def test_with_response_compression_rejects_invalid_encoding(self) -> None: - """Test enabling response compression rejects invalid encodings.""" + def test_response_compression_rejects_invalid_encoding(self) -> None: + """Test direct response compression rejects invalid encodings.""" with pytest.raises( ConfigurationError, match="unsupported response compression encoding", ): - AlternatorConfigBuilder().with_response_compression( - None, # type: ignore[arg-type] # validate runtime input + Config( + seed_hosts=("localhost",), + port=8000, + response_compression=( + None, # type: ignore[arg-type] # validate runtime input + ), ) def test_invalid_response_compression_raises(self) -> None: @@ -283,13 +226,14 @@ def test_invalid_compression_level_raises(self, gzip_level: int) -> None: ) def test_build_with_header_optimization(self) -> None: - """Test building config with header optimization.""" - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8000) - .with_header_optimization(whitelist={"Host", "Content-Type"}) - .build() + """Test config with header optimization.""" + config = Config( + seed_hosts=("localhost",), + port=8000, + header_optimization=HeaderOptimizationConfig( + enabled=True, + whitelist=frozenset({"Host", "Content-Type"}), + ), ) assert config.header_optimization.enabled is True assert config.header_optimization.whitelist == frozenset( @@ -298,148 +242,120 @@ def test_build_with_header_optimization(self) -> None: assert config.header_optimization.whitelist_callback is None def test_build_with_header_whitelist_callback(self) -> None: - """Test building config with a dynamic header whitelist callback.""" + """Test config with a dynamic header whitelist callback.""" def whitelist_callback(context: HeaderWhitelistContext) -> set[str]: assert context.config.port == 8000 return {"X-Dynamic-Header"} - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8000) - .with_header_optimization(whitelist_callback=whitelist_callback) - .build() + config = Config( + seed_hosts=("localhost",), + port=8000, + header_optimization=HeaderOptimizationConfig( + enabled=True, + whitelist_callback=whitelist_callback, + ), ) assert config.header_optimization.enabled is True assert config.header_optimization.whitelist is None assert config.header_optimization.whitelist_callback is whitelist_callback def test_build_with_key_affinity(self) -> None: - """Test building config with key affinity.""" - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8000) - .with_key_affinity( + """Test config with key affinity.""" + config = Config( + seed_hosts=("localhost",), + port=8000, + key_affinity=KeyRouteAffinityConfig( KeyRouteAffinityMode.RMW, - table_pk_map={"users": "user_id"}, - ) - .build() + table_pk_attributes={"users": "user_id"}, + ), ) assert config.key_affinity.mode == KeyRouteAffinityMode.RMW assert config.key_affinity.table_pk_attributes == {"users": "user_id"} def test_build_with_refresh_intervals(self) -> None: - """Test building config with custom refresh intervals.""" - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8000) - .with_refresh_intervals(active_ms=500, idle_ms=30000) - .build() + """Test config with custom refresh intervals.""" + config = Config( + seed_hosts=("localhost",), + port=8000, + node_list_polling=NodeListPollingConfig( + active_interval_ms=500, + idle_interval_ms=30000, + ), ) assert config.node_list_polling.active_interval_ms == 500 assert config.node_list_polling.idle_interval_ms == 30000 def test_build_with_timeouts(self) -> None: - """Test building config with custom timeout values.""" - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8000) - .with_timeouts( + """Test config with custom timeout values.""" + config = Config( + seed_hosts=("localhost",), + port=8000, + timeouts=TimeoutConfig( discovery_seconds=10.0, connect_seconds=3.0, read_seconds=60.0, - ) - .build() + ), ) assert config.timeouts.discovery_seconds == 10.0 assert config.timeouts.connect_seconds == 3.0 assert config.timeouts.read_seconds == 60.0 def test_build_with_timeouts_default_values(self) -> None: - """Test that with_timeouts preserves defaults when not overridden.""" - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8000) - .with_timeouts() - .build() - ) + """Test timeout defaults.""" + config = Config(seed_hosts=("localhost",), port=8000) assert config.timeouts.discovery_seconds == 5.0 assert config.timeouts.connect_seconds == 5.0 assert config.timeouts.read_seconds == 30.0 def test_build_with_pool_connections(self) -> None: - """Test building config with custom pool connections.""" - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8000) - .with_pool_connections(500) - .build() - ) + """Test config with custom pool connections.""" + config = Config(seed_hosts=("localhost",), port=8000, max_pool_connections=500) assert config.max_pool_connections == 500 def test_build_default_pool_connections(self) -> None: """Test default pool connections value.""" - config = ( - AlternatorConfigBuilder().with_seeds("localhost").with_port(8000).build() - ) + config = Config(seed_hosts=("localhost",), port=8000) assert config.max_pool_connections == 200 def test_build_with_aws_region(self) -> None: - """Test building config with custom SDK region placeholder.""" - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8000) - .with_aws_region("us-west-2") - .build() - ) + """Test config with custom SDK region placeholder.""" + config = Config(seed_hosts=("localhost",), port=8000, aws_region="us-west-2") assert config.aws_region == "us-west-2" def test_build_with_user_agent(self) -> None: - """Test building config with user-agent callback.""" + """Test config with user-agent callback.""" def customize(default: str) -> str: return f"service-a {default}" - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8000) - .with_user_agent(customize) - .build() - ) + config = Config(seed_hosts=("localhost",), port=8000, user_agent=customize) assert config.user_agent is customize def test_build_with_user_agent_string(self) -> None: - """Test building config with literal user-agent replacement.""" - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8000) - .with_user_agent("service-a/1.0") - .build() + """Test config with literal user-agent replacement.""" + config = Config( + seed_hosts=("localhost",), port=8000, user_agent="service-a/1.0" ) assert config.user_agent == "service-a/1.0" def test_build_with_user_agent_none(self) -> None: - """Test building config with explicit user-agent suppression.""" - config = ( - AlternatorConfigBuilder() - .with_seeds("localhost") - .with_port(8000) - .with_user_agent(None) - .build() - ) + """Test config with explicit user-agent suppression.""" + config = Config(seed_hosts=("localhost",), port=8000, user_agent=None) assert config.user_agent is None + def test_top_level_exports_config_names(self) -> None: + """Top-level package exports the current config names.""" + import alternator + + assert alternator.Auth is not None + assert alternator.Config is Config + assert alternator.TLS is TLS + assert alternator.UserAgent is not None + assert alternator.UserAgentCustomizer is not None + -class TestTlsConfig: +class TestTLS: """Tests for TLS factory methods.""" def test_trust_all(self) -> None: @@ -464,34 +380,10 @@ def test_with_custom_ca(self) -> None: assert len(tls.custom_ca_cert_paths) == 2 assert Path("/etc/ssl/ca1.pem") in tls.custom_ca_cert_paths - def test_default_session_cache(self) -> None: - """Test default session cache configuration.""" + def test_default_session_tickets(self) -> None: + """Test default session ticket configuration.""" tls = TLS() - assert tls.session_cache.enabled is True - assert tls.session_cache.cache_size == 1024 - assert tls.session_cache.timeout_seconds == 86400 - - -class TestTlsSessionCacheConfig: - """Tests for TlsSessionCacheConfig.""" - - def test_default_values(self) -> None: - """Test default values.""" - cache = TlsSessionCacheConfig() - assert cache.enabled is True - assert cache.cache_size == 1024 - assert cache.timeout_seconds == 86400 - - def test_custom_values(self) -> None: - """Test custom values.""" - cache = TlsSessionCacheConfig( - enabled=False, - cache_size=512, - timeout_seconds=3600, - ) - assert cache.enabled is False - assert cache.cache_size == 512 - assert cache.timeout_seconds == 3600 + assert tls.session_tickets_enabled is True class TestKeyRouteAffinityConfig: diff --git a/tests/unit/test_headers.py b/tests/unit/test_headers.py index d7bfff7..d3bbab4 100644 --- a/tests/unit/test_headers.py +++ b/tests/unit/test_headers.py @@ -6,7 +6,7 @@ import pytest from alternator.config import ( - DEFAULT_USER_AGENT, + _DEFAULT_USER_AGENT, CompressionAlgorithm, Config, HeaderOptimizationConfig, @@ -236,13 +236,13 @@ class TestUserAgentHeaderHandler: def test_replaces_existing_user_agent(self) -> None: """Test the Alternator user-agent replaces an SDK-generated value.""" - handler = create_user_agent_header_handler(DEFAULT_USER_AGENT) + handler = create_user_agent_header_handler(_DEFAULT_USER_AGENT) request = MagicMock() request.headers = {"User-Agent": "Boto3/1.0 Botocore/1.0"} handler(request) - assert request.headers["User-Agent"] == DEFAULT_USER_AGENT + assert request.headers["User-Agent"] == _DEFAULT_USER_AGENT def test_removes_existing_user_agent_when_unset(self) -> None: """Test an unset Alternator user-agent removes SDK-generated values.""" @@ -256,7 +256,7 @@ def test_removes_existing_user_agent_when_unset(self) -> None: def test_handles_missing_headers_attribute(self) -> None: """Test handler handles request without headers attribute.""" - handler = create_user_agent_header_handler(DEFAULT_USER_AGENT) + handler = create_user_agent_header_handler(_DEFAULT_USER_AGENT) request = MagicMock(spec=[]) handler(request) @@ -488,7 +488,7 @@ def test_default_user_agent_readded_after_header_filter(self) -> None: assert "User-Agent" not in request.headers final_handlers["set_user_agent"](request) - assert request.headers["User-Agent"] == DEFAULT_USER_AGENT + assert request.headers["User-Agent"] == _DEFAULT_USER_AGENT def test_user_agent_remains_absent_after_header_filter_when_unset(self) -> None: """Test final User-Agent is absent when Alternator user-agent is None.""" diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py index 7e1464d..2f9c264 100644 --- a/tests/unit/test_http.py +++ b/tests/unit/test_http.py @@ -11,7 +11,7 @@ import pytest from alternator._http import create_ssl_context, create_sync_http_fetcher -from alternator.config import TLS, TlsSessionCacheConfig +from alternator.config import TLS class MockHTTPHandler(BaseHTTPRequestHandler): @@ -200,32 +200,26 @@ def test_verify_hostname_can_be_disabled(self) -> None: assert context.check_hostname is False - def test_session_cache_enabled_by_default(self) -> None: - """Test session caching is enabled by default.""" + def test_session_tickets_enabled_by_default(self) -> None: + """Test session tickets are enabled by default.""" tls_config = TLS.system_default() context = create_ssl_context(tls_config) - # OP_NO_TICKET should NOT be set when session cache is enabled + # OP_NO_TICKET should NOT be set when session tickets are enabled assert not (context.options & ssl.OP_NO_TICKET) - def test_session_cache_custom_config(self) -> None: - """Test custom session cache configuration.""" - cache_config = TlsSessionCacheConfig( - enabled=True, - cache_size=512, - timeout_seconds=3600, - ) - tls_config = TLS(session_cache=cache_config) + def test_session_tickets_custom_config(self) -> None: + """Test custom session ticket configuration.""" + tls_config = TLS(session_tickets_enabled=True) context = create_ssl_context(tls_config) # Should not raise and should have reasonable settings assert context is not None - def test_session_cache_disabled(self) -> None: - """Test session caching can be disabled.""" - cache_config = TlsSessionCacheConfig(enabled=False) - tls_config = TLS(session_cache=cache_config) + def test_session_tickets_disabled(self) -> None: + """Test session tickets can be disabled.""" + tls_config = TLS(session_tickets_enabled=False) context = create_ssl_context(tls_config) - # OP_NO_TICKET should be set when session cache is disabled + # OP_NO_TICKET should be set when session tickets are disabled assert context.options & ssl.OP_NO_TICKET diff --git a/tests/unit/test_request_lifecycle.py b/tests/unit/test_request_lifecycle.py index 5ebbc03..bdc28f6 100644 --- a/tests/unit/test_request_lifecycle.py +++ b/tests/unit/test_request_lifecycle.py @@ -12,7 +12,7 @@ from botocore.exceptions import EndpointConnectionError from alternator.config import ( - DEFAULT_USER_AGENT, + _DEFAULT_USER_AGENT, CompressionAlgorithm, Config, HeaderOptimizationConfig, @@ -138,7 +138,7 @@ def test_configured_user_agent_replaces_sdk_user_agent_before_send() -> None: seed_hosts=["seed"], port=8000, header_optimization=HeaderOptimizationConfig(enabled=True), - user_agent=DEFAULT_USER_AGENT, + user_agent=_DEFAULT_USER_AGENT, ) client = boto3.client( "dynamodb", @@ -154,7 +154,7 @@ def test_configured_user_agent_replaces_sdk_user_agent_before_send() -> None: _StaticManager(("node-b",)), config, auth_enabled=False, - user_agent=DEFAULT_USER_AGENT, + user_agent=_DEFAULT_USER_AGENT, ) seen: dict[str, str] = {} @@ -169,7 +169,7 @@ def capture_before_send(request: AWSPreparedRequest, **_: object) -> None: with pytest.raises(RuntimeError, match="captured"): client.list_tables() - assert seen["user_agent"] == DEFAULT_USER_AGENT + assert seen["user_agent"] == _DEFAULT_USER_AGENT assert "Boto3" not in seen["user_agent"] assert "Botocore" not in seen["user_agent"] diff --git a/tests/unit/test_helper.py b/tests/unit/test_session.py similarity index 77% rename from tests/unit/test_helper.py rename to tests/unit/test_session.py index ecbe2d3..d5caf56 100644 --- a/tests/unit/test_helper.py +++ b/tests/unit/test_session.py @@ -42,7 +42,7 @@ def _config_for_server( ) -def test_helper_is_exported() -> None: +def test_session_is_exported() -> None: """Session is available from the top-level package.""" assert alternator.Session is Session assert alternator.AsyncSession is AsyncSession @@ -57,60 +57,58 @@ def test_session_accepts_seed_keywords() -> None: assert session.config.scheme == "https" -def test_helper_lifecycle_and_node_diagnostics( +def test_session_lifecycle_and_node_diagnostics( fake_alternator_server: FakeAlternatorServer, ) -> None: """Session exposes explicit lifecycle and live-node inspection.""" fake_alternator_server.set_localnodes(["node2", "node1"]) - helper = Session(_config_for_server(fake_alternator_server)) + session = Session(_config_for_server(fake_alternator_server)) - assert helper.nodes == [] - assert helper.refresh_nodes() is True - assert helper.nodes == ["node1", "node2"] - assert helper.active_nodes == ["node1", "node2"] - assert helper.quarantined_nodes == [] + assert session.nodes == [] + assert session.refresh_nodes() is True + assert session.nodes == ["node1", "node2"] - helper.start() - helper.start() - helper.stop() - helper.stop() - assert helper.nodes == [] + session.start() + session.start() + session.stop() + session.stop() + assert session.nodes == [] -def test_helper_created_clients_borrow_helper_manager( +def test_session_created_clients_borrow_session_manager( fake_alternator_server: FakeAlternatorServer, ) -> None: - """Closing a helper-created client does not stop the helper manager.""" + """Closing a session-created client does not stop the session manager.""" fake_alternator_server.set_localnodes(["node1"]) - with Session(_config_for_server(fake_alternator_server)) as helper: - client = helper.client("dynamodb") - resource = helper.resource("dynamodb") + with Session(_config_for_server(fake_alternator_server)) as session: + client = session.client("dynamodb") + resource = session.resource("dynamodb") - assert getattr(client, MANAGER_ATTR) is helper._manager - assert getattr(resource, MANAGER_ATTR) is helper._manager + assert getattr(client, MANAGER_ATTR) is session._manager + assert getattr(resource, MANAGER_ATTR) is session._manager assert getattr(client, MANAGER_OWNS_ATTR) is False assert getattr(resource, MANAGER_OWNS_ATTR) is False _close_client(client) - assert helper.refresh_nodes() is True + assert session.refresh_nodes() is True -def test_helper_update_returns_new_helper( +def test_session_update_returns_new_session( fake_alternator_server: FakeAlternatorServer, ) -> None: - """update returns a fresh helper with merged settings.""" + """update returns a fresh session with merged settings.""" config = _config_for_server(fake_alternator_server) - helper = Session(config, auth=Auth.disabled(), region_name="us-west-2") - updated = helper.update(region_name="us-east-1") + session = Session(config, auth=Auth.disabled(), region_name="us-west-2") + updated = session.update(region_name="us-east-1") - assert updated is not helper + assert updated is not session assert updated.config is config assert updated._auth == Auth.disabled() assert updated._boto_kwargs["region_name"] == "us-east-1" -def test_helper_topology_checks(fake_alternator_server: FakeAlternatorServer) -> None: +def test_session_topology_checks(fake_alternator_server: FakeAlternatorServer) -> None: """Session exposes lightweight topology configuration checks.""" fake_alternator_server.set_localnodes(["node1"], query="dc=dc1") fake_alternator_server.set_localnodes(["node1"], query="dc=dc1&rack=rack1") @@ -125,15 +123,15 @@ def test_helper_topology_checks(fake_alternator_server: FakeAlternatorServer) -> ) ).validate_scope() - invalid_helper = Session( + invalid_session = Session( _config_for_server( fake_alternator_server, routing_scope=RackScope(datacenter="dc1", rack=""), ) ) - assert not invalid_helper.supports_topology_filters() + assert not invalid_session.supports_topology_filters() with pytest.raises(ConfigurationError, match="non-empty"): - invalid_helper.validate_scope() + invalid_session.validate_scope() assert Session( _config_for_server( @@ -143,19 +141,19 @@ def test_helper_topology_checks(fake_alternator_server: FakeAlternatorServer) -> ).supports_topology_filters() -def test_helper_partition_key_diagnostics_from_config( +def test_session_partition_key_diagnostics_from_config( fake_alternator_server: FakeAlternatorServer, ) -> None: """Configured partition-key mappings are exposed without private access.""" - helper = Session( + session = Session( _config_for_server( fake_alternator_server, key_affinity=KeyRouteAffinityConfig(table_pk_attributes={"tbl": "pk"}), ) ) - assert helper.partition_key_for("tbl") == "pk" - assert helper.partition_key_for("missing") is None + assert session.partition_key_for("tbl") == "pk" + assert session.partition_key_for("missing") is None def test_no_fallback_scope_does_not_use_seed_hosts( @@ -237,21 +235,19 @@ async def test_async_session_rejects_unsupported_service_name() -> None: @pytest.mark.asyncio -async def test_async_helper_lifecycle_and_node_diagnostics( +async def test_async_session_lifecycle_and_node_diagnostics( fake_alternator_server: FakeAlternatorServer, ) -> None: """AsyncSession exposes async lifecycle and live-node inspection.""" fake_alternator_server.set_localnodes(["node2", "node1"]) - helper = AsyncSession(_config_for_server(fake_alternator_server)) - - assert helper.nodes == [] - assert await helper.refresh_nodes() is True - assert helper.nodes == ["node1", "node2"] - assert helper.active_nodes == ["node1", "node2"] - assert helper.quarantined_nodes == [] - - await helper.start() - await helper.start() - await helper.stop() - await helper.stop() - assert helper.nodes == [] + session = AsyncSession(_config_for_server(fake_alternator_server)) + + assert session.nodes == [] + assert await session.refresh_nodes() is True + assert session.nodes == ["node1", "node2"] + + await session.start() + await session.start() + await session.stop() + await session.stop() + assert session.nodes == [] diff --git a/tests/unit/test_tls.py b/tests/unit/test_tls.py index 8733f96..287b722 100644 --- a/tests/unit/test_tls.py +++ b/tests/unit/test_tls.py @@ -6,7 +6,7 @@ import pytest -from alternator.config import TLS, TlsSessionCacheConfig +from alternator.config import TLS from alternator.core.tls import create_ssl_context from alternator.exceptions import ConfigurationError @@ -46,17 +46,17 @@ def test_hostname_verification_enabled(self) -> None: assert ctx.check_hostname is True - def test_session_cache_enabled(self) -> None: - """Test session tickets enabled when session cache is enabled.""" - tls_config = TLS(session_cache=TlsSessionCacheConfig(enabled=True)) + def test_session_tickets_enabled(self) -> None: + """Test session tickets enabled when configured.""" + tls_config = TLS(session_tickets_enabled=True) ctx = create_ssl_context(tls_config) # OP_NO_TICKET should NOT be set (tickets enabled) assert not (ctx.options & ssl.OP_NO_TICKET) - def test_session_cache_disabled(self) -> None: - """Test session tickets disabled when session cache is disabled.""" - tls_config = TLS(session_cache=TlsSessionCacheConfig(enabled=False)) + def test_session_tickets_disabled(self) -> None: + """Test session tickets disabled when configured.""" + tls_config = TLS(session_tickets_enabled=False) ctx = create_ssl_context(tls_config) # OP_NO_TICKET should be set (tickets disabled) @@ -173,7 +173,7 @@ def test_key_log_file_is_configured(self, tmp_path: Path) -> None: assert key_log_path.parent == tmp_path -class TestTlsConfig: +class TestTLS: """Tests for TLS class.""" def test_default_values(self) -> None: @@ -184,7 +184,7 @@ def test_default_values(self) -> None: assert config.trust_system_ca_certs is True assert config.trust_all_certificates is False assert config.verify_hostname is True - assert config.session_cache.enabled is True + assert config.session_tickets_enabled is True assert config.client_cert_path is None assert config.client_key_path is None assert config.key_log_file_path is None @@ -234,34 +234,3 @@ def test_sdk_client_cert_with_combined_cert(self) -> None: config = TLS(client_cert_path=Path("/path/to/client-combined.pem")) assert config.sdk_client_cert == "/path/to/client-combined.pem" - - -class TestTlsSessionCacheConfig: - """Tests for TlsSessionCacheConfig class.""" - - def test_default_values(self) -> None: - """Test TlsSessionCacheConfig default values.""" - config = TlsSessionCacheConfig() - - assert config.enabled is True - assert config.cache_size == 1024 - assert config.timeout_seconds == 86400 # 24 hours - - def test_custom_values(self) -> None: - """Test TlsSessionCacheConfig with custom values.""" - config = TlsSessionCacheConfig( - enabled=False, - cache_size=2048, - timeout_seconds=3600, - ) - - assert config.enabled is False - assert config.cache_size == 2048 - assert config.timeout_seconds == 3600 - - def test_immutable(self) -> None: - """Test TlsSessionCacheConfig is immutable (frozen).""" - config = TlsSessionCacheConfig() - - with pytest.raises(AttributeError): - config.enabled = False # type: ignore[misc] # testing frozen dataclass immutability