altcodepro-polydb-python 2.5.19__tar.gz → 2.5.21__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {altcodepro_polydb_python-2.5.19/src/altcodepro_polydb_python.egg-info → altcodepro_polydb_python-2.5.21}/PKG-INFO +1 -1
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/pyproject.toml +1 -1
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21/src/altcodepro_polydb_python.egg-info}/PKG-INFO +1 -1
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/altcodepro_polydb_python.egg-info/SOURCES.txt +3 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/__init__.py +1 -1
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/AzureKeyVaultAdapter.py +16 -2
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/AzureTableStorageAdapter.py +61 -4
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/DynamoDBAdapter.py +7 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/base/NoSQLKVAdapter.py +3 -1
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/databaseFactory.py +117 -20
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/schema.py +41 -4
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/types.py +2 -0
- altcodepro_polydb_python-2.5.21/tests/test_azure_keyvault_credential.py +34 -0
- altcodepro_polydb_python-2.5.21/tests/test_azure_table_self_heal.py +64 -0
- altcodepro_polydb_python-2.5.21/tests/test_factory_wiring.py +247 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/LICENSE +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/MANIFEST.in +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/README.md +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/requirements-aws.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/requirements-azure.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/requirements-dev.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/requirements-gcp.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/requirements-generic.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/requirements.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/setup.cfg +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/setup.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/altcodepro_polydb_python.egg-info/dependency_links.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/altcodepro_polydb_python.egg-info/requires.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/altcodepro_polydb_python.egg-info/top_level.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/PolyDB.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/AWSSecretsManagerAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/AzureBlobStorageAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/AzureFileStorageAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/AzureQueueAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/BlockchainBlobAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/BlockchainFileAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/BlockchainKVAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/BlockchainQueueAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/EFSAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/FirestoreAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/GCPFilestoreAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/GCPPubSubAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/GCPSecretManagerAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/GCPStorageAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/KafkaQueueAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/MongoDBAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/PostgreSQLAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/RabbitMQAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/S3Adapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/S3CompatibleAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/SQSAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/VaultAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/VercelBlobAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/VercelFileAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/VercelKVAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/VercelQueueAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/adapters/__init__.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/advanced_query.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/aio.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/audit/AuditStorage.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/audit/__init__.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/audit/context.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/audit/manager.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/audit/models.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/base/ObjectStorageAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/base/QueueAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/base/SecretsAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/base/SharedFilesAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/base/__init__.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/batch.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/cache.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/cloudDatabaseFactory.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/errors.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/json_safe.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/models.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/monitoring.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/multitenancy.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/observability/__init__.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/observability/logging.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/overflow_gc.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/py.typed +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/query.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/registry.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/retry.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/security.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/services/__init__.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/services/compliance_service.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/services/security_service.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/utils.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/validation.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_advanced_query_injection.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_async_api.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_atomic_decrement.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_audit_chain_cutover.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_audit_hmac.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_aws.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_azure.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_azure_queue_receipt_handle.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_azure_table_empty_filter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_azure_table_id_property_persisted.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_azure_table_id_return_value_sanitization_mismatch.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_azure_table_large_record_patch_roundtrip.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_azure_table_unpack_entity.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_blockchain.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_blockchain_overflow_guard.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_cloud_factory.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_field_encryption.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_gcp.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_kafka.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_like_pattern_escaping.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_mongodb.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_monitoring_health.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_multi_engine.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_multitenancy_ddl_injection.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_nosql_kv_overflow_write_path.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_nosql_operator_injection.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_observability.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_overflow_gc.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_postgresql.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_postgresql_deserialize_row.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_queue_extend_delay_cancel.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_rabbitmq.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_read_projection.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_redis_kv.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_schema_ddl_injection.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_secrets_aws.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_secrets_vault.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_session_vars.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/tests/test_vercel.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: altcodepro-polydb-python
|
|
3
|
-
Version: 2.5.
|
|
3
|
+
Version: 2.5.21
|
|
4
4
|
Summary: Production-ready multi-cloud database abstraction layer with connection pooling, retry logic, and thread safety
|
|
5
5
|
Author: AltCodePro
|
|
6
6
|
Project-URL: Homepage, https://github.com/altcodepro/polydb-python
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "altcodepro-polydb-python"
|
|
7
|
-
version = "2.5.
|
|
7
|
+
version = "2.5.21"
|
|
8
8
|
description = "Production-ready multi-cloud database abstraction layer with connection pooling, retry logic, and thread safety"
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: altcodepro-polydb-python
|
|
3
|
-
Version: 2.5.
|
|
3
|
+
Version: 2.5.21
|
|
4
4
|
Summary: Production-ready multi-cloud database abstraction layer with connection pooling, retry logic, and thread safety
|
|
5
5
|
Author: AltCodePro
|
|
6
6
|
Project-URL: Homepage, https://github.com/altcodepro/polydb-python
|
|
@@ -90,15 +90,18 @@ tests/test_audit_chain_cutover.py
|
|
|
90
90
|
tests/test_audit_hmac.py
|
|
91
91
|
tests/test_aws.py
|
|
92
92
|
tests/test_azure.py
|
|
93
|
+
tests/test_azure_keyvault_credential.py
|
|
93
94
|
tests/test_azure_queue_receipt_handle.py
|
|
94
95
|
tests/test_azure_table_empty_filter.py
|
|
95
96
|
tests/test_azure_table_id_property_persisted.py
|
|
96
97
|
tests/test_azure_table_id_return_value_sanitization_mismatch.py
|
|
97
98
|
tests/test_azure_table_large_record_patch_roundtrip.py
|
|
99
|
+
tests/test_azure_table_self_heal.py
|
|
98
100
|
tests/test_azure_table_unpack_entity.py
|
|
99
101
|
tests/test_blockchain.py
|
|
100
102
|
tests/test_blockchain_overflow_guard.py
|
|
101
103
|
tests/test_cloud_factory.py
|
|
104
|
+
tests/test_factory_wiring.py
|
|
102
105
|
tests/test_field_encryption.py
|
|
103
106
|
tests/test_gcp.py
|
|
104
107
|
tests/test_kafka.py
|
|
@@ -4,7 +4,7 @@ PolyDB - Enterprise Cloud-Independent Database Abstraction
|
|
|
4
4
|
Full LINQ support, field-level audit, cache, soft delete, overflow storage
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
__version__ = "2.5.
|
|
7
|
+
__version__ = "2.5.20"
|
|
8
8
|
|
|
9
9
|
from .aio import AsyncDatabaseFactory, AsyncPolyDB
|
|
10
10
|
from .audit.context import AuditContext
|
|
@@ -19,10 +19,24 @@ class AzureKeyVaultAdapter(SecretsAdapter):
|
|
|
19
19
|
self._initialize_client()
|
|
20
20
|
|
|
21
21
|
def _initialize_client(self) -> None:
|
|
22
|
-
from azure.identity import DefaultAzureCredential
|
|
23
22
|
from azure.keyvault.secrets import SecretClient
|
|
24
23
|
|
|
25
|
-
self._client = SecretClient(vault_url=self.vault_url, credential=
|
|
24
|
+
self._client = SecretClient(vault_url=self.vault_url, credential=self._build_credential())
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def _build_credential():
|
|
28
|
+
"""Inside Azure Container Apps / App Service / Functions a managed identity is available
|
|
29
|
+
(IDENTITY_ENDPOINT / MSI_ENDPOINT). DefaultAzureCredential tries EnvironmentCredential FIRST, and a deployment that
|
|
30
|
+
also exports AZURE_CLIENT_ID + AZURE_CLIENT_SECRET (for some other purpose) then fails with AADSTS7000232
|
|
31
|
+
"MSI identity should not use ClientSecretCredential". So when a managed identity exists it goes first
|
|
32
|
+
(AZURE_CLIENT_ID, if set, names a user-assigned identity); the normal chain stays as the fallback, so local
|
|
33
|
+
development (az login, environment variables) behaves as before."""
|
|
34
|
+
from azure.identity import ChainedTokenCredential, DefaultAzureCredential, ManagedIdentityCredential
|
|
35
|
+
|
|
36
|
+
if os.getenv("IDENTITY_ENDPOINT") or os.getenv("MSI_ENDPOINT"):
|
|
37
|
+
client_id = os.getenv("AZURE_CLIENT_ID") or None
|
|
38
|
+
return ChainedTokenCredential(ManagedIdentityCredential(client_id=client_id), DefaultAzureCredential())
|
|
39
|
+
return DefaultAzureCredential()
|
|
26
40
|
|
|
27
41
|
# Key Vault secret names may only contain alphanumerics and dashes --
|
|
28
42
|
# our secret keys (e.g. "tenant-42/stripe/api_key") use slashes, so
|
|
@@ -47,6 +47,59 @@ _RETRY_TOTAL = int(os.getenv("AZURE_TABLE_RETRY_TOTAL", "3"))
|
|
|
47
47
|
logging.getLogger("azure").setLevel(logging.ERROR)
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
|
|
51
|
+
def _is_table_missing(exc: BaseException) -> bool:
|
|
52
|
+
text = f"{type(exc).__name__} {exc}"
|
|
53
|
+
return "TableNotFound" in text or "The table specified does not exist" in text
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _SelfHealingTableClient:
|
|
57
|
+
"""Wraps an Azure TableClient so a table that disappears while the process is running (deleted by hand, a data
|
|
58
|
+
reset, a restore) is created again and the operation retried once, instead of failing with TableNotFound until
|
|
59
|
+
the process restarts. Lazy iterators (query_entities / list_entities) are healed only if the failure happens
|
|
60
|
+
before the first row was produced."""
|
|
61
|
+
|
|
62
|
+
_LAZY = ("query_entities", "list_entities")
|
|
63
|
+
|
|
64
|
+
def __init__(self, adapter: "AzureTableStorageAdapter", table_name: str, client: Any):
|
|
65
|
+
self._adapter = adapter
|
|
66
|
+
self._table_name = table_name
|
|
67
|
+
self._client = client
|
|
68
|
+
|
|
69
|
+
def _heal(self) -> None:
|
|
70
|
+
self._adapter._ensured_tables.discard(self._table_name)
|
|
71
|
+
self._adapter._client.create_table_if_not_exists(self._table_name)
|
|
72
|
+
self._adapter._ensured_tables.add(self._table_name)
|
|
73
|
+
logger.warning(f"Azure Table {self._table_name} was missing; created it again")
|
|
74
|
+
|
|
75
|
+
def __getattr__(self, name: str):
|
|
76
|
+
target = getattr(self._client, name)
|
|
77
|
+
if not callable(target):
|
|
78
|
+
return target
|
|
79
|
+
if name in self._LAZY:
|
|
80
|
+
def lazy(*args, **kwargs):
|
|
81
|
+
produced = False
|
|
82
|
+
try:
|
|
83
|
+
for item in target(*args, **kwargs):
|
|
84
|
+
produced = True
|
|
85
|
+
yield item
|
|
86
|
+
except Exception as exc: # noqa: BLE001
|
|
87
|
+
if produced or not _is_table_missing(exc):
|
|
88
|
+
raise
|
|
89
|
+
self._heal()
|
|
90
|
+
yield from getattr(self._client, name)(*args, **kwargs)
|
|
91
|
+
return lazy
|
|
92
|
+
|
|
93
|
+
def call(*args, **kwargs):
|
|
94
|
+
try:
|
|
95
|
+
return target(*args, **kwargs)
|
|
96
|
+
except Exception as exc: # noqa: BLE001
|
|
97
|
+
if not _is_table_missing(exc):
|
|
98
|
+
raise
|
|
99
|
+
self._heal()
|
|
100
|
+
return getattr(self._client, name)(*args, **kwargs)
|
|
101
|
+
return call
|
|
102
|
+
|
|
50
103
|
class AzureTableStorageAdapter(NoSQLKVAdapter):
|
|
51
104
|
"""
|
|
52
105
|
Azure Table Storage adapter with:
|
|
@@ -441,17 +494,21 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
|
|
|
441
494
|
try:
|
|
442
495
|
self._client.create_table_if_not_exists(table_name)
|
|
443
496
|
logger.info(f"✅ Azure Table ensured/created: {table_name}")
|
|
497
|
+
self._ensured_tables.add(table_name)
|
|
444
498
|
except Exception as e:
|
|
445
499
|
msg = str(e)
|
|
446
|
-
if "TableAlreadyExists"
|
|
447
|
-
|
|
448
|
-
|
|
500
|
+
if "TableAlreadyExists" in msg or "already exists" in msg.lower():
|
|
501
|
+
self._ensured_tables.add(table_name)
|
|
502
|
+
else:
|
|
503
|
+
# Do NOT remember a failed creation as "ensured": the next call tries again instead of
|
|
504
|
+
# failing with TableNotFound for the rest of the process's life.
|
|
505
|
+
logger.error(f"Could not create table {table_name}: {e}")
|
|
449
506
|
|
|
450
507
|
if cached is None:
|
|
451
508
|
cached = self._client.get_table_client(table_name=table_name)
|
|
452
509
|
self._table_clients_cache[table_name] = cached
|
|
453
510
|
|
|
454
|
-
return cached
|
|
511
|
+
return _SelfHealingTableClient(self, table_name, cached)
|
|
455
512
|
|
|
456
513
|
def _restore_overflow_properties(self, entity_dict: JsonDict) -> JsonDict:
|
|
457
514
|
restored = {}
|
|
@@ -196,6 +196,13 @@ class DynamoDBAdapter(NoSQLKVAdapter):
|
|
|
196
196
|
def _blob_key(self, model: type, pk: str, rk: str, checksum: str) -> str:
|
|
197
197
|
return f"overflow/{self._table_name(model)}/{pk}/{rk}/{checksum}.json"
|
|
198
198
|
|
|
199
|
+
def _check_overflow(self, data: JsonDict): # type: ignore[override]
|
|
200
|
+
"""DynamoDB overflows inside `_put_raw` (`_maybe_overflow_to_s3`, into this adapter's own bucket), never in the
|
|
201
|
+
base class. The base whole-record overflow replaced the record with a stub pointing at a key in the separate
|
|
202
|
+
object store, which `_resolve_overflow` (it reads this adapter's bucket) can never find -- the record was lost
|
|
203
|
+
on read. Returning the data untouched leaves the one working path in charge."""
|
|
204
|
+
return data, None
|
|
205
|
+
|
|
199
206
|
def _maybe_overflow_to_s3(
|
|
200
207
|
self, model: type, pk: str, rk: str, payload: JsonDict
|
|
201
208
|
) -> Optional[JsonDict]:
|
|
@@ -291,7 +291,9 @@ class NoSQLKVAdapter:
|
|
|
291
291
|
) -> Tuple[List[JsonDict], Optional[str]]:
|
|
292
292
|
# Basic implementation - override per provider
|
|
293
293
|
offset = int(continuation_token) if continuation_token else 0
|
|
294
|
-
|
|
294
|
+
# Fetch through the end of THIS page (+1 to learn whether another follows) and drop what earlier pages already
|
|
295
|
+
# returned -- reading from the start every time made every page after the first a repeat of the first.
|
|
296
|
+
results = self.query(model, query, limit=offset + page_size + 1)[offset:]
|
|
295
297
|
|
|
296
298
|
has_more = len(results) > page_size
|
|
297
299
|
if has_more:
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.21}/src/polydb/databaseFactory.py
RENAMED
|
@@ -19,7 +19,7 @@ from dataclasses import dataclass, field
|
|
|
19
19
|
from datetime import datetime
|
|
20
20
|
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
|
|
21
21
|
|
|
22
|
-
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
22
|
+
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
|
|
23
23
|
|
|
24
24
|
from .adapters.PostgreSQLAdapter import PostgreSQLAdapter
|
|
25
25
|
from .audit.context import AuditContext
|
|
@@ -32,6 +32,7 @@ from .errors import AdapterConfigurationError
|
|
|
32
32
|
from .models import PageRequest, PageResult
|
|
33
33
|
from .monitoring import HealthCheck, MetricsCollector, PerformanceMonitor
|
|
34
34
|
from .query import QueryBuilder
|
|
35
|
+
from .retry import _is_non_retryable
|
|
35
36
|
from .security import DataMasking, FieldEncryption
|
|
36
37
|
from .types import JsonDict, Lookup, ModelMeta
|
|
37
38
|
|
|
@@ -53,6 +54,31 @@ _DEFAULT_RETRY = retry(
|
|
|
53
54
|
reraise=True,
|
|
54
55
|
)
|
|
55
56
|
|
|
57
|
+
_TRANSIENT_MARKERS = (
|
|
58
|
+
"timeout", "timed out", "connection reset", "connection refused", "connection closed", "connection aborted",
|
|
59
|
+
"server closed the connection", "temporarily unavailable", "too many requests", "throttl", "service unavailable",
|
|
60
|
+
"serverbusy", "503", "could not connect",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_transient(exc: BaseException) -> bool:
|
|
65
|
+
if _is_non_retryable(exc):
|
|
66
|
+
return False
|
|
67
|
+
if isinstance(exc, (ConnectionError, TimeoutError)):
|
|
68
|
+
return True
|
|
69
|
+
s = str(exc).lower()
|
|
70
|
+
return any(m in s for m in _TRANSIENT_MARKERS)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _TRANSIENT_RETRY(fn):
|
|
74
|
+
return retry(
|
|
75
|
+
retry=retry_if_exception(_is_transient),
|
|
76
|
+
wait=wait_exponential(multiplier=0.5, min=0.5, max=6),
|
|
77
|
+
stop=stop_after_attempt(3),
|
|
78
|
+
reraise=True,
|
|
79
|
+
)(fn)
|
|
80
|
+
|
|
81
|
+
|
|
56
82
|
_UNIQUE_VIOLATION_MARKERS = (
|
|
57
83
|
"23505", # Postgres SQLSTATE
|
|
58
84
|
"duplicate key value violates", # Postgres message
|
|
@@ -140,6 +166,7 @@ def _extract_meta(model: Union[type, str]) -> ModelMeta:
|
|
|
140
166
|
provider=raw.get("provider"),
|
|
141
167
|
cache=raw.get("cache", False),
|
|
142
168
|
cache_ttl=raw.get("cache_ttl"),
|
|
169
|
+
encrypted_fields=tuple(raw.get("encrypted_fields") or ()),
|
|
143
170
|
)
|
|
144
171
|
return ModelMeta(storage="nosql", table=None, collection=None)
|
|
145
172
|
|
|
@@ -348,7 +375,42 @@ class DatabaseFactory:
|
|
|
348
375
|
return result
|
|
349
376
|
|
|
350
377
|
def _run(self, fn: Callable[[], Any]) -> Any:
|
|
351
|
-
|
|
378
|
+
"""Run one storage operation, retrying only TRANSIENT failures (connection loss, timeouts, throttling) with
|
|
379
|
+
exponential backoff when retries are enabled. Constraint violations, bad arguments and anything the retry
|
|
380
|
+
classifier marks non-retryable are raised at once -- a retry could not change their outcome."""
|
|
381
|
+
if not self._enable_retries:
|
|
382
|
+
return fn()
|
|
383
|
+
return _TRANSIENT_RETRY(fn)()
|
|
384
|
+
|
|
385
|
+
def _audit_write(
|
|
386
|
+
self, action: str, name: str, meta: ModelMeta, entity_id: Any, success: bool,
|
|
387
|
+
before: Optional[JsonDict], after: Optional[JsonDict], error: Optional[str],
|
|
388
|
+
) -> None:
|
|
389
|
+
"""Record a write in the audit log. Never raises: an audit failure must not turn a completed write into an
|
|
390
|
+
error. Encrypted fields are masked so the log never holds their plaintext."""
|
|
391
|
+
if not (self._enable_audit and self._audit):
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
def _mask(row: Optional[JsonDict]) -> Optional[JsonDict]:
|
|
395
|
+
if not row or not meta.encrypted_fields:
|
|
396
|
+
return row
|
|
397
|
+
return {k: ("[encrypted]" if k in meta.encrypted_fields else v) for k, v in row.items()}
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
before_m, after_m = _mask(before), _mask(after)
|
|
401
|
+
changed = (
|
|
402
|
+
sorted(k for k in set(after_m or {}) | set(before_m or {}) if (before_m or {}).get(k) != (after_m or {}).get(k))
|
|
403
|
+
if before_m is not None and after_m is not None else None
|
|
404
|
+
)
|
|
405
|
+
if isinstance(entity_id, dict):
|
|
406
|
+
entity_id = entity_id.get("id")
|
|
407
|
+
self._audit.record(
|
|
408
|
+
action=action, model=name, entity_id=None if entity_id is None else str(entity_id),
|
|
409
|
+
storage_type=meta.storage, provider=meta.provider or "default", success=success,
|
|
410
|
+
before=before_m, after=after_m, error=error, changed_fields=changed,
|
|
411
|
+
)
|
|
412
|
+
except Exception as exc: # noqa: BLE001
|
|
413
|
+
logger.warning("Audit write failed (non-fatal): %s", exc)
|
|
352
414
|
|
|
353
415
|
def _is_sql(self, meta: ModelMeta, override: Optional[EngineOverride] = None) -> bool:
|
|
354
416
|
if override and override.force_sql:
|
|
@@ -432,9 +494,12 @@ class DatabaseFactory:
|
|
|
432
494
|
with monitor as m:
|
|
433
495
|
result = self._run(_op)
|
|
434
496
|
m.rows_affected = 1
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
497
|
+
else:
|
|
498
|
+
result = self._run(_op)
|
|
499
|
+
self._audit_write("create", name, meta, entity_id, True, None, after_plain, None)
|
|
500
|
+
return result
|
|
501
|
+
except Exception as exc:
|
|
502
|
+
self._audit_write("create", name, meta, entity_id, False, None, None, str(exc))
|
|
438
503
|
raise
|
|
439
504
|
|
|
440
505
|
# ──────────────────────────────────────────────────────────
|
|
@@ -467,14 +532,26 @@ class DatabaseFactory:
|
|
|
467
532
|
projected = bool(fields or omit)
|
|
468
533
|
sql_columns = select_columns(fields, omit, columns)
|
|
469
534
|
|
|
535
|
+
drop_deleted = False
|
|
470
536
|
if self._soft_delete and not include_deleted:
|
|
471
|
-
|
|
537
|
+
if self._is_sql(meta, engine_override):
|
|
538
|
+
query = self._apply_soft_delete_filter(query)
|
|
539
|
+
else:
|
|
540
|
+
# A NoSQL equality filter on `deleted_at = None` matches nothing when the attribute was never written
|
|
541
|
+
# (Azure Table, DynamoDB), so filter the returned rows instead.
|
|
542
|
+
drop_deleted = True
|
|
472
543
|
|
|
473
544
|
adapters = self._adapters_for(model, meta, engine_override)
|
|
474
545
|
use_external_cache = (
|
|
475
546
|
self._enable_cache and self._cache and getattr(meta, "cache", False) and not projected
|
|
476
547
|
)
|
|
477
548
|
encrypted_fields = getattr(meta, "encrypted_fields", [])
|
|
549
|
+
# The cache key must cover everything that changes the result: the query, the window, and the session variables
|
|
550
|
+
# (e.g. the tenant a row-level-security policy filters by) -- otherwise two callers share an entry.
|
|
551
|
+
cache_key_query = {
|
|
552
|
+
"q": query or {}, "limit": limit, "offset": offset,
|
|
553
|
+
"sv": sorted((session_vars or {}).items()), "e": bool(engine_override),
|
|
554
|
+
}
|
|
478
555
|
|
|
479
556
|
def _op() -> List[JsonDict]:
|
|
480
557
|
if self._is_sql(meta, engine_override):
|
|
@@ -497,22 +574,27 @@ class DatabaseFactory:
|
|
|
497
574
|
nosql_kwargs: Dict[str, Any] = {}
|
|
498
575
|
if projected:
|
|
499
576
|
nosql_kwargs = {"fields": fields, "omit": omit}
|
|
577
|
+
skip = offset or 0
|
|
500
578
|
raw = adapters.nosql.query(
|
|
501
|
-
cls, query=query, limit=limit
|
|
502
|
-
**nosql_kwargs,
|
|
579
|
+
cls, query=query, limit=(limit + skip) if limit else None,
|
|
580
|
+
no_cache=no_cache or bool(use_external_cache), **nosql_kwargs,
|
|
503
581
|
)
|
|
582
|
+
if skip:
|
|
583
|
+
raw = raw[skip:] # NoSQL adapters take no offset: read through it, then drop the skipped rows
|
|
584
|
+
if drop_deleted:
|
|
585
|
+
raw = [r for r in raw if not r.get("deleted_at")]
|
|
504
586
|
if self.encryption and encrypted_fields:
|
|
505
587
|
raw = [self.encryption.decrypt_fields(r, encrypted_fields) for r in raw]
|
|
506
588
|
if self._cache and use_external_cache and not no_cache:
|
|
507
589
|
ttl = cache_ttl or getattr(meta, "cache_ttl", 300)
|
|
508
590
|
try:
|
|
509
|
-
self._cache.set(name,
|
|
591
|
+
self._cache.set(name, cache_key_query, raw, ttl)
|
|
510
592
|
except Exception as _ce:
|
|
511
593
|
logger.warning("Cache set failed (non-fatal): %s", _ce)
|
|
512
594
|
return raw
|
|
513
595
|
|
|
514
596
|
if self._cache and use_external_cache and not no_cache:
|
|
515
|
-
cached = self._cache.get(name,
|
|
597
|
+
cached = self._cache.get(name, cache_key_query)
|
|
516
598
|
if cached is not None:
|
|
517
599
|
return cached
|
|
518
600
|
|
|
@@ -677,9 +759,12 @@ class DatabaseFactory:
|
|
|
677
759
|
with monitor as m:
|
|
678
760
|
result = self._run(_op)
|
|
679
761
|
m.rows_affected = 1
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
762
|
+
else:
|
|
763
|
+
result = self._run(_op)
|
|
764
|
+
self._audit_write("update", name, meta, entity_id, True, before, after_plain, None)
|
|
765
|
+
return result
|
|
766
|
+
except Exception as exc:
|
|
767
|
+
self._audit_write("update", name, meta, entity_id, False, before, None, str(exc))
|
|
683
768
|
raise
|
|
684
769
|
|
|
685
770
|
# ──────────────────────────────────────────────────────────
|
|
@@ -737,9 +822,12 @@ class DatabaseFactory:
|
|
|
737
822
|
with monitor as m:
|
|
738
823
|
result = self._run(_op)
|
|
739
824
|
m.rows_affected = 1
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
825
|
+
else:
|
|
826
|
+
result = self._run(_op)
|
|
827
|
+
self._audit_write("upsert", name, meta, (after_plain or {}).get('id'), True, None, after_plain, None)
|
|
828
|
+
return result
|
|
829
|
+
except Exception as exc:
|
|
830
|
+
self._audit_write("upsert", name, meta, None, False, None, None, str(exc))
|
|
743
831
|
raise
|
|
744
832
|
|
|
745
833
|
# ──────────────────────────────────────────────────────────
|
|
@@ -850,9 +938,12 @@ class DatabaseFactory:
|
|
|
850
938
|
with monitor as m:
|
|
851
939
|
result = self._run(_op)
|
|
852
940
|
m.rows_affected = 1
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
941
|
+
else:
|
|
942
|
+
result = self._run(_op)
|
|
943
|
+
self._audit_write("delete", name, meta, entity_id, True, before, None, None)
|
|
944
|
+
return result
|
|
945
|
+
except Exception as exc:
|
|
946
|
+
self._audit_write("delete", name, meta, entity_id, False, before, None, str(exc))
|
|
856
947
|
raise
|
|
857
948
|
|
|
858
949
|
# ──────────────────────────────────────────────────────────
|
|
@@ -906,8 +997,12 @@ class DatabaseFactory:
|
|
|
906
997
|
name = _model_name(model)
|
|
907
998
|
meta = _extract_meta(model)
|
|
908
999
|
|
|
1000
|
+
drop_deleted = False
|
|
909
1001
|
if self._soft_delete and not include_deleted:
|
|
910
|
-
|
|
1002
|
+
if self._is_sql(meta, engine_override):
|
|
1003
|
+
query = self._apply_soft_delete_filter(query)
|
|
1004
|
+
else:
|
|
1005
|
+
drop_deleted = True
|
|
911
1006
|
|
|
912
1007
|
adapters = self._adapters_for(model, meta, engine_override)
|
|
913
1008
|
encrypted_fields = getattr(meta, "encrypted_fields", [])
|
|
@@ -924,6 +1019,8 @@ class DatabaseFactory:
|
|
|
924
1019
|
else type(name, (), {"__polydb__": meta.__dict__})
|
|
925
1020
|
)
|
|
926
1021
|
raw, token = adapters.nosql.query_page(cls, query, page_size, continuation_token)
|
|
1022
|
+
if drop_deleted:
|
|
1023
|
+
raw = [r for r in raw if not r.get("deleted_at")]
|
|
927
1024
|
if self.encryption and encrypted_fields:
|
|
928
1025
|
raw = [self.encryption.decrypt_fields(r, encrypted_fields) for r in raw]
|
|
929
1026
|
return raw, token
|
|
@@ -131,6 +131,27 @@ class SchemaBuilder:
|
|
|
131
131
|
|
|
132
132
|
return sql
|
|
133
133
|
|
|
134
|
+
def to_add_missing_columns(self, table_name: str) -> List[str]:
|
|
135
|
+
"""`ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for every non-key column. `CREATE TABLE IF NOT EXISTS` never adds a
|
|
136
|
+
column that was declared after the table was first created; appending these to the same migration makes an edited
|
|
137
|
+
column list reach existing tables. Added columns are always nullable (existing rows have no value) and never
|
|
138
|
+
UNIQUE; a declared default is kept."""
|
|
139
|
+
validate_table_name(table_name)
|
|
140
|
+
keys = set(self.primary_keys or [])
|
|
141
|
+
out: List[str] = []
|
|
142
|
+
for col in self.columns:
|
|
143
|
+
if col.name in keys or col.primary_key:
|
|
144
|
+
continue
|
|
145
|
+
parts = [validate_column_name(col.name)]
|
|
146
|
+
if col.type == ColumnType.VARCHAR and col.max_length:
|
|
147
|
+
parts.append(f"VARCHAR({col.max_length})")
|
|
148
|
+
else:
|
|
149
|
+
parts.append(col.type.value)
|
|
150
|
+
if col.default is not None:
|
|
151
|
+
parts.append(_render_default(col.default))
|
|
152
|
+
out.append(f"ALTER TABLE {table_name} ADD COLUMN IF NOT EXISTS {' '.join(parts)};")
|
|
153
|
+
return out
|
|
154
|
+
|
|
134
155
|
def to_create_indexes(self, table_name: str) -> List[str]:
|
|
135
156
|
"""Generate CREATE INDEX statements"""
|
|
136
157
|
statements = []
|
|
@@ -185,15 +206,31 @@ class MigrationManager:
|
|
|
185
206
|
|
|
186
207
|
# Check if already applied
|
|
187
208
|
existing = self.sql.execute(
|
|
188
|
-
"SELECT version FROM polydb_migrations WHERE version = %s", [version], fetch_one=True
|
|
209
|
+
"SELECT version, checksum FROM polydb_migrations WHERE version = %s", [version], fetch_one=True
|
|
189
210
|
)
|
|
190
211
|
|
|
191
|
-
if existing:
|
|
192
|
-
return False
|
|
193
|
-
|
|
194
212
|
# Calculate checksum
|
|
195
213
|
checksum = hashlib.sha256(up_sql.encode()).hexdigest()
|
|
196
214
|
|
|
215
|
+
if existing:
|
|
216
|
+
stored = existing.get("checksum") if isinstance(existing, dict) else None
|
|
217
|
+
if not stored or stored == checksum:
|
|
218
|
+
return False
|
|
219
|
+
# The DDL under this version was edited (a column added, an index changed). Re-apply it -- the statements
|
|
220
|
+
# this codebase generates are idempotent (IF NOT EXISTS) -- and remember the new checksum. A failure here
|
|
221
|
+
# must not stop a deployment from starting: keep the old checksum so the next start tries again.
|
|
222
|
+
try:
|
|
223
|
+
self.sql.execute(up_sql)
|
|
224
|
+
self.sql.execute(
|
|
225
|
+
"UPDATE polydb_migrations SET checksum = %s WHERE version = %s", [checksum, version]
|
|
226
|
+
)
|
|
227
|
+
return True
|
|
228
|
+
except Exception as exc: # noqa: BLE001
|
|
229
|
+
import logging
|
|
230
|
+
|
|
231
|
+
logging.getLogger(__name__).warning("Re-applying edited migration %s failed (kept old): %s", version, exc)
|
|
232
|
+
return False
|
|
233
|
+
|
|
197
234
|
try:
|
|
198
235
|
# Execute migration
|
|
199
236
|
self.sql.execute(up_sql)
|
|
@@ -24,6 +24,8 @@ class ModelMeta:
|
|
|
24
24
|
provider: Optional[str] = None
|
|
25
25
|
cache: bool = False
|
|
26
26
|
cache_ttl: Optional[int] = None
|
|
27
|
+
# Fields stored encrypted when the factory has encryption enabled (a tuple: the dataclass is frozen/hashable).
|
|
28
|
+
encrypted_fields: Tuple[str, ...] = ()
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
@runtime_checkable
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Inside a managed-identity host the managed identity is tried first, so a stray AZURE_CLIENT_SECRET cannot break Key Vault."""
|
|
2
|
+
import sys
|
|
3
|
+
import types
|
|
4
|
+
from unittest.mock import MagicMock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def fake_identity(monkeypatch):
|
|
11
|
+
mod = types.ModuleType("azure.identity")
|
|
12
|
+
mod.ChainedTokenCredential = MagicMock(name="Chained")
|
|
13
|
+
mod.DefaultAzureCredential = MagicMock(name="Default")
|
|
14
|
+
mod.ManagedIdentityCredential = MagicMock(name="MI")
|
|
15
|
+
monkeypatch.setitem(sys.modules, "azure.identity", mod)
|
|
16
|
+
return mod
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_a_managed_identity_host_puts_the_managed_identity_first(fake_identity, monkeypatch):
|
|
20
|
+
from polydb.adapters.AzureKeyVaultAdapter import AzureKeyVaultAdapter
|
|
21
|
+
monkeypatch.setenv("IDENTITY_ENDPOINT", "http://169.254.0.1/msi")
|
|
22
|
+
monkeypatch.setenv("AZURE_CLIENT_ID", "mi-client-id")
|
|
23
|
+
AzureKeyVaultAdapter._build_credential()
|
|
24
|
+
fake_identity.ManagedIdentityCredential.assert_called_once_with(client_id="mi-client-id")
|
|
25
|
+
chain_args = fake_identity.ChainedTokenCredential.call_args.args
|
|
26
|
+
assert chain_args[0] is fake_identity.ManagedIdentityCredential.return_value and chain_args[1] is fake_identity.DefaultAzureCredential.return_value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_elsewhere_the_default_chain_is_unchanged(fake_identity, monkeypatch):
|
|
30
|
+
from polydb.adapters.AzureKeyVaultAdapter import AzureKeyVaultAdapter
|
|
31
|
+
monkeypatch.delenv("IDENTITY_ENDPOINT", raising=False)
|
|
32
|
+
monkeypatch.delenv("MSI_ENDPOINT", raising=False)
|
|
33
|
+
assert AzureKeyVaultAdapter._build_credential() is fake_identity.DefaultAzureCredential.return_value
|
|
34
|
+
fake_identity.ManagedIdentityCredential.assert_not_called()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""A missing Azure Table is created again and the call retried; a failed creation is not remembered as success."""
|
|
2
|
+
from unittest.mock import MagicMock
|
|
3
|
+
|
|
4
|
+
from polydb.adapters.AzureTableStorageAdapter import _SelfHealingTableClient, _is_table_missing
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _Err(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _adapter():
|
|
12
|
+
a = MagicMock()
|
|
13
|
+
a._ensured_tables = {"t"}
|
|
14
|
+
return a
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_detects_the_missing_table_error():
|
|
18
|
+
assert _is_table_missing(_Err("ErrorCode:TableNotFound"))
|
|
19
|
+
assert _is_table_missing(_Err("The table specified does not exist."))
|
|
20
|
+
assert not _is_table_missing(_Err("timeout"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_a_call_on_a_missing_table_creates_it_and_retries_once():
|
|
24
|
+
client = MagicMock()
|
|
25
|
+
client.get_entity.side_effect = [_Err("TableNotFound"), {"ok": 1}]
|
|
26
|
+
a = _adapter()
|
|
27
|
+
got = _SelfHealingTableClient(a, "t", client).get_entity(partition_key="p", row_key="r")
|
|
28
|
+
assert got == {"ok": 1} and a._client.create_table_if_not_exists.called and "t" in a._ensured_tables
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_other_errors_are_not_swallowed():
|
|
32
|
+
client = MagicMock()
|
|
33
|
+
client.get_entity.side_effect = _Err("boom")
|
|
34
|
+
try:
|
|
35
|
+
_SelfHealingTableClient(_adapter(), "t", client).get_entity()
|
|
36
|
+
raise AssertionError("should raise")
|
|
37
|
+
except _Err:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_a_lazy_query_on_a_missing_table_heals_before_the_first_row():
|
|
42
|
+
client = MagicMock()
|
|
43
|
+
def gone():
|
|
44
|
+
raise _Err("TableNotFound")
|
|
45
|
+
yield
|
|
46
|
+
client.query_entities.side_effect = [gone(), iter([{"a": 1}, {"a": 2}])]
|
|
47
|
+
a = _adapter()
|
|
48
|
+
rows = list(_SelfHealingTableClient(a, "t", client).query_entities("x"))
|
|
49
|
+
assert rows == [{"a": 1}, {"a": 2}] and a._client.create_table_if_not_exists.called
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_a_failure_after_rows_were_produced_is_raised_not_replayed():
|
|
53
|
+
client = MagicMock()
|
|
54
|
+
def partial():
|
|
55
|
+
yield {"a": 1}
|
|
56
|
+
raise _Err("TableNotFound")
|
|
57
|
+
client.query_entities.return_value = partial()
|
|
58
|
+
it = _SelfHealingTableClient(_adapter(), "t", client).query_entities("x")
|
|
59
|
+
assert next(it) == {"a": 1}
|
|
60
|
+
try:
|
|
61
|
+
next(it)
|
|
62
|
+
raise AssertionError("should raise")
|
|
63
|
+
except _Err:
|
|
64
|
+
pass
|