├── .github ├── scripts │ └── release.py └── workflows │ ├── publish.yml │ ├── release.yml │ └── testing_linting.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── __init__.py ├── __tests__ ├── __init__.py ├── test_case_converter.py ├── test_http_event.py ├── test_http_response.py ├── test_jwt.py ├── test_logger.py ├── test_mappers.py ├── test_password.py └── test_validators.py ├── py_aws_lambda_toolkit ├── __init__.py ├── case_converter.py ├── dynamodb_shortcuts.py ├── http_event.py ├── http_response.py ├── jwt.py ├── logger.py ├── mappers.py ├── password.py ├── scan_filter_builder.py ├── settings.py ├── status.py └── validators.py ├── pyproject.toml ├── requirements.txt └── setup.cfg /.github/scripts/release.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # https://www.seanh.cc/2022/05/21/publishing-python-packages-from-github-actions/ 3 | import json 4 | import subprocess 5 | 6 | 7 | def get_last_version() -> str: 8 | """Return the version number of the last release.""" 9 | json_string = ( 10 | subprocess.run( 11 | ["gh", "release", "view", "--json", "tagName"], 12 | check=True, 13 | stdout=subprocess.PIPE, 14 | stderr=subprocess.PIPE, 15 | ) 16 | .stdout.decode("utf8") 17 | .strip() 18 | ) 19 | 20 | return json.loads(json_string)["tagName"] 21 | 22 | 23 | def bump_patch_number(version_number: str) -> str: 24 | """Return a copy of `version_number` with the patch number incremented.""" 25 | major, minor, patch = version_number.split(".") 26 | return f"{major}.{minor}.{int(patch) + 1}" 27 | 28 | 29 | def create_new_patch_release(): 30 | """Create a new patch release on GitHub.""" 31 | try: 32 | last_version_number = get_last_version() 33 | except subprocess.CalledProcessError as err: 34 | if err.stderr.decode("utf8").startswith("HTTP 404:"): 35 | # The project doesn't have any releases yet. 36 | new_version_number = "0.0.1" 37 | else: 38 | raise 39 | else: 40 | new_version_number = bump_patch_number(last_version_number) 41 | 42 | subprocess.run( 43 | ["gh", "release", "create", "--generate-notes", new_version_number], 44 | check=True, 45 | ) 46 | 47 | 48 | if __name__ == "__main__": 49 | create_new_patch_release() 50 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI.org 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | pypi-publish: 7 | name: Upload release to PyPI 8 | runs-on: ubuntu-latest 9 | env: 10 | name: pypi-publish 11 | url: https://pypi.org/p/py-aws-lambda-toolkit 12 | permissions: 13 | id-token: write 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | - name: Update version 20 | run: | 21 | sed -i "s/version = .*/version = ${{ github.event.release.tag_name }}/g" setup.cfg 22 | - run: python3 -m pip install --upgrade build && python3 -m build 23 | - name: Publish package 24 | uses: pypa/gh-action-pypi-publish@release/v1 25 | with: 26 | password: ${{ secrets.PYPI_API_TOKEN }} 27 | skip-existing: true 28 | verbose: true 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create a new patch release 2 | on: workflow_dispatch 3 | jobs: 4 | github: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout 8 | uses: actions/checkout@v3 9 | - name: Create new patch release 10 | run: .github/scripts/release.py 11 | env: 12 | GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 13 | -------------------------------------------------------------------------------- /.github/workflows/testing_linting.yml: -------------------------------------------------------------------------------- 1 | name: Automated Testing and Linting 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | release: 11 | types: [published] 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ['3.10'] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install flake8 pytest 30 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 31 | - name: Lint with ruff 32 | run: | 33 | # stop the build if there are Python syntax errors or undefined names 34 | ruff --format=github --select=E9,F63,F7,F82 --target-version=py37 . 35 | # default set of ruff rules with GitHub Annotations 36 | ruff --format=github --target-version=py37 . 37 | - name: Test with unittest 38 | run: | 39 | python -m unittest discover 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Local development settings 7 | .env 8 | .venv/ 9 | *.env 10 | 11 | # Distribution / packaging 12 | dist/ 13 | build/ 14 | *.egg-info/ 15 | *.egg 16 | .ruff_cache/ 17 | 18 | # IDE files 19 | .vscode/ 20 | .idea/ 21 | 22 | # Logs and databases 23 | *.log 24 | *.sqlite3 25 | 26 | # Other 27 | *.pyc 28 | .DS_Store 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Julio Flores 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include py_aws_lambda_toolkit/*.py -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Py-AWS-Lambda-Toolkit 2 | 3 | This lightweight Python toolkit streamlines the creation of AWS Lambda functions using the Serverless Framework. It offers a range of features to enhance your development process: 4 | 5 | - **DynamoDB**: shortcuts: Avoid boilerplate code for DynamoDB operations. 6 | - **HTTP**: event processing: Get the event of the HTTP request and parse it, splitting it into the path, query string, body, headers, etc. 7 | - **HTTP**: response shortcuts: Create HTTP responses with the correct format and status code. 8 | - **JWT**: authentication: Create and verify JWT tokens as easily as possible. 9 | - **Logger**: Log messages with a format that is easy to read. 10 | - **Mappers**: Remove specified fields from a dictionary or list of dictionaries. 11 | - **Parser**: Convert dictionary keys to snake case or camel case. 12 | - **Password**: hashing: Hash passwords with salt and verify them. 13 | - **DynamoDB**: scan builder: Build a scan query with specified filters. 14 | - **Validator**: Validate a dictionary with specified rules. 15 | 16 | ## Installation 17 | 18 | **Attention**: This package is currently undergoing maintenance. To test the package's modules, please access them directly from this repository. 19 | 20 | Install the package with pip: 21 | 22 | ```bash 23 | pip install py-aws-lambda-toolkit 24 | ``` 25 | 26 | ## Usage 27 | 28 | Use the package in your code: 29 | 30 | ```python 31 | import logging 32 | from py_aws_lambda_toolkit.http_event import process_event 33 | from py_aws_lambda_toolkit.http_response import create_response 34 | from py_aws_lambda_toolkit.status import StatusCode 35 | from py_aws_lambda_toolkit.logger import logging 36 | 37 | status = StatusCode() 38 | 39 | def handler(event, context): 40 | # Process the event 41 | event_data = process_event(event) 42 | event_body = event_data.get("body", {}) 43 | 44 | logging.info("Event body: %s", event_body) 45 | 46 | # Create a response 47 | response = create_response( 48 | { "ok": True, "message": "Processed event successfully" }, 49 | status=status.code_200_success, 50 | ) 51 | 52 | return response 53 | 54 | ``` 55 | 56 | ## Contributing 57 | 58 | Contributions are welcome! For bug reports or requests please [submit an issue](https://github.com/0riion/py-aws-lambda-toolkit/issues). For code contributions please create a pull request. 59 | 60 | ## License 61 | 62 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 63 | 64 | ## Further Reading 65 | 66 | This project is in an starting phase. More documentation will be added soon and the project will be improved with more features. 67 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GA-20/py-aws-lambda-toolkit/fd713129741a5dfd46f6a80b9bdbd50da58119dc/__init__.py -------------------------------------------------------------------------------- /__tests__/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GA-20/py-aws-lambda-toolkit/fd713129741a5dfd46f6a80b9bdbd50da58119dc/__tests__/__init__.py -------------------------------------------------------------------------------- /__tests__/test_case_converter.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from py_aws_lambda_toolkit.case_converter import CaseConverter 3 | 4 | 5 | class TestParsers(unittest.TestCase): 6 | case_converter = CaseConverter() 7 | 8 | def test_recursive_to_camel(self): 9 | data = { 10 | 'test_key': 'test_value', 11 | 'test_list': [ 12 | { 13 | 'test_key': 'test_value' 14 | } 15 | ] 16 | } 17 | expected = { 18 | 'testKey': 'test_value', 19 | 'testList': [ 20 | { 21 | 'testKey': 'test_value' 22 | } 23 | ] 24 | } 25 | actual = self.case_converter._recursive_to_camel(data) 26 | self.assertEqual(actual, expected) 27 | 28 | def test_recursive_to_snake(self): 29 | data = { 30 | 'testKey': 'test_value', 31 | 'testList': [ 32 | { 33 | 'testKey': 'test_value' 34 | } 35 | ] 36 | } 37 | expected = { 38 | 'test_key': 'test_value', 39 | 'test_list': [ 40 | { 41 | 'test_key': 'test_value' 42 | } 43 | ] 44 | } 45 | actual = self.case_converter._recursive_to_snake(data) 46 | self.assertEqual(actual, expected) 47 | 48 | def test_to_camel_str(self): 49 | data = 'test_key' 50 | expected = 'testKey' 51 | actual = self.case_converter._to_camel_str(data) 52 | self.assertEqual(actual, expected) 53 | 54 | def test_to_snake_str(self): 55 | data = 'testKey' 56 | expected = 'test_key' 57 | actual = self.case_converter._to_snake_str(data) 58 | self.assertEqual(actual, expected) 59 | 60 | def test_camelize(self): 61 | data = { 62 | 'test_key': 'test_value', 63 | 'test_list': [ 64 | { 65 | 'test_key': 'test_value' 66 | } 67 | ] 68 | } 69 | expected = { 70 | 'testKey': 'test_value', 71 | 'testList': [ 72 | { 73 | 'testKey': 'test_value' 74 | } 75 | ] 76 | } 77 | actual = self.case_converter.camelize(data) 78 | self.assertEqual(actual, expected) 79 | 80 | def test_snakeify(self): 81 | data = { 82 | 'testKey': 'test_value', 83 | 'testList': [ 84 | { 85 | 'testKey': 'test_value' 86 | } 87 | ] 88 | } 89 | expected = { 90 | 'test_key': 'test_value', 91 | 'test_list': [ 92 | { 93 | 'test_key': 'test_value' 94 | } 95 | ] 96 | } 97 | actual = self.case_converter.snakeify(data) 98 | self.assertEqual(actual, expected) 99 | 100 | 101 | if __name__ == '__main__': 102 | unittest.main() 103 | -------------------------------------------------------------------------------- /__tests__/test_http_event.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | 4 | class TestProcessEvent(unittest.TestCase): 5 | def test_process_event(self): 6 | # TODO: Add tests 7 | pass 8 | 9 | 10 | if __name__ == '__main__': 11 | unittest.main() 12 | -------------------------------------------------------------------------------- /__tests__/test_http_response.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | class TestCreateResponse(unittest.TestCase): 4 | 5 | def _create_headers(self): 6 | pass 7 | 8 | 9 | if __name__ == '__main__': 10 | unittest.main() 11 | -------------------------------------------------------------------------------- /__tests__/test_jwt.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from py_aws_lambda_toolkit.jwt import ( 3 | create_token, 4 | verify_token 5 | ) 6 | import jwt 7 | 8 | class TestJWT(unittest.TestCase): 9 | 10 | def test_create_token(self,): 11 | user_id = 12345 12 | exp_time = 3600 13 | jwt_secret = 'secret' 14 | token = create_token(user_id, exp_time, jwt_secret) 15 | decoded_token = jwt.decode(token, jwt_secret, algorithms=['HS256']) 16 | self.assertEqual(decoded_token['sub'], user_id) 17 | 18 | def test_verify_token(self,): 19 | user_id = 12345 20 | exp_time = 3600 21 | jwt_secret = 'secret' 22 | token = create_token(user_id, exp_time, jwt_secret) 23 | decoded_token = verify_token(token, jwt_secret) 24 | self.assertEqual(decoded_token, user_id) 25 | 26 | 27 | if __name__ == '__main__': 28 | unittest.main() 29 | -------------------------------------------------------------------------------- /__tests__/test_logger.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from py_aws_lambda_toolkit.logger import logger 3 | 4 | class TestLogger(unittest.TestCase): 5 | 6 | def test_logger(self,): 7 | logger.info('test logger') 8 | logger.error('test logger') 9 | logger.debug('test logger') 10 | logger.warning('test logger') 11 | self.assertEqual(True, True) 12 | 13 | 14 | if __name__ == '__main__': 15 | unittest.main() 16 | -------------------------------------------------------------------------------- /__tests__/test_mappers.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from py_aws_lambda_toolkit.mappers import mapper 3 | 4 | 5 | class TestMappers(unittest.TestCase): 6 | 7 | def test_mapper(self): 8 | data = { 9 | 'name': 'John', 10 | 'age': 30, 11 | 'city': 'New York' 12 | } 13 | fields = ['age', 'city'] 14 | expected = {'name': 'John'} 15 | result = mapper(data, fields) 16 | self.assertEqual(expected, result) 17 | 18 | def test_mapper_with_list(self): 19 | data = [ 20 | { 21 | 'name': 'John', 22 | 'age': 30, 23 | 'city': 'New York' 24 | }, 25 | { 26 | 'name': 'Jane', 27 | 'age': 25, 28 | 'city': 'Boston' 29 | } 30 | ] 31 | fields = ['age', 'city'] 32 | expected = [ 33 | { 34 | 'name': 'John' 35 | }, 36 | { 37 | 'name': 'Jane' 38 | } 39 | ] 40 | result = mapper(data, fields) 41 | self.assertEqual(expected, result) 42 | 43 | def test_mapper_with_invalid_data(self): 44 | data = 'John' 45 | fields = ['age', 'city'] 46 | with self.assertRaises(TypeError): 47 | mapper(data, fields) 48 | 49 | 50 | if __name__ == '__main__': 51 | unittest.main() 52 | -------------------------------------------------------------------------------- /__tests__/test_password.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from py_aws_lambda_toolkit.password import ( 3 | hash_password, 4 | verify_password, 5 | ) 6 | 7 | 8 | class TestPassword(unittest.TestCase): 9 | 10 | password = "testPassword" 11 | salt = "testSalt" 12 | hashed_password_regex = r"^[a-f0-9]{64}$" 13 | 14 | def test_hash_password(self): 15 | hashed_password = hash_password(self.password, self.salt) 16 | self.assertRegex(hashed_password, self.hashed_password_regex) 17 | 18 | def test_verify_password(self): 19 | hashed_password = hash_password(self.password, self.salt) 20 | self.assertTrue(verify_password(self.password, hashed_password, self.salt)) 21 | self.assertFalse(verify_password("wrongPassword", hashed_password, self.salt)) 22 | 23 | if __name__ == '__main__': 24 | unittest.main() 25 | -------------------------------------------------------------------------------- /__tests__/test_validators.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from py_aws_lambda_toolkit.validators import ( 3 | validate_path, 4 | validate_data, 5 | ) 6 | 7 | 8 | class TestValidators(unittest.TestCase): 9 | 10 | def test_validate_multiple_type(self): 11 | schema = { 12 | "$schema": "http://json-schema.org/draft-07/schema#", 13 | "title": "Validate multiple type", 14 | "type": "object", 15 | "properties": { 16 | "integer": { 17 | "type": ["integer", "string"] 18 | }, 19 | "string": { 20 | "type": ["string", "integer"] 21 | }, 22 | "boolean": { 23 | "type": ["boolean", "integer"] 24 | }, 25 | "number": { 26 | "type": ["number", "integer"] 27 | }, 28 | "array": { 29 | "type": ["array", "integer"] 30 | }, 31 | "object": { 32 | "type": ["object", "integer"] 33 | }, 34 | "null": { 35 | "type": ["null", "integer"] 36 | }, 37 | } 38 | } 39 | 40 | data = { 41 | "integer": 1, 42 | "string": "1", 43 | "boolean": True, 44 | "number": 1.0, 45 | "array": [1], 46 | "object": {"integer": 1}, 47 | "null": None, 48 | } 49 | result = validate_data(data, schema) 50 | self.assertIsNone(result) 51 | 52 | def test_validate_path(self): 53 | path = { 54 | "id": "00000000-0000-0000-0000-000000000000" 55 | } 56 | result = validate_path(path) 57 | self.assertIsNone(result) 58 | -------------------------------------------------------------------------------- /py_aws_lambda_toolkit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GA-20/py-aws-lambda-toolkit/fd713129741a5dfd46f6a80b9bdbd50da58119dc/py_aws_lambda_toolkit/__init__.py -------------------------------------------------------------------------------- /py_aws_lambda_toolkit/case_converter.py: -------------------------------------------------------------------------------- 1 | from camel_converter import ( 2 | to_camel, 3 | to_snake, 4 | dict_to_camel, 5 | dict_to_snake 6 | ) 7 | 8 | 9 | class CaseConverter: 10 | 11 | """ 12 | This class provides methods for converting strings and dictionaries to CamelCase 13 | and snake_case. 14 | 15 | Methods: 16 | _recursive_to_camel: Recursively converts a dictionary or list of dictionaries 17 | to CamelCase. 18 | _recursive_to_snake: Recursively converts a dictionary or list of dictionaries 19 | to snake_case. 20 | _to_camel_str: Converts a string to CamelCase. 21 | _to_snake_str: Converts a string to snake_case. 22 | camelize: Converts a string, dictionary, or list of dictionaries to CamelCase. 23 | snakeify: Converts a string, dictionary, or list of dictionaries to snake_case. 24 | """ 25 | 26 | def _allowed_types(self, data): 27 | """ 28 | Checks if the data is one of the allowed types. 29 | Args: 30 | data (any): The data to check. 31 | Returns: 32 | bool: True if the data is one of the allowed types, False otherwise. 33 | """ 34 | if isinstance(data, str) or isinstance(data, int) or isinstance(data, float): 35 | return True 36 | 37 | if isinstance(data, bool) or isinstance(data, type(None)): 38 | return True 39 | 40 | return False 41 | 42 | def _to_camel_str(self, data): 43 | """ 44 | Converts a string to CamelCase. 45 | 46 | Args: 47 | data (str): The string to convert. 48 | 49 | Returns: 50 | str: The converted string. 51 | 52 | Raises: 53 | TypeError: If the data is not a string. 54 | """ 55 | 56 | if not isinstance(data, str): 57 | raise TypeError('_to_camel_str: Data must be a string.') 58 | return to_camel(data) 59 | 60 | def _to_snake_str(self, data): 61 | """ 62 | Converts a string to snake_case. 63 | 64 | Args: 65 | data (str): The string to convert. 66 | 67 | Returns: 68 | str: The converted string. 69 | 70 | Raises: 71 | TypeError: If the data is not a string. 72 | """ 73 | 74 | # if the data is a single word like "Hello", "hello", "hellO", "HELLO" 75 | # return data 76 | 77 | if not isinstance(data, str): 78 | raise TypeError('_to_snake_str: Data must be a string.') 79 | 80 | return to_snake(data) 81 | 82 | def _recursive_to_camel(self, data): 83 | """ 84 | Recursively converts a dictionary or list of dictionaries to CamelCase. 85 | 86 | Args: 87 | data (dict or list): The data to convert. 88 | 89 | Returns: 90 | dict or list: The converted data. 91 | 92 | Raises: 93 | TypeError: If the data is not a dictionary or list. 94 | """ 95 | 96 | if isinstance(data, dict): 97 | return dict_to_camel(data) 98 | 99 | if isinstance(data, list): 100 | return [self._recursive_to_camel(item) for item in data] 101 | 102 | if self._allowed_types(data): 103 | return data 104 | 105 | raise TypeError( 106 | '_recursive_to_camel: Data must be a dict or a list of dicts.') 107 | 108 | def _recursive_to_snake(self, data): 109 | """ 110 | Recursively converts a dictionary or list of dictionaries to snake_case. 111 | 112 | Args: 113 | data (dict or list): The data to convert. 114 | 115 | Returns: 116 | dict or list: The converted data. 117 | 118 | Raises: 119 | TypeError: If the data is not a dictionary or list. 120 | """ 121 | 122 | if isinstance(data, dict): 123 | return dict_to_snake(data) 124 | 125 | if isinstance(data, list): 126 | return [self._recursive_to_snake(item) for item in data] 127 | 128 | if self._allowed_types(data): 129 | return data 130 | 131 | raise TypeError( 132 | '_recursive_to_snake: Data must be a dict or a list of dicts.') 133 | 134 | def camelize(self, data): 135 | """ 136 | Converts a string, dictionary, or list of dictionaries to CamelCase. 137 | 138 | Args: 139 | data (str or dict or list): The data to convert. 140 | 141 | Returns: 142 | str or dict or list: The converted data. 143 | 144 | Raises: 145 | TypeError: If the data is not a string, dictionary, or list. 146 | """ 147 | 148 | if self._allowed_types(data): 149 | return data 150 | 151 | if isinstance(data, dict) or isinstance(data, list): 152 | return self._recursive_to_camel(data) 153 | 154 | raise TypeError( 155 | 'camelize: Data must be a string, a dict, or a list of dicts.') 156 | 157 | def snakeify(self, data): 158 | """ 159 | Converts a string, dictionary, or list of dictionaries to snake_case. 160 | 161 | Args: 162 | data (str or dict or list): The data to convert. 163 | 164 | Returns: 165 | str or dict or list: The converted data. 166 | 167 | Raises: 168 | TypeError: If the data is not a string, dictionary, or list. 169 | """ 170 | 171 | if self._allowed_types(data): 172 | return data 173 | 174 | if isinstance(data, dict) or isinstance(data, list): 175 | return self._recursive_to_snake(data) 176 | 177 | raise TypeError( 178 | 'camelize: Data must be a string, a dict, or a list of dicts.') 179 | -------------------------------------------------------------------------------- /py_aws_lambda_toolkit/dynamodb_shortcuts.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from boto3.dynamodb.conditions import Attr 3 | 4 | 5 | def list_all(table, scan_params): 6 | """ 7 | Lists all the items in the table. 8 | 9 | Args: 10 | table (boto3.resource('dynamodb').Table): The DynamoDB table. 11 | scan_params (dict): The scan parameters. 12 | 13 | Returns: 14 | dict: A dictionary of the items in the table. 15 | """ 16 | 17 | try: 18 | response = table.scan(**scan_params) 19 | data = response['Items'] 20 | 21 | while 'LastEvaluatedKey' in response: 22 | scan_params['ExclusiveStartKey'] = response['LastEvaluatedKey'] 23 | response = table.scan(**scan_params) 24 | data.extend(response['Items']) 25 | 26 | return {'items': data, 'total': len(data)} 27 | except Exception as err: 28 | raise err 29 | 30 | 31 | def exits_by_attr(table, attribute, value): 32 | """ 33 | Checks if a resource exists with the given attribute and value. 34 | 35 | Args: 36 | table (boto3.resource('dynamodb').Table): The DynamoDB table. 37 | attribute (str): The attribute to check. 38 | value (any): The value to check. 39 | 40 | Returns: 41 | bool: True if the resource exists, False otherwise. 42 | """ 43 | 44 | try: 45 | 46 | if not attribute or not value: 47 | raise Exception('Attribute and value are required') 48 | 49 | response = table.scan( 50 | FilterExpression=Attr(attribute).eq(value) 51 | ) 52 | 53 | return response['Count'] > 0 54 | 55 | except Exception as err: 56 | logging.error(f"Error while checking: {err}") 57 | raise 58 | 59 | 60 | def exits_by_key(table, partition_key, partition_value, sort_key=None, sort_value=None): 61 | """ 62 | Checks if a resource exists with the given partition key and sort key (if any). 63 | 64 | Args: 65 | table (boto3.resource('dynamodb').Table): The DynamoDB table. 66 | partition_key (str): The partition key. 67 | partition_value (any): The partition value. 68 | sort_key (str): The sort key (optional). 69 | sort_value (any): The sort value (optional). 70 | 71 | Returns: 72 | bool: True if the resource exists, False otherwise. 73 | """ 74 | 75 | try: 76 | 77 | if not partition_value or not partition_value: 78 | raise Exception('Partition key and value are required') 79 | 80 | if sort_key and sort_value: 81 | response = table.get_item( 82 | Key={ 83 | partition_key: partition_value, 84 | sort_key: sort_value 85 | } 86 | ) 87 | 88 | else: 89 | response = table.get_item( 90 | Key={ 91 | partition_key: partition_value 92 | } 93 | ) 94 | 95 | return response['Item'] is not None 96 | 97 | except Exception as err: 98 | logging.error(f"Error while checking {partition_value} : {err}") 99 | raise 100 | -------------------------------------------------------------------------------- /py_aws_lambda_toolkit/http_event.py: -------------------------------------------------------------------------------- 1 | from .logger import logging 2 | from typing import Dict 3 | from .case_converter import CaseConverter 4 | 5 | case_converter = CaseConverter() 6 | 7 | 8 | def process_event(event: Dict) -> Dict: 9 | try: 10 | body = event.get('body') 11 | headers = event.get('headers') 12 | http_method = event.get('httpMethod') 13 | is_base64_encoded = event.get('isBase64Encoded') 14 | multi_value_headers = event.get('multiValueHeaders') 15 | path = event.get('path') 16 | path_parameters = event.get('pathParameters') 17 | query_string_parameters = event.get('queryStringParameters') 18 | request_context = event.get('requestContext') 19 | resource = event.get('resource') 20 | stage_variables = event.get('stageVariables') 21 | 22 | logging.info({ 23 | 'requestContext': request_context, 24 | 'httpMethod': http_method, 25 | 'path': path, 26 | 'headers': headers 27 | }) 28 | 29 | query_string_parameters = case_converter.snakeify( 30 | query_string_parameters) 31 | path_parameters = case_converter.snakeify(path_parameters) 32 | body = case_converter.snakeify(body) 33 | 34 | return { 35 | 'body': body, 36 | 'headers': headers, 37 | 'httpMethod': http_method, 38 | 'isBase64Encoded': is_base64_encoded, 39 | 'multiValueHeaders': multi_value_headers, 40 | 'path': path, 41 | 'pathParameters': path_parameters, 42 | 'queryStringParameters': query_string_parameters, 43 | 'requestContext': request_context, 44 | 'resource': resource, 45 | 'stageVariables': stage_variables 46 | } 47 | except Exception as e: 48 | logging.error(e) 49 | raise e 50 | -------------------------------------------------------------------------------- /py_aws_lambda_toolkit/http_response.py: -------------------------------------------------------------------------------- 1 | import json 2 | from .case_converter import CaseConverter 3 | from .settings import ( 4 | ACCESS_CONTROL_ALLOW_ORIGIN, 5 | ACCESS_CONTROL_ALLOW_CREDENTIALS, 6 | CONTENT_TYPE, 7 | ACCESS_CONTROL_ALLOWED_METHODS, 8 | ACCESS_CONTROL_ALLOWED_HEADERS 9 | ) 10 | 11 | case_converter = CaseConverter() 12 | 13 | 14 | def create_response( 15 | response, 16 | status=200, 17 | custom_headers=None, 18 | content_type=CONTENT_TYPE 19 | ): 20 | """ 21 | Creates an HTTP response object. 22 | 23 | Parameters: 24 | response (dict): The response body. 25 | status (int): The HTTP status code to be returned. 26 | headers (dict): The headers to be returned with the response. 27 | content_type (str): The content type of the response. 28 | 29 | Returns: 30 | dict: The HTTP response object with keys statusCode, headers, and body. 31 | """ 32 | # TODO: Add support for binary data 33 | # TODO: Add to global config 34 | headers = { 35 | 'Access-Control-Allow-Origin': ACCESS_CONTROL_ALLOW_ORIGIN, 36 | 'Access-Control-Allow-Credentials': ACCESS_CONTROL_ALLOW_CREDENTIALS, 37 | 'Content-Type': content_type, 38 | 'Access-Control-Allow-Methods': ACCESS_CONTROL_ALLOWED_METHODS, 39 | 'Access-Control-Allow-Headers': ACCESS_CONTROL_ALLOWED_HEADERS, 40 | } 41 | 42 | if custom_headers is not None: 43 | headers.update(custom_headers) 44 | 45 | body = case_converter.camelize(response) 46 | 47 | return { 48 | 'statusCode': status, 49 | 'headers': headers, 50 | 'body': json.dumps(body) if body is not None else None 51 | } 52 | -------------------------------------------------------------------------------- /py_aws_lambda_toolkit/jwt.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | import time 3 | 4 | 5 | def create_token(user_id, exp_time, jwt_secret): 6 | """ 7 | Creates a JWT token. 8 | Args: 9 | user_id (str): The user ID to be included in the token. 10 | exp_time (int): The expiration time of the token, in seconds. 11 | jwt_secret (str): The secret used to sign the token. 12 | Returns: 13 | str: The JWT token. 14 | """ 15 | payload = {'sub': user_id, 'exp': int(time.time()) + exp_time} 16 | token = jwt.encode(payload, jwt_secret, algorithm='HS256') 17 | return token 18 | 19 | 20 | def verify_token(token, jwt_secret): 21 | """ 22 | Verifies a JWT token. 23 | Args: 24 | token (str): The JWT token to be verified. 25 | jwt_secret (str): The secret used to sign the token. 26 | Returns: 27 | str: The user ID from the token. 28 | Raises: 29 | ExpiredSignatureError: If the token has expired. 30 | InvalidTokenError: If the token is invalid. 31 | """ 32 | try: 33 | decoded_token = jwt.decode(token, jwt_secret, algorithms=['HS256']) 34 | return decoded_token['sub'] 35 | except jwt.ExpiredSignatureError: 36 | raise Exception('Token has expired') 37 | except jwt.InvalidTokenError: 38 | raise Exception('Invalid token') 39 | -------------------------------------------------------------------------------- /py_aws_lambda_toolkit/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | log_format = "%(asctime)s - %(levelname)s - %(message)s" 4 | logging.basicConfig(format=log_format) 5 | logger = logging.getLogger(__name__) 6 | logger.setLevel(logging.INFO) 7 | -------------------------------------------------------------------------------- /py_aws_lambda_toolkit/mappers.py: -------------------------------------------------------------------------------- 1 | def mapper(data, fields): 2 | """ 3 | Removes the specified fields from the dictionary or list of dictionaries. 4 | 5 | Args: 6 | - data: a dictionary or list of dictionaries. 7 | - fields: a list of strings with the names of the fields to remove. 8 | 9 | Returns: 10 | - The dictionary or list of dictionaries with the specified fields removed. 11 | """ 12 | 13 | if isinstance(data, dict): 14 | return {key: value for key, value in data.items() if key not in fields} 15 | elif isinstance(data, list): 16 | return [{ 17 | key: value for key, value in item.items() 18 | if key not in fields 19 | } for item in data] 20 | else: 21 | raise TypeError("'data' must be a dictionary or a list") 22 | -------------------------------------------------------------------------------- /py_aws_lambda_toolkit/password.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | 4 | def hash_password(password, salt): 5 | """ 6 | Hashes a password using SHA-256. 7 | 8 | Args: 9 | password (str): The password to be hashed. 10 | salt (str): A random salt to be used in the hashing process. 11 | 12 | Returns: 13 | str: The hashed password. 14 | """ 15 | 16 | password = password.encode('utf-8') 17 | salt = salt.encode('utf-8') 18 | hashed_password = hashlib.sha256(password + salt).hexdigest() 19 | return hashed_password 20 | 21 | 22 | def verify_password(password, hashed_password, salt): 23 | """ 24 | Verifies a password by comparing the hashed password to the provided password. 25 | 26 | Args: 27 | password (str): The password to be verified. 28 | hashed_password (str): The hashed password to be compared to. 29 | salt (str): The salt that was used to hash the password. 30 | 31 | Returns: 32 | bool: True if the passwords match, False otherwise. 33 | """ 34 | 35 | password = password.encode('utf-8') 36 | salt = salt.encode('utf-8') 37 | hashed_password_to_check = hashlib.sha256(password + salt).hexdigest() 38 | return hashed_password == hashed_password_to_check 39 | -------------------------------------------------------------------------------- /py_aws_lambda_toolkit/scan_filter_builder.py: -------------------------------------------------------------------------------- 1 | from boto3.dynamodb.conditions import Attr 2 | 3 | 4 | def equal(filter_expression, attribute, value): 5 | if bool(filter_expression): 6 | filter_expression &= Attr(attribute).eq(value) 7 | else: 8 | filter_expression = Attr(attribute).eq(value) 9 | 10 | return filter_expression 11 | 12 | 13 | def less_than(filter_expression, attribute, value): 14 | if bool(filter_expression): 15 | filter_expression &= Attr(attribute).lt(value) 16 | else: 17 | filter_expression = Attr(attribute).lt(value) 18 | 19 | return filter_expression 20 | 21 | 22 | def less_than_or_equal(filter_expression, attribute, value): 23 | if bool(filter_expression): 24 | filter_expression &= Attr(attribute).lte(value) 25 | else: 26 | filter_expression = Attr(attribute).lte(value) 27 | 28 | return filter_expression 29 | 30 | 31 | def greater_than(filter_expression, attribute, value): 32 | if bool(filter_expression): 33 | filter_expression &= Attr(attribute).gt(value) 34 | else: 35 | filter_expression = Attr(attribute).gt(value) 36 | 37 | return filter_expression 38 | 39 | 40 | def greater_than_or_equal(filter_expression, attribute, value): 41 | if bool(filter_expression): 42 | filter_expression &= Attr(attribute).gte(value) 43 | else: 44 | filter_expression = Attr(attribute).gte(value) 45 | 46 | return filter_expression 47 | 48 | 49 | def between(filter_expression, attribute, value): 50 | if bool(filter_expression): 51 | filter_expression &= Attr(attribute).between(value[0], value[1]) 52 | else: 53 | filter_expression = Attr(attribute).between(value[0], value[1]) 54 | 55 | return filter_expression 56 | 57 | 58 | def in_list(filter_expression, attribute, value): 59 | if bool(filter_expression): 60 | filter_expression &= Attr(attribute).is_in(value) 61 | else: 62 | filter_expression = Attr(attribute).is_in(value) 63 | 64 | return filter_expression 65 | 66 | 67 | def contains(filter_expression, attribute, value): 68 | if bool(filter_expression): 69 | filter_expression &= Attr(attribute).contains(value) 70 | else: 71 | filter_expression = Attr(attribute).contains(value) 72 | 73 | return filter_expression 74 | 75 | 76 | def not_exists(filter_expression, attribute, value): 77 | if bool(filter_expression): 78 | filter_expression &= Attr(attribute).not_exists() 79 | else: 80 | filter_expression = Attr(attribute).not_exists() 81 | 82 | return filter_expression 83 | 84 | 85 | def exists(filter_expression, attribute, value): 86 | if bool(filter_expression): 87 | filter_expression &= Attr(attribute).exists() 88 | else: 89 | filter_expression = Attr(attribute).exists() 90 | 91 | return filter_expression 92 | 93 | 94 | actions = { 95 | 'eq': equal, 96 | 'lt': less_than, 97 | 'lte': less_than_or_equal, 98 | 'gt': greater_than, 99 | 'gte': greater_than_or_equal, 100 | 'between': between, 101 | 'in': in_list, 102 | 'contains': contains, 103 | 'not_exists': not_exists, 104 | 'exists': exists, 105 | } 106 | 107 | 108 | def apply_filter_expression(filters): 109 | filter_expression = {} 110 | 111 | for key, value in filters.items(): 112 | 113 | if '__' not in key: 114 | raise Exception('Invalid filter key') 115 | 116 | action, attribute = key.split('__') 117 | filter_expression = actions[action]( 118 | filter_expression, attribute, value) 119 | 120 | return filter_expression 121 | -------------------------------------------------------------------------------- /py_aws_lambda_toolkit/settings.py: -------------------------------------------------------------------------------- 1 | ALLOW_METHODS = [ 2 | 'GET', 3 | 'POST', 4 | 'OPTIONS', 5 | 'PUT', 6 | 'PATCH', 7 | 'DELETE' 8 | ] 9 | 10 | ALLOW_HEADERS = [ 11 | 'Origin', 12 | 'X-Requested-With', 13 | 'Content-Type', 14 | 'Accept', 15 | 'X-API-AUTH', 16 | 'X-Amz-Date', 17 | 'X-Api-Key', 18 | 'X-Amz-Security-Token', 19 | 'X-Amz-User-Agent' 20 | ] 21 | 22 | ACCESS_CONTROL_ALLOW_ORIGIN = '*' 23 | ACCESS_CONTROL_ALLOW_CREDENTIALS = True 24 | CONTENT_TYPE = 'application/json' 25 | ACCESS_CONTROL_ALLOWED_METHODS = ', '.join(ALLOW_METHODS) 26 | ACCESS_CONTROL_ALLOWED_HEADERS = ', '.join(ALLOW_HEADERS) 27 | -------------------------------------------------------------------------------- /py_aws_lambda_toolkit/status.py: -------------------------------------------------------------------------------- 1 | """ 2 | Descriptive HTTP status codes, for code readability. 3 | 4 | See RFC 2616 - https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html 5 | And RFC 6585 - https://tools.ietf.org/html/rfc6585 6 | And RFC 4918 - https://tools.ietf.org/html/rfc4918 7 | """ 8 | 9 | 10 | def is_informational(code): 11 | return 100 <= code <= 199 12 | 13 | 14 | def is_success(code): 15 | return 200 <= code <= 299 16 | 17 | 18 | def is_redirect(code): 19 | return 300 <= code <= 399 20 | 21 | 22 | def is_client_error(code): 23 | return 400 <= code <= 499 24 | 25 | 26 | def is_server_error(code): 27 | return 500 <= code <= 599 28 | 29 | 30 | HTTP_100_CONTINUE = 100 31 | HTTP_101_SWITCHING_PROTOCOLS = 101 32 | HTTP_102_PROCESSING = 102 33 | HTTP_103_EARLY_HINTS = 103 34 | HTTP_200_OK = 200 35 | HTTP_201_CREATED = 201 36 | HTTP_202_ACCEPTED = 202 37 | HTTP_203_NON_AUTHORITATIVE_INFORMATION = 203 38 | HTTP_204_NO_CONTENT = 204 39 | HTTP_205_RESET_CONTENT = 205 40 | HTTP_206_PARTIAL_CONTENT = 206 41 | HTTP_207_MULTI_STATUS = 207 42 | HTTP_208_ALREADY_REPORTED = 208 43 | HTTP_226_IM_USED = 226 44 | HTTP_300_MULTIPLE_CHOICES = 300 45 | HTTP_301_MOVED_PERMANENTLY = 301 46 | HTTP_302_FOUND = 302 47 | HTTP_303_SEE_OTHER = 303 48 | HTTP_304_NOT_MODIFIED = 304 49 | HTTP_305_USE_PROXY = 305 50 | HTTP_306_RESERVED = 306 51 | HTTP_307_TEMPORARY_REDIRECT = 307 52 | HTTP_308_PERMANENT_REDIRECT = 308 53 | HTTP_400_BAD_REQUEST = 400 54 | HTTP_401_UNAUTHORIZED = 401 55 | HTTP_402_PAYMENT_REQUIRED = 402 56 | HTTP_403_FORBIDDEN = 403 57 | HTTP_404_NOT_FOUND = 404 58 | HTTP_405_METHOD_NOT_ALLOWED = 405 59 | HTTP_406_NOT_ACCEPTABLE = 406 60 | HTTP_407_PROXY_AUTHENTICATION_REQUIRED = 407 61 | HTTP_408_REQUEST_TIMEOUT = 408 62 | HTTP_409_CONFLICT = 409 63 | HTTP_410_GONE = 410 64 | HTTP_411_LENGTH_REQUIRED = 411 65 | HTTP_412_PRECONDITION_FAILED = 412 66 | HTTP_413_REQUEST_ENTITY_TOO_LARGE = 413 67 | HTTP_414_REQUEST_URI_TOO_LONG = 414 68 | HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415 69 | HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE = 416 70 | HTTP_417_EXPECTATION_FAILED = 417 71 | HTTP_418_IM_A_TEAPOT = 418 72 | HTTP_421_MISDIRECTED_REQUEST = 421 73 | HTTP_422_UNPROCESSABLE_ENTITY = 422 74 | HTTP_423_LOCKED = 423 75 | HTTP_424_FAILED_DEPENDENCY = 424 76 | HTTP_425_TOO_EARLY = 425 77 | HTTP_426_UPGRADE_REQUIRED = 426 78 | HTTP_428_PRECONDITION_REQUIRED = 428 79 | HTTP_429_TOO_MANY_REQUESTS = 429 80 | HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE = 431 81 | HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS = 451 82 | HTTP_500_INTERNAL_SERVER_ERROR = 500 83 | HTTP_501_NOT_IMPLEMENTED = 501 84 | HTTP_502_BAD_GATEWAY = 502 85 | HTTP_503_SERVICE_UNAVAILABLE = 503 86 | HTTP_504_GATEWAY_TIMEOUT = 504 87 | HTTP_505_HTTP_VERSION_NOT_SUPPORTED = 505 88 | HTTP_506_VARIANT_ALSO_NEGOTIATES = 506 89 | HTTP_507_INSUFFICIENT_STORAGE = 507 90 | HTTP_508_LOOP_DETECTED = 508 91 | HTTP_509_BANDWIDTH_LIMIT_EXCEEDED = 509 92 | HTTP_510_NOT_EXTENDED = 510 93 | HTTP_511_NETWORK_AUTHENTICATION_REQUIRED = 511 94 | -------------------------------------------------------------------------------- /py_aws_lambda_toolkit/validators.py: -------------------------------------------------------------------------------- 1 | from jsonschema import validate 2 | from jsonschema.exceptions import ValidationError 3 | 4 | 5 | path_id_schema = { 6 | "$schema": "http://json-schema.org/draft-07/schema#", 7 | "title": "Validate id comes in the object path", 8 | "type": "object", 9 | "properties": { 10 | "id": { 11 | "type": "string", 12 | "format": "uuid" 13 | } 14 | }, 15 | "required": [ 16 | "id" 17 | ] 18 | } 19 | 20 | 21 | def validate_path(path): 22 | """ 23 | Validates the path object. 24 | 25 | Args: 26 | path (dict): The path object to be validated. 27 | 28 | Raises: 29 | ValidationError: If the path is invalid. 30 | """ 31 | 32 | if not path: 33 | raise ValidationError("Path is empty") 34 | 35 | try: 36 | validate(path, path_id_schema) 37 | except ValidationError as err: 38 | raise ValidationError(err.message) 39 | 40 | 41 | def validate_data(data, schema): 42 | """ 43 | Validates the data object against the schema. 44 | 45 | Args: 46 | data (dict): The data object to be validated. 47 | schema (dict): The schema to be used for validation. 48 | 49 | Raises: 50 | ValidationError: If the data is invalid. 51 | """ 52 | 53 | if not data or not schema: 54 | raise ValidationError("Data or schema is empty") 55 | 56 | try: 57 | validate(data, schema) 58 | except ValidationError as err: 59 | raise ValidationError(err.message) 60 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=54", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==23.1.0 2 | boto3==1.26.125 3 | botocore==1.29.125 4 | camel-converter==3.0.0 5 | jmespath==1.0.1 6 | jsonschema==4.17.3 7 | PyJWT==2.6.0 8 | pyrsistent==0.19.3 9 | python-dateutil==2.8.2 10 | s3transfer==0.6.0 11 | six==1.16.0 12 | urllib3==1.26.15 13 | ruff==0.0.277 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = py-aws-lambda-toolkit 3 | version = 0.0.0 4 | author = Julio Flores 5 | author_email = juliocesarflores12@gmail.com 6 | author_url = juliofloresdev.com 7 | description = A toolkit for serverless and AWS, with a focus on AWS Lambda and dynamodb. 8 | long_description = file: README.md 9 | long_description_content_type = text/markdown 10 | url = https://github.com/0riion/py-aws-lambda-toolkit 11 | license = MIT 12 | license_file = LICENSE 13 | classifiers = 14 | Development Status :: 1 - Planning 15 | Intended Audience :: Developers 16 | License :: OSI Approved :: MIT License 17 | Programming Language :: Python :: 3.8 18 | Operating System :: OS Independent 19 | Topic :: Software Development :: Libraries :: Python Modules 20 | Topic :: Utilities 21 | keywords = 22 | aws 23 | serverless 24 | lambda 25 | dynamodb 26 | toolkit 27 | python 28 | aws 29 | 30 | [options] 31 | packages = find: 32 | python_requires = >=3.8 33 | install_requires = 34 | boto3==1.26.118 35 | jsonschema==4.17.3 36 | camel-converter==3.0.0 37 | PyJWT==2.6.0 38 | ruff==0.0.277 39 | include_package_data = True 40 | --------------------------------------------------------------------------------