boto3-assist 0.32.0__tar.gz → 0.33.0__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.
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/PKG-INFO +1 -1
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/pyproject.toml +1 -1
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_model_base.py +220 -1
- boto3_assist-0.33.0/src/boto3_assist/version.py +1 -0
- boto3_assist-0.33.0/tests/unit/dynamodb_tests/dynamodb_model_merge_test.py +427 -0
- boto3_assist-0.32.0/src/boto3_assist/version.py +0 -1
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.env.docker +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.env.docker.001 +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.env.docker.nosql.workbench +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.env.unittest +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.gitignore +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.vscode/launch.json +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.vscode/settings.json +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.vscode/tasks.json +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.windsurf/rules/cascade.yaml +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/LICENSE-EXPLAINED.txt +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/LICENSE.txt +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/README.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/aws_regions_with_status.csv +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/aws_regions_with_status.json +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/devops/build.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/devops/readme.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/design-patterns.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/001-guide-single-table-design.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/002-guide-defining-models.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/003-guide-service-layers.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/004-guide-testing-with-moto.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/005-guide-projections-and-reserved-keywords.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/006-guide-how-dynamodb-stores-data.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/007-guide-batch-operations.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/008-guide-transactions.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/009-guide-conditional-writes.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/010-guide-update-expressions.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/issues/BOTO3_ASSIST_BEFORE_AFTER.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/issues/BOTO3_ASSIST_DECIMAL_PATTERN.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/issues/BOTO3_ASSIST_IMPLEMENTATION_CHECKLIST.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/overview.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/roadmap.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/tech-debt.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/unit-test-patterns.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/__init__.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/cloudwatch/log_report.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/QUICK_REFERENCE_KEY_DEBUGGING.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/RUNTIME_KEY_DEBUGGING_SUMMARY.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/batch_operations_example.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/conditional_writes_example.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/debug_keys_example.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/decimal_conversion_example.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/models/order_item_model.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/models/order_model.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/models/product_model.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/models/user_model.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/models/user_post_model.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/order_example/main.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/order_example/products.json +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/runtime_key_debugging_example.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/services/order_item_service.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/services/order_service.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/services/product_service.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/services/table_service.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/services/user_post_service.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/services/user_service.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/services/user_service_client_example.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/services/user_service_resource_example.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/transactions_example.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/update_expressions_example.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/user_post_example/main.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/ec2/regions_report.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/module-headers.txt +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/mypy.ini +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/publish_to_pypi.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/publish_to_pypi.sh +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/pysetup.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/pysetup.sh +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/requirements.dev.txt +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/requirements.txt +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/run-checks.sh +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/run-unit-tests.sh +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/__init__.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/aws_config.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/aws_lambda/event_info.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/aws_lambda/mock_context.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/boto3session.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cloudwatch/cloudwatch_connection.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cloudwatch/cloudwatch_connection_tracker.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cloudwatch/cloudwatch_log_connection.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cloudwatch/cloudwatch_logs.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cloudwatch/cloudwatch_query.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cognito/cognito_authorizer.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cognito/cognito_connection.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cognito/cognito_utility.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cognito/jwks_cache.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cognito/user.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/connection.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/connection_tracker.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_connection.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_helpers.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_importer.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_index.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_iservice.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_key.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_model_base_interfaces.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_re_indexer.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_reindexer.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_reserved_words.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_reserved_words.txt +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/readme.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/troubleshooting.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/ec2/ec2_connection.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/environment_services/__init__.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/environment_services/environment_loader.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/environment_services/environment_variables.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/erc/__init__.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/erc/ecr_connection.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/errors/custom_exceptions.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/http_status_codes.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/models/serializable_model.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/role_assumption_mixin.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/s3/s3.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/s3/s3_bucket.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/s3/s3_connection.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/s3/s3_event_data.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/s3/s3_object.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/securityhub/securityhub.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/securityhub/securityhub_connection.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/session_setup_mixin.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/ssm/connection.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/ssm/parameter_store/parameter_store.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/datetime_utility.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/decimal_conversion_utility.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/dictionary_utility.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/file_operations.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/http_utility.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/logging_utility.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/numbers_utility.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/serialization_utility.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/string_utility.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/__init__.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/integration/cross_account_connection_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/integration/tenant.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/integration/tenant_services.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/aws_config_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/common/db_test_helpers.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb/decimal_backward_compatibility_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb/decimal_conversion_integration_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb/test_dynamodb_key_to_dict.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/__init__.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/db_models/cms/base.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/db_models/cms/content_block.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/db_models/cms/page.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/db_models/cms/template.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/db_models/simple_model.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/db_models/task.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/db_models/user_model.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/db_models/user_required_fields_model.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_batch_operations_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_conditional_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_fail_if_exists_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_model_base_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_model_projections_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_model_serializtion_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_moto_sorting_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_primary_key_get_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_primary_key_sort_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_query_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_reindex_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_transactions_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_update_expressions_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/examples_test/README.md +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/examples_test/__init__.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/examples_test/order_service_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/examples_test/user_service_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/lambda_tests/__init__.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/lambda_tests/event_info_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/models_tests/__init__.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/models_tests/models/person.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/models_tests/models/user.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/models_tests/serializable_model_person_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/models_tests/serializable_model_user_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/models_tests/serializable_model_wide_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/parameter_store/__init__.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/parameter_store/parameter_store_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/s3/__init__.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/s3/files/test.txt +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/s3/s3_event_data_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/s3/s3_file_delete_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/s3/s3_file_upload_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/session_tests/test_boto3_session_manager.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/utilities/__init__.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/utilities/decimal_conversion_utility_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/utilities/serialization_utility_test.py +0 -0
- {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/utilities/string_utility_test.py +0 -0
{boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_model_base.py
RENAMED
|
@@ -6,11 +6,12 @@ MIT License. See Project Root for the license information.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
import datetime as dt
|
|
9
|
+
from enum import Enum
|
|
9
10
|
|
|
10
11
|
# import decimal
|
|
11
12
|
# import inspect
|
|
12
13
|
# import uuid
|
|
13
|
-
from typing import TypeVar, List, Dict, Any
|
|
14
|
+
from typing import TypeVar, List, Dict, Any, Set
|
|
14
15
|
from boto3.dynamodb.types import TypeSerializer, TypeDeserializer
|
|
15
16
|
from boto3_assist.utilities.serialization_utility import Serialization
|
|
16
17
|
from boto3_assist.utilities.decimal_conversion_utility import DecimalConversionUtility
|
|
@@ -25,6 +26,37 @@ from boto3_assist.models.serializable_model import SerializableModel
|
|
|
25
26
|
from boto3_assist.utilities.string_utility import StringUtility
|
|
26
27
|
|
|
27
28
|
|
|
29
|
+
class MergeStrategy(Enum):
|
|
30
|
+
"""Strategy for merging updates into an existing model."""
|
|
31
|
+
|
|
32
|
+
NON_NULL_WINS = "non_null_wins"
|
|
33
|
+
"""Only overwrite if the update value is not None (default, most common)."""
|
|
34
|
+
|
|
35
|
+
UPDATES_WIN = "updates_win"
|
|
36
|
+
"""Update values always win, even if None."""
|
|
37
|
+
|
|
38
|
+
EXISTING_WINS = "existing_wins"
|
|
39
|
+
"""Only fill in fields that are currently None in the existing model."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _ClearFieldSentinel:
|
|
43
|
+
"""Sentinel class to explicitly mark a field for clearing to None."""
|
|
44
|
+
|
|
45
|
+
_instance = None
|
|
46
|
+
|
|
47
|
+
def __new__(cls):
|
|
48
|
+
if cls._instance is None:
|
|
49
|
+
cls._instance = super().__new__(cls)
|
|
50
|
+
return cls._instance
|
|
51
|
+
|
|
52
|
+
def __repr__(self) -> str:
|
|
53
|
+
return "CLEAR_FIELD"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Singleton sentinel value - use this to explicitly clear a field to None
|
|
57
|
+
CLEAR_FIELD = _ClearFieldSentinel()
|
|
58
|
+
|
|
59
|
+
|
|
28
60
|
def exclude_from_serialization(method):
|
|
29
61
|
"""
|
|
30
62
|
Decorator to mark methods or properties to be excluded from serialization.
|
|
@@ -179,6 +211,80 @@ class DynamoDBModelBase(SerializableModel):
|
|
|
179
211
|
# attempt to map it
|
|
180
212
|
return DynamoDBSerializer.map(source=item, target=self)
|
|
181
213
|
|
|
214
|
+
def merge(
|
|
215
|
+
self: T,
|
|
216
|
+
updates: Dict[str, Any] | DynamoDBModelBase | None,
|
|
217
|
+
strategy: MergeStrategy = MergeStrategy.NON_NULL_WINS,
|
|
218
|
+
include_fields: Set[str] | List[str] | None = None,
|
|
219
|
+
exclude_fields: Set[str] | List[str] | None = None,
|
|
220
|
+
) -> T:
|
|
221
|
+
"""
|
|
222
|
+
Merge updates into this instance based on the specified strategy.
|
|
223
|
+
|
|
224
|
+
Unlike map() which overwrites all fields, merge() selectively updates
|
|
225
|
+
fields based on the strategy and handles the common case where you want
|
|
226
|
+
to apply partial updates from an API request.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
updates: The source of updates - can be a dict or another model instance.
|
|
230
|
+
strategy: How to handle the merge:
|
|
231
|
+
- NON_NULL_WINS (default): Only overwrite if update value is not None.
|
|
232
|
+
Use CLEAR_FIELD sentinel to explicitly set a field to None.
|
|
233
|
+
- UPDATES_WIN: Update values always win, even if None.
|
|
234
|
+
- EXISTING_WINS: Only fill in fields that are currently None.
|
|
235
|
+
include_fields: If provided, only these fields will be considered for merge.
|
|
236
|
+
exclude_fields: Fields to exclude from the merge (e.g., 'id', 'created_at').
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Self with merged updates applied.
|
|
240
|
+
|
|
241
|
+
Example:
|
|
242
|
+
# Load existing from DB
|
|
243
|
+
existing = Product().map(db_response)
|
|
244
|
+
|
|
245
|
+
# Merge partial updates (only non-null fields applied)
|
|
246
|
+
existing.merge({"name": "New Name", "price": None}) # price unchanged
|
|
247
|
+
|
|
248
|
+
# Explicitly clear a field
|
|
249
|
+
from boto3_assist.dynamodb import CLEAR_FIELD
|
|
250
|
+
existing.merge({"description": CLEAR_FIELD}) # description set to None
|
|
251
|
+
|
|
252
|
+
# Fill gaps only (useful for defaults)
|
|
253
|
+
existing.merge(defaults, strategy=MergeStrategy.EXISTING_WINS)
|
|
254
|
+
"""
|
|
255
|
+
if updates is None:
|
|
256
|
+
return self
|
|
257
|
+
|
|
258
|
+
# Convert to dict if needed
|
|
259
|
+
updates_dict: Dict[str, Any]
|
|
260
|
+
if isinstance(updates, DynamoDBModelBase):
|
|
261
|
+
updates_dict = updates.to_resource_dictionary(include_indexes=False)
|
|
262
|
+
elif isinstance(updates, dict):
|
|
263
|
+
updates_dict = updates.copy()
|
|
264
|
+
else:
|
|
265
|
+
raise ValueError("Updates must be a dictionary or DynamoDBModelBase")
|
|
266
|
+
|
|
267
|
+
# Convert decimals if present
|
|
268
|
+
updates_dict = DecimalConversionUtility.convert_decimals_to_native_types(
|
|
269
|
+
updates_dict
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Apply field filters
|
|
273
|
+
if include_fields is not None:
|
|
274
|
+
include_set = set(include_fields)
|
|
275
|
+
updates_dict = {k: v for k, v in updates_dict.items() if k in include_set}
|
|
276
|
+
|
|
277
|
+
if exclude_fields is not None:
|
|
278
|
+
exclude_set = set(exclude_fields)
|
|
279
|
+
updates_dict = {
|
|
280
|
+
k: v for k, v in updates_dict.items() if k not in exclude_set
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
# Apply merge based on strategy
|
|
284
|
+
return DynamoDBSerializer.merge(
|
|
285
|
+
updates=updates_dict, target=self, strategy=strategy
|
|
286
|
+
)
|
|
287
|
+
|
|
182
288
|
def to_client_dictionary(self, include_indexes: bool = True):
|
|
183
289
|
"""
|
|
184
290
|
Convert the instance to a dictionary suitable for DynamoDB client.
|
|
@@ -380,3 +486,116 @@ class DynamoDBSerializer:
|
|
|
380
486
|
instance_dict[key.sort_key.attribute_name] = key.sort_key.value
|
|
381
487
|
|
|
382
488
|
return instance_dict
|
|
489
|
+
|
|
490
|
+
@staticmethod
|
|
491
|
+
def merge(updates: Dict[str, Any], target: T, strategy: MergeStrategy) -> T:
|
|
492
|
+
"""
|
|
493
|
+
Merge updates into the target object based on the specified strategy.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
updates: Dictionary of field updates to apply.
|
|
497
|
+
target: The target object to merge into.
|
|
498
|
+
strategy: The merge strategy to use.
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
The target object with updates merged.
|
|
502
|
+
"""
|
|
503
|
+
for key, update_value in updates.items():
|
|
504
|
+
if not Serialization.has_attribute(target, key):
|
|
505
|
+
continue
|
|
506
|
+
|
|
507
|
+
current_value = getattr(target, key, None)
|
|
508
|
+
|
|
509
|
+
# Handle CLEAR_FIELD sentinel - always clears to None
|
|
510
|
+
if isinstance(update_value, _ClearFieldSentinel):
|
|
511
|
+
try:
|
|
512
|
+
setattr(target, key, None)
|
|
513
|
+
except (AttributeError, TypeError):
|
|
514
|
+
pass # Property without setter or type issue
|
|
515
|
+
continue
|
|
516
|
+
|
|
517
|
+
# Apply strategy
|
|
518
|
+
should_update = False
|
|
519
|
+
|
|
520
|
+
if strategy == MergeStrategy.UPDATES_WIN:
|
|
521
|
+
# Updates always win
|
|
522
|
+
should_update = True
|
|
523
|
+
|
|
524
|
+
elif strategy == MergeStrategy.NON_NULL_WINS:
|
|
525
|
+
# Only update if the new value is not None
|
|
526
|
+
should_update = update_value is not None
|
|
527
|
+
|
|
528
|
+
elif strategy == MergeStrategy.EXISTING_WINS:
|
|
529
|
+
# Only update if current value is None
|
|
530
|
+
should_update = current_value is None
|
|
531
|
+
|
|
532
|
+
if should_update:
|
|
533
|
+
try:
|
|
534
|
+
# Handle nested objects/dicts
|
|
535
|
+
if (
|
|
536
|
+
isinstance(current_value, dict)
|
|
537
|
+
and isinstance(update_value, dict)
|
|
538
|
+
and strategy != MergeStrategy.UPDATES_WIN
|
|
539
|
+
):
|
|
540
|
+
# Recursively merge dicts
|
|
541
|
+
DynamoDBSerializer._merge_dict(
|
|
542
|
+
current_value, update_value, strategy
|
|
543
|
+
)
|
|
544
|
+
elif hasattr(current_value, "__dict__") and isinstance(
|
|
545
|
+
update_value, dict
|
|
546
|
+
):
|
|
547
|
+
# Nested object - recursively merge
|
|
548
|
+
DynamoDBSerializer.merge(
|
|
549
|
+
updates=update_value,
|
|
550
|
+
target=current_value,
|
|
551
|
+
strategy=strategy,
|
|
552
|
+
)
|
|
553
|
+
else:
|
|
554
|
+
setattr(target, key, update_value)
|
|
555
|
+
except (AttributeError, TypeError):
|
|
556
|
+
pass # Property without setter or type issue
|
|
557
|
+
|
|
558
|
+
return target
|
|
559
|
+
|
|
560
|
+
@staticmethod
|
|
561
|
+
def _merge_dict(
|
|
562
|
+
target_dict: Dict[str, Any],
|
|
563
|
+
updates_dict: Dict[str, Any],
|
|
564
|
+
strategy: MergeStrategy,
|
|
565
|
+
) -> None:
|
|
566
|
+
"""
|
|
567
|
+
Merge updates into a target dictionary based on strategy.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
target_dict: The dictionary to merge into (modified in place).
|
|
571
|
+
updates_dict: The dictionary of updates.
|
|
572
|
+
strategy: The merge strategy to use.
|
|
573
|
+
"""
|
|
574
|
+
for key, update_value in updates_dict.items():
|
|
575
|
+
current_value = target_dict.get(key)
|
|
576
|
+
|
|
577
|
+
# Handle CLEAR_FIELD sentinel
|
|
578
|
+
if isinstance(update_value, _ClearFieldSentinel):
|
|
579
|
+
target_dict[key] = None
|
|
580
|
+
continue
|
|
581
|
+
|
|
582
|
+
should_update = False
|
|
583
|
+
|
|
584
|
+
if strategy == MergeStrategy.UPDATES_WIN:
|
|
585
|
+
should_update = True
|
|
586
|
+
elif strategy == MergeStrategy.NON_NULL_WINS:
|
|
587
|
+
should_update = update_value is not None
|
|
588
|
+
elif strategy == MergeStrategy.EXISTING_WINS:
|
|
589
|
+
should_update = current_value is None
|
|
590
|
+
|
|
591
|
+
if should_update:
|
|
592
|
+
if (
|
|
593
|
+
isinstance(current_value, dict)
|
|
594
|
+
and isinstance(update_value, dict)
|
|
595
|
+
and strategy != MergeStrategy.UPDATES_WIN
|
|
596
|
+
):
|
|
597
|
+
DynamoDBSerializer._merge_dict(
|
|
598
|
+
current_value, update_value, strategy
|
|
599
|
+
)
|
|
600
|
+
else:
|
|
601
|
+
target_dict[key] = update_value
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.33.0"
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Geek Cafe, LLC
|
|
3
|
+
Maintainers: Eric Wilson
|
|
4
|
+
MIT License. See Project Root for the license information.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import unittest
|
|
8
|
+
from typing import Optional, Dict, Any
|
|
9
|
+
|
|
10
|
+
from boto3_assist.dynamodb.dynamodb_model_base import (
|
|
11
|
+
DynamoDBModelBase,
|
|
12
|
+
MergeStrategy,
|
|
13
|
+
CLEAR_FIELD,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SimpleModel(DynamoDBModelBase):
|
|
18
|
+
"""Simple test model for merge testing."""
|
|
19
|
+
|
|
20
|
+
def __init__(self):
|
|
21
|
+
super().__init__()
|
|
22
|
+
self.id: Optional[str] = None
|
|
23
|
+
self.name: Optional[str] = None
|
|
24
|
+
self.description: Optional[str] = None
|
|
25
|
+
self.price: Optional[float] = None
|
|
26
|
+
self.quantity: Optional[int] = None
|
|
27
|
+
self.is_active: Optional[bool] = None
|
|
28
|
+
self.metadata: Optional[Dict[str, Any]] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NestedModel(DynamoDBModelBase):
|
|
32
|
+
"""Model with nested objects for testing deep merge."""
|
|
33
|
+
|
|
34
|
+
def __init__(self):
|
|
35
|
+
super().__init__()
|
|
36
|
+
self.id: Optional[str] = None
|
|
37
|
+
self.name: Optional[str] = None
|
|
38
|
+
self.settings: Dict[str, Any] = {}
|
|
39
|
+
self.address: Optional[Dict[str, str]] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DynamoDBModelMergeTest(unittest.TestCase):
|
|
43
|
+
"""Tests for the merge() method on DynamoDBModelBase."""
|
|
44
|
+
|
|
45
|
+
# =========================================================================
|
|
46
|
+
# NON_NULL_WINS Strategy Tests (Default)
|
|
47
|
+
# =========================================================================
|
|
48
|
+
|
|
49
|
+
def test_merge_non_null_wins_updates_non_null_fields(self):
|
|
50
|
+
"""Non-null values in updates should overwrite existing values."""
|
|
51
|
+
# Arrange
|
|
52
|
+
existing = SimpleModel()
|
|
53
|
+
existing.id = "123"
|
|
54
|
+
existing.name = "Original Name"
|
|
55
|
+
existing.description = "Original Description"
|
|
56
|
+
existing.price = 10.0
|
|
57
|
+
|
|
58
|
+
updates = {"name": "Updated Name", "price": 25.0}
|
|
59
|
+
|
|
60
|
+
# Act
|
|
61
|
+
existing.merge(updates)
|
|
62
|
+
|
|
63
|
+
# Assert
|
|
64
|
+
self.assertEqual(existing.name, "Updated Name")
|
|
65
|
+
self.assertEqual(existing.price, 25.0)
|
|
66
|
+
self.assertEqual(existing.description, "Original Description") # unchanged
|
|
67
|
+
self.assertEqual(existing.id, "123") # unchanged
|
|
68
|
+
|
|
69
|
+
def test_merge_non_null_wins_ignores_none_values(self):
|
|
70
|
+
"""None values in updates should NOT overwrite existing values."""
|
|
71
|
+
# Arrange
|
|
72
|
+
existing = SimpleModel()
|
|
73
|
+
existing.id = "123"
|
|
74
|
+
existing.name = "Original Name"
|
|
75
|
+
existing.price = 10.0
|
|
76
|
+
|
|
77
|
+
updates = {"name": None, "price": None, "description": "New Description"}
|
|
78
|
+
|
|
79
|
+
# Act
|
|
80
|
+
existing.merge(updates)
|
|
81
|
+
|
|
82
|
+
# Assert
|
|
83
|
+
self.assertEqual(existing.name, "Original Name") # unchanged
|
|
84
|
+
self.assertEqual(existing.price, 10.0) # unchanged
|
|
85
|
+
self.assertEqual(existing.description, "New Description") # updated
|
|
86
|
+
|
|
87
|
+
def test_merge_non_null_wins_with_clear_field_sentinel(self):
|
|
88
|
+
"""CLEAR_FIELD sentinel should explicitly set field to None."""
|
|
89
|
+
# Arrange
|
|
90
|
+
existing = SimpleModel()
|
|
91
|
+
existing.id = "123"
|
|
92
|
+
existing.name = "Original Name"
|
|
93
|
+
existing.description = "Original Description"
|
|
94
|
+
|
|
95
|
+
updates = {"description": CLEAR_FIELD}
|
|
96
|
+
|
|
97
|
+
# Act
|
|
98
|
+
existing.merge(updates)
|
|
99
|
+
|
|
100
|
+
# Assert
|
|
101
|
+
self.assertEqual(existing.name, "Original Name") # unchanged
|
|
102
|
+
self.assertIsNone(existing.description) # explicitly cleared
|
|
103
|
+
|
|
104
|
+
def test_merge_non_null_wins_fills_none_fields(self):
|
|
105
|
+
"""Updates should fill in fields that are currently None."""
|
|
106
|
+
# Arrange
|
|
107
|
+
existing = SimpleModel()
|
|
108
|
+
existing.id = "123"
|
|
109
|
+
existing.name = None
|
|
110
|
+
existing.price = None
|
|
111
|
+
|
|
112
|
+
updates = {"name": "New Name", "price": 15.0}
|
|
113
|
+
|
|
114
|
+
# Act
|
|
115
|
+
existing.merge(updates)
|
|
116
|
+
|
|
117
|
+
# Assert
|
|
118
|
+
self.assertEqual(existing.name, "New Name")
|
|
119
|
+
self.assertEqual(existing.price, 15.0)
|
|
120
|
+
|
|
121
|
+
# =========================================================================
|
|
122
|
+
# UPDATES_WIN Strategy Tests
|
|
123
|
+
# =========================================================================
|
|
124
|
+
|
|
125
|
+
def test_merge_updates_win_overwrites_everything(self):
|
|
126
|
+
"""UPDATES_WIN should overwrite all fields, including with None."""
|
|
127
|
+
# Arrange
|
|
128
|
+
existing = SimpleModel()
|
|
129
|
+
existing.id = "123"
|
|
130
|
+
existing.name = "Original Name"
|
|
131
|
+
existing.description = "Original Description"
|
|
132
|
+
existing.price = 10.0
|
|
133
|
+
|
|
134
|
+
updates = {"name": "Updated Name", "description": None, "price": 25.0}
|
|
135
|
+
|
|
136
|
+
# Act
|
|
137
|
+
existing.merge(updates, strategy=MergeStrategy.UPDATES_WIN)
|
|
138
|
+
|
|
139
|
+
# Assert
|
|
140
|
+
self.assertEqual(existing.name, "Updated Name")
|
|
141
|
+
self.assertIsNone(existing.description) # overwritten with None
|
|
142
|
+
self.assertEqual(existing.price, 25.0)
|
|
143
|
+
self.assertEqual(existing.id, "123") # not in updates, unchanged
|
|
144
|
+
|
|
145
|
+
def test_merge_updates_win_with_all_none(self):
|
|
146
|
+
"""UPDATES_WIN with all None values should clear those fields."""
|
|
147
|
+
# Arrange
|
|
148
|
+
existing = SimpleModel()
|
|
149
|
+
existing.name = "Original"
|
|
150
|
+
existing.description = "Description"
|
|
151
|
+
|
|
152
|
+
updates = {"name": None, "description": None}
|
|
153
|
+
|
|
154
|
+
# Act
|
|
155
|
+
existing.merge(updates, strategy=MergeStrategy.UPDATES_WIN)
|
|
156
|
+
|
|
157
|
+
# Assert
|
|
158
|
+
self.assertIsNone(existing.name)
|
|
159
|
+
self.assertIsNone(existing.description)
|
|
160
|
+
|
|
161
|
+
# =========================================================================
|
|
162
|
+
# EXISTING_WINS Strategy Tests
|
|
163
|
+
# =========================================================================
|
|
164
|
+
|
|
165
|
+
def test_merge_existing_wins_only_fills_gaps(self):
|
|
166
|
+
"""EXISTING_WINS should only update fields that are currently None."""
|
|
167
|
+
# Arrange
|
|
168
|
+
existing = SimpleModel()
|
|
169
|
+
existing.id = "123"
|
|
170
|
+
existing.name = "Original Name"
|
|
171
|
+
existing.description = None
|
|
172
|
+
existing.price = None
|
|
173
|
+
|
|
174
|
+
updates = {
|
|
175
|
+
"name": "Should Not Change",
|
|
176
|
+
"description": "New Description",
|
|
177
|
+
"price": 20.0,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# Act
|
|
181
|
+
existing.merge(updates, strategy=MergeStrategy.EXISTING_WINS)
|
|
182
|
+
|
|
183
|
+
# Assert
|
|
184
|
+
self.assertEqual(existing.name, "Original Name") # unchanged
|
|
185
|
+
self.assertEqual(existing.description, "New Description") # filled
|
|
186
|
+
self.assertEqual(existing.price, 20.0) # filled
|
|
187
|
+
|
|
188
|
+
def test_merge_existing_wins_ignores_all_when_populated(self):
|
|
189
|
+
"""EXISTING_WINS should not change anything if all fields are populated."""
|
|
190
|
+
# Arrange
|
|
191
|
+
existing = SimpleModel()
|
|
192
|
+
existing.id = "123"
|
|
193
|
+
existing.name = "Original"
|
|
194
|
+
existing.description = "Original Desc"
|
|
195
|
+
existing.price = 10.0
|
|
196
|
+
|
|
197
|
+
updates = {"name": "New", "description": "New Desc", "price": 99.0}
|
|
198
|
+
|
|
199
|
+
# Act
|
|
200
|
+
existing.merge(updates, strategy=MergeStrategy.EXISTING_WINS)
|
|
201
|
+
|
|
202
|
+
# Assert
|
|
203
|
+
self.assertEqual(existing.name, "Original")
|
|
204
|
+
self.assertEqual(existing.description, "Original Desc")
|
|
205
|
+
self.assertEqual(existing.price, 10.0)
|
|
206
|
+
|
|
207
|
+
def test_merge_existing_wins_with_clear_field(self):
|
|
208
|
+
"""CLEAR_FIELD should still work with EXISTING_WINS strategy."""
|
|
209
|
+
# Arrange
|
|
210
|
+
existing = SimpleModel()
|
|
211
|
+
existing.name = "Original"
|
|
212
|
+
existing.description = "Original Desc"
|
|
213
|
+
|
|
214
|
+
updates = {"description": CLEAR_FIELD}
|
|
215
|
+
|
|
216
|
+
# Act
|
|
217
|
+
existing.merge(updates, strategy=MergeStrategy.EXISTING_WINS)
|
|
218
|
+
|
|
219
|
+
# Assert
|
|
220
|
+
self.assertIsNone(existing.description) # CLEAR_FIELD always works
|
|
221
|
+
|
|
222
|
+
# =========================================================================
|
|
223
|
+
# Field Filtering Tests
|
|
224
|
+
# =========================================================================
|
|
225
|
+
|
|
226
|
+
def test_merge_with_include_fields(self):
|
|
227
|
+
"""Only specified include_fields should be considered for merge."""
|
|
228
|
+
# Arrange
|
|
229
|
+
existing = SimpleModel()
|
|
230
|
+
existing.name = "Original"
|
|
231
|
+
existing.description = "Original Desc"
|
|
232
|
+
existing.price = 10.0
|
|
233
|
+
|
|
234
|
+
updates = {"name": "New Name", "description": "New Desc", "price": 99.0}
|
|
235
|
+
|
|
236
|
+
# Act
|
|
237
|
+
existing.merge(updates, include_fields=["name", "price"])
|
|
238
|
+
|
|
239
|
+
# Assert
|
|
240
|
+
self.assertEqual(existing.name, "New Name") # included
|
|
241
|
+
self.assertEqual(existing.description, "Original Desc") # not included
|
|
242
|
+
self.assertEqual(existing.price, 99.0) # included
|
|
243
|
+
|
|
244
|
+
def test_merge_with_exclude_fields(self):
|
|
245
|
+
"""Specified exclude_fields should be skipped during merge."""
|
|
246
|
+
# Arrange
|
|
247
|
+
existing = SimpleModel()
|
|
248
|
+
existing.id = "123"
|
|
249
|
+
existing.name = "Original"
|
|
250
|
+
existing.description = "Original Desc"
|
|
251
|
+
existing.price = 10.0
|
|
252
|
+
|
|
253
|
+
updates = {
|
|
254
|
+
"id": "999",
|
|
255
|
+
"name": "New Name",
|
|
256
|
+
"description": "New Desc",
|
|
257
|
+
"price": 99.0,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
# Act
|
|
261
|
+
existing.merge(updates, exclude_fields=["id", "price"])
|
|
262
|
+
|
|
263
|
+
# Assert
|
|
264
|
+
self.assertEqual(existing.id, "123") # excluded
|
|
265
|
+
self.assertEqual(existing.name, "New Name") # updated
|
|
266
|
+
self.assertEqual(existing.description, "New Desc") # updated
|
|
267
|
+
self.assertEqual(existing.price, 10.0) # excluded
|
|
268
|
+
|
|
269
|
+
def test_merge_with_both_include_and_exclude(self):
|
|
270
|
+
"""Both include and exclude can be used together."""
|
|
271
|
+
# Arrange
|
|
272
|
+
existing = SimpleModel()
|
|
273
|
+
existing.name = "Original"
|
|
274
|
+
existing.description = "Original Desc"
|
|
275
|
+
existing.price = 10.0
|
|
276
|
+
existing.quantity = 5
|
|
277
|
+
|
|
278
|
+
updates = {
|
|
279
|
+
"name": "New",
|
|
280
|
+
"description": "New Desc",
|
|
281
|
+
"price": 99.0,
|
|
282
|
+
"quantity": 100,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
# Act - include name, description, price but exclude price
|
|
286
|
+
existing.merge(
|
|
287
|
+
updates,
|
|
288
|
+
include_fields=["name", "description", "price"],
|
|
289
|
+
exclude_fields=["price"],
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Assert
|
|
293
|
+
self.assertEqual(existing.name, "New") # included, not excluded
|
|
294
|
+
self.assertEqual(existing.description, "New Desc") # included, not excluded
|
|
295
|
+
self.assertEqual(existing.price, 10.0) # included but also excluded
|
|
296
|
+
self.assertEqual(existing.quantity, 5) # not included
|
|
297
|
+
|
|
298
|
+
# =========================================================================
|
|
299
|
+
# Nested Object/Dict Tests
|
|
300
|
+
# =========================================================================
|
|
301
|
+
|
|
302
|
+
def test_merge_nested_dict_non_null_wins(self):
|
|
303
|
+
"""Nested dicts should be recursively merged with NON_NULL_WINS."""
|
|
304
|
+
# Arrange
|
|
305
|
+
existing = NestedModel()
|
|
306
|
+
existing.settings = {"theme": "dark", "language": "en", "notifications": True}
|
|
307
|
+
|
|
308
|
+
updates = {"settings": {"theme": "light", "language": None}}
|
|
309
|
+
|
|
310
|
+
# Act
|
|
311
|
+
existing.merge(updates)
|
|
312
|
+
|
|
313
|
+
# Assert
|
|
314
|
+
self.assertEqual(existing.settings["theme"], "light") # updated
|
|
315
|
+
self.assertEqual(existing.settings["language"], "en") # None ignored
|
|
316
|
+
self.assertEqual(existing.settings["notifications"], True) # unchanged
|
|
317
|
+
|
|
318
|
+
def test_merge_nested_dict_updates_win(self):
|
|
319
|
+
"""UPDATES_WIN should replace entire nested dict."""
|
|
320
|
+
# Arrange
|
|
321
|
+
existing = NestedModel()
|
|
322
|
+
existing.settings = {"theme": "dark", "language": "en"}
|
|
323
|
+
|
|
324
|
+
updates = {"settings": {"theme": "light"}}
|
|
325
|
+
|
|
326
|
+
# Act
|
|
327
|
+
existing.merge(updates, strategy=MergeStrategy.UPDATES_WIN)
|
|
328
|
+
|
|
329
|
+
# Assert
|
|
330
|
+
self.assertEqual(existing.settings, {"theme": "light"})
|
|
331
|
+
|
|
332
|
+
def test_merge_with_none_updates(self):
|
|
333
|
+
"""Passing None as updates should return self unchanged."""
|
|
334
|
+
# Arrange
|
|
335
|
+
existing = SimpleModel()
|
|
336
|
+
existing.name = "Original"
|
|
337
|
+
|
|
338
|
+
# Act
|
|
339
|
+
result = existing.merge(None)
|
|
340
|
+
|
|
341
|
+
# Assert
|
|
342
|
+
self.assertEqual(result.name, "Original")
|
|
343
|
+
self.assertIs(result, existing)
|
|
344
|
+
|
|
345
|
+
# =========================================================================
|
|
346
|
+
# Model-to-Model Merge Tests
|
|
347
|
+
# =========================================================================
|
|
348
|
+
|
|
349
|
+
def test_merge_from_another_model(self):
|
|
350
|
+
"""Should be able to merge from another DynamoDBModelBase instance."""
|
|
351
|
+
# Arrange
|
|
352
|
+
existing = SimpleModel()
|
|
353
|
+
existing.id = "123"
|
|
354
|
+
existing.name = "Original"
|
|
355
|
+
existing.description = "Original Desc"
|
|
356
|
+
|
|
357
|
+
updates_model = SimpleModel()
|
|
358
|
+
updates_model.name = "Updated Name"
|
|
359
|
+
updates_model.price = 50.0
|
|
360
|
+
|
|
361
|
+
# Act
|
|
362
|
+
existing.merge(updates_model)
|
|
363
|
+
|
|
364
|
+
# Assert
|
|
365
|
+
self.assertEqual(existing.name, "Updated Name")
|
|
366
|
+
self.assertEqual(existing.description, "Original Desc") # unchanged
|
|
367
|
+
self.assertEqual(existing.price, 50.0)
|
|
368
|
+
|
|
369
|
+
# =========================================================================
|
|
370
|
+
# Edge Cases
|
|
371
|
+
# =========================================================================
|
|
372
|
+
|
|
373
|
+
def test_merge_ignores_unknown_fields(self):
|
|
374
|
+
"""Fields not on the model should be ignored."""
|
|
375
|
+
# Arrange
|
|
376
|
+
existing = SimpleModel()
|
|
377
|
+
existing.name = "Original"
|
|
378
|
+
|
|
379
|
+
updates = {"name": "Updated", "unknown_field": "value", "another_unknown": 123}
|
|
380
|
+
|
|
381
|
+
# Act
|
|
382
|
+
existing.merge(updates)
|
|
383
|
+
|
|
384
|
+
# Assert
|
|
385
|
+
self.assertEqual(existing.name, "Updated")
|
|
386
|
+
self.assertFalse(hasattr(existing, "unknown_field"))
|
|
387
|
+
self.assertFalse(hasattr(existing, "another_unknown"))
|
|
388
|
+
|
|
389
|
+
def test_merge_returns_self(self):
|
|
390
|
+
"""Merge should return self for method chaining."""
|
|
391
|
+
# Arrange
|
|
392
|
+
existing = SimpleModel()
|
|
393
|
+
|
|
394
|
+
# Act
|
|
395
|
+
result = existing.merge({"name": "Test"})
|
|
396
|
+
|
|
397
|
+
# Assert
|
|
398
|
+
self.assertIs(result, existing)
|
|
399
|
+
|
|
400
|
+
def test_merge_with_empty_dict(self):
|
|
401
|
+
"""Merging empty dict should not change anything."""
|
|
402
|
+
# Arrange
|
|
403
|
+
existing = SimpleModel()
|
|
404
|
+
existing.name = "Original"
|
|
405
|
+
existing.price = 10.0
|
|
406
|
+
|
|
407
|
+
# Act
|
|
408
|
+
existing.merge({})
|
|
409
|
+
|
|
410
|
+
# Assert
|
|
411
|
+
self.assertEqual(existing.name, "Original")
|
|
412
|
+
self.assertEqual(existing.price, 10.0)
|
|
413
|
+
|
|
414
|
+
def test_clear_field_repr(self):
|
|
415
|
+
"""CLEAR_FIELD should have a readable repr."""
|
|
416
|
+
self.assertEqual(repr(CLEAR_FIELD), "CLEAR_FIELD")
|
|
417
|
+
|
|
418
|
+
def test_clear_field_is_singleton(self):
|
|
419
|
+
"""CLEAR_FIELD should be a singleton."""
|
|
420
|
+
from boto3_assist.dynamodb.dynamodb_model_base import _ClearFieldSentinel
|
|
421
|
+
|
|
422
|
+
another = _ClearFieldSentinel()
|
|
423
|
+
self.assertIs(CLEAR_FIELD, another)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
if __name__ == "__main__":
|
|
427
|
+
unittest.main()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.32.0"
|
|
File without changes
|