From e7d815a28bd3457cb3e5b06bdcad82df2b8746db Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 6 Aug 2025 16:41:36 +0000 Subject: [PATCH] Adding additional DynamoDB local pytest --- python-test-samples/README.md | 1 + .../dynamodb-crud-cli-local/README.md | 522 +++++++++++++ .../events/batch-get.json | 26 + .../events/batch-write.json | 75 ++ .../events/create-item.json | 4 + .../events/delete-item.json | 3 + .../events/update-item.json | 4 + .../img/dynamodb-crud-cli.png | Bin 0 -> 56353 bytes .../tests/requirements.txt | 11 + .../tests/unit/src/test_dynamodb_local.py | 721 ++++++++++++++++++ 10 files changed, 1367 insertions(+) create mode 100644 python-test-samples/dynamodb-crud-cli-local/README.md create mode 100644 python-test-samples/dynamodb-crud-cli-local/events/batch-get.json create mode 100644 python-test-samples/dynamodb-crud-cli-local/events/batch-write.json create mode 100755 python-test-samples/dynamodb-crud-cli-local/events/create-item.json create mode 100755 python-test-samples/dynamodb-crud-cli-local/events/delete-item.json create mode 100755 python-test-samples/dynamodb-crud-cli-local/events/update-item.json create mode 100644 python-test-samples/dynamodb-crud-cli-local/img/dynamodb-crud-cli.png create mode 100644 python-test-samples/dynamodb-crud-cli-local/tests/requirements.txt create mode 100644 python-test-samples/dynamodb-crud-cli-local/tests/unit/src/test_dynamodb_local.py diff --git a/python-test-samples/README.md b/python-test-samples/README.md index e25b76f2..8c1e3f40 100644 --- a/python-test-samples/README.md +++ b/python-test-samples/README.md @@ -14,6 +14,7 @@ This portion of the repository contains code samples for testing serverless appl |[API Gateway with Lambda and DynamoDB](./apigw-lambda-dynamodb)|This project contains unit and integration tests for a pattern using API Gateway, AWS Lambda and Amazon DynamoDB.| |[Schema and Contract Testing](./schema-and-contract-testing)|This project contains sample schema and contract tests for an event driven architecture.| |[Kinesis with Lambda and DynamoDB](./kinesis-lambda-dynamodb)|This project contains a example of testing an application with an Amazon Kinesis Data Stream.| +|[DynamoDB CRUD Local](./dynamodb-crud-local)|This project contains unit pytest for CRUD operations with DynamoDB on local Docker container.| |[SQS with Lambda](./apigw-sqs-lambda-sqs)|This project demonstrates testing SQS as a source and destination in an integration test| |[Step Functions Local](./step-functions-local)| An example of testing Step Functions workflow locally using pytest and Testcontainers | diff --git a/python-test-samples/dynamodb-crud-cli-local/README.md b/python-test-samples/dynamodb-crud-cli-local/README.md new file mode 100644 index 00000000..4ef979e1 --- /dev/null +++ b/python-test-samples/dynamodb-crud-cli-local/README.md @@ -0,0 +1,522 @@ +[![python: 3.10](https://img.shields.io/badge/Python-3.10-green)](https://img.shields.io/badge/Python-3.10-green) +[![AWS: DynamoDB](https://img.shields.io/badge/AWS-DynamoDB-blueviolet)](https://img.shields.io/badge/AWS-DynamoDB-blueviolet) +[![AWS: CLI](https://img.shields.io/badge/AWS-CLI-yellow)](https://img.shields.io/badge/AWS-CLI-yellow) +[![test: pytest](https://img.shields.io/badge/Test-Pytest-red)](https://img.shields.io/badge/Test-Pytest-red) +[![test: local](https://img.shields.io/badge/Test-Local-red)](https://img.shields.io/badge/Test-Local-red) + +# Local: Amazon DynamoDB CRUD Operations Testing + +## Introduction + +This project demonstrates how to test Amazon DynamoDB CRUD operations locally using Docker and PyTest. It provides complete examples of Create, Read, Update, and Delete operations without requiring actual AWS infrastructure, making it ideal for rapid development and automated testing cycles. + +--- + +## Contents + +- [Local: Amazon DynamoDB CRUD Operations Testing](#local-amazon-dynamodb-crud-operations-testing) + - [Introduction](#introduction) + - [Contents](#contents) + - [Architecture Overview](#architecture-overview) + - [Project Structure](#project-structure) + - [Prerequisites](#prerequisites) + - [Test Scenarios](#test-scenarios) + - [About the Test Process](#about-the-test-process) + - [Testing Workflows](#testing-workflows) + - [CRUD Operations Documentation](#crud-operations-documentation) + - [Additional Resources](#additional-resources) + +--- + +## Architecture Overview + +

+ DynamoDB CRUD Operations Testing +

+ +Components: + +- DynamoDB Local container via Docker +- AWS CLI for direct DynamoDB operations +- Python boto3 client for programmatic access +- PyTest for automated testing +- JSON files for test data management + +--- + +## Project Structure + +``` +├── img/dynamodb-crud-cli.png _# visual architecture diagram_ +├── events/ +│ ├── batch-get.json _# json file containing batch get input (aws cli debug)_ +│ ├── batch-write.json _# json file containing batch write input (aws cli debug)_ +│ ├── create-item.json _# json file containing create item input_ +│ ├── delete-item.json _# json file containing delete item input_ +│ └── update-item.json _# json file containing update item input_ +├── tests/ +│ ├── unit/src/test_dynamodb_local.py _# python PyTest test definition_ +│ └── requirements.txt _# pip requirements dependencies file_ +└── README.md _# instructions file_ +``` + +--- + +## Prerequisites + +- Docker +- AWS CLI v2 +- Python 3.10 or newer +- Basic understanding of DynamoDB operations + +--- + +## Test Scenarios + +### 1. Table Management Operations + +- Tests DynamoDB table creation with proper schema definition +- Verifies table existence and describes table properties +- Validates table deletion and cleanup operations + +### 2. Create Operations (PUT) + +- Tests item creation with basic attributes +- Verifies conditional PUT operations +- Validates return values and consumed capacity metrics +- Tests bulk item insertion scenarios + +### 3. Read Operations (GET/SCAN/QUERY) + +- Tests single item retrieval by primary key +- Verifies scan operations for all table items +- Validates query operations with filter expressions +- Tests pagination with large datasets + +### 4. Update Operations + +- Tests item updates with SET expressions +- Verifies atomic counter increments +- Validates conditional updates based on existing values +- Tests update operations with return values + +### 5. Delete Operations + +- Tests single item deletion by primary key +- Verifies conditional delete operations +- Validates batch delete operations +- Tests delete operations with return values + +### 6. PyTest Integration Tests (end to end python test) + +- **Full CRUD Workflow**: Complete lifecycle testing from creation to deletion +- **Concurrent Operations**: Tests parallel DynamoDB operations +- **Error Handling**: Validates proper error responses and exception handling +- **Performance Metrics**: Measures operation latency and throughput +- **Data Consistency**: Verifies eventual consistency behavior +- **Capacity Monitoring**: Tests consumed capacity units tracking + +--- + +## About the Test Process + +The test process leverages Docker and PyTest to provide comprehensive DynamoDB testing: + +1. **Container Setup**: Docker starts a local DynamoDB instance on port 8000 with no authentication required. + +2. **Environment Configuration**: Test fixtures configure AWS credentials and endpoint URLs to point to the local DynamoDB instance. + +3. **Table Management**: PyTest fixtures handle table creation, schema definition, and cleanup operations automatically. + +4. **Test Data Loading**: JSON files provide consistent test data for various CRUD operations, ensuring reproducible tests. + +5. **Operation Validation**: Each test validates both the operation success and the expected data state changes in DynamoDB. + +6. **Performance Monitoring**: Tests measure operation latencies and validate performance characteristics. + +7. **Cleanup**: After tests complete, the container and test data are automatically cleaned up. + +--- + +## Testing Workflows + +### Setup Docker Environment + +> Make sure Docker engine is running before running the tests. + +```shell +dynamodb-crud-local$ docker version +Client: Docker Engine - Community + Version: 24.0.6 + API version: 1.43 +(...) +``` + +### Run the Unit Test - End to end python test + +> Start the DynamoDB Local container in a separate terminal: + +```shell +dynamodb-crud-local$ +docker run --rm -d --name dynamodb-local --network host -p 8000:8000 amazon/dynamodb-local +``` + +> Set up the python environment: + +```shell +dynamodb-crud-local$ cd tests +export AWS_ACCESS_KEY_ID='DUMMYIDEXAMPLE' +export AWS_SECRET_ACCESS_KEY='DUMMYEXAMPLEKEY' +export AWS_REGION='us-east-1' +python3 -m venv venv +source venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt +``` + +#### Run the Unit Tests + +```shell +dynamodb-crud-local/tests$ +python3 -m pytest -s unit/src/test_dynamodb_local.py +``` + +Expected output: + +```shell +dynamodb-crud-local/tests$ python3 -m pytest -s unit/src/test_dynamodb_local.py +============================================== test session starts============================================== +platform linux -- Python 3.10.12, pytest-8.4.1, pluggy-1.6.0 +rootdir: /home/ubuntu/environment/python-test-samples/dynamodb-crud-cli-local/tests +plugins: timeout-2.4.0, xdist-3.8.0 +collected 10 items + +unit/src/test_dynamodb_local.py DynamoDB Local is running on port 8000 +DynamoDB Local health check passed +Table 'CRUDLocalTable' created successfully +Table status: ACTIVE, Item count: 0 +.Item created successfully: {'Id': '123', 'name': 'Batman'} +Create operation consumed capacity: 1.0 units +.Scan operation found 3 items +Get operation retrieved: {'Id': '123', 'name': 'Batman'} +Read operations completed successfully +.Item updated successfully: {'Id': '123', 'name': 'Robin', 'age': 35} +Update operation consumed capacity: 1.0 units +.Item deleted successfully +Delete operation consumed capacity: 1.0 units +.Full CRUD workflow completed successfully +Created: 3, Updated: 1, Deleted: 3 +Final verification: Table is empty after cleanup +.Concurrent operations test passed +Results: Success_Rate=100.0%, Avg_Operation_Time=247ms, Successful_Operations=10/10 +.Performance test completed: avg=6ms, operations=20, total_capacity=20.0 units +Batch operation time: 20ms for 20 deletes +.Error handling test passed - all expected errors were properly raised +.Data types and attributes test passed - all DynamoDB data types handled correctly +. + +============================================== 10 passed in 1.46s============================================== +``` + +#### Clean up section + +> Clean pyenv environment: + +```sh +dynamodb-crud-local/tests$ +deactivate +rm -rf venv/ +``` + +> Unsetting variables: + +```sh +unset AWS_ACCESS_KEY_ID +unset AWS_SECRET_ACCESS_KEY +unset AWS_REGION +``` + +> cleaning docker: + +```sh +dynamodb-crud-local$ +docker stop dynamodb-local +docker rmi amazon/dynamodb-local +``` + +#### Debug - PyTest Debugging + +For more detailed debugging in pytest: + +```sh +# Run with verbose output +python3 -m pytest -s -v unit/src/test_dynamodb_local.py + +# Run with debug logging +python3 -m pytest -s -v unit/src/test_dynamodb_local.py --log-cli-level=DEBUG + +# List available individual test +python3 -m pytest tests/unit/src/test_dynamodb_local.py --collect-only + +# Run a specific pytest test +python3 -m pytest -s -v unit/src/test_dynamodb_local.py::test_create_item_operation +``` + +--- + +### Manual CLI Testing (AWS CLI commands) + +> Start the DynamoDB Local container: + +```shell +dynamodb-crud-local$ +docker run --rm -d --name dynamodb-local --network host -p 8000:8000 amazon/dynamodb-local +``` + +> Configure environment variables: + +```shell +dynamodb-crud-local$ +export AWS_ACCESS_KEY_ID='DUMMYIDEXAMPLE' +export AWS_SECRET_ACCESS_KEY='DUMMYEXAMPLEKEY' +export AWS_REGION='us-east-1' +``` + +#### Create Table + +```shell +dynamodb-crud-local$ +aws dynamodb create-table --endpoint-url http://localhost:8000 \ + --table-name CRUDLocalTable \ + --attribute-definitions AttributeName=Id,AttributeType=S \ + --key-schema AttributeName=Id,KeyType=HASH \ + --billing-mode PAY_PER_REQUEST +``` + +Expected output: +```json +{ + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "Id", + "AttributeType": "S" + } + ], + "TableName": "CRUDLocalTable", + "KeySchema": [ + { + "AttributeName": "Id", + "KeyType": "HASH" + } + ], + "TableStatus": "ACTIVE", + (...) +} +``` + +#### Create Item + +```shell +dynamodb-crud-local$ +aws dynamodb put-item --endpoint-url http://localhost:8000 \ + --table-name CRUDLocalTable \ + --item file://events/create-item.json \ + --return-consumed-capacity TOTAL \ + --return-item-collection-metrics SIZE +``` + +Expected output: +```json +{ + "ConsumedCapacity": { + "TableName": "CRUDLocalTable", + "CapacityUnits": 1.0 + } +} +``` + +#### Read Items (Scan) + +```shell +dynamodb-crud-local$ +aws dynamodb scan --endpoint-url http://localhost:8000 \ + --table-name CRUDLocalTable +``` + +Expected output: +```json +{ + "Items": [ + { + "Id": { + "S": "123" + }, + "name": { + "S": "Batman" + } + } + ], + "Count": 1, + "ScannedCount": 1 +} +``` + +#### Update Item + +```shell +dynamodb-crud-local$ +aws dynamodb update-item --endpoint-url http://localhost:8000 \ + --table-name CRUDLocalTable \ + --key '{"Id": {"S": "123"}}' \ + --update-expression "SET #name = :n, age = :a" \ + --expression-attribute-names '{"#name": "name"}' \ + --expression-attribute-values file://events/update-item.json \ + --return-values ALL_NEW +``` + +Expected output: +```json +{ + "Attributes": { + "Id": { + "S": "123" + }, + "name": { + "S": "Robin" + }, + "age": { + "N": "35" + } + } +} +``` + +#### Delete Item + +```shell +dynamodb-crud-local$ +aws dynamodb delete-item --endpoint-url http://localhost:8000 \ + --table-name CRUDLocalTable \ + --key file://events/delete-item.json \ + --return-consumed-capacity TOTAL +``` + +Expected output: +```json +{ + "ConsumedCapacity": { + "TableName": "CRUDLocalTable", + "CapacityUnits": 1.0 + } +} +``` + +#### Clean up section + +> Delete table: + +```sh +dynamodb-crud-local$ +aws dynamodb delete-table --endpoint-url http://localhost:8000 \ + --table-name CRUDLocalTable +``` + +> cleanning docker + +```sh +docker stop dynamodb-local +docker rmi amazon/dynamodb-local +``` + +--- + +### Fast local development for DynamoDB + +#### AWS CLI Commands for Manual Verification + +If you need to manually verify table state or troubleshoot operations: + +#### List all tables + +```sh +dynamodb-crud-local$ +aws dynamodb list-tables --endpoint-url http://localhost:8000 +``` + +#### Describe table details + +```sh +dynamodb-crud-local$ +aws dynamodb describe-table --endpoint-url http://localhost:8000 \ + --table-name CRUDLocalTable +``` + +#### Get specific item + +```sh +dynamodb-crud-local$ +aws dynamodb get-item --endpoint-url http://localhost:8000 \ + --table-name CRUDLocalTable \ + --key '{"Id": {"S": "123"}}' +``` + +#### Query with conditions + +```sh +dynamodb-crud-local$ +aws dynamodb query --endpoint-url http://localhost:8000 \ + --table-name CRUDLocalTable \ + --key-condition-expression "Id = :id" \ + --expression-attribute-values '{":id": {"S": "123"}}' +``` + +#### Batch operations + +```sh +# Batch write items +dynamodb-crud-local$ +aws dynamodb batch-write-item --endpoint-url http://localhost:8000 \ + --request-items file://events/batch-write.json + +# Batch get items +dynamodb-crud-local$ +aws dynamodb batch-get-item --endpoint-url http://localhost:8000 \ + --request-items file://events/batch-get.json +``` + +--- + +## CRUD Operations Documentation + +### Table Schema + +| Attribute | Type | Key Type | Description | +|-----------|------|----------|-------------| +| `Id` | String (S) | HASH | Primary key for item identification | +| `name` | String (S) | - | Item name attribute | +| `age` | Number (N) | - | Optional numeric attribute | + +### Operation Summary + +| Operation | CLI Command | PyTest Test | Description | +|-----------|-------------|-------------|-------------| +| **Create Table** | `create-table` | `test_table_creation_and_setup` | Creates table with schema | +| **Create Item** | `put-item` | `test_create_item_operation` | Inserts new item | +| **Read Items** | `scan` / `get-item` | `test_read_operations_scan_and_get` | Retrieves items | +| **Update Item** | `update-item` | `test_update_item_operation` | Modifies existing item | +| **Delete Item** | `delete-item` | `test_delete_item_operation` | Removes item | +| **Full Workflow** | Combined | `test_crud_full_workflow` | Complete CRUD cycle | + +--- + +## Additional Resources + +- [DynamoDB Local Guide](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html) +- [AWS CLI DynamoDB Reference](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/dynamodb/index.html) +- [DynamoDB Developer Guide](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Introduction.html) +- [Boto3 DynamoDB Documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html) +- [PyTest Documentation](https://docs.pytest.org/) +- [Docker DynamoDB Local](https://hub.docker.com/r/amazon/dynamodb-local) + +[Top](#contents) \ No newline at end of file diff --git a/python-test-samples/dynamodb-crud-cli-local/events/batch-get.json b/python-test-samples/dynamodb-crud-cli-local/events/batch-get.json new file mode 100644 index 00000000..4e1fe6f8 --- /dev/null +++ b/python-test-samples/dynamodb-crud-cli-local/events/batch-get.json @@ -0,0 +1,26 @@ +{ + "CRUDLocalTable": { + "Keys": [ + { + "Id": {"S": "batch-001"} + }, + { + "Id": {"S": "batch-002"} + }, + { + "Id": {"S": "batch-003"} + }, + { + "Id": {"S": "batch-004"} + }, + { + "Id": {"S": "batch-005"} + } + ], + "ProjectionExpression": "Id, #name, #role, city, age", + "ExpressionAttributeNames": { + "#name": "name", + "#role": "role" + } + } +} \ No newline at end of file diff --git a/python-test-samples/dynamodb-crud-cli-local/events/batch-write.json b/python-test-samples/dynamodb-crud-cli-local/events/batch-write.json new file mode 100644 index 00000000..b1195081 --- /dev/null +++ b/python-test-samples/dynamodb-crud-cli-local/events/batch-write.json @@ -0,0 +1,75 @@ +{ + "CRUDLocalTable": [ + { + "PutRequest": { + "Item": { + "Id": {"S": "batch-001"}, + "name": {"S": "Batman"}, + "role": {"S": "Superhero"}, + "city": {"S": "Gotham"}, + "age": {"N": "35"} + } + } + }, + { + "PutRequest": { + "Item": { + "Id": {"S": "batch-002"}, + "name": {"S": "Superman"}, + "role": {"S": "Superhero"}, + "city": {"S": "Metropolis"}, + "age": {"N": "30"} + } + } + }, + { + "PutRequest": { + "Item": { + "Id": {"S": "batch-003"}, + "name": {"S": "Wonder Woman"}, + "role": {"S": "Superhero"}, + "city": {"S": "Themyscira"}, + "age": {"N": "25"} + } + } + }, + { + "PutRequest": { + "Item": { + "Id": {"S": "batch-004"}, + "name": {"S": "Flash"}, + "role": {"S": "Superhero"}, + "city": {"S": "Central City"}, + "age": {"N": "28"}, + "powers": {"SS": ["Super Speed", "Time Travel", "Dimensional Travel"]} + } + } + }, + { + "PutRequest": { + "Item": { + "Id": {"S": "batch-005"}, + "name": {"S": "Green Lantern"}, + "role": {"S": "Superhero"}, + "city": {"S": "Coast City"}, + "age": {"N": "32"}, + "sector": {"N": "2814"} + } + } + }, + { + "DeleteRequest": { + "Key": { + "Id": {"S": "old-record-001"} + } + } + }, + { + "DeleteRequest": { + "Key": { + "Id": {"S": "old-record-002"} + } + } + } + ] +} \ No newline at end of file diff --git a/python-test-samples/dynamodb-crud-cli-local/events/create-item.json b/python-test-samples/dynamodb-crud-cli-local/events/create-item.json new file mode 100755 index 00000000..cf086c10 --- /dev/null +++ b/python-test-samples/dynamodb-crud-cli-local/events/create-item.json @@ -0,0 +1,4 @@ +{ + "Id": {"S": "123"}, + "name": {"S": "Batman"} +} diff --git a/python-test-samples/dynamodb-crud-cli-local/events/delete-item.json b/python-test-samples/dynamodb-crud-cli-local/events/delete-item.json new file mode 100755 index 00000000..208c2239 --- /dev/null +++ b/python-test-samples/dynamodb-crud-cli-local/events/delete-item.json @@ -0,0 +1,3 @@ +{ + "Id": {"S": "123"} +} diff --git a/python-test-samples/dynamodb-crud-cli-local/events/update-item.json b/python-test-samples/dynamodb-crud-cli-local/events/update-item.json new file mode 100755 index 00000000..1dc4c38d --- /dev/null +++ b/python-test-samples/dynamodb-crud-cli-local/events/update-item.json @@ -0,0 +1,4 @@ +{ + ":n": {"S": "Robin"}, + ":a": {"N": "35"} +} diff --git a/python-test-samples/dynamodb-crud-cli-local/img/dynamodb-crud-cli.png b/python-test-samples/dynamodb-crud-cli-local/img/dynamodb-crud-cli.png new file mode 100644 index 0000000000000000000000000000000000000000..35d64c933c8a602ed604b74f571846ea2dd3a047 GIT binary patch literal 56353 zcmeFZWmKHo(gsKf2^Io@1QHw)Bv|9F!96&Q25H>g2>}9x;LtcE5ZtwK2<`-j#@!tn zZJ5qE_ulW2_06oAUo&gX4L`D}cT2sscU3*Lt6oChD@bBt5Mm%9Az?{Ni76u?J#hJyFZRbfn7EsSAks}q5iAVkcc&r8x$l~dGbh8wVW zcXC~KOzyS4-+8P)WPy4_jnr7$8U%V0NsC+AgBN5*UuTj}JV5H8%6Q=U!UXhI6mJZc zTlD^?KhHykU>aJ7l?#8bLMfb=Ps^UQzZ#kC4i-tycp_ce`*M8Myn9* z3sEHxMLH=T`@z5yo(R5fSM=3~g1M(c^p3bS7T$6+vi)RZT`3q9%Q58H?}#5&QE7wvStG)NXiVA+ut)#+JA`TbxC0WQ@7{2>*#~Ya0*C6XZjp6 zQTL9J>}`U-a0PMeDdBLjSCf-5DO3sSG4w@*Yg~tD7-P0(jNdMz3ZpFg3Aa3!(H#Bh z6p8i5S^!JnP+<=535xJjR==0rPgzA@M?d!XaY%@Y@$&#D#Lv)8r$xBr<#XZNQ8e`q z>CZf8e!~Qy%#aQ|Yj@IxU+LZlQSO4VM>G2-GnoW*4S2d?Q4m>=M`;CHSd z&-!s4qKc3|5%Y74e3Jd{pkk3X-aqjJlP1a+k++&yoF6etv360$7d`E8*gu-s;if!_ zZ+T(&a5k_Y2ag}MagpQVBRLxS_t$dmNG#%oIH>K9mdKl=aB%(FDSM;{qeG;gQLz#L zzs6EpMqwDED*NAjBor8X0S5FY2(1Bo#B_e-W9XUJ>uLA!YlOF|+kQTlv7xfFb*nm6?Z=jQ(eDDP7@H z%TGg>gcE*e9}es&2ivD>UhDk)?uN|NZn}7K^uq0tUfASH&XMJXXam8W+&8?hXirf9 z$mz@>kk&fD$a_=<>jV_gQ^n5f&ceXKs12az`0~JA3U$>ZF zNwMQw#99*n;x0LQzd|A(WTLn-tZXg=m4T8%(V-+zdfrqU`SYBdUng7iTO6kLip zBsqB+`KD|sM?aG+lg4$m0fHwOZC`$Z`7!&xjBWn|CL)|}5=gE#$ z4X^E(?0~m@N@lWW3UV?x^5b*4WJ{C}a`jL#jer#wg*p>-6CxAx6W2fA zHlv48CGQLa2&aiU2nSf8=3^FPrdEc;h74@;#$LTOT>+qF+i2_Z`PBqz$lKAeNmawT z`O}&$TPp$1_|!fo({z*bq*sZ7NpA;S5~>otnISBS%mCH}Tc3qb7C+0nOEqnW4Tmkw zEqI01;->v(Iv=tAn|U5x7FowTVF2m9ve7k0i)lFf(D^UQlT)>BW(;V4dVER{vnMzoj)DFy*%xCUJ7ZG+Mi(_2b< z52Bg_o4b7>cWlKZT*>tj*OVqZa_v6VU`rE@Fcm$ zX1P(SK+4=$^m*{IWKn1xnKhok>#LlXhH_4Fp#IS*`!YwPp$?Xn?2f%KPZBbmX1ZIO zPu8DUzf(m;B_wIaTu{GdHn&#p`cW3ME?*aGmB1ivBS(?-CVN+5E18(xy=`?+6*9Iw zmaJlx-Jj%OkiRrGnyvdLQsoOc`e;{V0v2u8AvHUmd6(5kQj{+0g%VU>*#orlB)cRPeP?-oNE{(9@cK zmKV-0>ETh=a|#@t0X;1@1a>_sc|~M$+S|5P|03A%O9qQR!R;o5&N^;^mJI&_UlqS0 zHIe(|7sDQK1XxtDX9`G%7c$oz*7IBtFQtHMO&kdZytyOzY`eUSeXef6kMJ?U44XPH z%S%0;IV%ljq=%-2PT<3b)IS+m#Z z@eox;bZFLK7AT8M15HEc#$^_=wg4;X*PO2I^&X!XZyz5S=bF4I%F`=othU=*amJe* zFEK3v0PiM0n-ej`)`G2Pq~>yGr<6#Q=4*N!Mz`h3=*#JBR3EGEFIZK&U#@d$i)k0z zoGoZU2Hdo_s?TSrW^518rj_(Tx@y%iH5DH=Ms-8#2fe#4i%lR)`y+GdFsXAV)h_k) z;-lQWY0FAiHAbz1!oB(Ux`W=BO|MGP8T#e=`i+W4h_^a;OzRmy6^Og!^(>xdigy(XoT)ry~nZEVpYZDZ}Vc!X9dD5J@G{rXW z-Z^&A`)hdKI=~CE0CfjHcN!2n9^1g-rVS52*`K}saXuv2e+!bkiQOH{k z`=-A3QH!50lBTG)1Rc__6VkH4174pSI}+YLtQ0=AK(wL9Gns-}8(IFoDr2T2B{!tg z$Z`4fFQ;!Yy{-J;b;!yyBLWODkfyYWygU*eqK$@x`~Vl}A)@sF@q-j(j)d}88wrUT z@joIa`G)*&%0uUG5C7dpCjb4=_dzlXq6><-s-}~syd1BQtu>Q@v8^G9$<5mCcLyYX zH(o^38sua^?q+Re1e>UPSvh89+(?R~IKs0ZL8z_vE6s4j^()CT1pPNA za&mqLV-sFwF^PW-NBky0Y3Af)#|r?sy1FvCvN72@m;zXMcz6KJtN>P4Mnn%rM|T@1 z12;w+$Jc*P^6z=XK#oQZ=5|izwl?Iy=QS|2b#@Y$=}2Ndau8i9QltY{~E~;_${Y@$nEb< z`HPC!6+sMsz<+jF5My%E@&*Y>7)e@8MAhxVZli00&W3Z2JDrol=2wZqM5Kfkxi2!= zZ|JnaR1s_Jq%(Y$o-Vt(2fJ*1e568reDyFgSic)bwP{vaXE~!xHB~Pw6+MgpgEzt- zyLleYA@SyHZ|B2POSU;Nm-#vxD z1*4O1f4Nv`)?8cYc4U8gZqP#{3xkE=F%?*b&XNnmkvU-sQ))bOV0BG&9H1r z)k&1>51wT{33Dc=mS49kbAf2L(C*8lC5(od3S4If?c?GnPIYKdK+cKSul7qT-q{UY zzc*1!kphzJT+wYntd1z_ioI7Bu$W}p4;)0s&SA{-To6Z}Igv*d`)B|=Fu_(L{ws1L;EoZe?C z-eV96nJXA6WB;+A$;&_JqasL3TqLSnc=tH{*n&m*F}Ggi+pEWa(47z`O&(S4zwG}% zl)musWoLGU`%Hsdgl&vu21Zw<0xm2aY7E zO^Sc;u;Pz3Wle+_s{2oXsZXv}a1G`f8##HCJY)q3EUP%i)ity;%L`M57OeA5-EiXc z)Uz)ut2kzEYDp$0pjK*XYMji>W*haMyZzHlJKMar{-UD!ci`flKh&H#3&rGXZj5?) zILzd_XyHQ5bIAW3D6SSrg?BmrU60l;_SMkT$MzAf+x>$*?7ItDPCA2)Uh|%eN3giN zD@oh{5GQ9^_0ZjN$;|D;patao{EYrIuVqD5eTN990#ts z{!QE6=tMCPw6awRKe*dJ-y`$A@p{>uU*3AVW_!bSecB~x9Jk%Y=)69_fjt@Zc3`W! z3=fvv3@_Q+3}F*N)7wpC`$G{_WIuH7=2@EClnY%d-bHwC`bTKVTRWG2UK>+Z_*EOo z5GAMSty=`29rEhDEm?q*6cpy7v3p#jTIqZ$$**J8oB}hYnA)7t%Yn)V;hjQvR0Sm^ zm>I>{*%LnN73s=xvX4?Bz;ZXgB4Fc4J{7dLuW#>+t6H`cWTh^e3h!FDgTOA+uL9IG zG=P*s5}XW;>|w7k-`QS{h{O1r&vwe0sWEa|c z`gBf3A$)g3$okx>jxD%0Eluybpv1SYdT%OgEst`fw&Fz}!Hnp+ZR@*@L#g|= zv=T3YB7@0f4p-Xuv;%-DvOzfz&vMlSp}l17$+)?Kci85b=DsoNQS zFL}<2lqqdS5q0XQwY8zf3z^)NHdRZ-+3mQ#Pu&=8GT#PL+4#`dZ0qk9mlnx8;w|T! z4CXo#*E-U*4-Jp&5(#Ce7X&Y3LFKO|t<+yDZT4|^AEXSi6~R3X!h6p$0mH;ZL=`?f z^0mL_h>8I&gRlNL7l6k~~xSJ$8+0 z?Lw#PstTOav`w}==S(0~pIOOPBJNiEe)45OQn_4RUUE$uO>h?L(;p-|Qw+s_s-t55 zno}}7Jp7)K0+s39rcS`E>nqq{9eRRwAE_*M;wC6%4BF0#M+ZHzlYE2aV8JT8ghjYY z+!3#MHPY2z1Lj+2be7(WaSTn$`MzXuy{&&IUo7U$5GUDx?4Ylkq*jk{9fI9JbGoNq zIRI$35ol~uWTKXxv!+vu@FIbL_eF&meGI+#1a9T!8rT z<#kRI0F^Ei2HmlpS`IuZGIB{)i>0zsJ=*iS87}|>b-*>1V5R-pM!;&IK_$w`(A_Z+ zY_knp(=(YN00iP~)?XsnuX+YN)H#F?lig{|)Zf`nVQ%7!;Qq0{ThzZ5f0{haXIq7= z%+?n^wC8)7?jt`@(z)n2BA~CXrmp3CEl;&iLGGPkM3f)tg_RO;_Lcus#d z9+0#tyioq{c)UDrXOJ|ep{I?cI5me^fD#A;chr+cSaH3 z%~`1Z0_=4~&5KRGe|^gS3&n-N{u-OKO8=^Aq5OKkR#uO5a{7Ay9$<4A*6i3Ol&w!m?=kYI`_bE=WZx2IZi*Q%ju@0(R^J8%I89s}IjY zgzNEX;;+dgyqvwMkeF(9kSf>21xQ>G#s|sjH(jon z>Q$Pi`@RhTByd3lP*|Jq()dIR>oCGDLm~vTxm^}2y{{uab1Hd;-!ryIDB001Up0NU zNNKe}fsQ-S+~raD8s0+rpd-OMP5-Zk?X`jZLQS@VZI4j`U0z}6t&=hf45m(|^O=S- zg4<&2_h)lmfPACwOs6GPX&4rQW~R=2=09)T!LevMl~R&C=384^nDz%uZ0%xTz?_`y zo4U3*F4sMX?}?}HhvwUvJL=P|&c@?%2lPfoR0`hVV7x!wuiOWT;JWqfP8{~xgMl{u z-TB75a>$k*&14af#USfMzz)AJ`RJH&1lKVrf>gzdCuY2GT%AeVsz9AVh5V?}`$)0b zD$%L9$yW%zyZ$S`x*FT=IDW5>W8Gy2-+T3}mGl;5dqRohJNA+ne0uBa z-Y^2Whi;b+nhft|Y~d$378G11Wk^3=a z0uwS@tCCDQ4B~y+>^tBC*YN$>yJl=01^X!gAH#DwR<%Jl4lUFV3&S$dpFQ_EH1a&0 zuP>5NXCtuIzepUKI!T70Y*0P>@%{U=8PCN?>k&QPk`?t9l^otSrFgtNMFY$o-Pc!*WS_od;`xMR~Ug_Np+k|t8*aE+b}4c%!QlD)lo znTh5GW!39jKM~Jm+dIAM^Cn#4yoRjTtbali3crMauO$=>#j6v{iikMYOBX(7*0V-J zG}%iCLr+%IClZ?qZbh}4DeG&3h0zpgr%}&EeL1l8+=WL>eSa;v_iG6c3g9vi$+XGK zGY8N49;8x;iRdqUtnJ>36Ss57FU&1H7j#Hp9v`2|g$=_u69+^R#LrbVn-!c*AY3zD zLRS>pwT9-JpMgv}sX;P!6mE-$O4q#_%{DnzKiIO6HYQ_kR}Sv9e@Di8iUgH`_rz_2 z=GSv&h+#a{o;pCQL|_ufDmpAB?73#UL-LAFgv|SOED)aS3+Fe0SiHPN zCrSV>dO7Ddz3v8Muca# znr5uGH&8op{~4kep_ruQCQrHxP#dklrnU_{z2rymIXepx zuxPogJmljh(?us63!j}&|LtTm*A8Kwd5vvrK}Y00Z_yG)ZilBPF|n^}3`@i^xvQ}q zYpQx}lY`nvwSNq*C$c}EWRU>!8#SM71`AaF=g z1JkP&@L;#YhWTf$1F!R04Sm_Ec>S=MsT$;POT1L=H7{xNcMrOg3r1n-LUH3PHksx; zYOjVC6!(iS{?uhE6q}@6N2ORo#i?|w;?g!mDjz~74=oW=HA%1co=xLpHPMgL+$Mbs zi3KyS7rLrAG^U3B)oe_!I*WwV!sBdu zH%Fh{17|qTYRog3l0NQg!5SOYT=HGVd{&kUi)>`4OeeecUKdorPW_&F9+}PR3E)GP z%6^1hHt`X-E3G8EeYM0ta+{#T1!~U-nnW-ROnHYIasJ@mY(A|@885C?FN3jndF!$e zy=qf-#UmpS{pTIvBACi6xRTIxm&Mjdct?S`=bmvEmL+f{0ajMql1!Wh zzd~Q@|I`xzbT^y@b79RWE|^!F`_!uSpv&;9lUCo<#`m=mbnY$i$5r`nRK8&Ymu4}( z1s>x%gd0|`1sF+;>E4}uL%pYdA6Wffz1f~M+>oVTajAs#`rHD#c8lHLURR+mV|YiF z1_ZZ=u~%k2Ty`vn?8KE6 zy}58B_t(m*doxR*<{?!a`d*l2u9R$Smi0bZ;UNP<30|K9UN^)fLp1_?WN?vK)pio=w?C#+El#Fn&e_eOzB#bG z*ml(kkJBx(u(yigKvl4|*cL8;W{@r#SWwWNOIAS*MA@N#Zd=S?6DLhzhZ{xM|0o5s znHW72Y8M(?=nm3y;V}UaGhr`}$V&goa8k?H z=gzgsPV~vzbIY(Zl6^TU`tR(9>6#^3Ve%$earWrNDN_zV*WL|1(yR*5Gh+VQQ<<~+ zj)B-Q=tG%f)cRYRJ*SMd=rn>gPoTlE2NU#4qTHpB#!_*Qw89iy4SM-q%tk+5kv!dt z6q{iXiavfV)RbJQ_ei7JVMvMoPXIhm;`fSSn`!>Nx*lMSc+|)48#IkCrOf@zyx#LR z*IO)HmG}8-{kyAyM|$r*wlr7zFodZ@*4A>LW$Wm0u=vdqRQO!?fz?BVm#r0scw=53t`x%0YI;5q{ zvi9RdPJ8)NOiV0F!a>!Ja(6)6Mwj&gFovpF?mN1}^`gV|fzI>w@#(7IO3OUQBMEN* zdtRN~P8a2i>e!1}M;s*{sx?`%Yvs5JUxf$FXCakS0lI@#3u4(!4tJl-LJOUh(vnYq znLbM4`2!fd@mnTvj{muhJWynhXUTxMmWWv&iScGeOi#7**?qGkY>#s|9{lE>#v?E( zt@7_Qh_{{GA6DukBIR*J`v3Qw3DW=T=_p|bx@ReZb$m|eXEaTmadRUC_q-|M;Oh7Z zGZZ)lmRk;ncNoTxrG8=pY(-%HvCkcHA9y+N`T(Jq10VM9nIhpH#7@WE&?9ox|J?0w z3BR*$ms>2Z_sOdWvdrxZr29wteLo@(({Xc}z;}Hc;5mqbMVEg_xM@qYeICJjMe z)qjA^;-r7fpj8UuqyB z-IpuN@1)_$sL_AL{hzb)UvdBUbpJ#DzWvvj{MVTL!^HmAcK_E^{>N9~eEk0uGUkI={V4+9C;oW(_nhP3P2;ZcdUh|hF?fQ02dF0v}@TYhcIm`tcqEE8={ zRFY(UbhW0gu4a1EkzuTdY4iL)FfW!J0TiW$ZkKB=0%+ zJWs)#uLyVmX$OtG2ES6&=4Z2F38@+OJK?lDl~kXGr*ueM6&f;IuL*|j)^v1&vM@au zEz6dk(F--U6*@+Q#dTO$R(EvH>{lK0L})sDCj%3;;s(k!%V!NoJ&8{LhP;rMe%XB6 z)cd(dgC3R)HLy>u$01)XV=zn36^)$<(wZks13`Fke_g!(4^skuauTg_@0h?ORi~S! zXX$(&@_stv+VT}Oct(U_M*ydD3!SD7&}+XYg0(xHy5F+(uxXeTKd!)a$woelli%`K z{n6psY?+7$n0l{P1tKI!8n}#9<6x>Xc1Nss)gFx>(ZTqcO0nhd!i7Z?O26vW_>ITp z!)NtbDpO5|v$tC*AxyfnwTn9voAS&{D9AW9E(nBgX(~SOmOL`<5CfyXBT;aryb?=~ z+ai%oQQQ5FsZVCC$B1d6tL0C$UY?1ahbfM>;7d6DxL=Z@Hum% z%SdFgL*i_5_`6=;n`LmLT`OzOQ>dMDBb7ygbnbm-L7esdIAM`nC3)+*N((5pkTjT494J^!Pl^7bRi0b6CZfKaJvq&l|uXZ=&J^=q+9NMAyAumAZO$w7M zmr5;h`TN5cX$0XrCr3^k-!(MT9e?Y1AR<5vWan;XLVgA8kO=Zv-83GH&ZJq5JN}XN zW>I+#b-)9nWmuyzDA!p;mpV+!NucFkwpQ2Ik(AS*#u!}sL9cHT{Z&4mY9c**Nitu< z+AJHT$fmQ-h5NCI(p?%^uZ~nUW7K}srkA{e>T~=hs}YGESM6+*Vh~Z%03mcd9kx2o z1o`qg5G;4aD*tyG!SmOC1;BM3JCYHHE_(Emt%ijjaE;XJBUutz<{Uf$Z_!0CIimcJ zigab5KBN-X9)_K&)=*=_DadQwwqblk7(>wOH;2E&_aQM#N%lwQkM!p*!iG$c4aVGp zP~?Gl^~X_b`n>1MveCz)dLq+eB&jIhvw6#IYBgZLoSBFl85nv_EmMO)DVKxZ)7kj} zKO1X@6koA0K42tL&kZPV#NUzA%4aZ0Jm&3VpEH<-VU^EwOt~rRu@|hrCDnU)bVy8r zjxc`0&v0r|ocB#IY6~br#q^vSv~+uaAFJOLqs1W~Ey?a`?D|Y37zHN^f|32d5KLO; z4eqHAUq@#JJvL4yD*wSJ5>;Apnt&3a^=O!S{D%rplIlop&f;0SqK;MI*ObHKj^3D(R){qb1I-7)MX(^cGZ%;g}njmO|Ll&R$+6J1gSqe1Ma6rSvbM@lrWzlD&3L z?3L27*jiqPlI6>dhMD7PoLU?Ug_leP)?k_enZ4$i?mqQOEz0kW9`j_sUjzxoC5ViU+z5IQJb%Z0)iiY!Tt6*nv<#YQFNSLIW{T^Mb_?0XzY1;X}3{bL6OS}&L ze!AmM3MTLGL+7U*h$ui+caK(aBO`So=PORIUomp9a+qD-&;vWhJmh4hrJsuw)5+H$ zJV;4N`KtNOd8KJr>C@mNb%xxMhG_fmt2suJy(+O_pY8qy3N^WZk}(Ks`d^MsSGPAbV;WV{Su`S1+{GHee$W}xrQB}x`opI z+DWTImFwQRh`yiFwx#WXFG8V`1f#g(kjM^Qq}t6ysY}aA(c2JZvxK4GLh~poj8F9X z!#jBkzg#jJu_K!gSrtiJTNL4VFY1|3RwrXrv|eQ1{Aw<4EZSihifFJ=aN4}Qe5^M# zlY5tdDL;-y#f0I1rWmf@$)wq!Xb6!_V=pLR)b-RsF6k&N?(8f8_fGddF0e`AV|jEY z^|!wu68rW`h6|6;DPEh9H_mKUdFzus_58K!JdL-=x7gnoVwYR`>+`zJU;DPfN{0vi zx%|MV&b{=;%^lvhi2*u?;>#HfR{?@fdl_Ai!ZFziG=^C_->2Oj4+<>2h)UmidE(O$ z7Mfp>Mc&@*qC3XXuIVs+9vcZ{o z-I?SgjgT9OBCjhAJ&dlMiAuP$( zGup2YUD+D6nn;ee@STP)l)M@@lU}2c+gUyn9-Gq|I;lJ8^d;9&jn&S!kISOhV$g0@ z?*l;0^FZ-oZ*~06T!5?`Z>%%Zrk0D@~Y_Ac%3=CM3y4mXo1ea|a^?EO?1lOMVdH zaJF!g4I6i!xj0N zHndkj-{>)YY5`7qa?X1p)1G7da*(vQ1tU$wdwmFDnsI|8d#Mv2p zsx@4qekB~M+G(H>zqz%kh0Ro9IwIX?VlYMUt)IyRl)rBuS_1_aHVoujJm0ZZ?(G%b z7WRb*oSb4?EzHB!!E%~iH-Rp9uMZ%X71~watX=M^_{(Ygm@bElA~TJq5ve?mP#7t{ zMQV=d8MRxtSkFB_pC-8=t2jZ!GxIl*8PLi2})QgnIFM$K*?H zvQ<`cuea^IYwV4WX|I5!<1(U4d*z z!>~6sxrU=ZHSuD&kJ2w|G_;3km5-4d>(f95JiDR!-O>66la&9*zVlUU0dYydNd%^l zP-m5Ev0MgiVx?FXL$X77w6&IPkA!=3p|Y8O(oCV8J4o;#Irj78!5MITdPVbXpKbS- z32O|9X()lq)P^z(qNKD++rQ8-CML9QeqO|LZG3VsS@G7NM96(YD`|Vzh|GKT_VP=8 zVZ-bLy(HEx5xz>>dhe5i+X>jg0*&#jf&$A@N3y#}?`-pM#+t9WCrD?hUFTCmKIhV& zSBV0X3qwp0-b? zY5-hXU~5(i13%X0_N^|`^M!aHADvG(h!;?`>rO@e1~fV{4Fnskn4r{Nxe=0XDmB*xP9*!$`9L+;#N{8#l5&_(3?S7=0Dzl z0>_I^zxXj$fucKA2Odhou`P~hupXlxkrgouRCss&p2nBQe&0Id_EQVPX4lT=<6I-% zYwjqsC#9>CDqzk@M;7#Cd-OQDQ49J>!P?_(tq@+8yyiK&e2c8}lv1v0n=+Ca*Qv6Vt~PGz zkU8X5a~y}#bYYXe zaE|MV7{%Ts2*A#nLW8=%%+W~HO^m?9V^3~_XXYq-nX;F)!E4JBOuAdCc(-b5eLFI< z+TB(ASNXV(OiN&DXqu?6%W*caG<#HrfRQb1-y<7+%1ncVyKjtR$g2l&2HzWBu zTn?(l7AUy762}M2%CleK14adDLvT|!eJICO?JPxdS7H2Ws$4Sd{JCVK=jKD;Rx?lbVT@l9%|V(pr6Vgo9H*W!;;mXG*@~J7(Xv}=z4Zy#)Vz25p~t2p9&lkI7|o&!jB z_24=HXj4`Gv370nQG96O&nAU}e4TIC4X2G$o|~18hrJ5*-{AK1^9y9Q@e}iXWy2z= zva>;pfwcy#%ic{n3u!*~$t3&^ykoZ3)@51r6I!A>xM%65^ZPNdxr5tA_#9WWx#Uwa zbgX2e9ne7i96mt3<@`s2Ec`k_GCZZ!8}+HB`TDCF-@^p&EkM)TLE!Z)V4ylYyf)r5 zlx9C3QMmfe2_0oAQ@}JTaMuJQ-R!B#89Rn3x195W3Wwws!lz|}kuq#7-0|gYU&C{( zjSp1R9gn-fd6Ag6ogC_d2OlbfJkV6c!3o zLK30@sCS-8=jW@z>5Ie-hGBRHXjQR|Nmy;394iZ6=<#K-F_-nWeLsr0f-{^u>E-L{nZ_-O| z&Hpw^o2hJ~{8u?+gjT*`^*HBH1tB`LrkBzdYyqEGbsTT+1aJ z4yhQ@T)r`vY>g>_Gj7$e-P5_M-^{xliGCWp!1qBuR`!>Mc&%3LL z;%3+LWL$Hyv30IMUD{QuTjw*Z>g8&_!n`U`&`j3S@&o{trwe8a0S=JhszSLmBg0Af zm}IAu9lA&2Ave+}JM-7|LsPY*L90%nRJBPxo$alF>lc+i*Mr^@&P_A8YE9G1G9|;en zTg(_k@GFVn`mM?l`6e3TAKcGyQEa@CZ=khj=|^h^?F!0k+!5k8S`Gu4Z8o5NW_ z9^hqKCr>8Q`a_30Rx|L(v7-J+{HPMa#+Fw1BlYefGTi`ojAeP;UPjCB;o(2?bB@VR zRX1=0*9hnwm)5=AJewyFWjt+J@dat%voP6i5S10~nX`#CM7z!-4n?DWyqc*3ddwSV z?*2_|U@axhE#80~7&%h@KJimb*9e>w>Np~yU{(FaCXnZ4EP>1;riizto-PbUi>oin zN2>CMAWZ(Jd5y`jBYKrN=~vZz(`5t79!&)rMde|xR+YN-u&HFeh1hQ53P;nY+O``8 zeGCb&Gk3*@QUvc<-FeO9&U@8?onG!H5<|H=t>5-|z2?>Dw$z(jforY$KgM&kH)e5P zO*$TS)tw){^#tvZYF8ZjXzPs9B0dVH&kH|!rLu`?yOLL4PPZEt+Go%^HC406=b-*c zanN0LzfXe8Y6?+(4)B@TpUQRft#S~y(SUBRg<8W3Yv4!mx13`_fU+9v z6USNg`?gm<8d}L`m_J@c?YyuYCl<@v^eTd z%8i!r7MGpmyI){6s{QMF2^Cx}3=QT&Dn|kZ?W^wd7gF_uou-UsR$ZRP-?e^hUoEBO zRs{qIYi7GY^7<}EDK!j{r{^uImcgfBEb7a@8@?r#P4yNB=QBFgQAQ-a-D@75q8OZ^ zQm^&eNZWXe#v`=7V%QR>Wo+IT>W?0p`sI_|iG$*NP^jS0=?OV8hvB=_T8~3}5cb0vU z@_d9MG{Q~Ok77Wh&HdrPWXDPZ)cA^z5y+*ll0aw>Pxz|R)aTTDrrA~>N1SBu40ao| zUz#P04@?Jlkn%XRhZ74~^1(=}YGo(1Ew9Cu2<8?T0?}$zNCA+hrjDnUmn**xb5rAK zoAfbZgGs7O(M=dZ~EqIIJnHsOBpH%;%_7}9e;m<8~J+CaL-|RP!{w2hqz(M`%UqQ z64w||LbRpmlor4H+80bGe7 zJSlbBH1jMBT$Ec`HW5^02$QJIKdNB4%94tyzvUqA*a@9ezDTbdw3rrgz)h0Zs#%2+#t#~L@>{g%cSoe%j1md{y2G@C`hFeT8Kh^f_YQ%MJ>OUTd zjTN@EoE+)6@}TIG`Q{AWDQUD2I=M_QhbHMX)D@-;__fhkPtL!_4TVq4Lyglf4n5ac zPJRfSEUWKKG}Zd7MO=_!9``W1BR)CZ8))n*%&}z-=6vPD@ZsNo3*heI$IzeQ)f0yo z{M2&@53zf?#rE@fLtenvP8eBpnFsPl^9iQ1w`iuo)uyNjEh+DO*6sG*QaxB(_8}=V zCbK=F1X<^^@b{XNWvO=gVlCQJxrqk)EU8W1P&{y6^@uL3q`Y1Jqm{V*L@zYq#dW-d z4E<=XJBfs!^{;W3OKLj?#XsK$mVEVLzP2?X5WHFfE*6Xu3LAj@Lye#|g%uvlGy(B*ut8H?$CMUC&%V8BgD5F=A?w3=lMn2zV*u^j4C=cqy_O( zj;|I9gy(a@_a@7y+L8v9Df-c?%yxV834u)$nkva%JRYK+i&uM_Kj%2@Od{S9n_U_; zPzEY%Thx*8N(wPRh4VzE(siz8#G=jlH_CN9f?Y3cYo!?ak0Liu1-l0b6bs=Y|h z=T>1v%)29`{?O8s$((mjop*poMuqWZrVr0MBxJT21bQ!SAaVoxO)c7~~OOuMa!p#KoR8rHMif`Zh?8q|e2=BC4slw6M%;F+er)9rNl2>u|{sPp|+Uz`A8oTL!&6OYS{y#CepBK!q*-vYgr#i zF8Q!@U6ccnG!{YSVlKD ziO+!kmR~cE93|Ds{!3hl!?=MvF!1o+FSI` zPLYkM?&qd#>|6AT%vrT70m(Ipx~vfO5_$iA(Q*@A)<*lwm2)BAa4@Z%b-rG|Hg}b% z0XMlMfywy_gCxcT9^~1)#dwZxX4Z>c?6*SnJBz=tAgb5O!=POW$YCK{A6Nf6i@oJL z&T}sOA?Q}oCaVb$j)c4U#1P9(EEMolGIP{BI$|dq#|-HYiNk(_Z+6GrAMb_4TBF?R zn;=`V2y+;F(`{?2*zGCva)Vh}mblxr`-z0*(`heP&ffBn>=2(WvfUrS&V3CS3-ofj}%JK0H zP7B!I86r0&^EAp@Vmah;h5_)P=5U1rdfx(TbghvR-dQJ%4Q;+^jJ{eM5VyO-r)qq0 zcV!#pd<3IubQHU67rR-7+RsHW8K|~STk`1HDe06~7vMkva<+9`z{ctHkD(9g7jy&aVvElVUne zlV2srySd?(SvL zxYow=&72n2LagdfXRSI)^Q`2aV|I0WDmwFiDKZ7gl-?NBa;mS3IGDN$61R~3a%w}i zyFYQ=a^~L6SXGpRGvM}pL?uzT>Kjg0T7zREuSP$F#cHyyv}?k$Mrt&2LFbLuRNDO9 za%{g1vi0VxKN(h@NDS0jsVxh1p5ek|MJnjNy1bn>r^3-O@ExJQ31q8N#6oH-3EROl z`Sn>rwN~b?gWRq;O#$7{Y>UfqXOPQhLalPca?}v>W#F>i`dxQjckNzs3!m4?g)6Xo zkHa@bJwlAr@yvYdpVq##qRD(pdt1ob=7~pkl^j+ey$OJ zo<(E!$~yq_nb8z29nGI$JB}2y#GJ>TiJ{w+29~a~Aei>)83M|)^LKvb3NB%Xfv@Cr zXDMr>JJQ-D1r+z+`khnXcCu<-M#fVs1R?hPXeHhf_ixZgG3gUyApKD?U`lkQ`!b?p z=yhk9N$mZtDfFX;d@cie?*buXCGa6ik$wN;&*eiz<{RW6&dSj5k%Vg8 zqxtW9P7Zx^26L{{s+4(ib+`w3|4ahHu>?Tae0LNl|PY?)3)h(d+Rp62l-VPW2@dT#$ebr`HkXdhvq^3NNnK znLeuxer=YPa%f=MYu5!dk(gB}*UXZziYAwL+C0HM8Qw#R9(S~4l3O~79S!ssJh=E+ zXSPpMnReZcYcK*aBX2sq*%y2Q(YZa++PuzAMPq+Jby|Ndy26I=iKvB6ve;wwMz+#s z#k6M|!}~9viWa>(2eN&yS_pnFhjt*~V3Klh!ra`gpHMUp2Jc)nPDO=T+|~S;#!4$t zgyC5;tbwc1`Qas?>yclnRepGGzOz2R;-@^I-l>t=O-G4!Vref0Sevup*D>E1Wxoy@ zb*FI(Lcd`mGS$i>Aq|7&BnNG)rcx|L6VzfV``LmT(2<<8$?{H%?S zcD6gN1%Jo<&;!Q@kSuG4n)Y3+rXCBoZdW@A>(d<_zl3a`^2&ewK2L=#tmlAUGMEZ- z;{OI1gIDpWM9n4iYF4P9PUVhgavtl)=BAaK$p4pUYo~ffCiNKJx1be~CI9I&s2R9P zeyQonS^xa?Uf1eP{K)YGxQ#y%ol-@m|DNfydy!E=*ZC!g>^&(xFhAQrrFx; za6M)C{c6N(bcD`P6C({5TPSmjmi-(EULVo8{DDk=>fH-|>MGs+XZp`Al3F#+tt}c7 z)oMt4oaL27p{Ro`(F{2-rASh&Ag1U@eii{8Ijcu z9&5HE^X2%?>wn)%)A<1mRGoAx4eU7>eS{l6oL?N&HF_Z;8xP=ZcB>F=qD&!m=r#*u zNMOozE4rz0r(T%6z{Xz&B7 z5{yH)A700RDnPDBj3~vO)eps)Z~2ETcwi9G@HI8xh_`Hf@0 zVy)33f8hzhf4>1UYWFlcVa6nPqZ=`>li`EppVY?<6ZvK|w8L>Dbw^4y*DV?9<^hdB zRpnAhd7*PmX8B;x9<|UqGn4r)A~tX1fPpV}=<^Jh-C+}y7$Ih9%_VTGISIQs)i2v$ z`Wjv-P;S?(ORtJZxMUGLI=#8`z4rAE^~+wD`)0wdCmlMhm7DYVN9#8PsLN4;hZDzM zYaiC}BwFSYOu>o-&u2FsDOfd(Iv?Rk)T?vZy5gjsfR04nCp#}#o~ka6^zTpx8hjkl6S`Pf~zU;TCa_Lgj^Y4DjsS~A<}oC`cXNxg z*1W}Hm~oH3TZa`+smvHvI6O5NT|Ir%{`>9}!BH^ud_MSB69Z~2z5m~^!~MjmFr1c0 zG!S!ygFu3$a39jINWeMl6t{hV8m-R!a%TI)M6KU&52^533(bL|8yg;S7kb*%5bA4s zPo%qL#Y-nGT$VI;Gxt~hBf;P5DwqtiLt!1xf`=Co2O5&lul+yL+Asz;CDBDr7M`rF z(=7Fit_IvjY*?LBsKgm;^PY!**Tt1LXk}lIEu8fB8~R~OmbaK7Vxh7_M|W<;P~+jS zAjiQA?3Uxn9OFINt<(v#VnFu*>B~*w%mT`!FL!W{PAZw?lOhCDXjR8;d|y_(`*5H( zQyi%f^BbM8h)VAM$%Io-_lI}yEahUdu$~R+E{r96;?&`ivolKRavFWJoi9%&PJSTj z|9$?+YqYWJHZV)r?}HvX0Pxm+Zn$7xkb{hTWdPW2u27B1FM8KbCFVT~T&f*=U18|> zu`V4J2$=WbF-prUtN*BP3zJnVd82PMkt6i&F4ooBt01TmEgJwy<_1(spfwk11owU! z?cJMyYisAwM0(rX`#srzjd^6dz0vaSQ1@vXk5ZIeiY-&Cy&X}61n|2xmG_-jqjK89 zi3NSGs~_MekCnb##5t@&Wm!B0QCF$xwyR{i=p#D0zrx2K_P1Dwd>8H@GW1h-Y1|yE z4WopT9$bmVF9NohP@^hqRsyo`Q+hC;%NoP~Gk0<6-j=e@lV5oJ?Y|{#^XpYprI#^~ zzh&j_tci;U(~R4OF=or9mM~#%lq8m$IpyjA~xXpTsYeqaH_1Dtg7@C%f;zw|Avj^p-cIhbYz2PIJ;HK_I}<9 zl|NU%+UAb_3@SAyW=bte8)I{@H5LpcXccZH9X**sv{#;}EVrU`Mqjg(uYcKqhO!P= zmd#3YOVyU)9Vgwm?j}!uTv?R$_6FyAC7!`zWF(nDUmk$y0=&yMtKkEOR!~qia+#51 zB~u90U{$FTqxBh;03hAyHd4kfho`mj)qtlerKtj7g|KQ;-8jt{wfrk}dco3lDZN~G z(tI_FyM#gWl>gv*Cw27YI(+{G9q%KxTI#Yu1|#;-yuyVAyy*uRm;y>R;}?y`MrOnl zr=P`%bJ@$z3K!_F zoWTz{qQh6^m|r!GM7rvy_34XNIvXb5tQe)rZp}gk_`G;{nq5gZL6@RcCwZehd7=}s z@j2ip;A^T5PE2VD^>g7kbY8>1JCyHPn%l3bSZYMdERs;EGmVKNjB)6rToxz37h?Qv z($AKA>O5_$xKCp!HOgB%z&~D)kbPfnvTs0&-iM9Ffe4Ll4u(>?xeeR<<|_bIeqGkm zF7mZz1MXQY$97h_)w2y_*=5zw%PIC}|z&+}JX>&+8Xy^Mc#8<0;mA@nZ#T`*RlkWB^M+0QVM9H)Q(kG$ zhbJ_@1)b>X~J~l*!m1 z&wR*a>15@MnmwJ)w!Z#F*VNjv7xkhb`;m}v7!sU744x#}H`wZI2TiovW`bmx+h!W{!lQHE0 z%@q*dapZ{BO+z`dLO-4n))IpB5RUX-YUfY<%U7Et>lzpl&J zI=soUjnja~ajAm8#UceH41tmPJwjnIA}8sduTor#Tbh|p+Scy^+@5Yy8b~yis7ML! zFBh61kr@KZa^mN48%fj4*RHmQfQnA9fbol0uCczpMBj+MQxE^rk#bn;*`23XG8iAp zZ~CGV-qRns7xHI|n(SmY4b*6y+-V}K9IdadloXi3qw%U;KiBVKms)+qlB>qAzYk=^ zGVTPW0Kn#~vTK)L&jKt4;)@TN)N{G5`>X^61TbXP+E81U{Zt z4HE4>XN-T*vc4}Q8NWc^^3kc)DURsv(Y_QDJCA7Wxgq(@KyYjmf&_8|K~MD8dYw#H z>iZ!$Rh0Yhz#Y@MmT|I3TAtL*h@bUSQh_&Dp&m$~zX~U-csVmgHS5}Qt!83EHvKVp z$4&IcMcLz#XfN-mShVyAc8;go&PrZ@av;JO&Y;1MXSdmT7TMf}BOgL&`XNPI*}@6C z5mhOZ9UZ+pO&~uL`R)#n#n-`1P8>!R?Xu$MgVK%GGt7WI-*38#W<>2^{G9s2`6VKQ zTDzDiNaZT{IL#`jMvlh!o7Ak=U21Zej@SM(E|9Vt^y{pfhmsRp3)xuGNXl|~NUIZ- z?@h;8#)4^6g^?-Dp38kp%L-FsW4BYZ?z5^EKT2Jyv+_XGDy5ERuq!L8^{S(Apfc2=qWF4E6C#1k^1sgYMaoq{!bI(E z$*C9Hr?MJ$?)LsUKC(Zt>A@-)9hjjaxl#epj+d=XWzqPt^5+RqP0*^j%R-+VmA z-u5jCC%8DP4bHz`qeUd4CP4LaRuV@6TT&8vy=(7uF(u6p$n`Vx`0i8O4c(0NT22u; zn~!B;wHmTtnRz?|y;y9HF54>>rc9;uWbJ=UY6PU*40QGI`NN)bgwb1g)7&E6*(eT4 zwW4k>L%8eP2TvGwpm(mDuMcFawk?1Y64^9-XP~hgB1tHcKx3cRze*VC=kqxJ=MJgL z@^{cJEm`(m!rNL!n2M53>|#SvIj6=Ok>(>5cHP_%q%I@t8X6jw<*1*U-M(S%&c%y_ zrb+n6{0m6Dg84862{_520@-tFC4*_cX=e(h$*{DhE>Brzz}v^NeIK>4D&lC<-x=I4 zaR%%vagLA{i(=U3DPT$gSL@}w*GWZz+bx&ekabfc>`kL?Bdn$qb(3f`>3)Cytz{nGrpbpj5zDj6=zD2Q!&jSEUHoC0k*!|If?fduo|H+2s5nyf_=(MxSZdHEq zF&eKWyQFDogVZ=#?_GMYW%@`2BYhf#zPx;fNy39tmd?c6N;15Fj>=&_d$K!5(ZeGt z$fW#UU0&|WsRa+v`f2eINdotG{a{-I65Qv^@i}d$LsrfIML<5A1;0hPHa<*U53}R~ z=J4sY=I$Z=K*oAeGpVSO(L20V4vEq39GVf6Oy+GxLFqLkQ# z*7{!t1i-ct`ZnMG0KvPm%jbt9hupT8AKl!cu%m`2`>b{&;05c= zd}9o~gKaWbqa1bngu5MhOmrmxJal|4YIf%it8cuz(J@;7Fy;E#?hd~0#JxJ_0PJ%1 zQ&knq=fuR|+2%CK*I(Ce6XG81k~ARV_FluWA;t5 z^CTf)9tpcfx3j5r38_*UxT|eelzSr#lEa&kP zKWDvF_YVddPI^*2q0!~v`iW0*9`YK?g?c1#AGqZG@LA_B_UV*NE1r}s!|CM3;)#4q zvO{XAk_dg@!L!b@KbIA4Oco(rwP%q{#_8@Cr$lTd3kt_x+uN^Mx#iBRXTWd==t5ge z&~5FrEoeL^m~Az~0-99bPGlvO1AfzPM(8&n#Uz7VJL>(cEPVz2tj5Ljgi@|_4#anf)erlk6F0Xmo2Nh&I4g9jr~_tqBcKjnc>U+e_~jb10`6=BB%#NnVnZq zfpbR-oDCxU<&8+OD}}c0N^nnh7so3peiR)``n)NAuJQ|SN0#3Cd5`egyli$+6S22$ z_f4bh6ghCJ!sdjZZhwtS2qO9G8f7t#czOeTqB5K%Cqq{;1#aF1VY5-{q@hd=+}~Wt zTCllwn9t9ne?QWzL#0@1JxSe|IUcCr2! z42L47IOZyCcW@Qcu5{rxn{zhrbPd z3RrKfzj8qDT~7t0D?W0ibKHQUo`rW|70?MCJE$IzINz-9g3XpBbf*oMCXF0dw!yz- zV?=lPMA*`-*A_(hF06b-I<`tX!y6ZscpPgg6XfiERV{npzUz@G~E^!SL`j&IpMH};?C~y5K9CIZ?M}*A!k&VPb_xF4K9a%SqTs_vmd9@xO zPDAQj2M9Q0C~zF=CpO>fA%YJlkDyv^Zo_VK{l1fwZ(( zN_uF|u^0(xgMMO}=ZwbdgA2i}M^D+Bmvfn|Myp11b7CJ?FSFB1K_~j1pm9)H85}&( zC=%Z4#vvBHY|vm@R$@H7$z8tIqoGZYM}uv_fsgMbQkqo(M4X2tr`9jeEhKRvoE@t z+BnXoVd4!!mtm+#UV37MeO)bPK{+Pr;}UQCn}wq?aOK$=Kd{d2L3{_1k8AtT@R#}T zM6D6>q%Q79DACsDGqUiJl4nk0`nP>_*c$L#+K#{>NF?AZ@4`KOq*eHpKcVxc769n zzgG{+wca;-Qd|6dsjf|`;_$x0`(U#~9L|y(67Z6e9y>G?c1q~vB~${>&$UC|wM!*& zVGO5Tk!mt8enMJD$u>6ZDiNukKk{)Rrfm z3!cYG_?*8UiA9Lw4zv^a3!)i1uNg)Zx61>cOPH(C6YAb zypWcldSt4>O4Hn${`$>?=V#)QBrUYpF9V3J7Ps=3bT|uvB73!GHbu=Np~n30FEj!x zm((h!BmZnM|ACbn4iKFJzKTC)Kp|{*Gn;>@ zG9Njk^%=-)L0c}>Cu{UY4wKeViS?Gb6yCUSeBoU`Ej-5eTT#tK7w3`kl-1i9xvK{19+Fi z*n0hTzUj4c)*A<}UFl9?>tKZ-s=GcuPU4zGoQfMeN{e7y%YCNH5i;FeqjYI9#@)zI zRzzRajI4l2#WF%(Kk}Z#ut1xARaPlxDybAxnwX+v*haV^$A_}0mJ%>C72aP2sEyKGeqXh z=NG_A#^FW$EykMa5=_R4X~$ll>Z3u!u};TcU%F0M7N7tOSL8QSd=`3^=k&DC2ZBCb zgpoF}$@ji{gQvgrO*H6rM!>|tecRHjj{KOg2`3^~NvTduSN><=tudXCmFxr}7kzY% z1hfi?PRCV@^IzEE8fpneB3lv6Sfmm$ra>)w*i$6We;zL~;yYuOkPnm7vn-vtQfHxQ zV$zN~b6+UtkICu@6~e(puKDdkK$@_T*W8q?&I#Ha~(bMkDO;$$s6OAg^BhMs<%Cm;&m^Cm9bzWsJn|NTpJ?-W9kocGh$M= zWPX7#_ql69m2MBoA%pT#0|~U=Uns(U68I>D5gKAKaM80CxS4K;4iP5DFvN&HqT8{52KnBT9Q%3D$=2M~mXNYH6draoa`Hyy#(?+GFXHRig+c8KP(Vy;clF zS}T1~UCg*F<-3J`|4bhL_o1`fV!q2R%ki)xyS1-q z%<%-gj`BLZAmw=uQrnmZI5y~Z9BP9sJ&)!quG*nu6XRmY6`{H%RcX4y(;{L|^#L14 zAewm~Phf<2po6YyWB_Yq{S<@VXiARKb-x?b^gV@nIOSA`;N#}2$w4IJEpSir#uJtcUf<#JGA<*e*YJ5 zP$H9$z$_hn(as1oWT*WmRZd8KApP)*S&H?>6vYR;z>@p#GY^$D!RLzR^kXeX9Qj?+ zC^;U(Sspaw;lnqz$HAGT%Z!}OqG~FxH$lu$1?(2+ec}k{&-!Gg^rLXH#e|oc`4==9 z6Cf7n-dc;EQ+^s3ZXpqP%rMAje^)Dy2z3a@zGxc|?%O>la-;HnP?}Fd%G2dbDU-H1 zEll9x66NKWq76caix#w{!g@rx2F@a=Mes|7x8%RC3jQtPZua&X=9}p%@=1%HZ5;Nh z&9G3jKW}dLRi~+yiCs>lpCL{N@sy$M@FPNT>GM6tRs* zE&2_)2CjlFH!V1HJ-9FXK2eJZ@|)iIs?ubID&2n+owPk#sHjuv%$b`!t*!w1D>!Z& z;}@Js^Vd42top8ti-_*o52wH11To(E{TJHMFTU`v%C(2gE@(EHfvk91gW0EIeY zIS)$sec};0#>d57Dpbj^Gx;OHp7IasNdqbXWLBsVzQJHpoV?SB7WdIymMT{!*9U1Ar&4@veB$( zv!Jc3<0UhXHLs#or{2q}F~_z29yoOy98v|L@>sEu`#aDvdj4}FJp7E&Z(JDhs%@hO z$A0ecqdUAH1tJU0G-F{nH>4fgkD;u-b9f4QSQzO1y6orFB#;z6H*zW`Cx_B!lCBm9 z3?0;b&J!w%iW;h@a!jp5|9(EUHNLC$y_(5s05_e~udTTNidl9YmTH%>QIy8=-{=g> z@>ZfS)Rt$i)&oOb2tTyU>8#%22I!J}xtyNhFK;a^q)1i##81CZuW~5H$=UaAxs@#k zGwe?f<0NEtuE(y!y=tECc`<)oYqyk5o-bG%T<%wA6z^v5K>bw4~2&!?8-RS7!+Hf%s{gd;tc@a7AoayZv z_@wda?w;(Jf#$K=_a@!;`0?(f$(hV0v|aaTFz&qbmDUS6Qmn4W5XVIihg%0CS=*5z)83hI5i*T`Zo(?R-4XXoXxE`d)bcN6yoz9gbm(WhZ7d4= z)z*fqW){&YStEA$dB@d9Q*z<<8Hby{WH7TQgyiy7FSud?{M`8h`~1I+K*YpH2Suhs z%V_;KB8h+IDCa;gf4Sf{43Yth+IwfpHb+g z%EIzOE{(-U0Ge>jcNwDrqx1G|KLCvsBiDahO~?b)_4Rnqv--XPn^;V4G`z1&hM0`! ze98E-0mWxeyV;G3w2#?>^_j zXK@fW6ywt&aealw6(Mo>2KEa$kXMPA@>?Q76%!FuAq5S%m>Nh$Z4gjAc5xnJZuC)2 zSr3{piKD4TT=4ecOTDBEN~NO^Ygj_ z)!}~K>o=R=Nh!^!dAEu$spwqKFjwHBoL&u;+#N1_QPnme3$BUX&V1*LR`Bj!&K4f5 zY5Fm1zUe+Ox#FVRvxJSo?_z2t!^xAla_nh9iN`LslNAZd{ZRtiA>`5{)TC~nV%7MUyEmjqlg zUl$aJ)AQcIm6e4f{|g*CX`BgU3yZlQr|1rC-XfL4Is2SW;1HHXz!Mk33(Vh^^}(d- zNg!~QC>R?4Mb8!`;BoPdaN|a^3V@w?QxSO^FK{URdbp1h^=i!8I|=23VjBBADBqUL zGdb#W1U*$=0ny3Z-^az=Je2NvDL+AnQr^>r=c~w$|G+Pk_0}h`q3e*nZ4!@}Eaal` z{hS13K*Fy8w&>;GYDYIKWzL6I$J!`r>oO{PblI;?FMe;-<_>uR-dF>=4jAu(jhAhX zQtn>AiYwVdBHfmTEjq+^T&kmnsw5MQuB-c@y#=0UWY3RLNe@#!kh*cl}PQIPFSh`L@HE`Z!C6ut%v zc#!gh6ZfeTy@wOWm~1+ThgT>trcXTXB@*%=0PF{&R?YPAnE2^-$y2A{Vf(c?ZY%LK+G~b? zDDD4mtIiYiy~xhX=9I`ak`x-EZL&xy=xon z@6VM6Y!CNHCD? zD#nrWkV`gS<-!Y1i@fp!!&#Arwy#0kvkAHb#gU!$8?Rva1EygbF$bIF+lp&PJLgXC zwuP;a(CM;KF9n(8+#Ew;(obhJs4W53j*6o;4fzFVwJR~&;Q)vq#-%J3CNwVIauq?+ z0BVf|FN_+GI@@5$C#!Y~tEW_LSFKgcW9tuCP9PWyLUlknoFPtU}z!)ve%hv34Pxw#+Sc+^F)oB zqI*CKFeS;Wiz}us%IOu6JQ!fTbxQnpDi$KvNk&D_(@6A(D9|Adr*(wdtwjv6G|{NU z(>XYi0(d&r00@8BpekZo-tT2^%685+y5GohUt(~qeR@2>Ukde|8>JF)zg0{!L@Qmj z_$@vG9~XgF0~{Y?vT84tDS)lQ{b&5%-+JJyLSHfPq6-@1{KO4|TQjckU)|kJ^|GO^ z$dd%9jVFSF)jvDE)L4;oj0~b=fW85*Ys`{;{S13YU}CT@B|jEUz-K-2op^*(J`?va zv+->7eY-rvzn{0DF&+vdAt`_uR>|*9#MIB3*h;WdG0VQil~yZ3AO9NhdmRWVc&v~D z%+;WzBKfgok{_i7#pqiX2vjje&;LgL_)s|A`_`#|fJ9wf++zD12Odw4^dDwCYE^v3 zUKT>rl7}SG?m$<%)9PJrQU%dX!C?kKyZraDVbON;h3)B6B{G&uRZ%sNe+*``@7>kn zN8|gqH>aUuv%u3pvPA}N|ELPXcl+9i&B&4-t;!MT)a6p` z?xRahQ+E?}>ZwBDdfTqf-S9_a)ebkdlA>d`fTpJn)4ThWE>kk`{VDE4or3#BkB!OV z)m@6%6NwFbX943pymBlTWIQ$xgch|Vyu<;juq+&Iy6=K=l)?W+$jYCBa!GD_@HZ33 zIXb)uLuszgs}H;C>~F z+5CnQr+`s2F6Dw!Glg-GUEEagF!a09*Kt`w@iG&7QBB9(ySeskYE`<^!Ps6MRBXm~ zv8NnE4VffRA3Kko2L99fHcrCD6)!%lm>iK1LMct(_4ELMwN@E9ig>_FuS#Vt`N<^^ z7j?%)ep>^uDe+^Mn`zT3X42oa&8Hl4e!_yHJ7VtmH8gEo2iA4x8C~FnqQfi09Rx*+ zQ5$Rq^=ygXp;e9pE)jeFi?=w(+;*r%7xb_L$>0gzmGw#>AbMfsX0i|CaQ9 zfTo(*oTppq2)M`OXXNiptWCnujh@}Fyr}Fn?qu~SsAy9X&buv36zjs)>LYK-rwT=Z zahBXCRO?cCL&CN9vdd>Xo2bw9TJGd&^Q`l!z4&`s`B%M-(m{kDH? zg;kLwda;a6EPKEQiqtxs^e)P~b>Y`p9&4N6snsm+eHX%hJ~3N-ze(Av#Sxmg5R+t> znr;OC>cy>tUPq7Lg|%7(!1pY%0}Mw-k02A#)0Z=ya+_D16q`Lx{6vo3Xv`g@s&0Pr z{B)Tv{Ma}uYuZ%Doz$vH-M6|Td^jP+RvA?X9BSc`p8r?lvOa~0+!NTDry5gj&dK1~Fg~L@Rt!<$rI6$=_pIWHS;Q{<}gU!4o|XBl?5F zPjxn;;4(_6WC@J=yF3FW_hMgxFf<%XiO-^oD5=rr^nNUYrZ)DbG|i)>xNSt%Xlg3b zqua}Bxd7jjc1=-6fOV#XnPDKbM6q^t-{{hzr=mD z2yvsJ)YqTf<=3dY=h9go-QL@4Ua=C~Tg|c#NNeF^=RkdWDw*%VW&-=@4^i-0-D|U zzGyl6=UDVq&v7LTnXHAQPvyz1d)o!W*N$MYX+rfG3^Vz@-vZ_Nk|0KbI| zo|fGx$T8#pa;Oi#+K!JtH^q)rfe;hNGKzas^;S|GT+LDFvlhJ7;^Rw@U29l5&vFkFAqn*7R+fJ=kB`G z2`if_jWv?}gA{wtCl9t-AH53O>O8;ZZ!g@Kc+%o^hbYhb1T(nL!Cobw)cnnlf2vT5 zoBUY2`c}zeKj`M>#?t-q-Me^x+==%!HoI6EYE2C+nJXqdt5Yiqw5_y&T32gts_3-a z{Xe%Z#JjhuU*P5*EPc!xzt>_Vi&j|M!pWD5dj3~AV(e41Y zFC|A;lw6hD7OE<8Jg2X|#{2-LX}QKMmbExblu7K`TmW~6J5hQE>*j9@_7U`Ohri$A zcylVXLLhfLC~YWQ6%@J=@nJ{WPFwj#{YA_ue2~~;n*mQVwd-sM+3J3F-fNSUhYY_a z+4Li_y?*T3l=G>DLP^pfaB;Un*g=0ym#8F>>lF9IN&0BNUe_h^PPE=ccKW4}^;We9 zCpAoVq>irG`!2Mi$dOf>D>fr`yE{O9x!Ok1UVLpEv`P@-b+WmVqz^AS5_XvsiXBgI z%4~xS$D`W8Go|lqbu2)TwL!3pxUD{=w-7*5?!?ept?j0~$qvQOadJLiNgs9-4w${$ zv0*^d6{Dte(lE@VHDnLx5N6K`{iI-;{a?;zTK;bH?fHwC-VmC3N|0*SAO&h7+=yfc zYiJn9G}TJBxL*i>`!iVgqK}a5Ox^NQG?D?o@iZKt=9&IU700x9k2$|R`X;z-Q(%+% z?||JzjELR(12P$ej)=hZ9`SB3U!x||Q(4TipU%ytUarSe7ufjf=BE@j!n@qcj^ao@ zL5WOK8S~gZ1{7D+E$aefvW0>SiE@G~NFrc^%lT&i7%GNen<-fyV|B0Z#+s1a1zgk3 z_-cV>5r93>)Eldz7Mf7!KSCWw7Y1j;p{g1$&*K4c{D;{7&k`wn~|hzQA{AD!aVl zD9V!2ZbW?{AHpzMjR?ewAT;>Y?Dttqx(C_rRQA1R=~X|0($*2~YQCSH)pujNUM+r7 z?tgm|OMaH?(NyuEsmAsZLSWyohHf8$dqpBCS z6jo%f(Cul6DeH}9?ZKHyBrR^OLlm`mb{!GD*6DXdVYt-+V6zdcR09>}AvdUOPz49`lN zHZY~w!%Gw=^uk0T9|5_#YNNY>(OxdiqMYdFjxDJpfo`n-6q|4qStI99KtT2=6-4Vm zz86L#^{QLLEq^yZo?4p_v|1ftLPyhKWWL=g?%f;4z7`#bQhk!{O$XF8DZ+Y_S(FY0 znWM<=<2^@vs-@p9wp7$eW7vItQav)>l;#D0>5Xp5?S3YLm`$)l{QX#x!V|%>SNOI< zh;yO!qQ*(Q#s0No3wVK!;*C&Z^JvOs9zQ4B`>D2B#TR82v#UAY==$WJ|5pe|XZjf_ zaz1}_QyL*kL=y6ezP@LwO?RTE$_QRa?BLZZn4F43bdCb2LIzsY`_WmdVbUhfDcRn0 z?cGx|GRx9G8T+@OVKiuJa|eZjH(`F?K9586**c--xAjqc|_oKK(K|XCuQJnS$WIj!CT{1yq31%K)vbtUM!X zgeC0MpNZ2!#MU(JanhmocB4E5FIju30_{B~$voeXUw9)(r!6>iUqM=ms+!z7oxO~^*O8JKiR~Ro>jLJ(C3`cbIma0Xod3rI!_VSyG>#RZSZsIZVf1)-FW41 z`_a^d;!e|x2$<4Q|?G{!g74jItv-t(R3DW_7mR7Kj+EEFFCYx3*u zyltR>jl(eFzfe$6AP!UPeswKV`yW^?dyh-oJN55fsPDV0zUb)mr=;vCum3V4dYL^% z49*YD7CtGssK8Y`04;r~OGKS9)M_yjJm(>zpZ|(J@;L~&lQA>_zDZ>W9qF|kuJZh+ z?hayo@FPouPM>Yj{aKa33kW2T^vV6j-hB|`E~WJ9|FQt6&;wXM?dN-0z=?B!L0*Q3 z&WLRSp{WeHbjB#bjpp;Mdiu)L7rIpL;aq(LruqG2g+(zwL$5L5`)s|`yF@VrzbXWK z1P8Q(EU_aAePx3Givsf76?nHA52f4505Fx>!(!3=GQHnFWVVmDTbYy~=PM9vg?o=A zY@O>zJ!Pzmf+uH3r!YrDM}bw(9~EBvKkQ9R{(wHf@hB-9Y%3ocA4b4yR_h(R{6|Mz z3yv$`uPLuCm)Xj)Edb-qiz9{hK#KL8+&*ettx?5{Yq3~P3cTjQ@;CVYw_E-V zaD|uA7)|EaHWuxoC6Pv-S`za~7S5(_kYX$VURaw{G*yF%BKAKh{b~^zvaH~E=%M}N z>qP|e`_9^10Cww=mitLCFeV~!tT5{YROqc%nx>C9RhWo$W!y@YMl)N(NzG&E)L<6V z58AFK9SGw+?)OsU?WM5(4QK49mWuG!iBN*dTn92J?DJi`g9KdD8_9a=+?a5n(egVl z_Tyy+;@dM(sfSS=2CC2`rylmSw4F`8SOYXI-x_6JoXi5fzi!o@_Q-bPjA|;^AN{*IOC26voJ{AL}lyk$W<_p-yBMZ`WYff|9W6xz_)nU zCFe!++{@%jg!l9(k4M^Hi4w0^L&tBxd3%@paEiuCzmtli!%LSDPG~#t8x-xUcY(IZ z{olaVe|2x*sW|obo>M$F2khd;T-O*`YUHL{1HKMLjW*+A(1BU zm~@|w**$3d?4U$9Ops`RufvrxlmQy z$Bma5cgOKKs&yFI9_g#DGaf8sTx}@f4HjcBur#;^_Hh|>X(FF25&*uH_;%-RS101< z6bW1Jnh=oV8AT$l@Og}_eAhJ68Fzw7 zl%Ndl*^ZK!w%^altQ9n+wIJF&TtWZM1jU)&iwZ+DGEKHC{XroV^KL`QxAK~sjocPiJDBOjJr z#7ENG3`S5*=O-}cj@Y+XDWjpv^UHrI0_5<4+_w--#DUV-k!}^$3{40%QuKJ)?5YswC zjgpUwS2i9+s=LH$+ns4|%)0}w$X2s5gRThD;Q{e1v5UV2o4MJ1eddebaRz32DjreH zv3QT>>T2u7>%R_~pn10Pp zmj{)rg_C=$yK6ZK^R~vPNtKz6l2s!SBm_hJT;cA{|E7A?<%sHIhySY%!-lWnV!Pq7 z_0bfntf10VVbOOs%VH-1KSDLd{-?e74u@-P|3@Q{Aj%NYq6I;O=+T)Zh#Del^b!)H z_c92H9z^dXh~A0Lh~7tZAx4`SeHdkodX~N4?|%1wzyF*+&Ns!eS+!Lk=d!-c@BI95Frk9h7_duhil~fKur{}V>(*XJi$<>C62%DyV?xx$qD16ihBYkh6qZVzUQ4OoF)$P0JZ(}4Lv1JsCxwp?oX^k7LU@LV`h(k|FN>JlC0EIj)di&>8DaL^B0`E%TFdefgKMkT)k1)VcgZq zoH(0GW;39Qh#NNHNqp*4AX(now)Y~b0NjKBS%&B_SZz%@`Ot}BDMnbVT~CYSJFWmM zQ3dP9+Sj!rIGfsR)Hd%`%Ru(p_h5A>8MGJgFvn(*I%JNFLG1I#+q;AhbV^Q<9`==B zpIIQjxbdQL4+ia}(G~Ado2{BmB*o4QONVUMO1l4wgdZpR9}Fflb;_fs8kWd) z=tXP+?CdU=`>=e{?9NN%Dx;eb?|32)UF{RNd4OSP11*^{=yz+&36Qh%BH8!sxLkL` zT^PHs0N|l(h?yOW&X$P%Cx@XcF(P6s^tfY!?3IWC)gwPzO20^}uVMLoT9v?{MucCH zb|caFH2?7Q-kcN!mDKBD ziy@(8ABoIAHtp3{f&_w}5q(_>vHZClz%>UemUhLjXS3_0-@=OZX z0K~91;lwyJ(M(%85W`5{TjUFVx6J+YsEiW$F?ZKZ$%|jbip;P;ne(Y0a{c2}P<`NSq@iy+-GT^NAj~9EpGd5 zEUS*8MKLk5?gI~JH&5WuA>ICFcG4-sN=O(s^}rJ}Pce&jS#(Fw+wqi*ZsAz@aI#&$!Of%)`(#SJZW9RJU;7$)#-U41V1+hS0 zW_{)4OVH|@TL5TvaKo==f-st#`Da9@uLQP_FqvH7Ef zEa)DpFp5E!q@yz~uatggYydtgW1oH^pfuJjjjcpIv9ORKuzL+Xh;GWZbuO2vd+AoJ z%TwJWgITQIYJk=GpX7VY9Qtglndjdhp1uv~iDFc0(~vQ=Kn$5Oc=ek{u`0VG#X%Ks zF6M|g2diQ3ETLDv3D|#GI@|^f3Ai6;wLa)47h+U=Z^bDr73gBhDg4`gh(McgwA;LO zh(feyAMeVIDSh;g_XWd2K1M2+LR0y2?_R`zT90UtTKr_K$RW$yf+C|?`xV9={A!7Y z@u`}$tNa5AntW~jV_%HskbL=O7weKpA`?;Y_?aD6MhZ@Q7SiH8S5$%&L zebD1Km-sN!j>Kj|<-n)0+pt2tegxCt_d7_$N3-31x%5GPZrQH*{i!k3x|av)BYFds zwa^Did-!+_5b7ktuLMTww#=84`y8?CSXKKQRX>)k7s!gv>=5Jo^<+>0{G!E0UQhzq z$pBL8g}sO00+m&S9e^srDz{|vH|#g0U9;RGk6TR$(;c09&%Bxmm&G8Xtd8V1_4m}I zp5$_cB*G~KzQ1$O`uKvyDLxHn4Q`p!Ve5$jfBQz_q2U0Lqm^l~Rx;HQ)QU}%{}S?3 z<`-3qn-`FQYmJQ|ONv5`C9`gYuU414VO3nProVBjtmf+w&C#=Y&RjcEDFu}>7?wcl zB|JE0l#sGg7=5&3{QRO>ZAZfSe%RR0pBgZA<0LPcdoH$vsM#*r z53daz04~Y;Ld8_VNui(I6M2=7!94tH>LNL$K1EJPBR9yC%|RflB0Dab`_|6}U|Gii zR*@LRkaW<)SC-cd_^Nlm8=RgWSyi8uRkAhtuX5jnkVh<7q{V$jJhVkMv5eULX$^-N1T%lcg()rOMkN2)We9_2tS z57Ag+?R7P#B4WCyna3OZL(>RhhEPk*bP+f zE=MFXX@$tvJ1m>6!re2;aQ>Fz`^$T_ehvSm^G(_J6YOTCFBYvDQNS?m-$oYosebiq z0D`I6QY9%jft%pQv29nC*=&V%#M#PN=G!Fjp;GO6=a}u=-a7K+ZZ|B+uOdK8Zmw2W zI+<$-bQ_x6C^n8V@c%~RK1X-x1zP|5)5!o?{A&BR|Mb@JjuMTng~hUn@W{IpP}Hw} zDp>$i!(t#fhh8Fg5-~HV%QUAKv9FO?Uz{qM-Kq#gbyJEQ^_sv*d<&+?K7GQ?wKy3x zc^76lbt{Riq*Y%!OG4XJ@t@AIls&b5&R#=U9yWjvS49ZZGe3=j2!(Z9*>Sf3pDY0u zo4TVi>uV3!AMkO*>wb18+1F$yyzwmmtt07)_9%IG;x_sHf|s6I`n8DnbYqlfvr2*$_Cz5x^inFL^`wHe?(bDWcsLx zxTMLy&3h{IZA&q)yHj0P{?7#62-PqHNU_M&Z2cUK`YXR5-$d$NOEY}>5bh`=?DW>G zeeG5&d4H`z-3+70JmTriUE`S-rz=D5-D~;v6$a*S(yM-8TYcUeIWEfA&SP^d3fRi# zvR%d2>;`Yx;*K@9*3{1^BzJR%TA4G@>?jqrG_otK~Cid zN07$SH%7MVkWoMSb!pq;M9@hh$w}OOspWXLTC1%)2G!mG{QUvoM|rb}4SFq`PU_Vze10GOgRkY=RkD9D5?P3-+jQ{>F%&B^kxpmQ-zO`}G}I0PEG?mrKj5$a;BH(U zig3g?sZV8UCM-E(`Nb;MNssEPS7ZZ>HIMOAS%8h1q*Oj_(qYaQA#cB8x5EkzX>Id9 z3%=-Woh_+Z)=zVc5k-p5x?hNFNGyPzLm*E`pa8&;$lR#JHCZkh{x%<{Mt2WXRCcb& zkD^-C6nY&~-y6}U(!CR;Fu^!kmXV~65G!IH7( z(_)>}w7TA~-&Kq3QU2}2;;8g{tJybjQj)bot7_7vKYJA)Wxx}t!A48VJWVG$Gb6E> z?iD47c!?_!_e^CY^&GFCZppHcR@4=7tx3;x-I>JFU16rW#@H11T&AD#b8J%1h$8J; zXGAz=?<(IL&!7nZXmBHRzH&Le=-Xw`fV}z@JPwN5Rle#w_pcJi0)a7s&9-}*Ypx%D zYtQpVJ`(oU$*}x}Nb{AKWe)Uu+u6O{H1D(BYQB8pF_Vlv+W%x*mnnNv>N@T4twBTK zF-z*G(u|77K39RWhuTC#O!>WKVaB-VQ>{SH?Vg-BpM*a?LI@hf%b&lVReveF%Y8#L zgPT*sDwmTN(^L7zMLhQ0Ybqx4B%8DLbq=U-pR-m)y=ruQz}9qvO9sEP$EP?wA0{f> zEAN!>GP+?Zt}``4iWwt5#k<&EP`$M+(RfAW=7ZbR44Bt3Y)g>RxRs@5Q`SmVRz9J2 zOH#?M{Y#kyFm+yD0l?A4+vsg-p;9s%gT|D@`KB^^bc>7V>aW*rA=d60o`s+!mzcVD zY0u|Q#L<0CKcAuDC|S0iQfz2BAyRg;aTT{znD ziM3@wM<13-kW$(-cpMS}k?+IfJ~$qcXSPyIjE^k&13L)F1b<%q>Hw?;NwjG$6j58* z%DxsPLeHdULCLLN(?M@v9meSKYOU9JWBx$CS{jR6PhsPYg2cVe;!NnI@}f{xy7wgb z%D4u5`rVrgRMtx9i@3d=99i@OGa;`DvrK#b+bjuYR8;*7^0I_EJjR7p+nS(t5u^jx zV6<;K;v=8)((-*f*T{1HhBZNYeZ(H(hT+zsLq*vSjPpfu!ev~*Usr&I(&OxHy-{O1 z%Y%KdaxHk_wj>dy%dRhg%*9&DA8q#*W z3@KX|lwe=@kUu4Dmy7voSWFc29J&f8p%~$fEfalvb>&ik?Eh*Y9>rS_0ChNYG!g!G zc&r@VQev}cZ;s>G(D%wb*pwVoCO}uq`gq`Ori#9T1)yg-jkA1E+?0Z-vWNz2rIB4^ zk0q)0_Cwj;px*ZTPPqz>kCwBi_%I)zhv$A8r$@QZ1mfW-(rs4?)F0pmlIEE`e*CIF z4-l#F&H(`@mxXPZR^Khf*7Tm)XKMpuwp?6-Qze)}rPa2zFkz5gG1g_ZK%BmTaHmPIt@knEwcaT;%G{Dw7B#}QQqv(#v z6xL##jh9zXWfnmi=Hkeed9*_8&EW^;+KS$gKA#n372mG@MN-}cNb48k(cJ^}svo2> zozAB9mD%xsxOV<(#xGR>cQ8a_DnOGZr~NJ9w@iuC+WJEseH?>i{UVV_tw#vx=V_}O z`Ybr1)*ogIS3|e#UDu%{Q{vO-F?>n)tgqE-s6IYbYOqJP4F|T3zj0$z5i1Dy`#Rl(u~yB*OrF(>wwyG>xbYd(exj1rq^8O(>*Rmd9ZB_8ZRdOji@$q}JYjTXy9e_21}{JYe|)z>RmE7$u6R zf45c(^O;0;dLJhll%sJvzZ`?H%O+U+l@YJt;-W% zEXoW`${oZ<;=EBDiCI=g2ATFMlNt6ZH*`;4sGa5xrVH7xIB zyS_|VRoHNHvTUK(tTtMHg$|D!9QLe%HAucC@R+v>K_8WnZlCE}=t00A?M-YgT@%nb z9_^MQ^=peF^>gwT=vy0iBz}0G+!%1#x>KE}^>4}ssN8Xm@^gXYHlM~D?ay3}(jD`0 zNWNo4u+EX6W3hT=Ym9=vF8Z?rrp4XnZi`AOI+xP`<_y>Qniixkg|3QgQHV1BQ6($a ze<^zZb+wtZ0s?k&8ItuK@zoN4$CQH%#~5sxk+8)^qcL-b$y*9q_mR0Eh>& zt2`J0K-TjJ@1TrvDoNoV?CY%%Fc=~xSAf8?f3*MZ)lwk#^<7XVCj77Z|Gase2FQxv zaaRle@TmQR%>+Hx?K9%O(L0Oq2}IbFsUne=foOkU^LMWjr?x?CI1a`iu0{tx_>W3Q z)-HjRXOv$K0i?#i`wjHrEx;;>k1>Uq|0pd(4Oly4U_2Y>KfVub3k0kfbzk_h{#(VL zBliE7f%;N6hF5U*FDl?q!Uw!JWKD~~^hF$^4)eA&hK^4T3<-$S+77p7w~9^9);~qA zZfsP$ZA{K6ac0Dy(h1#vGD|dw`u@AYCEph}U}9eoNiS+&Q#~v(kjV3D^o2M3Ej&WS z@imwKp;{Nk1<=I~Xe$E^c{8z;@+Q+7ezxsVjFQls#+SLk!DiLZ#?>E9gsSkhjQ>Vh3 z_9wXy5(J!`V5bNr`s;DXw5`UCvb5->*De9%U*4c~{SdmF0U*d1-(`BZ-resykLX$W zv!)I&Lq&mHypcczm(1jL>&f=Bq8wpH)yL9qjF8<4*FVL;2KJq=Am4&AKv~7Mxc9Lq zjYn!Ov~xJ(5J|J)RNOXqDjndh!g7>g5BT|E-B#5AZ}r6q^rCtX1_nou?m8)&%9L-x z&NpGdsw@X=^rb~atS*mYK|@fIYi2LxFkW7mxGdW#6aN+fwkBTzp(tP((RS9Q6pV;l zBCsV>9nPliXZ;r=*ka$Q2Lh&K&eqh`i||Dqn%uXl=8dIQGVtF7T!$=NV=kcc^P07* zo9W}sGOVMz41Dw5jWDU90Wvfu5=#Y72;9@n3Td4FMJ)z&KZp73?aA{Vi#g0o~*#7lqXN2;`b{W{&%hh#!y9GPX^-fiMh4IdvD#SpNfx0>@w`O^H zc@2pk`6|m5)py95?%1B|N=Zq%HsFVzZ{dncyXJRS(WFLWP?a%}*C^HaVEsxC^XURX zcdirduAEnq2#wu_A-+Vy71?jjPq48`&7J3m4i_Se3*DP>S*NcXl{U0wGN!jF)ujZ6 z!hnD$rq{ZLPV#Q6>{$u^7g+x~loU!lBh*r(#;dh_tAqL6@dSQxX5!Py#-xF8+xkAG zJg#lM`b81ojEZuoh}0`jLpk#uyao(nFFtZgu@EI1jz@m_G(FXN_{gGNxOiG1iZP(a zMhg;F9Xr!SW0D^H67iH^*5kLEZk05nTqjN7P07LXHXYs)!24o)abR*`c^MiXOV{sv zP;4UIcP|@FY;qL1yFylHHFU=Zn5*>lSC^glBB5bbui^9`{dB(>k!Y|9yEui}bp!*M zdy=4Yyp5~b*>o8I;lIW|--4f)de{1$d7q4d&&G-h*p{TxUa(KuQD1eX$nV`?S?fUzEP^P=GLzI!eQM~eamatuDRvl$}B@!_l9fjW)s)_=eFPt z56PVC_6ZG(u{niGjU~u3K*LrL)ju2x?s6C~g?qkH>b7tjd#|t*8U7r(^6)P^++2Vo{4$=fj`y9pt z%l5ov#T>ZuToDPnIuXtqI=?C{6xPr{{Dcbcq4K$f-kB;Y%K`f8zBI5Z`EZmqv2cj? z-btixAE)ZnP#h}S0$Rw0i<=$2B)_Cy`eFIkYu4&M`V&zXiZAu+uV2K7aP#?4W-%z6sxDc0Tk;4 z)pZsS;^M>O^IcOZ_JSXjc_i33uI24Z8<(q9>=Br|Z3*8xI>$nx`ao?f3T!hkQ=2na zx~R*h-D!lmAVqa1$KOnfCIb%JKuP{Okbum6-0p;Kv9${mM8r_2;ix>`dYIK|8tbT%{t<#WOm-Z(Tk}STn_j+himMCxfyvc7|Q_r zmPYw*@70e6+Y(zh5j^HQDIbYhyR&Q)Axi79;F+?gi$_^NbfwC-VrnD8N1IemO-E*|IHnSuG$9(xSI zMM3h8%oj&J@7i%uDZ>a+M615}^ZBFsUSFo0P8Jy2$JkP`pzDbRM+T8BY zNX|bzDEI$;z6?J907VM(^mduuF@E`6EQElUL6fmXl=sy))!=&q`#~)Heq$-Y9s*%$ zXEoEH5VLO;yZQM@QB&*81%R7^XDpcZlC3%2VP@YgmGP->LP4J9XUDhqvR`~t=w;1rr3{0)#?klMP=#1mMEKP{@TRLKH{{2tana#ymW$7vp zF3#Z>#d(N@gmQ@GBU03S@mrq#_RkJ9`rRJw(9;1Zc>-|KD~3<~SL&ihw0Hb(zmu() z*Re1UW2X+p62CId>%x-W%@&En_BfeVF0x>?EG zB35|;3NAvW1y?)FRnCJylYXNEVoI+>u>^|+%N<{9eZZ}w5{cf7zw2XR^VrZJ5SLMi zGMC|Wf_YC?k7`}ZqvUS}2k-va14gpt3}JF^85ssWX@VA;9rP;52>RO7+4Q1Ah|Twy zrfj6A%K`+CN=r}}JBErb`1OwGzOPj$f{Ce@smOc`YHgLKz)*X`i#Jlsbk=udb1lUl zjBy2WFAnOy{-z=0a5`z0cDQt83tc32sF-Tmc3Xq+&gV7OR)0TBfkE=Do-hSRi8R4} z+YacoURO5dy`3%Ef>dfZoQ@}@NLINjHM7rmV8fRVE546Q^8Zd4Ox=onR9@&83LBX+L0fUn|adXf7+Ak z%FD6No+h|C<+n9ugBgpU&Y|y>b9iM8%Azq8Vvr_*E>V>iD50|1vSUr6i8`b#^jO0d zWF1}@Gj*t^xs~oCU+BzV>vpP#;?kgng|k{65_?RB-pVeST`HjF#uZXW zV>Ev<;MjdyO=Z2dHuTEaZ9>A4LuRNkkRL60J!iYCV-k6vf0Y#%LI>9J;6%26+0Yz)ah zsTXjFY;Zi6lO&~kdb)Ur$}LyEUizR=y)+TKssL_0?tqlYF&>!u-P~hYa!6jx#*I+h zFPMvw$m+Dzec2|Zsa=u#qDjP`D|jBfgQFkL06Ue3`2(rR(-o!|XdIA$)Q+C$bXj7% zVYBI!lNyoK`YdZv5PvW`5X?C%^hoZF%(XZvmo*7hY8x$a7s ztN#vx|BJZ1BOF(Fj^ZHsbVUHnjf`MJY8a~+QJ^FPVr1QtoZpQ~J<2Zd`59rXMaj%5 z|JCSrNKDi_CQC$i=3p@7Yn*SteCDnMuw-PdP$B zRfW!C=!xQ4vL(7_+wBiy^UT~ygi29IQGWA#nYw&Hs-&c*LfaK<qdSu%a6p8tB_<&*MbTnbpVk;_ z{jPuma&sAdW_dHoHD>9TqKvXy9bHAK=|at-ZVxMDXVnY|x7WJsyczWl0FvMX;=9wd0`0CvXS#&3SYoghld2>Gc)n&T%P_CuMulma5zuQ@(VW32D?MqA z{y_e$6z81Zy__ca5zgPZ_4Vu9!O%L}3=1 z>>U;Iq9Fn;)}DOQ>3rO-y zpC!?ied?TyY*8)*CqRyfA6_pcg1HAb#9N^$k_;}zdb}5UO{d*bH1p#hyylp3y$UZA z$eX3)mQTbP%()p{MDve7eGYEf&2#Pwxn3FhEcYTT>nKdD*LCq#ycDHzgfy+Z56+9b<=Xzp(a8S2 zd#hm%6T4?m95m-b&7-duO$(K;V$z(v-E_2U!s6F7DD1I0aI3F-i~du^2GkX3e)J5AOuGDln8 zs$$2R9EED*QA5+I2v~@OMXF2Zpc=BKJtwaKk9F5X(N zN`;x94Uij#H9lK)Grsdjm@4Cxxtx*Ad1l)E?un3PVwsB{UqvI$I7%ft$yG#A9fq_^ z?K{u>#&Il*H$F4lWlnbUKld9C2ip>Mn$sQ(gnV(FFQH`z?-9|defaQvIFSDrg*s(} zbsDkAbOL2SklK4Mi|P!EesXS;T(*aGa5A#F`$(7Zf@>fLz?xITcujU15-jF#i1&&Q4r^bMEv2($4EDT^Cu%V#skST*b#FX%xy|OBR}9Lr zbQVpU{~_hiAnI(#W+x<*j+^}ilbMC|hCP-BticBb_icYt^!m?<4QGwByv76((3~zbD$nKeI^US3Yoq*0$QFga%n z`pn84k+?$kBoRjNl@~YnQEE%8hfu|@O_m`RD#rY^_uT`7n=}DxY@cCvi+K?(HF-W2 z?Qoyg$JaZ4P0N-t@*;0*h)DBp`eJuNz>v`nHtLSIoj<9AGlMO5IfHMi^|1Lb$VZQ8 zfy&#hc&w;UnqiHhU?yLagz7x~1G$XyU_%$mi$#$CwBG3lmZrr#eL{{UgI(WuL8(kn z4ognNORQcL_rUF{p=k%Uw!t%yDdWkF7N@zXAOUd;l{d}`ib^1VFz1b#XM7Zx!< z^a&G(jJw1~7~!+=W!}gH0&T4k&qdToduUu`IPaqA*ht&Ckqh!nkk-|l5FpjyfX`i` zwBa8CB1m7E?`%Z06z)|`E1WwPWs-VBoI^6!40-O} zvJT(-$$IK};Hz|!y4cV~T9Y#mft{biE+FZ7KN(0`K4sov4JZDoMG5 zgGj==E1Z5nq19py1tLg3>A5zw#?Ugpm}ij8J}SH<>TO3~sjb=}{p?)&2R1aFa2&uc z=|+dQQ&RE_zKc#O(U~riQAe)#pnAR(Z<&}9-(aPCSJ&q}N(J0! zsQ1C^qe67UE6T1zf!)s~9NCUfQz2x0#I>r3qj{vHID;%6PDcNJl(>z64cmG;Hu0I> z5l-hiVM02o%eJ{wu_8FhkC!RGDT?`QRRL8s{c5(~2a0=$@UVN(^J4OJu}y1FI=|n9 zuX*O30jyigtcDYf$N9U1>9C?OmbY`kTl{x8+AvRVmbQjvzT|x0?zKd(^9L z7WLG8!5s@gDxjzKxt@Zw+;$rN!A?I1bX`NWg5xf=a}|wvg(myv-R^O=zM)%DX1OG5 zTx5IqWDbk6?e|6c!ye~Tni9H+QB$T^vkxd5G*_Zd?lIk$PsJVXg($}ZDF&l`=9X>3 z5$JOSpTE~Z6DKR~oUo_hh7NK-{=I{zOko{(l*YGpz!=*&HREvF?T~vvm_{&On)*iA zJ|u)95zBiH92H|PSNHpk@b&UJr(vvc4p)6QiTV&@Ag{?OYxHt5jIu?sa#fYLZ068Z z=64W{v0esue}$nQ&2`}|u1U5F+rc+y;lRnysLyZ*1~%)}p$AbCi)L2m*q)JrW`&x! zNqc~WM?+0|R)a%UuHmrUck5m8tOI#ZBI(wzt6b|3FODw#)2GJt6Sh%=ce814^wCIl zDA!XwSy?%uXE~*9-@UYN9U?`c;-n$3-!7%i@8=2E1@QTMZ<)uC+d5NrKD~yELq01~ zEx!Z)d^A_$xEGIQcu8Y_t8iczF!gJ3oJ^cfI-Kd;G7jbKG+cR7!m7B(T-?kQ|Ksyd zld}nvp%3q(yzZPt0J=|{i0`)~GCbP7+q9WV4w>nMu7Y^MDyTH8>Q2v`C^TzgX|ypJ z5``Jby>a?tqYFz8a_u5*qWd*))o^bu8P1rcjtpZ>^<3}(phnZ*r;O96kNUtD8Z^{r z=JyD2C?CY+eWBA!pNY1H0-Cxb%&h0yOFsudgqysB`+z4?jJq~jk#O>dvN8kpLN*CG zl+W|8H4jT{n_l(nn5uGtZ2mIz{cii>hMZ{>QT>$%4SFhB8W(|l4Q(k}fnZA>+>4%A z&@Pu3$gs}=Qk1oJy0_Kh_YFYNcd^6$%esRjs$(y%=h>L7 zrhx5CrM1ttFG+;Y3vC%mss^!t+80uFPja4E4vc}o(gy_e$?=@>OHWftJ+=HVm6OLNbANVqD2iC8EQf)9Uu~S{w$8wLP+I-rI zEWM&NnI=Yd0T0Ew&3UtZBmB8yQ&ms4&jIMmze6_gU9&= zoA~^J=&YTOobeDjl}N^=^2YTdEd{h!X}6L0+BeO~v<)iv7OX?RE3Z}Yn(@W)l)5r1 zF#itoVIIa;d-CNumb@Fe_v?5yHF!g}qz>%Jg%b`f$UD8R`o+@@RmHVl=+fvMk#)uA zVrL~P-s0mLTt0DskMMk?7?M$N^F#irGwVnn{ycEJnNFHN;FS|? zadL9{V2wmwTF+HP_Mqjb_4;3!cd%(qg`-p*IAfxoUVLu@T0b2JKEYP{)h8DAG%fTb z7Xzqs^Zd79Ti6QCo&aCUS1UfqB5`rc!vkCX*D+t;9|WhML^d9-$IK3dB%^Q`;W0+h zxRA`uOJ~J_ZB>YgkhC629cs_*JFJjcbgU@J>o;b(m)S4IER$MhFyeLZJDCd}%+lUT zmjff439YfdgMas&RSP_+BFUuOyoHt2FO#>9(K`8^B8&m*dmuBGyDXaZHK)t#i&;+Q z?@;dJA>-M#Uph5I4#(_ChOvHGyAg1@KM-q#u3yOwi=GeJeD~fZkK;r9LmQ7y?>3+UFe~Pan)OH z8GwtUKC#+67euTi?F3m90wGHseBTX?L8CqhKL7BF`VIvFr(c`b(_x07C7MxTj@#^i zI8XUiuI>j49J6^VQn;>Hrm(A8QU)x4e7wtK!z*vJMH{u8`=cnBaF75mIDTZdQ`v>Y z>~uU|Mq9|*Tc+QzKxFNk#VS`C_ZWBIi1wk@13$Wl9~}I5BdUJi?zJnb}|s_%wsM zrwP)-L2QRB7Q2Dp-%aIh-NJNTzuD*Z;E(=}u;SGYE{Ybe>eX1y*4ilA3@k+|R0v$kKj}NqYRaPLmr{q-U-5ukfNTnT;ugTtJ4tzhR_+d;NR-Ssi@e zUnob=D9rp->Aybuk^x{>ekqw){s(j=^l=36LLa|i^IxEa{{a@Tj;H}@%vNrB)&D9= z9(eKas+RjdKzsnw<}D`x)^Q)zo%vryu>vos@g6k&69x00uqF+)XiANh6=y@UA(>^JP&!qZp(S&1` z2a^RyG2OcR`27{UtN4WEz)f4(dp@_O%=u^kesm>36E9~wem0uoZy%cKwiV1C?2G*C zZ-4wh{btE&zP**2?1{kNACn*9Lkh?6|NiLX^-qmEyK=8.0.0 +pytest-xdist>=3.5.0 +pytest-timeout>=2.3.0 + +# AWS SDK for DynamoDB operations +boto3>=1.34.0 +botocore>=1.34.0 + +# Python utilities for data handling +urllib3>=2.0.0 diff --git a/python-test-samples/dynamodb-crud-cli-local/tests/unit/src/test_dynamodb_local.py b/python-test-samples/dynamodb-crud-cli-local/tests/unit/src/test_dynamodb_local.py new file mode 100644 index 00000000..72c526ac --- /dev/null +++ b/python-test-samples/dynamodb-crud-cli-local/tests/unit/src/test_dynamodb_local.py @@ -0,0 +1,721 @@ +import pytest +import boto3 +import json +import time +import socket +import threading +import queue +import os +from datetime import datetime +from botocore.exceptions import ClientError, NoCredentialsError +from decimal import Decimal + + +@pytest.fixture(scope="session") +def dynamodb_container(): + """ + Fixture to verify DynamoDB Local container is running. + This fixture assumes the container is already started externally. + """ + # Check if DynamoDB Local is running on port 8000 + def is_port_open(host, port): + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(5) + result = s.connect_ex((host, port)) + return result == 0 + except: + return False + + if not is_port_open("127.0.0.1", 8000): + pytest.skip("DynamoDB Local is not running on port 8000. Please start with 'docker run --rm -d --name dynamodb-local --network host -p 8000:8000 amazon/dynamodb-local'") + + print("DynamoDB Local is running on port 8000") + yield "http://127.0.0.1:8000" + + +@pytest.fixture(scope="session") +def dynamodb_client(): + """ + Fixture to create a DynamoDB client for local testing. + """ + return boto3.client( + 'dynamodb', + endpoint_url="http://127.0.0.1:8000", + region_name='us-east-1', + aws_access_key_id='dummy', + aws_secret_access_key='dummy' + ) + + +@pytest.fixture(scope="session") +def dynamodb_resource(): + """ + Fixture to create a DynamoDB resource for higher-level operations. + """ + return boto3.resource( + 'dynamodb', + endpoint_url="http://127.0.0.1:8000", + region_name='us-east-1', + aws_access_key_id='dummy', + aws_secret_access_key='dummy' + ) + + +@pytest.fixture(scope="function") +def test_table(dynamodb_client, dynamodb_resource): + """ + Fixture to create and manage a test table for each test function. + """ + table_name = "CRUDLocalTable" + + # Create table + try: + dynamodb_client.create_table( + TableName=table_name, + AttributeDefinitions=[ + { + 'AttributeName': 'Id', + 'AttributeType': 'S' + } + ], + KeySchema=[ + { + 'AttributeName': 'Id', + 'KeyType': 'HASH' + } + ], + BillingMode='PAY_PER_REQUEST' + ) + + # Wait for table to be active + waiter = dynamodb_client.get_waiter('table_exists') + waiter.wait(TableName=table_name, WaiterConfig={'Delay': 1, 'MaxAttempts': 10}) + + except ClientError as e: + if e.response['Error']['Code'] != 'ResourceInUseException': + raise + + # Get table resource + table = dynamodb_resource.Table(table_name) + + yield table + + # Cleanup: Delete table after test + try: + table.delete() + waiter = dynamodb_client.get_waiter('table_not_exists') + waiter.wait(TableName=table_name, WaiterConfig={'Delay': 1, 'MaxAttempts': 10}) + except ClientError: + pass # Table might already be deleted + + +@pytest.fixture(scope="session") +def health_check(dynamodb_container, dynamodb_client): + """ + Fixture to perform initial health check of DynamoDB Local. + """ + try: + # Try to list tables to verify connection + response = dynamodb_client.list_tables() + + print("DynamoDB Local health check passed") + return True + + except Exception as e: + pytest.fail(f"DynamoDB Local health check failed: {str(e)}") + + +def test_table_creation_and_setup(dynamodb_client, test_table, health_check): + """ + Test DynamoDB table creation and basic setup. + Validates table schema, status, and initial state. + """ + table_name = test_table.table_name + + # Describe the table to get detailed information + response = dynamodb_client.describe_table(TableName=table_name) + table_description = response['Table'] + + # Validate table properties + assert table_description['TableName'] == table_name + assert table_description['TableStatus'] == 'ACTIVE' + assert table_description['KeySchema'][0]['AttributeName'] == 'Id' + assert table_description['KeySchema'][0]['KeyType'] == 'HASH' + + # Validate billing mode + assert 'BillingModeSummary' in table_description + assert table_description['BillingModeSummary']['BillingMode'] == 'PAY_PER_REQUEST' + + # Check initial item count + scan_response = dynamodb_client.scan(TableName=table_name) + initial_count = scan_response['Count'] + + assert initial_count == 0, f"New table should be empty, found {initial_count} items" + + print(f"Table '{table_name}' created successfully") + print(f"Table status: {table_description['TableStatus']}, Item count: {initial_count}") + + +def test_create_item_operation(dynamodb_client, test_table, health_check): + """ + Test DynamoDB PUT item operation. + Validates item creation with proper attributes and capacity consumption. + """ + table_name = test_table.table_name + + # Test item data + test_item = { + 'Id': {'S': '123'}, + 'name': {'S': 'Batman'} + } + + # Create item with capacity tracking + response = dynamodb_client.put_item( + TableName=table_name, + Item=test_item, + ReturnConsumedCapacity='TOTAL', + ReturnItemCollectionMetrics='SIZE' + ) + + # Validate response + assert 'ConsumedCapacity' in response + consumed_capacity = response['ConsumedCapacity']['CapacityUnits'] + assert consumed_capacity > 0, "PUT operation should consume capacity" + + # Verify item was created by retrieving it + get_response = dynamodb_client.get_item( + TableName=table_name, + Key={'Id': {'S': '123'}} + ) + + assert 'Item' in get_response, "Created item should be retrievable" + retrieved_item = get_response['Item'] + assert retrieved_item['Id']['S'] == '123' + assert retrieved_item['name']['S'] == 'Batman' + + print(f"Item created successfully: {{'Id': '123', 'name': 'Batman'}}") + print(f"Create operation consumed capacity: {consumed_capacity} units") + + +def test_read_operations_scan_and_get(dynamodb_client, test_table, health_check): + """ + Test DynamoDB read operations: SCAN and GET. + Validates item retrieval using different access patterns. + """ + table_name = test_table.table_name + + # First, create test items + test_items = [ + {'Id': {'S': '123'}, 'name': {'S': 'Batman'}}, + {'Id': {'S': '456'}, 'name': {'S': 'Superman'}}, + {'Id': {'S': '789'}, 'name': {'S': 'Wonder Woman'}} + ] + + for item in test_items: + dynamodb_client.put_item(TableName=table_name, Item=item) + + # Test SCAN operation + scan_response = dynamodb_client.scan(TableName=table_name) + + assert scan_response['Count'] == len(test_items), f"Scan should return {len(test_items)} items" + assert scan_response['ScannedCount'] == len(test_items), "Scanned count should match item count" + + # Validate scan results + scanned_ids = {item['Id']['S'] for item in scan_response['Items']} + expected_ids = {'123', '456', '789'} + assert scanned_ids == expected_ids, "Scan should return all created items" + + # Test GET operation for specific item + get_response = dynamodb_client.get_item( + TableName=table_name, + Key={'Id': {'S': '123'}} + ) + + assert 'Item' in get_response, "GET operation should return the requested item" + retrieved_item = get_response['Item'] + assert retrieved_item['Id']['S'] == '123' + assert retrieved_item['name']['S'] == 'Batman' + + # Test GET for non-existent item + get_missing_response = dynamodb_client.get_item( + TableName=table_name, + Key={'Id': {'S': '999'}} + ) + + assert 'Item' not in get_missing_response, "GET for non-existent item should return empty" + + print(f"Scan operation found {scan_response['Count']} items") + print(f"Get operation retrieved: {{'Id': '123', 'name': 'Batman'}}") + print("Read operations completed successfully") + + +def test_update_item_operation(dynamodb_client, test_table, health_check): + """ + Test DynamoDB UPDATE item operation. + Validates item updates with SET expressions and attribute handling. + """ + table_name = test_table.table_name + + # Create initial item + initial_item = { + 'Id': {'S': '123'}, + 'name': {'S': 'Batman'} + } + + dynamodb_client.put_item(TableName=table_name, Item=initial_item) + + # Update item with new attributes + update_response = dynamodb_client.update_item( + TableName=table_name, + Key={'Id': {'S': '123'}}, + UpdateExpression='SET #name = :n, age = :a', + ExpressionAttributeNames={'#name': 'name'}, + ExpressionAttributeValues={ + ':n': {'S': 'Robin'}, + ':a': {'N': '35'} + }, + ReturnValues='ALL_NEW', + ReturnConsumedCapacity='TOTAL' + ) + + # Validate update response + assert 'Attributes' in update_response, "Update should return updated attributes" + updated_attributes = update_response['Attributes'] + + assert updated_attributes['Id']['S'] == '123' + assert updated_attributes['name']['S'] == 'Robin' + assert updated_attributes['age']['N'] == '35' + + # Validate capacity consumption + consumed_capacity = update_response['ConsumedCapacity']['CapacityUnits'] + assert consumed_capacity > 0, "Update operation should consume capacity" + + # Verify update by retrieving item + get_response = dynamodb_client.get_item( + TableName=table_name, + Key={'Id': {'S': '123'}} + ) + + retrieved_item = get_response['Item'] + assert retrieved_item['name']['S'] == 'Robin', "Name should be updated to Robin" + assert retrieved_item['age']['N'] == '35', "Age should be set to 35" + + print(f"Item updated successfully: {{'Id': '123', 'name': 'Robin', 'age': 35}}") + print(f"Update operation consumed capacity: {consumed_capacity} units") + + +def test_delete_item_operation(dynamodb_client, test_table, health_check): + """ + Test DynamoDB DELETE item operation. + Validates item deletion and capacity consumption. + """ + table_name = test_table.table_name + + # Create item to delete + test_item = { + 'Id': {'S': '123'}, + 'name': {'S': 'Batman'}, + 'age': {'N': '35'} + } + + dynamodb_client.put_item(TableName=table_name, Item=test_item) + + # Verify item exists before deletion + get_response = dynamodb_client.get_item( + TableName=table_name, + Key={'Id': {'S': '123'}} + ) + assert 'Item' in get_response, "Item should exist before deletion" + + # Delete item + delete_response = dynamodb_client.delete_item( + TableName=table_name, + Key={'Id': {'S': '123'}}, + ReturnConsumedCapacity='TOTAL', + ReturnValues='ALL_OLD' + ) + + # Validate delete response + assert 'ConsumedCapacity' in delete_response + consumed_capacity = delete_response['ConsumedCapacity']['CapacityUnits'] + assert consumed_capacity > 0, "Delete operation should consume capacity" + + # Validate returned attributes (before deletion) + if 'Attributes' in delete_response: + deleted_attributes = delete_response['Attributes'] + assert deleted_attributes['Id']['S'] == '123' + assert deleted_attributes['name']['S'] == 'Batman' + + # Verify item no longer exists + get_after_delete = dynamodb_client.get_item( + TableName=table_name, + Key={'Id': {'S': '123'}} + ) + assert 'Item' not in get_after_delete, "Item should not exist after deletion" + + print("Item deleted successfully") + print(f"Delete operation consumed capacity: {consumed_capacity} units") + + +def test_crud_full_workflow(dynamodb_client, test_table, health_check): + """ + Test complete CRUD workflow in sequence. + Validates the entire lifecycle from creation to cleanup. + """ + table_name = test_table.table_name + + # Step 1: CREATE - Add multiple items + test_items = [ + {'Id': {'S': '100'}, 'name': {'S': 'Clark Kent'}, 'role': {'S': 'Reporter'}}, + {'Id': {'S': '200'}, 'name': {'S': 'Bruce Wayne'}, 'role': {'S': 'CEO'}}, + {'Id': {'S': '300'}, 'name': {'S': 'Diana Prince'}, 'role': {'S': 'Ambassador'}} + ] + + created_items = 0 + for item in test_items: + response = dynamodb_client.put_item(TableName=table_name, Item=item) + created_items += 1 + + assert created_items == len(test_items), f"Should create {len(test_items)} items" + + # Step 2: READ - Verify all items exist + scan_response = dynamodb_client.scan(TableName=table_name) + assert scan_response['Count'] == len(test_items), "All items should be readable" + + # Step 3: UPDATE - Modify one item + update_response = dynamodb_client.update_item( + TableName=table_name, + Key={'Id': {'S': '100'}}, + UpdateExpression='SET #role = :r, age = :a', + ExpressionAttributeNames={'#role': 'role'}, + ExpressionAttributeValues={ + ':r': {'S': 'Superhero'}, + ':a': {'N': '30'} + }, + ReturnValues='ALL_NEW' + ) + + updated_attributes = update_response['Attributes'] + assert updated_attributes['role']['S'] == 'Superhero', "Role should be updated" + assert updated_attributes['age']['N'] == '30', "Age should be added" + + # Step 4: DELETE - Remove items + deleted_items = 0 + for item in test_items: + dynamodb_client.delete_item( + TableName=table_name, + Key={'Id': item['Id']} + ) + deleted_items += 1 + + # Step 5: VERIFY - Confirm cleanup + final_scan = dynamodb_client.scan(TableName=table_name) + assert final_scan['Count'] == 0, "Table should be empty after cleanup" + + print("Full CRUD workflow completed successfully") + print(f"Created: {created_items}, Updated: 1, Deleted: {deleted_items}") + print("Final verification: Table is empty after cleanup") + + +def test_concurrent_operations(dynamodb_client, test_table, health_check): + """ + Test concurrent DynamoDB operations to validate thread safety. + """ + table_name = test_table.table_name + results = queue.Queue() + num_threads = 10 + + def perform_crud_operation(thread_id): + """Helper function for concurrent CRUD operations""" + try: + start_time = time.time() + + # Create item + item_id = f"thread-{thread_id}" + create_response = dynamodb_client.put_item( + TableName=table_name, + Item={ + 'Id': {'S': item_id}, + 'name': {'S': f'User {thread_id}'}, + 'thread_id': {'N': str(thread_id)} + } + ) + + # Read item + get_response = dynamodb_client.get_item( + TableName=table_name, + Key={'Id': {'S': item_id}} + ) + + # Update item + update_response = dynamodb_client.update_item( + TableName=table_name, + Key={'Id': {'S': item_id}}, + UpdateExpression='SET #name = :n', + ExpressionAttributeNames={'#name': 'name'}, + ExpressionAttributeValues={':n': {'S': f'Updated User {thread_id}'}}, + ReturnValues='ALL_NEW' + ) + + # Delete item + delete_response = dynamodb_client.delete_item( + TableName=table_name, + Key={'Id': {'S': item_id}} + ) + + end_time = time.time() + operation_time = int((end_time - start_time) * 1000) + + results.put({ + 'thread_id': thread_id, + 'success': True, + 'operation_time': operation_time, + 'created': 'Item' in get_response, + 'updated': 'Attributes' in update_response + }) + + except Exception as e: + results.put({ + 'thread_id': thread_id, + 'success': False, + 'error': str(e), + 'operation_time': 0 + }) + + # Start concurrent threads + threads = [] + for i in range(num_threads): + thread = threading.Thread(target=perform_crud_operation, args=(i,)) + threads.append(thread) + thread.start() + + # Wait for all threads to complete + for thread in threads: + thread.join(timeout=30) + + # Analyze results + successful_operations = 0 + total_operation_time = 0 + + while not results.empty(): + result = results.get() + if result['success']: + successful_operations += 1 + total_operation_time += result['operation_time'] + else: + print(f"Thread {result['thread_id']} failed: {result.get('error', 'Unknown error')}") + + success_rate = successful_operations / num_threads * 100 + avg_operation_time = total_operation_time / successful_operations if successful_operations > 0 else 0 + + # Validate concurrent performance + assert success_rate >= 90, f"Concurrent operation success rate too low: {success_rate}%" + assert successful_operations >= num_threads - 2, f"Too many failed concurrent operations" + + # Verify table is clean after concurrent operations + final_scan = dynamodb_client.scan(TableName=table_name) + remaining_items = final_scan['Count'] + assert remaining_items == 0, f"Table should be clean after concurrent operations, found {remaining_items} items" + + print("Concurrent operations test passed") + print(f"Results: Success_Rate={success_rate}%, Avg_Operation_Time={int(avg_operation_time)}ms, Successful_Operations={successful_operations}/{num_threads}") + + +def test_performance_and_capacity(dynamodb_client, test_table, health_check): + """ + Test DynamoDB performance metrics and capacity consumption tracking. + """ + table_name = test_table.table_name + + # Perform multiple operations to measure performance + num_operations = 20 + operation_times = [] + total_consumed_capacity = 0 + + for i in range(num_operations): + start_time = time.time() + + # Create item + create_response = dynamodb_client.put_item( + TableName=table_name, + Item={ + 'Id': {'S': f'perf-test-{i}'}, + 'name': {'S': f'Performance Test Item {i}'}, + 'index': {'N': str(i)}, + 'timestamp': {'S': datetime.now().isoformat()} + }, + ReturnConsumedCapacity='TOTAL' + ) + + end_time = time.time() + operation_time = int((end_time - start_time) * 1000) + operation_times.append(operation_time) + + # Track capacity consumption + if 'ConsumedCapacity' in create_response: + total_consumed_capacity += create_response['ConsumedCapacity']['CapacityUnits'] + + # Analyze performance metrics + avg_operation_time = sum(operation_times) / len(operation_times) + min_operation_time = min(operation_times) + max_operation_time = max(operation_times) + + # Performance assertions + assert avg_operation_time < 100, f"Average operation time too slow: {avg_operation_time}ms" + assert min_operation_time < 50, f"Minimum operation time too slow: {min_operation_time}ms" + assert total_consumed_capacity > 0, "Operations should consume capacity" + + # Verify all items were created + scan_response = dynamodb_client.scan(TableName=table_name) + assert scan_response['Count'] == num_operations, f"Should have {num_operations} items" + + # Test batch operations for comparison + batch_start = time.time() + + # Clean up items for batch delete test + with test_table.batch_writer() as batch: + for i in range(num_operations): + batch.delete_item(Key={'Id': f'perf-test-{i}'}) + + batch_end = time.time() + batch_time = int((batch_end - batch_start) * 1000) + + # Verify cleanup + final_scan = dynamodb_client.scan(TableName=table_name) + assert final_scan['Count'] == 0, "Table should be empty after batch cleanup" + + print(f"Performance test completed: avg={int(avg_operation_time)}ms, operations={num_operations}, total_capacity={total_consumed_capacity} units") + print(f"Batch operation time: {batch_time}ms for {num_operations} deletes") + + +def test_error_handling_and_edge_cases(dynamodb_client, test_table, health_check): + """ + Test error handling scenarios and edge cases. + """ + table_name = test_table.table_name + + # Test 1: Conditional PUT that should fail + # First create an item + dynamodb_client.put_item( + TableName=table_name, + Item={'Id': {'S': 'conditional-test'}, 'name': {'S': 'Original'}} + ) + + # Try to create same item with condition that it doesn't exist + with pytest.raises(ClientError) as exc_info: + dynamodb_client.put_item( + TableName=table_name, + Item={'Id': {'S': 'conditional-test'}, 'name': {'S': 'Duplicate'}}, + ConditionExpression='attribute_not_exists(Id)' + ) + + assert exc_info.value.response['Error']['Code'] == 'ConditionalCheckFailedException' + + # Test 2: Update non-existent item with condition + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=table_name, + Key={'Id': {'S': 'non-existent'}}, + UpdateExpression='SET #name = :n', + ConditionExpression='attribute_exists(Id)', + ExpressionAttributeNames={'#name': 'name'}, + ExpressionAttributeValues={':n': {'S': 'Should Fail'}} + ) + + assert exc_info.value.response['Error']['Code'] == 'ConditionalCheckFailedException' + + # Test 3: Invalid table name + with pytest.raises(ClientError) as exc_info: + dynamodb_client.get_item( + TableName='NonExistentTable', + Key={'Id': {'S': 'test'}} + ) + + assert exc_info.value.response['Error']['Code'] == 'ResourceNotFoundException' + + # Test 4: Malformed key + with pytest.raises(ClientError) as exc_info: + dynamodb_client.get_item( + TableName=table_name, + Key={'WrongKey': {'S': 'test'}} + ) + + assert exc_info.value.response['Error']['Code'] == 'ValidationException' + + print("Error handling test passed - all expected errors were properly raised") + + +def test_data_types_and_attributes(dynamodb_client, test_table, health_check): + """ + Test various DynamoDB data types and attribute handling. + """ + table_name = test_table.table_name + + # Create item with various data types + complex_item = { + 'Id': {'S': 'data-types-test'}, + 'string_attr': {'S': 'Hello World'}, + 'number_attr': {'N': '42'}, + 'binary_attr': {'B': b'binary data'}, + 'boolean_attr': {'BOOL': True}, + 'null_attr': {'NULL': True}, + 'list_attr': {'L': [ + {'S': 'item1'}, + {'N': '123'}, + {'BOOL': False} + ]}, + 'map_attr': {'M': { + 'nested_string': {'S': 'nested value'}, + 'nested_number': {'N': '99'} + }}, + 'string_set': {'SS': ['value1', 'value2', 'value3']}, + 'number_set': {'NS': ['1', '2', '3']}, + 'decimal_attr': {'N': '123.456'} + } + + # Put item with all data types + put_response = dynamodb_client.put_item( + TableName=table_name, + Item=complex_item, + ReturnConsumedCapacity='TOTAL' + ) + + assert 'ConsumedCapacity' in put_response + + # Retrieve and validate item + get_response = dynamodb_client.get_item( + TableName=table_name, + Key={'Id': {'S': 'data-types-test'}} + ) + + assert 'Item' in get_response + retrieved_item = get_response['Item'] + + # Validate each data type + assert retrieved_item['string_attr']['S'] == 'Hello World' + assert retrieved_item['number_attr']['N'] == '42' + assert retrieved_item['boolean_attr']['BOOL'] == True + assert retrieved_item['null_attr']['NULL'] == True + assert len(retrieved_item['list_attr']['L']) == 3 + assert 'nested_string' in retrieved_item['map_attr']['M'] + assert len(retrieved_item['string_set']['SS']) == 3 + assert len(retrieved_item['number_set']['NS']) == 3 + + # Test attribute updates + update_response = dynamodb_client.update_item( + TableName=table_name, + Key={'Id': {'S': 'data-types-test'}}, + UpdateExpression='SET string_attr = :s ADD number_attr :inc', + ExpressionAttributeValues={ + ':s': {'S': 'Updated String'}, + ':inc': {'N': '10'} + }, + ReturnValues='ALL_NEW' + ) + + updated_item = update_response['Attributes'] + assert updated_item['string_attr']['S'] == 'Updated String' + assert updated_item['number_attr']['N'] == '52' # 42 + 10 + + print("Data types and attributes test passed - all DynamoDB data types handled correctly") \ No newline at end of file