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.
Files changed (193) hide show
  1. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/PKG-INFO +1 -1
  2. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/pyproject.toml +1 -1
  3. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_model_base.py +220 -1
  4. boto3_assist-0.33.0/src/boto3_assist/version.py +1 -0
  5. boto3_assist-0.33.0/tests/unit/dynamodb_tests/dynamodb_model_merge_test.py +427 -0
  6. boto3_assist-0.32.0/src/boto3_assist/version.py +0 -1
  7. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.env.docker +0 -0
  8. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.env.docker.001 +0 -0
  9. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.env.docker.nosql.workbench +0 -0
  10. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.env.unittest +0 -0
  11. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.gitignore +0 -0
  12. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.vscode/launch.json +0 -0
  13. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.vscode/settings.json +0 -0
  14. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.vscode/tasks.json +0 -0
  15. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/.windsurf/rules/cascade.yaml +0 -0
  16. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/LICENSE-EXPLAINED.txt +0 -0
  17. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/LICENSE.txt +0 -0
  18. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/README.md +0 -0
  19. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/aws_regions_with_status.csv +0 -0
  20. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/aws_regions_with_status.json +0 -0
  21. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/devops/build.py +0 -0
  22. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/devops/readme.md +0 -0
  23. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/design-patterns.md +0 -0
  24. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/001-guide-single-table-design.md +0 -0
  25. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/002-guide-defining-models.md +0 -0
  26. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/003-guide-service-layers.md +0 -0
  27. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/004-guide-testing-with-moto.md +0 -0
  28. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/005-guide-projections-and-reserved-keywords.md +0 -0
  29. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/006-guide-how-dynamodb-stores-data.md +0 -0
  30. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/007-guide-batch-operations.md +0 -0
  31. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/008-guide-transactions.md +0 -0
  32. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/009-guide-conditional-writes.md +0 -0
  33. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/help/dynamodb/010-guide-update-expressions.md +0 -0
  34. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/issues/BOTO3_ASSIST_BEFORE_AFTER.md +0 -0
  35. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/issues/BOTO3_ASSIST_DECIMAL_PATTERN.md +0 -0
  36. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/issues/BOTO3_ASSIST_IMPLEMENTATION_CHECKLIST.md +0 -0
  37. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/overview.md +0 -0
  38. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/roadmap.md +0 -0
  39. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/tech-debt.md +0 -0
  40. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/docs/unit-test-patterns.md +0 -0
  41. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/__init__.py +0 -0
  42. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/cloudwatch/log_report.py +0 -0
  43. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/QUICK_REFERENCE_KEY_DEBUGGING.md +0 -0
  44. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/RUNTIME_KEY_DEBUGGING_SUMMARY.md +0 -0
  45. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/batch_operations_example.py +0 -0
  46. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/conditional_writes_example.py +0 -0
  47. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/debug_keys_example.py +0 -0
  48. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/decimal_conversion_example.py +0 -0
  49. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/models/order_item_model.py +0 -0
  50. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/models/order_model.py +0 -0
  51. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/models/product_model.py +0 -0
  52. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/models/user_model.py +0 -0
  53. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/models/user_post_model.py +0 -0
  54. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/order_example/main.py +0 -0
  55. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/order_example/products.json +0 -0
  56. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/runtime_key_debugging_example.py +0 -0
  57. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/services/order_item_service.py +0 -0
  58. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/services/order_service.py +0 -0
  59. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/services/product_service.py +0 -0
  60. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/services/table_service.py +0 -0
  61. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/services/user_post_service.py +0 -0
  62. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/services/user_service.py +0 -0
  63. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/services/user_service_client_example.py +0 -0
  64. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/services/user_service_resource_example.py +0 -0
  65. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/transactions_example.py +0 -0
  66. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/update_expressions_example.py +0 -0
  67. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/dynamodb/user_post_example/main.py +0 -0
  68. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/examples/ec2/regions_report.py +0 -0
  69. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/module-headers.txt +0 -0
  70. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/mypy.ini +0 -0
  71. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/publish_to_pypi.py +0 -0
  72. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/publish_to_pypi.sh +0 -0
  73. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/pysetup.py +0 -0
  74. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/pysetup.sh +0 -0
  75. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/requirements.dev.txt +0 -0
  76. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/requirements.txt +0 -0
  77. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/run-checks.sh +0 -0
  78. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/run-unit-tests.sh +0 -0
  79. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/__init__.py +0 -0
  80. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/aws_config.py +0 -0
  81. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/aws_lambda/event_info.py +0 -0
  82. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/aws_lambda/mock_context.py +0 -0
  83. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/boto3session.py +0 -0
  84. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cloudwatch/cloudwatch_connection.py +0 -0
  85. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cloudwatch/cloudwatch_connection_tracker.py +0 -0
  86. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cloudwatch/cloudwatch_log_connection.py +0 -0
  87. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cloudwatch/cloudwatch_logs.py +0 -0
  88. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cloudwatch/cloudwatch_query.py +0 -0
  89. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cognito/cognito_authorizer.py +0 -0
  90. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cognito/cognito_connection.py +0 -0
  91. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cognito/cognito_utility.py +0 -0
  92. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cognito/jwks_cache.py +0 -0
  93. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/cognito/user.py +0 -0
  94. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/connection.py +0 -0
  95. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/connection_tracker.py +0 -0
  96. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb.py +0 -0
  97. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_connection.py +0 -0
  98. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_helpers.py +0 -0
  99. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_importer.py +0 -0
  100. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_index.py +0 -0
  101. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_iservice.py +0 -0
  102. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_key.py +0 -0
  103. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_model_base_interfaces.py +0 -0
  104. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_re_indexer.py +0 -0
  105. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_reindexer.py +0 -0
  106. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_reserved_words.py +0 -0
  107. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/dynamodb_reserved_words.txt +0 -0
  108. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/readme.md +0 -0
  109. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/dynamodb/troubleshooting.md +0 -0
  110. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/ec2/ec2_connection.py +0 -0
  111. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/environment_services/__init__.py +0 -0
  112. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/environment_services/environment_loader.py +0 -0
  113. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/environment_services/environment_variables.py +0 -0
  114. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/erc/__init__.py +0 -0
  115. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/erc/ecr_connection.py +0 -0
  116. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/errors/custom_exceptions.py +0 -0
  117. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/http_status_codes.py +0 -0
  118. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/models/serializable_model.py +0 -0
  119. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/role_assumption_mixin.py +0 -0
  120. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/s3/s3.py +0 -0
  121. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/s3/s3_bucket.py +0 -0
  122. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/s3/s3_connection.py +0 -0
  123. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/s3/s3_event_data.py +0 -0
  124. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/s3/s3_object.py +0 -0
  125. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/securityhub/securityhub.py +0 -0
  126. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/securityhub/securityhub_connection.py +0 -0
  127. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/session_setup_mixin.py +0 -0
  128. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/ssm/connection.py +0 -0
  129. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/ssm/parameter_store/parameter_store.py +0 -0
  130. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/datetime_utility.py +0 -0
  131. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/decimal_conversion_utility.py +0 -0
  132. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/dictionary_utility.py +0 -0
  133. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/file_operations.py +0 -0
  134. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/http_utility.py +0 -0
  135. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/logging_utility.py +0 -0
  136. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/numbers_utility.py +0 -0
  137. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/serialization_utility.py +0 -0
  138. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/src/boto3_assist/utilities/string_utility.py +0 -0
  139. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/__init__.py +0 -0
  140. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/integration/cross_account_connection_test.py +0 -0
  141. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/integration/tenant.py +0 -0
  142. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/integration/tenant_services.py +0 -0
  143. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/aws_config_test.py +0 -0
  144. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/common/db_test_helpers.py +0 -0
  145. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb/decimal_backward_compatibility_test.py +0 -0
  146. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb/decimal_conversion_integration_test.py +0 -0
  147. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb/test_dynamodb_key_to_dict.py +0 -0
  148. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/__init__.py +0 -0
  149. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/db_models/cms/base.py +0 -0
  150. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/db_models/cms/content_block.py +0 -0
  151. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/db_models/cms/page.py +0 -0
  152. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/db_models/cms/template.py +0 -0
  153. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/db_models/simple_model.py +0 -0
  154. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/db_models/task.py +0 -0
  155. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/db_models/user_model.py +0 -0
  156. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/db_models/user_required_fields_model.py +0 -0
  157. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_batch_operations_test.py +0 -0
  158. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_conditional_test.py +0 -0
  159. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_fail_if_exists_test.py +0 -0
  160. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_model_base_test.py +0 -0
  161. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_model_projections_test.py +0 -0
  162. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_model_serializtion_test.py +0 -0
  163. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_moto_sorting_test.py +0 -0
  164. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_primary_key_get_test.py +0 -0
  165. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_primary_key_sort_test.py +0 -0
  166. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_query_test.py +0 -0
  167. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_reindex_test.py +0 -0
  168. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_transactions_test.py +0 -0
  169. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/dynamodb_tests/dynamodb_update_expressions_test.py +0 -0
  170. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/examples_test/README.md +0 -0
  171. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/examples_test/__init__.py +0 -0
  172. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/examples_test/order_service_test.py +0 -0
  173. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/examples_test/user_service_test.py +0 -0
  174. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/lambda_tests/__init__.py +0 -0
  175. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/lambda_tests/event_info_test.py +0 -0
  176. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/models_tests/__init__.py +0 -0
  177. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/models_tests/models/person.py +0 -0
  178. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/models_tests/models/user.py +0 -0
  179. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/models_tests/serializable_model_person_test.py +0 -0
  180. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/models_tests/serializable_model_user_test.py +0 -0
  181. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/models_tests/serializable_model_wide_test.py +0 -0
  182. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/parameter_store/__init__.py +0 -0
  183. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/parameter_store/parameter_store_test.py +0 -0
  184. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/s3/__init__.py +0 -0
  185. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/s3/files/test.txt +0 -0
  186. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/s3/s3_event_data_test.py +0 -0
  187. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/s3/s3_file_delete_test.py +0 -0
  188. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/s3/s3_file_upload_test.py +0 -0
  189. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/session_tests/test_boto3_session_manager.py +0 -0
  190. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/utilities/__init__.py +0 -0
  191. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/utilities/decimal_conversion_utility_test.py +0 -0
  192. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/utilities/serialization_utility_test.py +0 -0
  193. {boto3_assist-0.32.0 → boto3_assist-0.33.0}/tests/unit/utilities/string_utility_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boto3_assist
3
- Version: 0.32.0
3
+ Version: 0.33.0
4
4
  Summary: Additional boto3 wrappers to make your life a little easier
5
5
  Author-email: Eric Wilson <boto3-assist@geekcafe.com>
6
6
  License-File: LICENSE-EXPLAINED.txt
@@ -15,7 +15,7 @@ addopts = "-m 'not integration'"
15
15
 
16
16
  [project]
17
17
  name = "boto3_assist"
18
- version = "0.32.0"
18
+ version = "0.33.0"
19
19
 
20
20
  authors = [
21
21
  { name="Eric Wilson", email="boto3-assist@geekcafe.com" }
@@ -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