├── .github └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE.txt ├── NOTICE.txt ├── README.md ├── THIRD-PARTY.txt ├── pdm.lock ├── pyproject.toml ├── src └── accustom │ ├── Exceptions │ ├── __init__.py │ ├── exceptions.py │ └── py.typed │ ├── __init__.py │ ├── constants.py │ ├── decorators.py │ ├── py.typed │ ├── redaction.py │ └── response.py └── tests ├── int ├── Makefile ├── redact.deploy.yaml ├── redact.execute.yaml ├── redact.py ├── test_redact.py ├── test_timeout.py ├── timeout.deploy.yaml ├── timeout.execute.yaml └── timeout.py └── unit ├── test_constants.py ├── test_redaction.py └── test_response.py /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | *Issue #, if available:* 2 | 3 | *Description of changes:* 4 | 5 | 6 | By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | .pdm-python 162 | 163 | # Project specific 164 | /.fleet 165 | /requirements* 166 | /tests/int/*.deploy.ready.yaml 167 | /tests/int/*.zip 168 | /tests/int/*.deployed 169 | /tests/int/*.executed 170 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/commitizen-tools/commitizen 3 | rev: 3.6.0 4 | hooks: 5 | - id: commitizen 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.4.0 8 | hooks: 9 | - id: check-builtin-literals 10 | - id: check-case-conflict 11 | - id: check-docstring-first 12 | - id: check-executables-have-shebangs 13 | - id: check-shebang-scripts-are-executable 14 | - id: check-symlinks 15 | - id: check-toml 16 | - id: check-yaml 17 | exclude: '^tests/int/' 18 | # Excluding these YAML files as they are CFN templates 19 | - id: destroyed-symlinks 20 | - id: detect-private-key 21 | - id: end-of-file-fixer 22 | - id: forbid-submodules 23 | - id: name-tests-test 24 | exclude: '^tests/int/(redact|timeout).py$' 25 | # Excluding these files as they are not test files 26 | args: [--pytest-test-first] 27 | - id: no-commit-to-branch 28 | args: [--branch, stable] 29 | - id: trailing-whitespace 30 | args: [--markdown-linebreak-ext=md] 31 | - repo: https://github.com/pre-commit/pygrep-hooks.git 32 | rev: v1.10.0 33 | hooks: 34 | - id: python-check-blanket-noqa 35 | - id: python-check-blanket-type-ignore 36 | exclude: '^tests/' 37 | # Excluding test files from this check 38 | - id: python-no-eval 39 | - id: python-no-log-warn 40 | - id: python-use-type-annotations 41 | - id: text-unicode-replacement-char 42 | - repo: https://github.com/pre-commit/mirrors-mypy 43 | rev: v1.4.1 44 | hooks: 45 | - id: mypy 46 | language: system 47 | # Need various MyPy plugins, thus using system python. Run "pdm install --dev --group lint" first 48 | - repo: https://github.com/PyCQA/isort 49 | rev: 5.12.0 50 | hooks: 51 | - id: isort 52 | args: [--check] 53 | - repo: https://github.com/psf/black 54 | rev: 23.7.0 55 | hooks: 56 | - id: black 57 | args: [--check] 58 | - repo: https://github.com/PyCQA/flake8 59 | rev: 6.1.0 60 | hooks: 61 | - id: flake8 62 | language: system 63 | # Need various flake8 plugins, thus using system python. Run "pdm install --dev --group lint" first 64 | - repo: https://github.com/aws-cloudformation/cfn-lint.git 65 | rev: v0.79.6 66 | hooks: 67 | - id: cfn-lint 68 | files: ^tests/int/.*\.yaml$ 69 | args: [--ignore-checks, W3002] 70 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | CloudFormation Accustom 2 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CloudFormation Accustom 2 | CloudFormation Accustom is a library for responding to Custom Resources in AWS CloudFormation using the decorator 3 | pattern. 4 | 5 | This library provides a cfnresponse method, some helper static classes, and some decorator methods to help with the 6 | function. 7 | 8 | ## Installation 9 | 10 | CloudFormation Accustom can be found under PyPI at 11 | [https://pypi.python.org/pypi/accustom](https://pypi.python.org/pypi/accustom). 12 | 13 | To install: 14 | 15 | ```bash 16 | python3 -m pip install accustom 17 | ``` 18 | 19 | To create a Lambda Code Bundle in Zip Format with CloudFormation Accustom and dependencies (including `requests`), 20 | create a directory with only your code in it and run the following. Alternatively you can create a Lambda Layer with 21 | CloudFormation Accustom and dependencies installed and use that as your base layer for custom resources. 22 | 23 | ```bash 24 | python3 -m pip install accustom -t . 25 | zip code.zip * -r 26 | ``` 27 | 28 | ## Quickstart 29 | 30 | The quickest way to use this library is to use the standalone decorator `@accustom.sdecorator`, in a Lambda function. 31 | 32 | ```python 33 | import accustom 34 | @accustom.sdecorator(expectedProperties=['key1','key2']) 35 | def resource_handler(event, context): 36 | result = (float(event['ResourceProperties']['key1']) + 37 | float(event['ResourceProperties']['key2'])) 38 | return { 'sum' : result } 39 | ``` 40 | 41 | In this configuration, the decorator will check to make sure the properties `key1` and `key2` have been passed by the 42 | user, and automatically send a response back to CloudFormation based upon the `event` object. 43 | 44 | As you can see, this greatly simplifies the developer effort required to get a working custom resource that will 45 | correctly respond to CloudFormation Custom Resource Requests. 46 | 47 | ## The Decorator Patterns 48 | 49 | The most important part of this library are the Decorator patterns. These provide Python decorators that can be put 50 | around handler functions, or resource specific functions, that prepare the data for ease of usage. These decorators will 51 | also handle exceptions for you. 52 | 53 | ### `@accustom.decorator()` 54 | 55 | This is the primary decorator in the library. The purpose of this decorator is to take the return value of the handler 56 | function, and respond back to CloudFormation based upon the input `event` automatically. 57 | 58 | It takes the following options: 59 | 60 | - `enforceUseOfClass` (Boolean) : When this is set to `True`, you must use a `ResponseObject`. This is implicitly set 61 | to true if no Lambda Context is provided. 62 | - `hideResourceDeleteFailure` (Boolean) : When this is set to `True` the function will return `SUCCESS` even on getting 63 | an Exception for `Delete` requests. 64 | - `redactConfig` (accustom.RedactionConfig) : For more details on how this works please see "Redacting Confidential 65 | Information From Logs" 66 | - `timeoutFunction` (Boolean): Will automatically send a failure signal to CloudFormation before Lambda timeout 67 | provided that this function is executed in Lambda. 68 | 69 | Without a `ResponseObject` the decorator will make the following assumptions: 70 | - if a Lambda Context is not passed, the function will return `FAILED` 71 | - if a dictionary is passed back, this will be used for the Data to be returned to CloudFormation and the function will 72 | return `SUCCESS`. 73 | - if a string is passed back, this will be put in the return attribute `Return` and the function will return `SUCCESS`. 74 | - if `None` or `True` is passed back, the function will return `SUCCESS` 75 | - if `False` is passed back, the function will return `FAILED` 76 | 77 | ### `@accustom.rdecorator()` 78 | 79 | This decorator, known as the "Resource Decorator" is used when you break the function into different resources, e.g. 80 | by making a decision based upon which `ResourceType` was passed to the handler and calling a function related to that 81 | resource. 82 | 83 | It takes the following option: 84 | - `decoratorHandleDelete` (Boolean) : When set to `True`, if a `Delete` request is made in `event` the decorator will 85 | return a `ResponseObject` with a with `SUCCESS` without actually executing the decorated function. 86 | - `genUUID` (Boolean) : When set to `True`, if the `PhysicalResourceId` in the `event` is not set, automatically 87 | generate a UUID4 and put it in the `PhysicalResoruceId` field. 88 | - `expectedProperties` (Array or Tuple) : Pass in a list or tuple of properties that you want to check for before 89 | running the decorated function. If any are missing, return `FAILED`. 90 | 91 | The most useful of these options is `expectedProperties`. With it is possible to quickly define mandatory properties 92 | for your resource and fail if they are not included. 93 | 94 | ### `@accustom.sdecorator()` 95 | This decorator is just a combination of `@accustom.decorator()` and `@accustom.rdecorator()`. This allows you to have a 96 | single, stand-alone resource handler that has some defined properties and can automatically handle delete. The options 97 | available to it is the combination of both of the options available to the other two Decorators, except for 98 | `redactProperties` which takes an accustom.StandaloneRedactionConfig object instead of an accustom.RedactionConfig 99 | object. For more information on `redactProperties` see "Redacting Confidential Information From Logs". 100 | 101 | The other important note about combining these two decorators is that `hideResourceDeleteFailure` becomes redundant if 102 | `decoratorHandleDelete` is set to `True`. 103 | 104 | ## Response Function and Object 105 | The `cfnresponse()` function and the `ResponseObject` are convenience function for interacting with CloudFormation. 106 | 107 | ### `cfnresponse()` 108 | `cfnresponse()` is a traditional function. At the very minimum it needs to take in the `event` and a status, `SUCCESS` 109 | or `FAILED`. In practice this function will likely not be used very often outside the library, but it is included for 110 | completeness. For more details look directly at the source code for this function. 111 | 112 | ### `ResponseObject` 113 | The `ResponseObject` allows you to define a message to be sent to CloudFormation. It only has one method, `send()`, 114 | which uses the `cfnresponse()` function under the hood to fire the event. A response object can be initialised and 115 | fired with: 116 | 117 | ```python 118 | import accustom 119 | 120 | def handler(event, context): 121 | r = accustom.ResponseObject() 122 | r.send(event) 123 | ``` 124 | 125 | If you are using the decorator pattern it is strongly recommended that you do not invoke the `send()` method, and 126 | instead allow the decorator to process the sending of the events for you by returning from your function. 127 | 128 | To construct a response object you can provide the following optional parameters: 129 | 130 | - `data` (Dictionary) : data to be passed in the response. Must be a dict if used 131 | - `physicalResourceId` (String) : Physical resource ID to be used in the response 132 | - `reason` (String) : Reason to pass back to CloudFormation in the response Object 133 | - `responseStatus` (accustom.Status): response Status to use in the response Object, defaults to `SUCCESS` 134 | - `squashPrintResponse` (Boolean) : In `DEBUG` logging the function will often print out the `Data` section of the 135 | response. If the `Data` contains confidential information you'll want to squash this output. This option, when set to 136 | `True`, will squash the output. 137 | 138 | ## Logging Recommendations 139 | The decorators utilise the [logging](https://docs.python.org/3/library/logging.html) library for logging. It is strongly 140 | recommended that your function does the same, and sets the logging level to at least `INFO`. Ensure the log level is set 141 | _before_ importing Accustom. 142 | 143 | ```python 144 | import logging 145 | logger = logging.getLogger(__name__) 146 | logging.getLogger().setLevel(logging.INFO) 147 | import accustom 148 | ``` 149 | 150 | ## Redacting Confidential Information From `DEBUG` Logs 151 | If you often pass confidential information like passwords and secrets in properties to Custom Resources, you may want to 152 | prevent certain properties from being printed to debug logs. To help with this we provide a functionality to either 153 | blocklist or allowlist Resource Properties based upon provided regular expressions. 154 | 155 | To utilise this functionality you must initialise and include a `RedactionConfig`. A `RedactionConfig` consists of some 156 | flags to define the redaction mode and if the response URL should be redacted, as well as a series of `RedactionRuleSet` 157 | objects that define what to redact based upon regular expressions. There is a special case of `RedactionConfig` called a 158 | `StandaloneRedactionConfig` that has one, and only one, `RedactionRuleSet` that is provided at initialisation. 159 | 160 | Each `RedactionRuleSet` defines a single regex that defines which ResourceTypes this rule set should be applied too. You 161 | can then apply any number of rules, based upon an explicit property name, or a regex. Please see the definitions, and an 162 | example below. 163 | 164 | ### `RedactionRuleSet` 165 | The `RedactionRuleSet` object allows you to define a series of properties or regexes which to allowlist or blocklist for 166 | a given resource type regex. It is initialised with the following: 167 | 168 | - `resourceRegex` (String) : The regex used to work out what resources to apply this too. 169 | 170 | #### `add_property_regex(propertiesRegex)` 171 | 172 | - `propertiesRegex` (String) : The regex used to work out what properties to allowlist/blocklist 173 | 174 | #### `add_property(propertyName)` 175 | 176 | - `propertyName` (String) : The name of the property to allowlist/blocklist 177 | 178 | 179 | ### `RedactionConfig` 180 | The `RedactionConfig` object allows you to create a collection of `RedactionRuleSet` objects as well as define what mode 181 | (allowlist/blocklist) to use, and if the presigned URL provided by CloudFormation should be redacted from the logs. 182 | 183 | - `redactMode` (accustom.RedactMode) : What redaction mode should be used, if it should be a blocklist or allowlist 184 | - `redactResponseURL` (Boolean) : If the response URL should be not be logged. 185 | 186 | #### `add_rule_set(ruleSet)` 187 | 188 | - `ruleSet` (accustom.RedactionRuleSet) : The rule set to be added to the RedactionConfig 189 | 190 | ### `StandaloneRedactionConfig` 191 | The `StandaloneRedactionConfig` object allows you to apply a single `RedactionRuleSet` object as well as define what 192 | mode (allowlist/blocklist) to use, and if the presigned URL provided by CloudFormation should be redacted from the logs. 193 | 194 | - `redactMode` (accustom.RedactMode) : What redaction mode should be used, if it should be a blocklist or allowlist 195 | - `redactResponseURL` (Boolean) : If the response URL should be not be logged. 196 | - `ruleSet` (accustom.RedactionRuleSet) : The rule set to be added to the RedactionConfig 197 | 198 | ### Example of Redaction 199 | 200 | The below example takes in two rule sets. The first ruleset applies to all resources types, and the second ruleset 201 | applies only to the `Custom::Test` resource type. 202 | 203 | All resources will have properties called `Test` and `Example` redacted and replaced with `[REDATED]`. The 204 | `Custom::Test` resource will also additionally redact properties called `Custom` and those that *start with* `DeleteMe`. 205 | 206 | Finally, as `redactResponseURL` is set to `True`, the response URL will not be printed in the debug logs. 207 | 208 | ```python3 209 | from accustom import RedactionRuleSet, RedactionConfig, decorator 210 | 211 | ruleSetDefault = RedactionRuleSet() 212 | ruleSetDefault.add_property_regex('^Test$') 213 | ruleSetDefault.add_property('Example') 214 | 215 | ruleSetCustom = RedactionRuleSet('^Custom::Test$') 216 | ruleSetCustom.add_property('Custom') 217 | ruleSetCustom.add_property_regex('^DeleteMe.*$') 218 | 219 | rc = RedactionConfig(redactResponseURL=True) 220 | rc.add_rule_set(ruleSetDefault) 221 | rc.add_rule_set(ruleSetCustom) 222 | 223 | @decorator(redactConfig=rc) 224 | def resource_handler(event, context): 225 | result = (float(event['ResourceProperties']['Test']) + 226 | float(event['ResourceProperties']['Example'])) 227 | return { 'sum' : result } 228 | ``` 229 | 230 | ## Note on Timeouts and Permissions 231 | The timeout is implemented using a *synchronous chained invocation* of your Lambda function. For this reason, please be 232 | aware of the following limitations: 233 | 234 | - The function must have access to the Lambda API Endpoints in order to self invoke. 235 | - The function must have permission to self invoke (i.e. lambda:InvokeFunction permission). 236 | 237 | If your requirements violate any of these conditions, set the `timeoutFunction` option to `False`. Please also note that 238 | this will *double* the invocations per request, so if you're not in the free tier for Lambda make sure you are aware of 239 | this as it may increase costs. 240 | 241 | ## Constants 242 | We provide three constants for ease of use: 243 | 244 | - Static value : how to access 245 | 246 | ### `Status` 247 | - `SUCCESS` : `accustom.Status.SUCCESS` 248 | - `FAILED` : `accustom.Status.FAILED` 249 | 250 | ### `RequestType` 251 | - `Create` : `accustom.RequestType.CREATE` 252 | - `Update` : `accustom.RequestType.UPDATE` 253 | - `Delete` : `accustom.RequestType.DELETE` 254 | 255 | ### `RedactMode` 256 | 257 | - Blocklisting : `accustom.RedactMode.BLOCKLIST` 258 | - Allowlisting : `accustom.RedactMode.ALLOWLIST` 259 | 260 | ## How to Contribute 261 | Feel free to open issues, fork, or submit a pull request: 262 | 263 | - Issue Tracker: [https://github.com/awslabs/cloudformation-accustom/issues](https://github.com/awslabs/cloudformation-accustom/issues) 264 | - Source Code: [https://github.com/awslabs/cloudformation-accustom](https://github.com/awslabs/cloudformation-accustom) 265 | -------------------------------------------------------------------------------- /THIRD-PARTY.txt: -------------------------------------------------------------------------------- 1 | ** boto3; version 1.17.5 -- https://pypi.org/project/boto3/ 2 | ** botocore; version 1.20.5 -- https://github.com/boto/botocore 3 | ** requests; version 2.25.1 -- https://github.com/psf/requests 4 | 5 | Apache License 6 | 7 | Version 2.0, January 2004 8 | 9 | http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND 10 | DISTRIBUTION 11 | 12 | 1. Definitions. 13 | 14 | "License" shall mean the terms and conditions for use, reproduction, and 15 | distribution as defined by Sections 1 through 9 of this document. 16 | 17 | "Licensor" shall mean the copyright owner or entity authorized by the 18 | copyright owner that is granting the License. 19 | 20 | "Legal Entity" shall mean the union of the acting entity and all other 21 | entities that control, are controlled by, or are under common control 22 | with that entity. For the purposes of this definition, "control" means 23 | (i) the power, direct or indirect, to cause the direction or management 24 | of such entity, whether by contract or otherwise, or (ii) ownership of 25 | fifty percent (50%) or more of the outstanding shares, or (iii) 26 | beneficial ownership of such entity. 27 | 28 | "You" (or "Your") shall mean an individual or Legal Entity exercising 29 | permissions granted by this License. 30 | 31 | "Source" form shall mean the preferred form for making modifications, 32 | including but not limited to software source code, documentation source, 33 | and configuration files. 34 | 35 | "Object" form shall mean any form resulting from mechanical 36 | transformation or translation of a Source form, including but not limited 37 | to compiled object code, generated documentation, and conversions to 38 | other media types. 39 | 40 | "Work" shall mean the work of authorship, whether in Source or Object 41 | form, made available under the License, as indicated by a copyright 42 | notice that is included in or attached to the work (an example is 43 | provided in the Appendix below). 44 | 45 | "Derivative Works" shall mean any work, whether in Source or Object form, 46 | that is based on (or derived from) the Work and for which the editorial 47 | revisions, annotations, elaborations, or other modifications represent, 48 | as a whole, an original work of authorship. For the purposes of this 49 | License, Derivative Works shall not include works that remain separable 50 | from, or merely link (or bind by name) to the interfaces of, the Work and 51 | Derivative Works thereof. 52 | 53 | "Contribution" shall mean any work of authorship, including the original 54 | version of the Work and any modifications or additions to that Work or 55 | Derivative Works thereof, that is intentionally submitted to Licensor for 56 | inclusion in the Work by the copyright owner or by an individual or Legal 57 | Entity authorized to submit on behalf of the copyright owner. For the 58 | purposes of this definition, "submitted" means any form of electronic, 59 | verbal, or written communication sent to the Licensor or its 60 | representatives, including but not limited to communication on electronic 61 | mailing lists, source code control systems, and issue tracking systems 62 | that are managed by, or on behalf of, the Licensor for the purpose of 63 | discussing and improving the Work, but excluding communication that is 64 | conspicuously marked or otherwise designated in writing by the copyright 65 | owner as "Not a Contribution." 66 | 67 | "Contributor" shall mean Licensor and any individual or Legal Entity on 68 | behalf of whom a Contribution has been received by Licensor and 69 | subsequently incorporated within the Work. 70 | 71 | 2. Grant of Copyright License. Subject to the terms and conditions of this 72 | License, each Contributor hereby grants to You a perpetual, worldwide, 73 | non-exclusive, no-charge, royalty-free, irrevocable copyright license to 74 | reproduce, prepare Derivative Works of, publicly display, publicly perform, 75 | sublicense, and distribute the Work and such Derivative Works in Source or 76 | Object form. 77 | 78 | 3. Grant of Patent License. Subject to the terms and conditions of this 79 | License, each Contributor hereby grants to You a perpetual, worldwide, 80 | non-exclusive, no-charge, royalty-free, irrevocable (except as stated in 81 | this section) patent license to make, have made, use, offer to sell, sell, 82 | import, and otherwise transfer the Work, where such license applies only to 83 | those patent claims licensable by such Contributor that are necessarily 84 | infringed by their Contribution(s) alone or by combination of their 85 | Contribution(s) with the Work to which such Contribution(s) was submitted. 86 | If You institute patent litigation against any entity (including a 87 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 88 | Contribution incorporated within the Work constitutes direct or contributory 89 | patent infringement, then any patent licenses granted to You under this 90 | License for that Work shall terminate as of the date such litigation is 91 | filed. 92 | 93 | 4. Redistribution. You may reproduce and distribute copies of the Work or 94 | Derivative Works thereof in any medium, with or without modifications, and 95 | in Source or Object form, provided that You meet the following conditions: 96 | 97 | (a) You must give any other recipients of the Work or Derivative Works a 98 | copy of this License; and 99 | 100 | (b) You must cause any modified files to carry prominent notices stating 101 | that You changed the files; and 102 | 103 | (c) You must retain, in the Source form of any Derivative Works that You 104 | distribute, all copyright, patent, trademark, and attribution notices 105 | from the Source form of the Work, excluding those notices that do not 106 | pertain to any part of the Derivative Works; and 107 | 108 | (d) If the Work includes a "NOTICE" text file as part of its 109 | distribution, then any Derivative Works that You distribute must include 110 | a readable copy of the attribution notices contained within such NOTICE 111 | file, excluding those notices that do not pertain to any part of the 112 | Derivative Works, in at least one of the following places: within a 113 | NOTICE text file distributed as part of the Derivative Works; within the 114 | Source form or documentation, if provided along with the Derivative 115 | Works; or, within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents of the 117 | NOTICE file are for informational purposes only and do not modify the 118 | License. You may add Your own attribution notices within Derivative Works 119 | that You distribute, alongside or as an addendum to the NOTICE text from 120 | the Work, provided that such additional attribution notices cannot be 121 | construed as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and may 124 | provide additional or different license terms and conditions for use, 125 | reproduction, or distribution of Your modifications, or for any such 126 | Derivative Works as a whole, provided Your use, reproduction, and 127 | distribution of the Work otherwise complies with the conditions stated in 128 | this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, any 131 | Contribution intentionally submitted for inclusion in the Work by You to the 132 | Licensor shall be under the terms and conditions of this License, without 133 | any additional terms or conditions. Notwithstanding the above, nothing 134 | herein shall supersede or modify the terms of any separate license agreement 135 | you may have executed with Licensor regarding such Contributions. 136 | 137 | 6. Trademarks. This License does not grant permission to use the trade 138 | names, trademarks, service marks, or product names of the Licensor, except 139 | as required for reasonable and customary use in describing the origin of the 140 | Work and reproducing the content of the NOTICE file. 141 | 142 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in 143 | writing, Licensor provides the Work (and each Contributor provides its 144 | Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 145 | KIND, either express or implied, including, without limitation, any 146 | warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or 147 | FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining 148 | the appropriateness of using or redistributing the Work and assume any risks 149 | associated with Your exercise of permissions under this License. 150 | 151 | 8. Limitation of Liability. In no event and under no legal theory, whether 152 | in tort (including negligence), contract, or otherwise, unless required by 153 | applicable law (such as deliberate and grossly negligent acts) or agreed to 154 | in writing, shall any Contributor be liable to You for damages, including 155 | any direct, indirect, special, incidental, or consequential damages of any 156 | character arising as a result of this License or out of the use or inability 157 | to use the Work (including but not limited to damages for loss of goodwill, 158 | work stoppage, computer failure or malfunction, or any and all other 159 | commercial damages or losses), even if such Contributor has been advised of 160 | the possibility of such damages. 161 | 162 | 9. Accepting Warranty or Additional Liability. While redistributing the Work 163 | or Derivative Works thereof, You may choose to offer, and charge a fee for, 164 | acceptance of support, warranty, indemnity, or other liability obligations 165 | and/or rights consistent with this License. However, in accepting such 166 | obligations, You may act only on Your own behalf and on Your sole 167 | responsibility, not on behalf of any other Contributor, and only if You 168 | agree to indemnify, defend, and hold each Contributor harmless for any 169 | liability incurred by, or claims asserted against, such Contributor by 170 | reason of your accepting any such warranty or additional liability. END OF 171 | TERMS AND CONDITIONS 172 | 173 | APPENDIX: How to apply the Apache License to your work. 174 | 175 | To apply the Apache License to your work, attach the following boilerplate 176 | notice, with the fields enclosed by brackets "[]" replaced with your own 177 | identifying information. (Don't include the brackets!) The text should be 178 | enclosed in the appropriate comment syntax for the file format. We also 179 | recommend that a file or class name and description of purpose be included on 180 | the same "printed page" as the copyright notice for easier identification 181 | within third-party archives. 182 | 183 | Copyright [yyyy] [name of copyright owner] 184 | 185 | Licensed under the Apache License, Version 2.0 (the "License"); 186 | 187 | you may not use this file except in compliance with the License. 188 | 189 | You may obtain a copy of the License at 190 | 191 | http://www.apache.org/licenses/LICENSE-2.0 192 | 193 | Unless required by applicable law or agreed to in writing, software 194 | 195 | distributed under the License is distributed on an "AS IS" BASIS, 196 | 197 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 198 | 199 | See the License for the specific language governing permissions and 200 | 201 | limitations under the License. 202 | 203 | * For boto3 see also this required NOTICE: 204 | boto3 205 | Copyright 2013-2017 Amazon.com, Inc. or its affiliates. All Rights 206 | Reserved. 207 | * For botocore see also this required NOTICE: 208 | Botocore 209 | Copyright 2012-2017 Amazon.com, Inc. or its affiliates. All Rights 210 | Reserved. 211 | * For requests see also this required NOTICE: 212 | Requests 213 | Copyright 2019 Kenneth Reitz 214 | -------------------------------------------------------------------------------- /pdm.lock: -------------------------------------------------------------------------------- 1 | # This file is @generated by PDM. 2 | # It is not intended for manual editing. 3 | 4 | [metadata] 5 | groups = ["default", "lint", "test"] 6 | cross_platform = true 7 | static_urls = false 8 | lock_version = "4.3" 9 | content_hash = "sha256:e9c4dd90489def2764e6ac28eea35bbb206a2360efb948759c7a33db01f1abfb" 10 | 11 | [[package]] 12 | name = "aws-lambda-typing" 13 | version = "2.17.0" 14 | requires_python = ">=3.6,<4.0" 15 | summary = "A package that provides type hints for AWS Lambda event, context and response objects" 16 | files = [ 17 | {file = "aws-lambda-typing-2.17.0.tar.gz", hash = "sha256:6a14a6e258a59200f163133ab17905dc7474c88bee32e440808d16a828327b5d"}, 18 | {file = "aws_lambda_typing-2.17.0-py3-none-any.whl", hash = "sha256:7b3e56cac5eedf98e64d84ba41e8a8b2bef239ac3a2db69a35e61f1f7d6154be"}, 19 | ] 20 | 21 | [[package]] 22 | name = "awscli" 23 | version = "1.27.109" 24 | requires_python = ">= 3.7" 25 | summary = "Universal Command Line Environment for AWS." 26 | dependencies = [ 27 | "PyYAML<5.5,>=3.10", 28 | "botocore==1.29.109", 29 | "colorama<0.4.5,>=0.2.5", 30 | "docutils<0.17,>=0.10", 31 | "rsa<4.8,>=3.1.2", 32 | "s3transfer<0.7.0,>=0.6.0", 33 | ] 34 | files = [ 35 | {file = "awscli-1.27.109-py3-none-any.whl", hash = "sha256:3ea60e0f046547b2fc03c7f1eac8716b9c79a64a2ed33c57e3dc2bc986659345"}, 36 | {file = "awscli-1.27.109.tar.gz", hash = "sha256:0ebac3b96a29536644e1427c2a4bedd9109b71a3e74481798c44c174c87e8bd3"}, 37 | ] 38 | 39 | [[package]] 40 | name = "black" 41 | version = "23.3.0" 42 | requires_python = ">=3.7" 43 | summary = "The uncompromising code formatter." 44 | dependencies = [ 45 | "click>=8.0.0", 46 | "mypy-extensions>=0.4.3", 47 | "packaging>=22.0", 48 | "pathspec>=0.9.0", 49 | "platformdirs>=2", 50 | "tomli>=1.1.0; python_version < \"3.11\"", 51 | "typing-extensions>=3.10.0.0; python_version < \"3.10\"", 52 | ] 53 | files = [ 54 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915"}, 55 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9"}, 56 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2"}, 57 | {file = "black-23.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c"}, 58 | {file = "black-23.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c"}, 59 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6"}, 60 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b"}, 61 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d"}, 62 | {file = "black-23.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70"}, 63 | {file = "black-23.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326"}, 64 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:3238f2aacf827d18d26db07524e44741233ae09a584273aa059066d644ca7b30"}, 65 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:f0bd2f4a58d6666500542b26354978218a9babcdc972722f4bf90779524515f3"}, 66 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:92c543f6854c28a3c7f39f4d9b7694f9a6eb9d3c5e2ece488c327b6e7ea9b266"}, 67 | {file = "black-23.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a150542a204124ed00683f0db1f5cf1c2aaaa9cc3495b7a3b5976fb136090ab"}, 68 | {file = "black-23.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:6b39abdfb402002b8a7d030ccc85cf5afff64ee90fa4c5aebc531e3ad0175ddb"}, 69 | {file = "black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4"}, 70 | {file = "black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"}, 71 | ] 72 | 73 | [[package]] 74 | name = "boto3" 75 | version = "1.26.109" 76 | requires_python = ">= 3.7" 77 | summary = "The AWS SDK for Python" 78 | dependencies = [ 79 | "botocore<1.30.0,>=1.29.109", 80 | "jmespath<2.0.0,>=0.7.1", 81 | "s3transfer<0.7.0,>=0.6.0", 82 | ] 83 | files = [ 84 | {file = "boto3-1.26.109-py3-none-any.whl", hash = "sha256:a1c48674c2ff25ddced599c51d14ba7a6dbe9fd51641e8d63a6887bebd9712d7"}, 85 | {file = "boto3-1.26.109.tar.gz", hash = "sha256:d388cb7f54f1a3056f91ffcfb5cf18b226454204e5df7a5c10774718c3fbb166"}, 86 | ] 87 | 88 | [[package]] 89 | name = "boto3-stubs" 90 | version = "1.28.20" 91 | requires_python = ">=3.7" 92 | summary = "Type annotations for boto3 1.28.20 generated with mypy-boto3-builder 7.17.2" 93 | dependencies = [ 94 | "botocore-stubs", 95 | "types-s3transfer", 96 | ] 97 | files = [ 98 | {file = "boto3-stubs-1.28.20.tar.gz", hash = "sha256:21050c5dc37a8f34dfe7ed8b1b12d1fd9ff187eaf4bd9bfa187585e07e6b6b76"}, 99 | {file = "boto3_stubs-1.28.20-py3-none-any.whl", hash = "sha256:e08cc66bf744e12cbdcbcd64ab520900ee5fc61c22bceefe08a2f04cdb1d5c0c"}, 100 | ] 101 | 102 | [[package]] 103 | name = "boto3-stubs" 104 | version = "1.28.20" 105 | extras = ["cloudformation"] 106 | requires_python = ">=3.7" 107 | summary = "Type annotations for boto3 1.28.20 generated with mypy-boto3-builder 7.17.2" 108 | dependencies = [ 109 | "boto3-stubs==1.28.20", 110 | "mypy-boto3-cloudformation<1.29.0,>=1.28.0", 111 | ] 112 | files = [ 113 | {file = "boto3-stubs-1.28.20.tar.gz", hash = "sha256:21050c5dc37a8f34dfe7ed8b1b12d1fd9ff187eaf4bd9bfa187585e07e6b6b76"}, 114 | {file = "boto3_stubs-1.28.20-py3-none-any.whl", hash = "sha256:e08cc66bf744e12cbdcbcd64ab520900ee5fc61c22bceefe08a2f04cdb1d5c0c"}, 115 | ] 116 | 117 | [[package]] 118 | name = "boto3-stubs" 119 | version = "1.28.20" 120 | extras = ["logs"] 121 | requires_python = ">=3.7" 122 | summary = "Type annotations for boto3 1.28.20 generated with mypy-boto3-builder 7.17.2" 123 | dependencies = [ 124 | "boto3-stubs==1.28.20", 125 | "mypy-boto3-logs<1.29.0,>=1.28.0", 126 | ] 127 | files = [ 128 | {file = "boto3-stubs-1.28.20.tar.gz", hash = "sha256:21050c5dc37a8f34dfe7ed8b1b12d1fd9ff187eaf4bd9bfa187585e07e6b6b76"}, 129 | {file = "boto3_stubs-1.28.20-py3-none-any.whl", hash = "sha256:e08cc66bf744e12cbdcbcd64ab520900ee5fc61c22bceefe08a2f04cdb1d5c0c"}, 130 | ] 131 | 132 | [[package]] 133 | name = "botocore" 134 | version = "1.29.109" 135 | requires_python = ">= 3.7" 136 | summary = "Low-level, data-driven core of boto 3." 137 | dependencies = [ 138 | "jmespath<2.0.0,>=0.7.1", 139 | "python-dateutil<3.0.0,>=2.1", 140 | "urllib3<1.27,>=1.25.4", 141 | ] 142 | files = [ 143 | {file = "botocore-1.29.109-py3-none-any.whl", hash = "sha256:cf43dddb7e2ba5425fe19fad68b10043307b61d9103d06566f1ab6034e38b8db"}, 144 | {file = "botocore-1.29.109.tar.gz", hash = "sha256:2e449525f0ccedb31fdb962a77caac48b4c486c23515b84c5989a39a1823a024"}, 145 | ] 146 | 147 | [[package]] 148 | name = "botocore-stubs" 149 | version = "1.29.109" 150 | requires_python = ">=3.7,<4.0" 151 | summary = "Type annotations and code completion for botocore" 152 | dependencies = [ 153 | "types-awscrt", 154 | ] 155 | files = [ 156 | {file = "botocore_stubs-1.29.109-py3-none-any.whl", hash = "sha256:e3181ee8493384ebf70f12d56a35341dfbaa7b34e3f241ed01c58df89ee8410b"}, 157 | {file = "botocore_stubs-1.29.109.tar.gz", hash = "sha256:5464cd79076d4f9d36c3163167c3d435fd73367168345f9637c4b8e88f5b54fe"}, 158 | ] 159 | 160 | [[package]] 161 | name = "certifi" 162 | version = "2022.12.7" 163 | requires_python = ">=3.6" 164 | summary = "Python package for providing Mozilla's CA Bundle." 165 | files = [ 166 | {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, 167 | {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, 168 | ] 169 | 170 | [[package]] 171 | name = "cfgv" 172 | version = "3.3.1" 173 | requires_python = ">=3.6.1" 174 | summary = "Validate configuration and produce human readable error messages." 175 | files = [ 176 | {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, 177 | {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, 178 | ] 179 | 180 | [[package]] 181 | name = "charset-normalizer" 182 | version = "3.1.0" 183 | requires_python = ">=3.7.0" 184 | summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 185 | files = [ 186 | {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"}, 187 | {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"}, 188 | {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"}, 189 | {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"}, 190 | {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"}, 191 | {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"}, 192 | {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"}, 193 | {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"}, 194 | {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"}, 195 | {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"}, 196 | {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"}, 197 | {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"}, 198 | {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"}, 199 | {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"}, 200 | {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"}, 201 | {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"}, 202 | {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"}, 203 | {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"}, 204 | {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"}, 205 | {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"}, 206 | {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"}, 207 | {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"}, 208 | {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"}, 209 | {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"}, 210 | {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"}, 211 | {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"}, 212 | {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"}, 213 | {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"}, 214 | {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"}, 215 | {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"}, 216 | {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"}, 217 | {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"}, 218 | {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"}, 219 | {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"}, 220 | {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"}, 221 | {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"}, 222 | {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"}, 223 | {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"}, 224 | {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"}, 225 | {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"}, 226 | {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"}, 227 | {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"}, 228 | {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"}, 229 | {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"}, 230 | {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"}, 231 | {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"}, 232 | {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"}, 233 | ] 234 | 235 | [[package]] 236 | name = "click" 237 | version = "8.1.3" 238 | requires_python = ">=3.7" 239 | summary = "Composable command line interface toolkit" 240 | dependencies = [ 241 | "colorama; platform_system == \"Windows\"", 242 | ] 243 | files = [ 244 | {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 245 | {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 246 | ] 247 | 248 | [[package]] 249 | name = "colorama" 250 | version = "0.4.4" 251 | requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 252 | summary = "Cross-platform colored terminal text." 253 | files = [ 254 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 255 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 256 | ] 257 | 258 | [[package]] 259 | name = "distlib" 260 | version = "0.3.6" 261 | summary = "Distribution utilities" 262 | files = [ 263 | {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, 264 | {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, 265 | ] 266 | 267 | [[package]] 268 | name = "docutils" 269 | version = "0.16" 270 | requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 271 | summary = "Docutils -- Python Documentation Utilities" 272 | files = [ 273 | {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, 274 | {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, 275 | ] 276 | 277 | [[package]] 278 | name = "exceptiongroup" 279 | version = "1.1.1" 280 | requires_python = ">=3.7" 281 | summary = "Backport of PEP 654 (exception groups)" 282 | files = [ 283 | {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, 284 | {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, 285 | ] 286 | 287 | [[package]] 288 | name = "filelock" 289 | version = "3.11.0" 290 | requires_python = ">=3.7" 291 | summary = "A platform independent file lock." 292 | files = [ 293 | {file = "filelock-3.11.0-py3-none-any.whl", hash = "sha256:f08a52314748335c6460fc8fe40cd5638b85001225db78c2aa01c8c0db83b318"}, 294 | {file = "filelock-3.11.0.tar.gz", hash = "sha256:3618c0da67adcc0506b015fd11ef7faf1b493f0b40d87728e19986b536890c37"}, 295 | ] 296 | 297 | [[package]] 298 | name = "flake8" 299 | version = "6.0.0" 300 | requires_python = ">=3.8.1" 301 | summary = "the modular source code checker: pep8 pyflakes and co" 302 | dependencies = [ 303 | "mccabe<0.8.0,>=0.7.0", 304 | "pycodestyle<2.11.0,>=2.10.0", 305 | "pyflakes<3.1.0,>=3.0.0", 306 | ] 307 | files = [ 308 | {file = "flake8-6.0.0-py2.py3-none-any.whl", hash = "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7"}, 309 | {file = "flake8-6.0.0.tar.gz", hash = "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181"}, 310 | ] 311 | 312 | [[package]] 313 | name = "flake8-pyproject" 314 | version = "1.2.3" 315 | requires_python = ">= 3.6" 316 | summary = "Flake8 plug-in loading the configuration from pyproject.toml" 317 | dependencies = [ 318 | "Flake8>=5", 319 | "TOMLi; python_version < \"3.11\"", 320 | ] 321 | files = [ 322 | {file = "flake8_pyproject-1.2.3-py3-none-any.whl", hash = "sha256:6249fe53545205af5e76837644dc80b4c10037e73a0e5db87ff562d75fb5bd4a"}, 323 | ] 324 | 325 | [[package]] 326 | name = "identify" 327 | version = "2.5.22" 328 | requires_python = ">=3.7" 329 | summary = "File identification library for Python" 330 | files = [ 331 | {file = "identify-2.5.22-py2.py3-none-any.whl", hash = "sha256:f0faad595a4687053669c112004178149f6c326db71ee999ae4636685753ad2f"}, 332 | {file = "identify-2.5.22.tar.gz", hash = "sha256:f7a93d6cf98e29bd07663c60728e7a4057615068d7a639d132dc883b2d54d31e"}, 333 | ] 334 | 335 | [[package]] 336 | name = "idna" 337 | version = "3.4" 338 | requires_python = ">=3.5" 339 | summary = "Internationalized Domain Names in Applications (IDNA)" 340 | files = [ 341 | {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, 342 | {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, 343 | ] 344 | 345 | [[package]] 346 | name = "iniconfig" 347 | version = "2.0.0" 348 | requires_python = ">=3.7" 349 | summary = "brain-dead simple config-ini parsing" 350 | files = [ 351 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 352 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 353 | ] 354 | 355 | [[package]] 356 | name = "isort" 357 | version = "5.12.0" 358 | requires_python = ">=3.8.0" 359 | summary = "A Python utility / library to sort Python imports." 360 | files = [ 361 | {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, 362 | {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, 363 | ] 364 | 365 | [[package]] 366 | name = "jmespath" 367 | version = "1.0.1" 368 | requires_python = ">=3.7" 369 | summary = "JSON Matching Expressions" 370 | files = [ 371 | {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, 372 | {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, 373 | ] 374 | 375 | [[package]] 376 | name = "mccabe" 377 | version = "0.7.0" 378 | requires_python = ">=3.6" 379 | summary = "McCabe checker, plugin for flake8" 380 | files = [ 381 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 382 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 383 | ] 384 | 385 | [[package]] 386 | name = "mypy" 387 | version = "1.2.0" 388 | requires_python = ">=3.7" 389 | summary = "Optional static typing for Python" 390 | dependencies = [ 391 | "mypy-extensions>=1.0.0", 392 | "tomli>=1.1.0; python_version < \"3.11\"", 393 | "typing-extensions>=3.10", 394 | ] 395 | files = [ 396 | {file = "mypy-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:701189408b460a2ff42b984e6bd45c3f41f0ac9f5f58b8873bbedc511900086d"}, 397 | {file = "mypy-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fe91be1c51c90e2afe6827601ca14353bbf3953f343c2129fa1e247d55fd95ba"}, 398 | {file = "mypy-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d26b513225ffd3eacece727f4387bdce6469192ef029ca9dd469940158bc89e"}, 399 | {file = "mypy-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3a2d219775a120581a0ae8ca392b31f238d452729adbcb6892fa89688cb8306a"}, 400 | {file = "mypy-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:2e93a8a553e0394b26c4ca683923b85a69f7ccdc0139e6acd1354cc884fe0128"}, 401 | {file = "mypy-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3efde4af6f2d3ccf58ae825495dbb8d74abd6d176ee686ce2ab19bd025273f41"}, 402 | {file = "mypy-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:695c45cea7e8abb6f088a34a6034b1d273122e5530aeebb9c09626cea6dca4cb"}, 403 | {file = "mypy-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0e9464a0af6715852267bf29c9553e4555b61f5904a4fc538547a4d67617937"}, 404 | {file = "mypy-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8293a216e902ac12779eb7a08f2bc39ec6c878d7c6025aa59464e0c4c16f7eb9"}, 405 | {file = "mypy-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:f46af8d162f3d470d8ffc997aaf7a269996d205f9d746124a179d3abe05ac602"}, 406 | {file = "mypy-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:70894c5345bea98321a2fe84df35f43ee7bb0feec117a71420c60459fc3e1eed"}, 407 | {file = "mypy-1.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4a99fe1768925e4a139aace8f3fb66db3576ee1c30b9c0f70f744ead7e329c9f"}, 408 | {file = "mypy-1.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:023fe9e618182ca6317ae89833ba422c411469156b690fde6a315ad10695a521"}, 409 | {file = "mypy-1.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4d19f1a239d59f10fdc31263d48b7937c585810288376671eaf75380b074f238"}, 410 | {file = "mypy-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:2de7babe398cb7a85ac7f1fd5c42f396c215ab3eff731b4d761d68d0f6a80f48"}, 411 | {file = "mypy-1.2.0-py3-none-any.whl", hash = "sha256:d8e9187bfcd5ffedbe87403195e1fc340189a68463903c39e2b63307c9fa0394"}, 412 | {file = "mypy-1.2.0.tar.gz", hash = "sha256:f70a40410d774ae23fcb4afbbeca652905a04de7948eaf0b1789c8d1426b72d1"}, 413 | ] 414 | 415 | [[package]] 416 | name = "mypy-boto3-cloudformation" 417 | version = "1.28.19" 418 | requires_python = ">=3.7" 419 | summary = "Type annotations for boto3.CloudFormation 1.28.19 service generated with mypy-boto3-builder 7.17.2" 420 | files = [ 421 | {file = "mypy-boto3-cloudformation-1.28.19.tar.gz", hash = "sha256:efb08a2a6d7c744d0d8d60f04514c531355aa7972b53f025d9e08e3adf3a5504"}, 422 | {file = "mypy_boto3_cloudformation-1.28.19-py3-none-any.whl", hash = "sha256:aadf78eb2f2e3b2e83a4844a80d0c5d0d72ad11c453a11efdd28b0c309b05bf6"}, 423 | ] 424 | 425 | [[package]] 426 | name = "mypy-boto3-logs" 427 | version = "1.28.16" 428 | requires_python = ">=3.7" 429 | summary = "Type annotations for boto3.CloudWatchLogs 1.28.16 service generated with mypy-boto3-builder 7.17.1" 430 | files = [ 431 | {file = "mypy-boto3-logs-1.28.16.tar.gz", hash = "sha256:2d6c613f17ecafff8d56ccdadc6642d1abdbd4674434a683ca8966304e201220"}, 432 | {file = "mypy_boto3_logs-1.28.16-py3-none-any.whl", hash = "sha256:f8998bf7df00f712d507e6f4a830841e8b3806a865871dafdd03e4d06072e658"}, 433 | ] 434 | 435 | [[package]] 436 | name = "mypy-extensions" 437 | version = "1.0.0" 438 | requires_python = ">=3.5" 439 | summary = "Type system extensions for programs checked with the mypy type checker." 440 | files = [ 441 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 442 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 443 | ] 444 | 445 | [[package]] 446 | name = "nodeenv" 447 | version = "1.7.0" 448 | requires_python = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" 449 | summary = "Node.js virtual environment builder" 450 | dependencies = [ 451 | "setuptools", 452 | ] 453 | files = [ 454 | {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, 455 | {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, 456 | ] 457 | 458 | [[package]] 459 | name = "packaging" 460 | version = "23.0" 461 | requires_python = ">=3.7" 462 | summary = "Core utilities for Python packages" 463 | files = [ 464 | {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, 465 | {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, 466 | ] 467 | 468 | [[package]] 469 | name = "pathspec" 470 | version = "0.11.1" 471 | requires_python = ">=3.7" 472 | summary = "Utility library for gitignore style pattern matching of file paths." 473 | files = [ 474 | {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, 475 | {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, 476 | ] 477 | 478 | [[package]] 479 | name = "platformdirs" 480 | version = "3.2.0" 481 | requires_python = ">=3.7" 482 | summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 483 | files = [ 484 | {file = "platformdirs-3.2.0-py3-none-any.whl", hash = "sha256:ebe11c0d7a805086e99506aa331612429a72ca7cd52a1f0d277dc4adc20cb10e"}, 485 | {file = "platformdirs-3.2.0.tar.gz", hash = "sha256:d5b638ca397f25f979350ff789db335903d7ea010ab28903f57b27e1b16c2b08"}, 486 | ] 487 | 488 | [[package]] 489 | name = "pluggy" 490 | version = "1.0.0" 491 | requires_python = ">=3.6" 492 | summary = "plugin and hook calling mechanisms for python" 493 | files = [ 494 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 495 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 496 | ] 497 | 498 | [[package]] 499 | name = "pre-commit" 500 | version = "3.2.2" 501 | requires_python = ">=3.8" 502 | summary = "A framework for managing and maintaining multi-language pre-commit hooks." 503 | dependencies = [ 504 | "cfgv>=2.0.0", 505 | "identify>=1.0.0", 506 | "nodeenv>=0.11.1", 507 | "pyyaml>=5.1", 508 | "virtualenv>=20.10.0", 509 | ] 510 | files = [ 511 | {file = "pre_commit-3.2.2-py2.py3-none-any.whl", hash = "sha256:0b4210aea813fe81144e87c5a291f09ea66f199f367fa1df41b55e1d26e1e2b4"}, 512 | {file = "pre_commit-3.2.2.tar.gz", hash = "sha256:5b808fcbda4afbccf6d6633a56663fed35b6c2bc08096fd3d47ce197ac351d9d"}, 513 | ] 514 | 515 | [[package]] 516 | name = "pyasn1" 517 | version = "0.4.8" 518 | summary = "ASN.1 types and codecs" 519 | files = [ 520 | {file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"}, 521 | {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, 522 | ] 523 | 524 | [[package]] 525 | name = "pycodestyle" 526 | version = "2.10.0" 527 | requires_python = ">=3.6" 528 | summary = "Python style guide checker" 529 | files = [ 530 | {file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"}, 531 | {file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"}, 532 | ] 533 | 534 | [[package]] 535 | name = "pyflakes" 536 | version = "3.0.1" 537 | requires_python = ">=3.6" 538 | summary = "passive checker of Python programs" 539 | files = [ 540 | {file = "pyflakes-3.0.1-py2.py3-none-any.whl", hash = "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf"}, 541 | {file = "pyflakes-3.0.1.tar.gz", hash = "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd"}, 542 | ] 543 | 544 | [[package]] 545 | name = "pytest" 546 | version = "7.3.0" 547 | requires_python = ">=3.7" 548 | summary = "pytest: simple powerful testing with Python" 549 | dependencies = [ 550 | "colorama; sys_platform == \"win32\"", 551 | "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", 552 | "iniconfig", 553 | "packaging", 554 | "pluggy<2.0,>=0.12", 555 | "tomli>=1.0.0; python_version < \"3.11\"", 556 | ] 557 | files = [ 558 | {file = "pytest-7.3.0-py3-none-any.whl", hash = "sha256:933051fa1bfbd38a21e73c3960cebdad4cf59483ddba7696c48509727e17f201"}, 559 | {file = "pytest-7.3.0.tar.gz", hash = "sha256:58ecc27ebf0ea643ebfdf7fb1249335da761a00c9f955bcd922349bcb68ee57d"}, 560 | ] 561 | 562 | [[package]] 563 | name = "python-dateutil" 564 | version = "2.8.2" 565 | requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 566 | summary = "Extensions to the standard Python datetime module" 567 | dependencies = [ 568 | "six>=1.5", 569 | ] 570 | files = [ 571 | {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, 572 | {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, 573 | ] 574 | 575 | [[package]] 576 | name = "pyyaml" 577 | version = "5.4.1" 578 | requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 579 | summary = "YAML parser and emitter for Python" 580 | files = [ 581 | {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, 582 | {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, 583 | {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"}, 584 | {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"}, 585 | {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, 586 | {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, 587 | {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, 588 | ] 589 | 590 | [[package]] 591 | name = "requests" 592 | version = "2.28.2" 593 | requires_python = ">=3.7, <4" 594 | summary = "Python HTTP for Humans." 595 | dependencies = [ 596 | "certifi>=2017.4.17", 597 | "charset-normalizer<4,>=2", 598 | "idna<4,>=2.5", 599 | "urllib3<1.27,>=1.21.1", 600 | ] 601 | files = [ 602 | {file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"}, 603 | {file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"}, 604 | ] 605 | 606 | [[package]] 607 | name = "rsa" 608 | version = "4.7.2" 609 | requires_python = ">=3.5, <4" 610 | summary = "Pure-Python RSA implementation" 611 | dependencies = [ 612 | "pyasn1>=0.1.3", 613 | ] 614 | files = [ 615 | {file = "rsa-4.7.2-py3-none-any.whl", hash = "sha256:78f9a9bf4e7be0c5ded4583326e7461e3a3c5aae24073648b4bdfa797d78c9d2"}, 616 | {file = "rsa-4.7.2.tar.gz", hash = "sha256:9d689e6ca1b3038bc82bf8d23e944b6b6037bc02301a574935b2dd946e0353b9"}, 617 | ] 618 | 619 | [[package]] 620 | name = "s3transfer" 621 | version = "0.6.0" 622 | requires_python = ">= 3.7" 623 | summary = "An Amazon S3 Transfer Manager" 624 | dependencies = [ 625 | "botocore<2.0a.0,>=1.12.36", 626 | ] 627 | files = [ 628 | {file = "s3transfer-0.6.0-py3-none-any.whl", hash = "sha256:06176b74f3a15f61f1b4f25a1fc29a4429040b7647133a463da8fa5bd28d5ecd"}, 629 | {file = "s3transfer-0.6.0.tar.gz", hash = "sha256:2ed07d3866f523cc561bf4a00fc5535827981b117dd7876f036b0c1aca42c947"}, 630 | ] 631 | 632 | [[package]] 633 | name = "setuptools" 634 | version = "67.6.1" 635 | requires_python = ">=3.7" 636 | summary = "Easily download, build, install, upgrade, and uninstall Python packages" 637 | files = [ 638 | {file = "setuptools-67.6.1-py3-none-any.whl", hash = "sha256:e728ca814a823bf7bf60162daf9db95b93d532948c4c0bea762ce62f60189078"}, 639 | {file = "setuptools-67.6.1.tar.gz", hash = "sha256:257de92a9d50a60b8e22abfcbb771571fde0dbf3ec234463212027a4eeecbe9a"}, 640 | ] 641 | 642 | [[package]] 643 | name = "six" 644 | version = "1.16.0" 645 | requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 646 | summary = "Python 2 and 3 compatibility utilities" 647 | files = [ 648 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 649 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 650 | ] 651 | 652 | [[package]] 653 | name = "tomli" 654 | version = "2.0.1" 655 | requires_python = ">=3.7" 656 | summary = "A lil' TOML parser" 657 | files = [ 658 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 659 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 660 | ] 661 | 662 | [[package]] 663 | name = "types-awscrt" 664 | version = "0.16.13.post1" 665 | requires_python = ">=3.7,<4.0" 666 | summary = "Type annotations and code completion for awscrt" 667 | files = [ 668 | {file = "types_awscrt-0.16.13.post1-py3-none-any.whl", hash = "sha256:697c52422bc3f24302402139ec4511723feb990b5a36a8505a941bbbee1322d5"}, 669 | {file = "types_awscrt-0.16.13.post1.tar.gz", hash = "sha256:7f537fc433264a748145ae1148a7a61b33b6f5492d73ef51e5deb1ff8d5d1787"}, 670 | ] 671 | 672 | [[package]] 673 | name = "types-requests" 674 | version = "2.28.11.17" 675 | summary = "Typing stubs for requests" 676 | dependencies = [ 677 | "types-urllib3<1.27", 678 | ] 679 | files = [ 680 | {file = "types-requests-2.28.11.17.tar.gz", hash = "sha256:0d580652ce903f643f8c3b494dd01d29367ea57cea0c7ad7f65cf3169092edb0"}, 681 | {file = "types_requests-2.28.11.17-py3-none-any.whl", hash = "sha256:cc1aba862575019306b2ed134eb1ea994cab1c887a22e18d3383e6dd42e9789b"}, 682 | ] 683 | 684 | [[package]] 685 | name = "types-s3transfer" 686 | version = "0.6.0.post7" 687 | requires_python = ">=3.7,<4.0" 688 | summary = "Type annotations and code completion for s3transfer" 689 | dependencies = [ 690 | "types-awscrt", 691 | ] 692 | files = [ 693 | {file = "types_s3transfer-0.6.0.post7-py3-none-any.whl", hash = "sha256:d9c669b30fdd61347720434aacb8ecc4645d900712a70b10f495104f9039c07b"}, 694 | {file = "types_s3transfer-0.6.0.post7.tar.gz", hash = "sha256:40e665643f0647832d51c4a26d8a8275cda9134b02bf22caf28198b79bcad382"}, 695 | ] 696 | 697 | [[package]] 698 | name = "types-urllib3" 699 | version = "1.26.25.10" 700 | summary = "Typing stubs for urllib3" 701 | files = [ 702 | {file = "types-urllib3-1.26.25.10.tar.gz", hash = "sha256:c44881cde9fc8256d05ad6b21f50c4681eb20092552351570ab0a8a0653286d6"}, 703 | {file = "types_urllib3-1.26.25.10-py3-none-any.whl", hash = "sha256:12c744609d588340a07e45d333bf870069fc8793bcf96bae7a96d4712a42591d"}, 704 | ] 705 | 706 | [[package]] 707 | name = "typing-extensions" 708 | version = "4.5.0" 709 | requires_python = ">=3.7" 710 | summary = "Backported and Experimental Type Hints for Python 3.7+" 711 | files = [ 712 | {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, 713 | {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, 714 | ] 715 | 716 | [[package]] 717 | name = "urllib3" 718 | version = "1.26.15" 719 | requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 720 | summary = "HTTP library with thread-safe connection pooling, file post, and more." 721 | files = [ 722 | {file = "urllib3-1.26.15-py2.py3-none-any.whl", hash = "sha256:aa751d169e23c7479ce47a0cb0da579e3ede798f994f5816a74e4f4500dcea42"}, 723 | {file = "urllib3-1.26.15.tar.gz", hash = "sha256:8a388717b9476f934a21484e8c8e61875ab60644d29b9b39e11e4b9dc1c6b305"}, 724 | ] 725 | 726 | [[package]] 727 | name = "virtualenv" 728 | version = "20.21.0" 729 | requires_python = ">=3.7" 730 | summary = "Virtual Python Environment builder" 731 | dependencies = [ 732 | "distlib<1,>=0.3.6", 733 | "filelock<4,>=3.4.1", 734 | "platformdirs<4,>=2.4", 735 | ] 736 | files = [ 737 | {file = "virtualenv-20.21.0-py3-none-any.whl", hash = "sha256:31712f8f2a17bd06234fa97fdf19609e789dd4e3e4bf108c3da71d710651adbc"}, 738 | {file = "virtualenv-20.21.0.tar.gz", hash = "sha256:f50e3e60f990a0757c9b68333c9fdaa72d7188caa417f96af9e52407831a3b68"}, 739 | ] 740 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["pdm-backend", ] 3 | build-backend = "pdm.backend" 4 | 5 | [project] 6 | name = "accustom" 7 | description = "Accustom is a library for responding to Custom Resources in AWS CloudFormation using the decorator pattern." 8 | authors = [ 9 | { name = "Taylor Bertie", email = "bertiet@amazon.com" }, 10 | ] 11 | dependencies = [ 12 | "boto3~=1.26", 13 | "botocore~=1.29", 14 | "requests~=2.28", 15 | "typing-extensions~=4.5", 16 | ] 17 | requires-python = ">=3.9" 18 | readme = "README.md" 19 | keywords = [ 20 | "cloudformation", 21 | "lambda", 22 | "custom", 23 | "resource", 24 | "decorator", 25 | ] 26 | classifiers = [ 27 | "Development Status :: 4 - Beta", 28 | "Intended Audience :: Developers", 29 | "License :: OSI Approved :: Apache Software License", 30 | "Programming Language :: Python :: 3", 31 | "Programming Language :: Python :: 3.9", 32 | "Topic :: Software Development :: Libraries :: Application Frameworks", 33 | ] 34 | dynamic = ["version"] 35 | 36 | [tool.commitizen] 37 | name = "cz_conventional_commits" 38 | version = "1.4.0" 39 | tag_format = "$version" 40 | 41 | [project.license] 42 | text = "Apache-2.0" 43 | 44 | [project.urls] 45 | Homepage = "https://github.com/awslabs/cloudformation-accustom" 46 | 47 | [tool.pdm.build] 48 | includes = ["src"] 49 | package-dir = "src" 50 | 51 | [tool.pdm.version] 52 | source = "scm" 53 | 54 | [tool.pdm.dev-dependencies] 55 | test = [ 56 | "pytest~=7.3", 57 | "awscli~=1.27", 58 | ] 59 | lint = [ 60 | "black~=23.3", 61 | "Flake8-pyproject~=1.2", 62 | "flake8~=6.0", 63 | "isort~=5.12", 64 | "pre-commit~=3.2", 65 | "mypy~=1.2", 66 | "boto3-stubs[cloudformation]~=1.28", 67 | "aws-lambda-typing~=2.17", 68 | "types-requests~=2.28", 69 | "pyyaml~=5.4", 70 | ] 71 | 72 | [tool.pdm.scripts.flake8] 73 | cmd = "flake8" 74 | help = "Run the flake8 the code" 75 | site_packages = true 76 | 77 | [tool.pdm.scripts.isort] 78 | cmd = "isort ." 79 | help = "Run isort against the code" 80 | site_packages = true 81 | 82 | [tool.pdm.scripts.black] 83 | cmd = "black ." 84 | help = "Run black against the code" 85 | site_packages = true 86 | 87 | [tool.pdm.scripts.mypy] 88 | cmd = "mypy ." 89 | help = "Run mypy against the code" 90 | site_packages = true 91 | 92 | [tool.pdm.scripts._require_base] 93 | cmd = "pdm export --format requirements --without-hashes" 94 | help = "Base command for outputting requirements" 95 | 96 | [tool.pdm.scripts.require] 97 | composite = [ 98 | "_require_base --output requirements.txt --prod", 99 | "_require_base --output requirements_test.txt --dev --group build", 100 | "_require_base --output requirements_lint.txt --dev --group lint" 101 | ] 102 | help = "Generates requirements.txt and requirements_lint.txt for reference/manual install" 103 | 104 | [tool.pdm.scripts.int_make] 105 | cmd = "make --directory tests/int" 106 | help = "Executes an arbitary make command againsts the integration tests" 107 | 108 | [tool.pdm.scripts.lint] 109 | cmd = "pre-commit run --all-files" 110 | help = "Check code style against linters using pre-commit" 111 | site_packages = true 112 | 113 | [tool.pdm.scripts.post_lock] 114 | composite = [ 115 | "require", 116 | ] 117 | help = "post_lock Hook: Generate requirements.txt and requirement_build.txt for reference/manual install" 118 | 119 | [tool.pdm.scripts.int_test] 120 | cmd = "pytest" 121 | help = "Run all tests including intergration tests" 122 | site_packages = true 123 | 124 | [tool.pdm.scripts.pre_int_test] 125 | composite = [ 126 | "require", 127 | "pdm install --dev --group test", 128 | "int_make unexecute", 129 | "int_make execute", 130 | ] 131 | help = "pre_test hook: Installs the packages and prepares enviroments needed to perform integration testing" 132 | 133 | [tool.pdm.scripts.post_int_test] 134 | composite = [ 135 | "int_make clean", 136 | ] 137 | help = "post_test hook: Cleans up enviroments needed to perform integration testing" 138 | 139 | [tool.pdm.scripts.test] 140 | cmd = "pytest --ignore='tests/int'" 141 | help = "Run only unit testing" 142 | site_packages = true 143 | 144 | [tool.pdm.scripts.pre_test] 145 | composite = [ 146 | "require", 147 | "pdm install --dev --group test", 148 | ] 149 | help = "pre_test hook: Installs the packages needed to perform unit testing" 150 | 151 | [tool.pdm.scripts.pre_lint] 152 | composite = [ 153 | "pdm install --dev --group lint", 154 | ] 155 | help = "pre_lint hook: Installs the paackages needed to perform linting" 156 | 157 | [tool.pdm.scripts.pre_publish] 158 | composite = [ 159 | "lint", 160 | "test", 161 | ] 162 | help = "pre_publish hook: Ensure that linting and testing is performed before publishing" 163 | 164 | [tool.pdm.scripts.format] 165 | composite = [ 166 | "isort", 167 | "black", 168 | ] 169 | help = "Format the code using Black and iSort" 170 | 171 | [tool.pytest.ini_options] 172 | addopts = "--ignore=__pypackages__" 173 | 174 | [tool.flake8] 175 | ignore = [ 176 | "E123", 177 | "E203", 178 | "W503", 179 | ] 180 | exclude = [ 181 | "__pypackages__", 182 | ".venv", 183 | ] 184 | max_line_length = 120 185 | per-file-ignores = [ 186 | "__init__.py:F401, F403", 187 | ] 188 | 189 | [tool.isort] 190 | profile = "black" 191 | line_length = 120 192 | multi_line_output = 3 193 | include_trailing_comma = true 194 | src_paths = [ 195 | "src", 196 | "test", 197 | ] 198 | 199 | [tool.black] 200 | line-length = 120 201 | 202 | [tool.mypy] 203 | python_version = "3.9" 204 | platform = "manylinux2014_x86_64" 205 | explicit_package_bases = true 206 | check_untyped_defs = true 207 | warn_unused_configs = true 208 | show_error_codes = true 209 | color_output = true 210 | pretty = true 211 | mypy_path = "src" 212 | exclude = [ 213 | "^__pypackages__/", 214 | "^.venv/", 215 | ] 216 | -------------------------------------------------------------------------------- /src/accustom/Exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | """ 5 | Bring exceptions into the root of the submodule 6 | """ 7 | 8 | from accustom.Exceptions.exceptions import ( 9 | CannotApplyRuleToStandaloneRedactionConfig, 10 | ConflictingValue, 11 | DataIsNotDictException, 12 | FailedToSendResponseException, 13 | InvalidResponseStatusException, 14 | NoPhysicalResourceIdException, 15 | NotValidRequestObjectException, 16 | ResponseTooLongException, 17 | ) 18 | -------------------------------------------------------------------------------- /src/accustom/Exceptions/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | """ 5 | Exceptions for the accustom library. 6 | 7 | These are the exceptions that can be returned by accustom 8 | """ 9 | 10 | 11 | class CannotApplyRuleToStandaloneRedactionConfig(Exception): 12 | """Indicates that a second rule set was attempted to be applied to a standalone""" 13 | 14 | def __init__(self, *args): 15 | Exception.__init__(self, *args) 16 | 17 | 18 | class ConflictingValue(Exception): 19 | """Indicates that there is already a record with this value""" 20 | 21 | def __init__(self, *args): 22 | Exception.__init__(self, *args) 23 | 24 | 25 | class NoPhysicalResourceIdException(Exception): 26 | """Indicates that there was no valid value to use for PhysicalResourceId""" 27 | 28 | def __init__(self, *args): 29 | Exception.__init__(self, *args) 30 | 31 | 32 | class InvalidResponseStatusException(Exception): 33 | """Indicates that there response code was not SUCCESS or FAILED""" 34 | 35 | def __init__(self, *args): 36 | Exception.__init__(self, *args) 37 | 38 | 39 | class DataIsNotDictException(Exception): 40 | """Indicates that a Dictionary was not passed as Data""" 41 | 42 | def __init__(self, *args): 43 | Exception.__init__(self, *args) 44 | 45 | 46 | class FailedToSendResponseException(Exception): 47 | """Indicates there was a problem sending the response""" 48 | 49 | def __init__(self, *args): 50 | Exception.__init__(self, *args) 51 | 52 | 53 | class NotValidRequestObjectException(Exception): 54 | """Indicates that the event passed in is not a valid Request Object""" 55 | 56 | def __init__(self, *args): 57 | Exception.__init__(self, *args) 58 | 59 | 60 | class ResponseTooLongException(Exception): 61 | """Indicates that the produced response exceeds 4096 bytes and thus is too long""" 62 | 63 | def __init__(self, *args): 64 | Exception.__init__(self, *args) 65 | -------------------------------------------------------------------------------- /src/accustom/Exceptions/py.typed: -------------------------------------------------------------------------------- 1 | # Marker file that implements PEP 561 2 | -------------------------------------------------------------------------------- /src/accustom/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | """ 5 | Import classes, functions, and submodules to be accessible from library 6 | """ 7 | 8 | import accustom.Exceptions 9 | from accustom.constants import RedactMode, RequestType, Status 10 | from accustom.decorators import decorator, rdecorator, sdecorator 11 | from accustom.redaction import RedactionConfig, RedactionRuleSet, StandaloneRedactionConfig 12 | from accustom.response import ResponseObject, cfnresponse, collapse_data, is_valid_event 13 | -------------------------------------------------------------------------------- /src/accustom/constants.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | """ Constants for the accustom library. 5 | 6 | These are constants are the Custom Resource Request Type Constants 7 | """ 8 | 9 | 10 | class Status: 11 | """CloudFormation Custom Resource Status Constants 12 | 13 | https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-responses.html 14 | """ 15 | 16 | SUCCESS = "SUCCESS" 17 | FAILED = "FAILED" 18 | 19 | 20 | class RequestType: 21 | """CloudFormation Custom Resource Request Type Constants 22 | 23 | https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requesttypes.html 24 | """ 25 | 26 | CREATE = "Create" 27 | UPDATE = "Update" 28 | DELETE = "Delete" 29 | 30 | 31 | class RedactMode: 32 | """RedactMode Options""" 33 | 34 | ALLOWLIST = "allow" 35 | BLOCKLIST = "block" 36 | 37 | # DEPRECATED NAMES 38 | BLACKLIST = "black" 39 | WHITELIST = "white" 40 | -------------------------------------------------------------------------------- /src/accustom/decorators.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | """ Decorators for the accustom library. 5 | 6 | This includes two decorators, one for the handler function, and one to apply to any resource handling 7 | functions. 8 | """ 9 | from __future__ import annotations 10 | 11 | import copy 12 | import json 13 | import logging 14 | from functools import wraps 15 | from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, TypeVar, Union 16 | from uuid import uuid4 17 | 18 | from boto3 import client 19 | from botocore import exceptions as boto_exceptions 20 | from botocore.client import Config 21 | from typing_extensions import Concatenate, ParamSpec 22 | 23 | from accustom.constants import RequestType, Status 24 | from accustom.Exceptions import ( 25 | DataIsNotDictException, 26 | FailedToSendResponseException, 27 | InvalidResponseStatusException, 28 | NoPhysicalResourceIdException, 29 | NotValidRequestObjectException, 30 | ResponseTooLongException, 31 | ) 32 | from accustom.redaction import RedactionConfig, StandaloneRedactionConfig 33 | from accustom.response import ResponseObject, is_valid_event 34 | 35 | if TYPE_CHECKING: 36 | from aws_lambda_typing.context import Context 37 | from aws_lambda_typing.events import CloudFormationCustomResourceEvent 38 | 39 | logger = logging.getLogger(__name__) 40 | 41 | _T = TypeVar("_T") 42 | _P = ParamSpec("_P") 43 | 44 | # Time in milliseconds to set the alarm for (in milliseconds) 45 | # Should be set to twice the worst case response time to send to S3 46 | # Setting to 2 seconds for safety 47 | TIMEOUT_THRESHOLD = 2000 48 | 49 | 50 | # noinspection PyPep8Naming 51 | def decorator( 52 | enforceUseOfClass: bool = False, 53 | hideResourceDeleteFailure: bool = False, 54 | redactConfig: Optional[RedactionConfig] = None, 55 | timeoutFunction: bool = False, 56 | ) -> Callable[[Callable[Concatenate[CloudFormationCustomResourceEvent, Context, _P], _T]], _T]: 57 | """Decorate a function to add exception handling and emit CloudFormation responses. 58 | 59 | Usage with Lambda: 60 | import accustom 61 | @accustom.decorator() 62 | def function_handler(event, context) 63 | sum = (float(event['ResourceProperties']['key1']) + 64 | float(event['ResourceProperties']['key2'])) 65 | return { 'sum' : sum } 66 | 67 | Usage outside Lambda: 68 | import accustom 69 | @accustom.decorator() 70 | def function_handler(event) 71 | sum = (float(event['ResourceProperties']['key1']) + 72 | float(event['ResourceProperties']['key2'])) 73 | r = accustom.ResponseObject(data={'sum':sum},physicalResourceId='abc') 74 | return r 75 | 76 | Args: 77 | enforceUseOfClass (boolean): When true send a FAILED signal if a ResponseObject class is not utilised. 78 | This is implicitly set to true if no Lambda Context is provided. 79 | hideResourceDeleteFailure (boolean): When true will return SUCCESS even on getting an Exception for 80 | DELETE requests. 81 | redactConfig (RedactionConfig): Configuration of how to redact the event object. 82 | timeoutFunction (boolean): Will automatically send a failure signal to CloudFormation before Lambda timeout 83 | provided that this function is executed in Lambda 84 | 85 | Returns: 86 | dict: The response object sent to CloudFormation 87 | 88 | Raises: 89 | FailedToSendResponseException 90 | NotValidRequestObjectException 91 | 92 | Decorated Function Arguments: 93 | event (dict): The request object being processed (Required). 94 | context (dict): The Lambda context of this execution (optional) 95 | 96 | """ 97 | 98 | # noinspection PyMissingOrEmptyDocstring 99 | def inner_decorator(func: Callable[Concatenate[CloudFormationCustomResourceEvent, Context, _P], _T]): 100 | # noinspection PyMissingOrEmptyDocstring 101 | @wraps(func) 102 | def handler_wrapper( 103 | event: CloudFormationCustomResourceEvent, context: Context, *args, **kwargs 104 | ) -> Union[Dict[str, Any], str]: 105 | nonlocal redactConfig 106 | nonlocal timeoutFunction 107 | logger.info("Request received, processing...") 108 | if not is_valid_event(event): 109 | # If it is not a valid event we need to raise an exception 110 | message = ( 111 | "The event object passed is not a valid Request Object as per " 112 | + "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html" 113 | ) 114 | logger.error(message) 115 | raise NotValidRequestObjectException(message) 116 | 117 | # Timeout Function Handler 118 | if "LambdaParentRequestId" in event["ResourceProperties"]: 119 | logger.info( 120 | f"This request has been invoked as a child, for parent logs please see request ID: " 121 | f'{event["ResourceProperties"]["LambdaParentRequestId"]}' 122 | ) 123 | elif context is None and timeoutFunction: 124 | logger.warning( 125 | "You cannot use the timeoutFunction option outside of Lambda. To suppress this warning, " 126 | "set timeoutFunction to False" 127 | ) 128 | elif timeoutFunction: 129 | # Attempt to invoke the function. Depending on the error we get may continue execution or return 130 | logger.info("Request has been invoked in Lambda with timeoutFunction set, attempting to invoke self") 131 | p_event = copy.deepcopy(event) 132 | p_event["ResourceProperties"]["LambdaParentRequestId"] = context.aws_request_id 133 | payload = json.dumps(p_event).encode("UTF-8") 134 | timeout = (context.get_remaining_time_in_millis() - TIMEOUT_THRESHOLD) / 1000 135 | # Edge case where time is set to very low timeout, use half the timeout threshold as the timeout for 136 | # the Lambda Function 137 | if timeout <= 0: 138 | timeout = TIMEOUT_THRESHOLD / 2000 139 | config = Config(connect_timeout=2, read_timeout=timeout, retries={"max_attempts": 0}) 140 | b_lambda = client("lambda", config=config) 141 | 142 | # Normally we would just do a catch-all error handler but in this case we want to be paranoid 143 | try: 144 | response = b_lambda.invoke( 145 | FunctionName=context.invoked_function_arn, InvocationType="RequestResponse", Payload=payload 146 | ) 147 | # Further checks 148 | if "FunctionError" in response: 149 | response.get("Payload", "".encode("UTF-8")) 150 | message = f"Invocation got an error: {payload.decode()}" 151 | logger.error(message) 152 | return ResponseObject(reason=message, responseStatus=Status.FAILED).send(event, context) 153 | else: 154 | # In this case the function returned without error which means we can assume the chained 155 | # invocation sent a response, so we do not have too. 156 | logger.info("Completed execution of chained invocation, returning payload") 157 | response.get("Payload", "".encode("UTF-8")) 158 | return payload.decode() 159 | 160 | except boto_exceptions.ClientError as e: 161 | logger.warning( 162 | f"Caught exception {str(e)} while trying to invoke function. Running handler locally." 163 | ) 164 | logger.warning( 165 | "You cannot use the timeoutFunction option without the ability for the function to invoke " 166 | "itself. To suppress this warning, set timeoutFunction to False" 167 | ) 168 | except boto_exceptions.ConnectionError as e: 169 | logger.error(f"Got error {str(e)} while trying to invoke function. Running handler locally") 170 | logger.error( 171 | "You cannot use the timeoutFunction option without the ability to connect to the Lambda API " 172 | "from within the function. As we may not have time to execute the function, returning failure." 173 | ) 174 | return ResponseObject( 175 | reason="Unable to call Lambda to do chained invoke, returning failure.", 176 | responseStatus=Status.FAILED, 177 | ).send(event, context) 178 | except boto_exceptions.ReadTimeoutError: 179 | # This should be a critical failure 180 | logger.error("Waited the read timeout and function did not return, returning an error") 181 | return ResponseObject( 182 | reason="Lambda function timed out, returning failure.", responseStatus=Status.FAILED 183 | ).send(event, context) 184 | except Exception as e: 185 | message = ( 186 | f"Got an {e.__class__} I did not understand while trying to invoke child function: " f"{str(e)}" 187 | ) 188 | logger.error(message) 189 | return ResponseObject(reason=message, responseStatus=Status.FAILED).send(event, context) 190 | 191 | # Debug Logging Handler 192 | if logger.getEffectiveLevel() <= logging.DEBUG: 193 | if context is not None: 194 | logger.debug(f"Running request with Lambda RequestId: {context.aws_request_id}") 195 | if redactConfig is not None and isinstance(redactConfig, (StandaloneRedactionConfig, RedactionConfig)): 196 | # noinspection PyProtectedMember 197 | logger.debug("Request Body: " + json.dumps(redactConfig._redact(event))) 198 | elif redactConfig is not None: 199 | logger.warning("A non valid RedactionConfig was provided, and ignored") 200 | logger.debug("Request Body: " + json.dumps(event)) 201 | else: 202 | logger.debug("Request Body: " + json.dumps(event)) 203 | 204 | try: 205 | logger.info(f'Running CloudFormation request {event["RequestId"]} for stack: {event["StackId"]}') 206 | # Run the function 207 | result: Any = func(event, context, *args, **kwargs) 208 | 209 | except Exception as e: 210 | # If there was an exception thrown by the function, send a failure response 211 | result = ResponseObject( 212 | physicalResourceId=str(uuid4()) if context is None else None, 213 | reason=f'Function {func.__name__} failed due to exception "{str(e)}"', 214 | responseStatus=Status.FAILED, 215 | ) 216 | logger.error(result.reason) 217 | 218 | if not isinstance(result, ResponseObject): 219 | # If a ResponseObject is not provided, work out what kind of response object to pass, or return a 220 | # failure if it is an invalid response type, or if the enforceUseOfClass is explicitly or implicitly set 221 | if context is None: 222 | result = ResponseObject( 223 | reason=f"Response Object of type {result.__class__} was not a ResponseObject and there is no " 224 | f"Lambda Context", 225 | responseStatus=Status.FAILED, 226 | ) 227 | logger.error(result.reason) 228 | elif enforceUseOfClass: 229 | result = ResponseObject( 230 | reason=f"Response Object of type {result.__class__} was not a ResponseObject instance and " 231 | f"enforceUseOfClass set to true", 232 | responseStatus=Status.FAILED, 233 | ) 234 | logger.error(result.reason) 235 | elif result is False: 236 | result = ResponseObject( 237 | reason=f"Function {func.__name__} returned False.", responseStatus=Status.FAILED 238 | ) 239 | logger.debug(result.reason) 240 | elif isinstance(result, dict): 241 | result = ResponseObject(data=result) 242 | elif isinstance(result, str): 243 | result = ResponseObject(data={"Return": result}) 244 | elif result is None or result is True: 245 | result = ResponseObject() 246 | else: 247 | result = ResponseObject( 248 | reason=f"Return value from Function {func.__name__} is of unsupported type {result.__class__}", 249 | responseStatus=Status.FAILED, 250 | ) 251 | logger.error(result.reason) 252 | 253 | # This block will hide resources on delete failure if the flag is set to true 254 | if ( 255 | event["RequestType"] == RequestType.DELETE 256 | and result.responseStatus == Status.FAILED 257 | and hideResourceDeleteFailure 258 | ): 259 | logger.warning("Hiding Resource DELETE request failure") 260 | if result.data is not None: 261 | if not result.squashPrintResponse: 262 | logger.debug("Data:\n" + json.dumps(result.data)) 263 | else: 264 | logger.debug("Data: [REDACTED]") 265 | if result.reason is not None: 266 | logger.debug(f"Reason: {result.reason}") 267 | if result.physicalResourceId is not None: 268 | logger.debug(f"PhysicalResourceId: {result.physicalResourceId}") 269 | result = ResponseObject( 270 | reason="There may be resources created by this Custom Resource that have not been cleaned up " 271 | "despite the fact this resource is in DELETE_COMPLETE", 272 | physicalResourceId=result.physicalResourceId, 273 | ) 274 | 275 | try: 276 | return_value = result.send(event, context) 277 | except NoPhysicalResourceIdException: 278 | message = "An unexpected error has occurred, No Physical Resource ID provided in response." 279 | logger.error(message) 280 | result = ResponseObject( 281 | reason=message, physicalResourceId=result.physicalResourceId, responseStatus=Status.FAILED 282 | ) 283 | return_value = result.send(event, context) 284 | except InvalidResponseStatusException: 285 | message = f'Status provided "{result.responseStatus}" is not a valid status.' 286 | logger.error(message) 287 | result = ResponseObject( 288 | reason=message, physicalResourceId=result.physicalResourceId, responseStatus=Status.FAILED 289 | ) 290 | return_value = result.send(event, context) 291 | except DataIsNotDictException as e: 292 | message = f"Malformed Data Block in Response, Exception; {str(e)}" 293 | logger.error(message) 294 | result = ResponseObject( 295 | reason=message, physicalResourceId=result.physicalResourceId, responseStatus=Status.FAILED 296 | ) 297 | return_value = result.send(event, context) 298 | except ResponseTooLongException as e: 299 | message = str(e) 300 | logger.error(message) 301 | result = ResponseObject( 302 | reason=message, physicalResourceId=result.physicalResourceId, responseStatus=Status.FAILED 303 | ) 304 | return_value = result.send(event, context) 305 | except FailedToSendResponseException as e: 306 | # Capturing and re-raising exception to prevent generic exception handler from kicking in 307 | raise e 308 | except Exception as e: 309 | # Generic error capture 310 | message = f"Malformed request, Exception: {str(e)}" 311 | logger.error(message) 312 | result = ResponseObject( 313 | reason=message, physicalResourceId=result.physicalResourceId, responseStatus=Status.FAILED 314 | ) 315 | return_value = result.send(event, context) 316 | return return_value 317 | 318 | return handler_wrapper 319 | 320 | return inner_decorator 321 | 322 | 323 | # noinspection PyPep8Naming 324 | def rdecorator( 325 | decoratorHandleDelete: bool = False, expectedProperties: Optional[List[str]] = None, genUUID: bool = True 326 | ): 327 | """Decorate a function to add input validation for resource handler functions. 328 | 329 | Usage with Lambda: 330 | import accustom 331 | @accustom.rdecorator(expectedProperties=['key1','key2'],genUUID=False) 332 | def resource_function(event, context): 333 | sum = (float(event['ResourceProperties']['key1']) + 334 | float(event['ResourceProperties']['key2'])) 335 | return { 'sum' : sum } 336 | @accustom.decorator() 337 | def function_handler(event, context) 338 | return resource_function(event,context) 339 | 340 | Usage outside Lambda: 341 | import accustom 342 | @accustom.rdecorator(expectedProperties=['key1','key2']) 343 | def resource_function(event, context=None) 344 | sum = (float(event['ResourceProperties']['key1']) + 345 | float(event['ResourceProperties']['key2'])) 346 | r = accustom.ResponseObject(data={'sum':sum},physicalResourceId=event['PhysicalResourceId']) 347 | return r 348 | @accustom.decorator() 349 | def function_handler(event) 350 | return resource_function(event) 351 | 352 | Args: 353 | decoratorHandleDelete (boolean): When set to true, if a delete request is made in event the decorator will 354 | return a ResponseObject with a with SUCCESS without actually executing the decorated function 355 | genUUID (boolean): When set to true, if the PhysicalResourceId in the event is not set, automatically 356 | generate a UUID4 and put it in the PhysicalResourceId field. 357 | expectedProperties (list of expected properties): Pass in a list or tuple of properties that you want to 358 | check for before running the decorated function. 359 | 360 | Returns: 361 | The result of the decorated function, or a ResponseObject with SUCCESS depending on the event and flags. 362 | 363 | Raises: 364 | NotValidRequestObjectException 365 | Any exception raised by the decorated function. 366 | 367 | 368 | Decorated Function Arguments: 369 | event (dict): The request object being processed (Required). 370 | """ 371 | 372 | # noinspection PyMissingOrEmptyDocstring 373 | def resource_decorator_inner( 374 | func: Callable[Concatenate[CloudFormationCustomResourceEvent, _P], _T] 375 | ) -> Callable[Concatenate[CloudFormationCustomResourceEvent, _P], _T]: 376 | # noinspection PyMissingOrEmptyDocstring 377 | @wraps(func) 378 | def resource_decorator_handler(event: CloudFormationCustomResourceEvent, *args, **kwargs) -> _T: 379 | if not is_valid_event(event): 380 | # If it is not a valid event we need to raise an exception 381 | message = ( 382 | "The event object passed is not a valid Request Object as per " 383 | + "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html" 384 | ) 385 | logger.error(message) 386 | raise NotValidRequestObjectException(message) 387 | logger.info(f'Supported resource {event["ResourceType"]}') 388 | 389 | # Set the Physical Resource ID to a randomly generated UUID if it is not present 390 | if genUUID and "PhysicalResourceId" not in event: 391 | event["PhysicalResourceId"] = str(uuid4()) # type: ignore[typeddict-unknown-key] 392 | logger.info(f'Set PhysicalResourceId to {event["PhysicalResourceId"]}') # type: ignore[typeddict-item] 393 | 394 | # Handle Delete when decoratorHandleDelete is set to True 395 | if decoratorHandleDelete and event["RequestType"] == RequestType.DELETE: 396 | logger.info(f"Request type {RequestType.DELETE} detected, returning success without calling function") 397 | return ResponseObject( 398 | physicalResourceId=event["PhysicalResourceId"] # type: ignore[typeddict-item,return-value] 399 | ) 400 | 401 | # Validate important properties exist except on Delete 402 | if ( 403 | event["RequestType"] != RequestType.DELETE 404 | and expectedProperties is not None 405 | and isinstance(expectedProperties, (list, tuple)) 406 | ): 407 | for index, item in enumerate(expectedProperties): 408 | if item not in event["ResourceProperties"]: 409 | err_msg = f"Property {item} missing, sending failure signal" 410 | logger.info(err_msg) 411 | return ResponseObject( 412 | reason=err_msg, 413 | responseStatus=Status.FAILED, 414 | physicalResourceId=event.get("PhysicalResourceId"), # type: ignore[arg-type, return-value] 415 | ) 416 | 417 | # If a list or tuple was not provided then log a warning 418 | elif expectedProperties is not None: 419 | logger.warning("expectedProperties passed to decorator is not a list, properties were not validated.") 420 | 421 | # Pre-validation complete, calling function 422 | return func(event, *args, **kwargs) 423 | 424 | return resource_decorator_handler 425 | 426 | return resource_decorator_inner 427 | 428 | 429 | # noinspection PyPep8Naming 430 | def sdecorator( 431 | decoratorHandleDelete: bool = False, 432 | expectedProperties: Optional[List[str]] = None, 433 | genUUID: bool = True, 434 | enforceUseOfClass: bool = False, 435 | hideResourceDeleteFailure: bool = False, 436 | redactConfig: Optional[RedactionConfig] = None, 437 | timeoutFunction: bool = True, 438 | ): 439 | """Decorate a function to add input validation for resource handler functions, exception handling and send 440 | CloudFormation responses. 441 | 442 | 443 | Usage with Lambda: 444 | import accustom 445 | @accustom.sdecorator(expectedProperties=['key1','key2'],genUUID=False) 446 | def resource_handler(event, context): 447 | sum = (float(event['ResourceProperties']['key1']) + 448 | float(event['ResourceProperties']['key2'])) 449 | return { 'sum' : sum } 450 | 451 | Usage outside Lambda: 452 | import accustom 453 | @accustom.sdecorator(expectedProperties=['key1','key2']) 454 | def resource_handler(event, context=None) 455 | sum = (float(event['ResourceProperties']['key1']) + 456 | float(event['ResourceProperties']['key2'])) 457 | r = accustom.ResponseObject(data={'sum':sum},physicalResourceId=event['PhysicalResourceId']) 458 | return r 459 | 460 | Args: 461 | decoratorHandleDelete (boolean): When set to true, if a delete request is made in event the decorator will 462 | return SUCCESS to CloudFormation without actually executing the decorated function 463 | genUUID (boolean): When set to true, if the PhysicalResourceId in the event is not set, automatically generate 464 | a UUID4 and put it in the PhysicalResourceId field. 465 | expectedProperties (list of expected properties): Pass in a list or tuple of properties that you want to check 466 | for before running the decorated function. 467 | enforceUseOfClass (boolean): When true send a FAILED signal if a ResponseObject class is not utilised. 468 | This is implicitly set to true if no Lambda Context is provided. 469 | hideResourceDeleteFailure (boolean): When true will return SUCCESS even on getting an Exception for DELETE 470 | requests. Note that this particular flag is made redundant if decoratorHandleDelete is set to True. 471 | redactConfig (StandaloneRedactionConfig): Configuration of how to redact the event object. 472 | timeoutFunction (boolean): Will automatically send a failure signal to CloudFormation 1 second before Lambda 473 | timeout provided that this function is executed in Lambda 474 | 475 | Returns: 476 | The response object sent to CloudFormation 477 | 478 | Raises: 479 | FailedToSendResponseException 480 | NotValidRequestObjectException 481 | """ 482 | if ( 483 | redactConfig is not None 484 | and not isinstance(redactConfig, StandaloneRedactionConfig) 485 | and logger.getEffectiveLevel() <= logging.DEBUG 486 | ): 487 | logger.warning("A non valid StandaloneRedactionConfig was provided, and ignored") 488 | redactConfig = None 489 | 490 | # noinspection PyMissingOrEmptyDocstring 491 | def standalone_decorator_inner(func: Callable[Concatenate[CloudFormationCustomResourceEvent, Context, _P], _T]): 492 | # noinspection PyMissingOrEmptyDocstring 493 | @wraps(func) 494 | @decorator( 495 | enforceUseOfClass=enforceUseOfClass, 496 | hideResourceDeleteFailure=hideResourceDeleteFailure, 497 | redactConfig=redactConfig, 498 | timeoutFunction=timeoutFunction, 499 | ) 500 | @rdecorator(decoratorHandleDelete=decoratorHandleDelete, expectedProperties=expectedProperties, genUUID=genUUID) 501 | def standalone_decorator_handler( 502 | event: CloudFormationCustomResourceEvent, context: Context, *args, **kwargs 503 | ) -> _T: 504 | return func(event, context, *args, **kwargs) 505 | 506 | return standalone_decorator_handler 507 | 508 | return standalone_decorator_inner 509 | -------------------------------------------------------------------------------- /src/accustom/py.typed: -------------------------------------------------------------------------------- 1 | # Marker file that implements PEP 561 2 | -------------------------------------------------------------------------------- /src/accustom/redaction.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | """ RedactionConfig and StandaloneRedactConfig for the accustom library 5 | 6 | This allows you to define a redaction policy for accustom 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | import copy 12 | import logging 13 | import re 14 | from typing import TYPE_CHECKING, Any, Dict, List, Mapping, MutableMapping, cast 15 | 16 | from accustom.constants import RedactMode 17 | from accustom.Exceptions import ( 18 | CannotApplyRuleToStandaloneRedactionConfig, 19 | ConflictingValue, 20 | NotValidRequestObjectException, 21 | ) 22 | from accustom.response import is_valid_event 23 | 24 | if TYPE_CHECKING: 25 | from aws_lambda_typing.events import CloudFormationCustomResourceEvent 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | _RESOURCEREGEX_DEFAULT = "^.*$" 30 | REDACTED_STRING = "[REDACTED]" 31 | 32 | 33 | # noinspection PyPep8Naming 34 | class RedactionRuleSet(object): 35 | """Class that allows you to define a redaction rule set for accustom""" 36 | 37 | def __init__(self, resourceRegex: str = _RESOURCEREGEX_DEFAULT) -> None: 38 | """Init function for the class 39 | 40 | Args: 41 | resourceRegex (String): The regex used to work out what resources to apply this too. 42 | 43 | Raises: 44 | TypeError 45 | 46 | """ 47 | if not isinstance(resourceRegex, str): 48 | raise TypeError("resourceRegex must be a string") 49 | 50 | self.resourceRegex: str = resourceRegex 51 | self._properties: List[str] = [] 52 | 53 | def add_property_regex(self, propertiesRegex: str) -> None: 54 | """Allows you to add a property regex to allowlist/blocklist 55 | 56 | Args: 57 | propertiesRegex (String): The regex used to work out what properties to allowlist/blocklist 58 | 59 | Raises: 60 | TypeError 61 | 62 | """ 63 | if not isinstance(propertiesRegex, str): 64 | raise TypeError("propertiesRegex must be a string") 65 | self._properties.append(propertiesRegex) 66 | 67 | def add_property(self, propertyName: str) -> None: 68 | """Allows you to add a specific property to allowlist/blocklist 69 | 70 | Args: 71 | propertyName (String): The name of the property to allowlist/blocklist 72 | 73 | Raises: 74 | TypeError 75 | 76 | """ 77 | if not isinstance(propertyName, str): 78 | raise TypeError("propertyName must be a string") 79 | self._properties.append("^" + propertyName + "$") 80 | 81 | 82 | # noinspection PyPep8Naming 83 | class RedactionConfig(object): 84 | """Class that defines a redaction policy for accustom""" 85 | 86 | def __init__(self, redactMode: str = RedactMode.BLOCKLIST, redactResponseURL: bool = False) -> None: 87 | """Init function for the class 88 | 89 | Args: 90 | redactMode (RedactMode.BLOCKLIST or RedactMode.ALLOWLIST): Determine if we should allowlist or blocklist 91 | resources, defaults to blocklist 92 | redactResponseURL (boolean): Prevents the pre-signed URL from being printed preventing out of band responses 93 | (recommended for production) 94 | 95 | Raises: 96 | TypeError 97 | 98 | """ 99 | if redactMode == RedactMode.BLACKLIST: 100 | logger.warning( 101 | "The usage of RedactMode.BLACKLIST is deprecated, " "please change to use RedactMode.BLOCKLIST" 102 | ) 103 | redactMode = RedactMode.BLOCKLIST 104 | if redactMode == RedactMode.WHITELIST: 105 | logging.warning( 106 | "The usage of RedactMode.WHITELIST is deprecated, " "please change to use RedactMode.ALLOWLIST" 107 | ) 108 | redactMode = RedactMode.ALLOWLIST 109 | 110 | if redactMode != RedactMode.BLOCKLIST and redactMode != RedactMode.ALLOWLIST: 111 | raise TypeError("Invalid Redaction Type") 112 | 113 | if not isinstance(redactResponseURL, bool): 114 | raise TypeError("redactResponseURL must be of boolean type") 115 | 116 | self.redactMode: str = redactMode 117 | self.redactResponseURL: bool = redactResponseURL 118 | self._redactProperties: Dict[str, List[str]] = {} 119 | 120 | def add_rule_set(self, ruleSet: RedactionRuleSet) -> None: 121 | """This function will add a RedactionRuleSet object to the RedactionConfig. 122 | 123 | Args: 124 | ruleSet (RedactionRuleSet): The rule to be added to the RedactionConfig 125 | 126 | Raises: 127 | TypeError 128 | ConflictingValue 129 | 130 | """ 131 | 132 | if not isinstance(ruleSet, RedactionRuleSet): 133 | raise TypeError("Please use RedactionRuleSet class") 134 | if ruleSet.resourceRegex in self._redactProperties: 135 | raise ConflictingValue(f"There is already a record set for resource of regex: {ruleSet.resourceRegex}") 136 | 137 | # noinspection PyProtectedMember 138 | self._redactProperties[ruleSet.resourceRegex] = ruleSet._properties 139 | 140 | def _redact(self, event: CloudFormationCustomResourceEvent) -> Mapping[str, Any]: 141 | """Internal Function. Not to be consumed outside accustom Library. 142 | 143 | This function will take in an event and return the event redacted as per the redaction config. 144 | """ 145 | if not is_valid_event(event): 146 | # If it is not a valid event we need to raise an exception 147 | message = ( 148 | "The event object passed is not a valid Request Object as per " 149 | + "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html" 150 | ) 151 | logger.error(message) 152 | raise NotValidRequestObjectException(message) 153 | ec: MutableMapping[str, Any] = cast(MutableMapping, copy.deepcopy(event)) 154 | if self.redactMode == RedactMode.ALLOWLIST: 155 | if "ResourceProperties" in ec: 156 | ec["ResourceProperties"] = {} 157 | if "OldResourceProperties" in ec: 158 | ec["OldResourceProperties"] = {} 159 | for resourceRegex, propertyRegex in self._redactProperties.items(): 160 | if re.search(resourceRegex, event["ResourceType"]) is not None: 161 | # Go through the Properties looking to see if they're in the ResourceProperties or OldResourceProperties 162 | for index, item in enumerate(propertyRegex): 163 | r = re.compile(item) 164 | if self.redactMode == RedactMode.BLOCKLIST: 165 | if "ResourceProperties" in ec: 166 | for m_item in filter(r.match, ec["ResourceProperties"]): 167 | ec["ResourceProperties"][m_item] = REDACTED_STRING 168 | if "OldResourceProperties" in ec: 169 | for m_item in filter(r.match, ec["OldResourceProperties"]): 170 | ec["OldResourceProperties"][m_item] = REDACTED_STRING 171 | elif self.redactMode == RedactMode.ALLOWLIST: 172 | if "ResourceProperties" in ec: 173 | for m_item in filter(r.match, event["ResourceProperties"]): 174 | ec["ResourceProperties"][m_item] = event["ResourceProperties"][m_item] 175 | if "OldResourceProperties" in ec: 176 | for m_item in filter( 177 | r.match, event["OldResourceProperties"] # type: ignore[typeddict-item] 178 | ): 179 | ec["OldResourceProperties"][m_item] = event[ 180 | "OldResourceProperties" # type: ignore[typeddict-item] 181 | ][m_item] 182 | if self.redactMode == RedactMode.ALLOWLIST: 183 | if "ResourceProperties" in ec: 184 | for key, value in event["ResourceProperties"].items(): 185 | if key not in ec["ResourceProperties"]: 186 | ec["ResourceProperties"][key] = REDACTED_STRING 187 | if "OldResourceProperties" in ec: 188 | for key, value in event["OldResourceProperties"].items(): # type: ignore[typeddict-item] 189 | if key not in ec["OldResourceProperties"]: 190 | ec["OldResourceProperties"][key] = REDACTED_STRING 191 | 192 | if self.redactResponseURL: 193 | del ec["ResponseURL"] 194 | return ec 195 | 196 | def __str__(self): 197 | return f"RedactionConfig({self.redactMode})" 198 | 199 | def __repr__(self): 200 | return str(self) 201 | 202 | 203 | # noinspection PyPep8Naming 204 | class StandaloneRedactionConfig(RedactionConfig): 205 | """ 206 | Class that defines a redaction policy for a standalone function 207 | """ 208 | 209 | def __init__( 210 | self, ruleSet: RedactionRuleSet, redactMode: str = RedactMode.BLOCKLIST, redactResponseURL: bool = False 211 | ): 212 | """Init function for the class 213 | 214 | Args: 215 | redactMode (RedactMode.BLOCKLIST or RedactMode.ALLOWLIST): Determine if we should allowlist or blocklist 216 | resources, defaults to blocklist 217 | redactResponseURL (boolean): Prevents the pre-signed URL from being printed preventing out of band responses 218 | (recommended for production) 219 | ruleSet (RedactionRuleSet): The single rule set to be applied 220 | 221 | Raises: 222 | TypeError 223 | """ 224 | 225 | RedactionConfig.__init__(self, redactMode=redactMode, redactResponseURL=redactResponseURL) 226 | ruleSet.resourceRegex = _RESOURCEREGEX_DEFAULT 227 | # override resource regex to be default 228 | assert ruleSet is not None 229 | RedactionConfig.add_rule_set(self, ruleSet) 230 | 231 | def add_rule_set(self, ruleSet: RedactionRuleSet): 232 | """Overrides the add_rule_set operation with one that will immediately throw an exception 233 | 234 | Raises 235 | CannotApplyRuleToStandaloneRedactionConfig 236 | """ 237 | raise CannotApplyRuleToStandaloneRedactionConfig("StandaloneRedactionConfig does not support additional rules.") 238 | -------------------------------------------------------------------------------- /src/accustom/response.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | """ ResponseObject and cfnresponse function for the accustom library 5 | 6 | This allows you to communicate with CloudFormation 7 | 8 | """ 9 | from __future__ import annotations 10 | 11 | import json 12 | import logging 13 | import sys 14 | from typing import TYPE_CHECKING, Any, Dict, Optional 15 | from urllib.parse import urlparse 16 | 17 | import requests 18 | 19 | from accustom.constants import RequestType, Status 20 | from accustom.Exceptions import ( 21 | DataIsNotDictException, 22 | FailedToSendResponseException, 23 | InvalidResponseStatusException, 24 | NoPhysicalResourceIdException, 25 | NotValidRequestObjectException, 26 | ResponseTooLongException, 27 | ) 28 | 29 | if TYPE_CHECKING: 30 | from aws_lambda_typing.context import Context 31 | from aws_lambda_typing.events import CloudFormationCustomResourceEvent 32 | 33 | logger = logging.getLogger(__name__) 34 | CUSTOM_RESOURCE_SIZE_LIMIT = 4096 35 | 36 | 37 | def is_valid_event(event: CloudFormationCustomResourceEvent) -> bool: 38 | """This function takes in a CloudFormation Request Object and checks for the required fields as per: 39 | https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html 40 | 41 | Args: 42 | event (dict): The request object being processed. 43 | 44 | Returns: 45 | bool: If the request object is a valid request object 46 | 47 | """ 48 | if not ( 49 | all( 50 | v in event 51 | for v in ("RequestType", "ResponseURL", "StackId", "RequestId", "ResourceType", "LogicalResourceId") 52 | ) 53 | ): 54 | # Check we have all the required fields 55 | return False 56 | 57 | if event["RequestType"] not in [RequestType.CREATE, RequestType.DELETE, RequestType.UPDATE]: 58 | # Check if the request type is a valid request type 59 | return False 60 | 61 | scheme = urlparse(event["ResponseURL"]).scheme 62 | if scheme == "" or scheme not in ("http", "https"): 63 | # Check if the URL appears to be a valid HTTP or HTTPS URL 64 | # Technically it should always be an HTTPS URL but hedging bets for testing to allow http 65 | return False 66 | 67 | if event["RequestType"] in [RequestType.UPDATE, RequestType.DELETE] and "PhysicalResourceId" not in event: 68 | # If it is an Update or Delete request there needs to be a PhysicalResourceId key 69 | return False 70 | 71 | # All checks passed 72 | return True 73 | 74 | 75 | def collapse_data(response_data: Dict[str, Any]): 76 | """This function takes in a dictionary and collapses it into single object keys 77 | 78 | For example: it would translate something like this: 79 | 80 | { "Address" { "Street" : "Apple Street" }} 81 | 82 | Into this: 83 | 84 | { "Address.Street" : "Apple Street" } 85 | 86 | Where there is an explict instance of a dot-notated item, this will override any collapsed items 87 | 88 | Args: 89 | response_data (dict): The data object that needs to be collapsed 90 | Returns: 91 | dict: collapsed response data with higher level keys removed and replaced with dot-notation 92 | """ 93 | 94 | for item in list(response_data): 95 | if isinstance(response_data[item], dict): 96 | response_data[item] = collapse_data(response_data[item]) 97 | for c_item in response_data[item]: 98 | new_key = f"{item}.{c_item}" 99 | if new_key not in response_data: 100 | # This if statement prevents overrides of existing keys 101 | response_data[new_key] = response_data[item][c_item] 102 | del response_data[item] 103 | 104 | return response_data 105 | 106 | 107 | # noinspection PyPep8Naming 108 | def cfnresponse( 109 | event: CloudFormationCustomResourceEvent, 110 | responseStatus: str, 111 | responseReason: Optional[str] = None, 112 | responseData: Optional[Dict[str, Any]] = None, 113 | physicalResourceId: Optional[str] = None, 114 | context: Optional[Context] = None, 115 | squashPrintResponse: bool = False, 116 | ): 117 | """Format and send CloudFormation Custom Resource Objects 118 | 119 | This section is derived off the cfnresponse source code provided by Amazon: 120 | 121 | https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html 122 | 123 | Creates a JSON payload that is sent back to the ResponseURL (pre-signed S3 URL). 124 | 125 | Args: 126 | event: A dict containing CloudFormation custom resource request field 127 | responseStatus (Status.SUCCESS or Status.FAILED): Should have the value of 'SUCCESS' or 'FAILED' 128 | responseData (dict): The response data to be passed to CloudFormation. If it contains ExceptionThrown 129 | on FAILED this is given as reason overriding responseReason. 130 | responseReason (str): The reason for this result. 131 | physicalResourceId (str): The PhysicalResourceID to be sent back to CloudFormation 132 | context (context object): Can be used in lieu of a PhysicalResourceId to use the Lambda Context to derive 133 | an ID. 134 | squashPrintResponse (boolean): When logging set to debug and this is set to False, it will print the response 135 | (defaults to False). If set to True this will also send the response with NoEcho set to True. 136 | 137 | Note that either physicalResourceId or context must be defined, and physicalResourceId supersedes 138 | context 139 | 140 | Returns: 141 | Dictionary of Response Sent 142 | 143 | Raises: 144 | NoPhysicalResourceIdException 145 | InvalidResponseStatusException 146 | DataIsNotDictException 147 | FailedToSendResponseException 148 | NotValidRequestObjectException 149 | ResponseTooLongException 150 | 151 | """ 152 | if not is_valid_event(event): 153 | # If it is not a valid event we need to raise an exception 154 | message = ( 155 | "The event object passed is not a valid Request Object as per " 156 | + "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html" 157 | ) 158 | logger.error(message) 159 | raise NotValidRequestObjectException(message) 160 | 161 | if physicalResourceId is None and "PhysicalResourceId" in event: 162 | physicalResourceId = event["PhysicalResourceId"] # type: ignore[typeddict-item] 163 | elif physicalResourceId is None and context is not None: 164 | physicalResourceId = context.log_stream_name 165 | elif physicalResourceId is None and context is None: 166 | raise NoPhysicalResourceIdException( 167 | "Both physicalResourceId and context are None, and there is no physicalResourceId in the event." 168 | ) 169 | 170 | if responseStatus != Status.FAILED and responseStatus != Status.SUCCESS: 171 | raise InvalidResponseStatusException(f"{responseStatus} is not a valid status") 172 | 173 | if responseData is not None and not isinstance(responseData, dict): 174 | raise DataIsNotDictException("Data provided was not a dictionary") 175 | 176 | if responseStatus == Status.FAILED: 177 | if responseReason is not None and responseData is not None and "ExceptionThrown" in responseData: 178 | responseReason = f"There was an exception thrown in execution of '{responseData['ExceptionThrown']}'" 179 | elif responseReason is None: 180 | responseReason = "Unknown failure occurred" 181 | 182 | if context is not None: 183 | # noinspection PyUnresolvedReferences 184 | responseReason = f"{responseReason} -- See the details in CloudWatch Log Stream: {context.log_stream_name}" 185 | 186 | elif context is not None and responseReason is None: 187 | # noinspection PyUnresolvedReferences 188 | responseReason = f"See the details in CloudWatch Log Stream: {context.log_stream_name}" 189 | 190 | responseUrl = event["ResponseURL"] 191 | 192 | responseBody = {"Status": responseStatus} 193 | if responseReason is not None: 194 | responseBody["Reason"] = responseReason 195 | if physicalResourceId is not None: 196 | responseBody["PhysicalResourceId"] = physicalResourceId 197 | responseBody["StackId"] = event["StackId"] 198 | responseBody["RequestId"] = event["RequestId"] 199 | responseBody["LogicalResourceId"] = event["LogicalResourceId"] 200 | if responseData is not None: 201 | responseBody["Data"] = collapse_data(responseData) 202 | if squashPrintResponse: 203 | responseBody["NoEcho"] = "true" 204 | 205 | json_responseBody = json.dumps(responseBody) 206 | json_responseSize = sys.getsizeof(json_responseBody) 207 | logger.debug(f"Determined size of message to {json_responseSize:d}n bytes") 208 | 209 | if json_responseSize >= CUSTOM_RESOURCE_SIZE_LIMIT: 210 | raise ResponseTooLongException( 211 | f"Response ended up {json_responseSize:d}n bytes long which exceeds {CUSTOM_RESOURCE_SIZE_LIMIT:d}n bytes" 212 | ) 213 | 214 | logger.info("Sending response to pre-signed URL.") 215 | logger.debug(f"URL: {responseUrl}") 216 | if not squashPrintResponse: 217 | logger.debug("Response Body: " + json_responseBody) 218 | 219 | headers = {"content-type": "", "content-length": str(json_responseSize)} 220 | 221 | # Flush the buffers to attempt to prevent log truncations when resource is deleted 222 | # by stack in next action 223 | sys.stdout.flush() 224 | # Flush stdout buffer 225 | sys.stderr.flush() 226 | # Flush stderr buffer 227 | 228 | try: 229 | response = requests.put(responseUrl, data=json_responseBody, headers=headers) 230 | if "x-amz-id-2" in response.headers and "x-amz-request-id" in response.headers: 231 | logger.debug("Got headers for PUT request to pre-signed URL. Printing to debug log.") 232 | logger.debug(f"x-amz-request-id =\n{response.headers['x-amz-request-id']}") 233 | logger.debug(f"x-amz-id-2 =\n{response.headers['x-amz-id-2']}") 234 | if response.status_code != 200: 235 | # Exceptions will only be thrown on timeout or other errors, in order to catch an invalid 236 | # status code like 403 we will need to explicitly check the status code. In normal operation 237 | # we should get a "200 OK" response to our PUT. 238 | message = ( 239 | f"Unable to send response to URL, status code received: {response.status_code:d} " f"{response.reason}" 240 | ) 241 | logger.error(message) 242 | raise FailedToSendResponseException(message) 243 | logger.debug(f"Response status code: {response.status_code:d} {response.reason}") 244 | 245 | except FailedToSendResponseException as e: 246 | # Want to explicitly catch this exception we just raised in order to raise it unmodified 247 | raise e 248 | 249 | except Exception as e: 250 | logger.error(f"Unable to send response to URL, reason given: {str(e)}") 251 | raise FailedToSendResponseException(str(e)) 252 | 253 | return responseBody 254 | 255 | 256 | class ResponseObject(object): 257 | """Class that allows you to init a ResponseObject for easy function writing""" 258 | 259 | # noinspection PyPep8Naming 260 | def __init__( 261 | self, 262 | data: Optional[Dict[str, Any]] = None, 263 | physicalResourceId: Optional[str] = None, 264 | reason: Optional[str] = None, 265 | responseStatus: str = Status.SUCCESS, 266 | squashPrintResponse: bool = False, 267 | ): 268 | """Init function for the class 269 | 270 | Args: 271 | data (dict): data to be passed in the response. Must be a dict if used 272 | physicalResourceId (str): Physical resource ID to be used in the response 273 | reason (str): Reason to pass back to CloudFormation in the response Object 274 | responseStatus (Status.SUCCESS or Status.FAILED): response Status to use in the response Object, 275 | defaults to SUCCESS 276 | squashPrintResponse (boolean): When logging set to debug and this is set to False, it will print the 277 | response (defaults to False). If set to True it will also be sent with NoEcho set to true. 278 | 279 | Raises: 280 | DataIsNotDictException 281 | TypeError 282 | 283 | """ 284 | if data is not None and not isinstance(data, dict): 285 | raise DataIsNotDictException("Data provided was not a dictionary") 286 | 287 | if not isinstance(physicalResourceId, str) and physicalResourceId is not None: 288 | raise TypeError("physicalResourceId must be of type string") 289 | 290 | if not isinstance(reason, str) and reason is not None: 291 | raise TypeError("message must be of type string") 292 | 293 | if responseStatus != Status.SUCCESS and responseStatus != Status.FAILED: 294 | raise TypeError("Invalid response status") 295 | 296 | if not isinstance(squashPrintResponse, bool): 297 | raise TypeError("squashPrintResponse must be of boolean type") 298 | 299 | self.data = data 300 | self.physicalResourceId = physicalResourceId 301 | self.reason = reason 302 | self.responseStatus = responseStatus 303 | self.squashPrintResponse = squashPrintResponse 304 | 305 | def send(self, event: CloudFormationCustomResourceEvent, context: Optional[Context] = None): 306 | """Send this CloudFormation Custom Resource Object 307 | 308 | Creates a JSON payload that is sent back to the ResponseURL (pre-signed S3 URL) based upon this response object 309 | using the cfnresponse function. 310 | 311 | Args: 312 | event: A dict containing CloudFormation custom resource request field 313 | context: Can be used in lieu of a PhysicalResourceId to use the Lambda Context to derive an ID. 314 | 315 | Note that either physicalResourceId in the object or context must be defined, and physicalResourceId 316 | supersedes context 317 | 318 | Returns: 319 | Dictionary of Response Sent 320 | 321 | Raises: 322 | NoPhysicalResourceIdException 323 | InvalidResponseStatusException 324 | DataIsNotDictException 325 | FailedToSendResponseException 326 | NotValidRequestObjectException 327 | ResponseTooLongException 328 | """ 329 | return cfnresponse( 330 | event, 331 | self.responseStatus, 332 | self.reason, 333 | self.data, 334 | self.physicalResourceId, 335 | context, 336 | self.squashPrintResponse, 337 | ) 338 | 339 | def __str__(self): 340 | return f"Response(Status={self.responseStatus})" 341 | 342 | def __repr__(self): 343 | return str(self) 344 | -------------------------------------------------------------------------------- /tests/int/Makefile: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | execute: redact.executed timeout.executed 5 | deploy: redact.deployed timeout.deployed 6 | package: redact.deploy.ready.yaml timeout.deploy.ready.yaml 7 | 8 | redact.executed: redact.execute.yaml redact.deployed 9 | aws cloudformation deploy --template-file $< --stack-name $(subst .,-,$@) --disable-rollback 10 | echo $(subst .,-,$@) > $@ 11 | 12 | timeout.executed: timeout.execute.yaml timeout.deployed 13 | -aws cloudformation deploy --template-file $< --stack-name $(subst .,-,$@) --disable-rollback 14 | echo $(subst .,-,$@) > $@ 15 | 16 | %.deployed: %.deploy.ready.yaml 17 | aws cloudformation deploy --template-file $< --stack-name $(subst .,-,$@) --capabilities CAPABILITY_IAM 18 | echo $(subst .,-,$@) > $@ 19 | 20 | %.deploy.ready.yaml: %.deploy.yaml %.zip 21 | ifndef BUCKET_TO_DEPLOY 22 | $(error BUCKET_TO_DEPLOY is undefined) 23 | endif 24 | aws cloudformation package --template-file $< --s3-bucket ${BUCKET_TO_DEPLOY} --output-template-file $@ 25 | 26 | %.zip: %.py 27 | mkdir -p code_tmp 28 | python3 -m pip install -r ../../requirements.txt -t code_tmp --platform manylinux2014_x86_64 --python-version 3.8 \ 29 | --implementation cp --only-binary=:all: --compile 30 | cp -a ../../accustom code_tmp/ 31 | cp $< code_tmp/ 32 | cd code_tmp; zip $@ * -r 33 | mv code_tmp/$@ . 34 | rm -fdr code_tmp 35 | 36 | clean: undeploy 37 | rm -f *.deploy.ready.yaml 38 | rm -f *.zip 39 | 40 | undeploy: unexecute 41 | ls *.deployed | tr . - | xargs -n1 aws cloudformation delete-stack --stack-name 42 | ls *.deployed | tr . - | xargs -n1 aws cloudformation wait stack-delete-complete --stack-name 43 | rm -f *.deployed 44 | 45 | unexecute: 46 | ls *.executed | tr . - | xargs -n1 aws cloudformation delete-stack --stack-name 47 | ls *.executed | tr . - | xargs -n1 aws cloudformation wait stack-delete-complete --stack-name 48 | rm -f *.executed 49 | 50 | .PHONY: package deploy execute clean unexecute undeploy 51 | -------------------------------------------------------------------------------- /tests/int/redact.deploy.yaml: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | AWSTemplateFormatVersion: 2010-09-09 5 | Description: > 6 | This template deploys accustom using a function that will timeout before it completes. 7 | Resources: 8 | Function: 9 | Type: AWS::Lambda::Function 10 | Properties: 11 | Code: redact.zip 12 | Handler: redact.handler 13 | MemorySize: 256 14 | Role: !GetAtt Role.Arn 15 | Runtime: python3.10 16 | Timeout: 40 17 | 18 | Role: 19 | Type: AWS::IAM::Role 20 | Properties: 21 | AssumeRolePolicyDocument: 22 | Version: 2012-10-17 23 | Statement: 24 | - Effect: Allow 25 | Principal: 26 | Service: 27 | - lambda.amazonaws.com 28 | Action: 29 | - sts:AssumeRole 30 | Path: / 31 | Policies: 32 | - PolicyName: invokeFunction 33 | PolicyDocument: 34 | Version: 2012-10-17 35 | Statement: 36 | - Effect: Allow 37 | Action: 38 | - logs:* 39 | Resource: '*' 40 | Outputs: 41 | FunctionArn: 42 | Value: !GetAtt Function.Arn 43 | Export: 44 | Name: ST-Function 45 | -------------------------------------------------------------------------------- /tests/int/redact.execute.yaml: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | AWSTemplateFormatVersion: "2010-09-09" 5 | Description: > 6 | This template tests the deployed function and executes it. It should fail to complete in time and not timeout. 7 | Resources: 8 | HelloWorldExecution: 9 | Type: Custom::HelloWorld 10 | Properties: 11 | ServiceToken: !ImportValue ST-Function 12 | Test: Unredacted 13 | Example: Unredacted 14 | Custom: Unredacted 15 | DeleteMe: Unredacted 16 | DeleteMeExtended: Unredacted 17 | Unredacted: Unredacted 18 | TestExecution: 19 | Type: Custom::Test 20 | Properties: 21 | ServiceToken: !ImportValue ST-Function 22 | Test: Unredacted 23 | Example: Unredacted 24 | Custom: Unredacted 25 | DeleteMe: Unredacted 26 | DeleteMeExtended: Unredacted 27 | Unredacted: Unredacted 28 | Outputs: 29 | HelloWorldOutput: 30 | Value: 31 | Fn::Join: 32 | - "|" 33 | - - !GetAtt HelloWorldExecution.log_group_name 34 | - !GetAtt HelloWorldExecution.log_stream_name 35 | - !GetAtt HelloWorldExecution.aws_request_id 36 | TestOutput: 37 | Value: 38 | Fn::Join: 39 | - "|" 40 | - - !GetAtt TestExecution.log_group_name 41 | - !GetAtt TestExecution.log_stream_name 42 | - !GetAtt TestExecution.aws_request_id 43 | -------------------------------------------------------------------------------- /tests/int/redact.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | """ 4 | Lambda Code for testng Redaction Functionality 5 | """ 6 | import logging 7 | 8 | from accustom import RedactionConfig, RedactionRuleSet, ResponseObject, decorator 9 | 10 | logging.getLogger().setLevel(logging.DEBUG) 11 | 12 | ruleSetDefault = RedactionRuleSet() 13 | ruleSetDefault.add_property_regex("^Test$") 14 | ruleSetDefault.add_property("Example") 15 | 16 | ruleSetCustom = RedactionRuleSet("^Custom::Test$") 17 | ruleSetCustom.add_property("Custom") 18 | ruleSetCustom.add_property_regex("^DeleteMe.*$") 19 | 20 | rc = RedactionConfig(redactResponseURL=True) 21 | rc.add_rule_set(ruleSetDefault) 22 | rc.add_rule_set(ruleSetCustom) 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | # noinspection PyUnusedLocal 28 | @decorator(hideResourceDeleteFailure=True, redactConfig=rc) 29 | def handler(event, context) -> ResponseObject: 30 | """ 31 | Stub handler function for this Lambda Function 32 | 33 | :param event: Event Input 34 | :param context: Lambda Context 35 | :return: A ResponseObject containing the log location 36 | """ 37 | # Passing the context event information back to the function 38 | return ResponseObject( 39 | data={ 40 | "log_group_name": context.log_group_name, 41 | "log_stream_name": context.log_stream_name, 42 | "aws_request_id": context.aws_request_id, 43 | } 44 | ) 45 | -------------------------------------------------------------------------------- /tests/int/test_redact.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | """ 4 | Unit Test Library for the Redaction Processes 5 | """ 6 | from __future__ import annotations 7 | 8 | import json 9 | import logging 10 | from pathlib import Path 11 | from typing import TYPE_CHECKING, List 12 | from unittest import TestCase 13 | from unittest import main as ut_main 14 | 15 | import boto3 16 | 17 | if TYPE_CHECKING: 18 | from mypy_boto3_cloudformation.service_resource import CloudFormationServiceResource, Stack 19 | from mypy_boto3_logs.client import CloudWatchLogsClient 20 | from mypy_boto3_logs.paginator import FilterLogEventsPaginator 21 | from mypy_boto3_logs.type_defs import FilterLogEventsResponseTypeDef 22 | 23 | logging.getLogger().setLevel(logging.DEBUG) 24 | 25 | EXECUTED_FILE = f"{Path(__file__).parent}/redact.executed" 26 | cfn_resource: CloudFormationServiceResource = boto3.resource("cloudformation") 27 | logs_client: CloudWatchLogsClient = boto3.client("logs") 28 | log_events_paginator: FilterLogEventsPaginator = logs_client.get_paginator("filter_log_events") 29 | 30 | 31 | class PreparationTests(TestCase): 32 | def test_executed_file_exists(self) -> None: 33 | self.assertTrue(Path(EXECUTED_FILE).is_file()) 34 | 35 | def test_stack_status(self) -> None: 36 | stack_name: str 37 | with open(EXECUTED_FILE, "r") as f: 38 | stack_name = f.read().strip() 39 | stack = cfn_resource.Stack(stack_name) 40 | self.assertIn(stack.stack_status, ["CREATE_COMPLETE", "UPDATE_COMPLETE"]) 41 | 42 | 43 | class RedactTestCase(TestCase): 44 | stack_name: str 45 | stack: Stack 46 | 47 | # noinspection PyMissingOrEmptyDocstring 48 | @classmethod 49 | def setUpClass(cls) -> None: 50 | with open(EXECUTED_FILE, "r") as f: 51 | cls.stack_name = f.read().strip() 52 | cls.stack = cfn_resource.Stack(cls.stack_name) 53 | 54 | 55 | class OutputTests(RedactTestCase): 56 | def test_verify_output_hello_world(self) -> None: 57 | self.assertIn("HelloWorldOutput", [o["OutputKey"] for o in self.stack.outputs]) 58 | 59 | def test_verify_output_test(self) -> None: 60 | self.assertIn("TestOutput", [o["OutputKey"] for o in self.stack.outputs]) 61 | 62 | 63 | class HelloWorldLogs(RedactTestCase): 64 | output_name: str = "HelloWorldOutput" 65 | log_messages: List[str] 66 | 67 | # noinspection PyMissingOrEmptyDocstring 68 | def setUp(self) -> None: 69 | output = {o["OutputKey"]: o for o in self.stack.outputs}[self.output_name] 70 | log_group_name, log_stream_name, aws_request_id = output["OutputValue"].split("|", 2) 71 | log_output: List[FilterLogEventsResponseTypeDef] = list( 72 | log_events_paginator.paginate( 73 | logGroupName=log_group_name, 74 | logStreamNames=[log_stream_name], 75 | filterPattern=f'"{aws_request_id}" "Request Body"', 76 | ) 77 | ) 78 | self.log_messages = [] 79 | for r in log_output: 80 | for e in r["events"]: 81 | self.log_messages.append(e["message"]) 82 | 83 | def test_number_messages(self): 84 | self.assertEqual(1, len(self.log_messages)) 85 | 86 | def test_redaction(self): 87 | request = json.loads(self.log_messages[0].split("Request Body: ", 1)[1]) 88 | properties = request["ResourceProperties"] 89 | del properties["ServiceToken"] 90 | self.assertEqual( 91 | properties, 92 | { 93 | "Test": "[REDACTED]", 94 | "DeleteMe": "Unredacted", 95 | "Unredacted": "Unredacted", 96 | "Example": "[REDACTED]", 97 | "DeleteMeExtended": "Unredacted", 98 | "Custom": "Unredacted", 99 | }, 100 | ) 101 | 102 | 103 | class TestLogs(RedactTestCase): 104 | output_name: str = "TestOutput" 105 | log_messages: List[str] 106 | 107 | # noinspection PyMissingOrEmptyDocstring 108 | def setUp(self) -> None: 109 | output = {o["OutputKey"]: o for o in self.stack.outputs}[self.output_name] 110 | log_group_name, log_stream_name, aws_request_id = output["OutputValue"].split("|", 2) 111 | log_output: List[FilterLogEventsResponseTypeDef] = list( 112 | log_events_paginator.paginate( 113 | logGroupName=log_group_name, 114 | logStreamNames=[log_stream_name], 115 | filterPattern=f'"{aws_request_id}" "Request Body"', 116 | ) 117 | ) 118 | self.log_messages = [] 119 | for r in log_output: 120 | for e in r["events"]: 121 | self.log_messages.append(e["message"]) 122 | 123 | def test_number_messages(self): 124 | self.assertEqual(1, len(self.log_messages)) 125 | 126 | def test_redaction(self): 127 | request = json.loads(self.log_messages[0].split("Request Body: ", 1)[1]) 128 | properties = request["ResourceProperties"] 129 | del properties["ServiceToken"] 130 | self.assertEqual( 131 | properties, 132 | { 133 | "Test": "[REDACTED]", 134 | "DeleteMe": "[REDACTED]", 135 | "Unredacted": "Unredacted", 136 | "Example": "[REDACTED]", 137 | "DeleteMeExtended": "[REDACTED]", 138 | "Custom": "[REDACTED]", 139 | }, 140 | ) 141 | 142 | 143 | if __name__ == "__main__": 144 | ut_main() 145 | -------------------------------------------------------------------------------- /tests/int/test_timeout.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | """ 4 | Unit Test Library for the Timeout and Chaining Functions 5 | """ 6 | import logging 7 | from pathlib import Path 8 | from unittest import TestCase 9 | from unittest import main as ut_main 10 | 11 | import boto3 12 | 13 | logging.getLogger().setLevel(logging.DEBUG) 14 | 15 | EXECUTED_FILE = f"{Path(__file__).parent}/timeout.executed" 16 | cfn_resource = boto3.resource("cloudformation") 17 | 18 | 19 | class PreparationTests(TestCase): 20 | def test_executed_file_exists(self) -> None: 21 | self.assertTrue(Path(EXECUTED_FILE).is_file()) 22 | 23 | def test_stack_status(self) -> None: 24 | stack_name: str 25 | with open(EXECUTED_FILE, "r") as f: 26 | stack_name = f.read().strip() 27 | stack = cfn_resource.Stack(stack_name) 28 | self.assertIn(stack.stack_status, ["CREATE_FAILED", "UPDATE_FAILED"]) 29 | 30 | 31 | class TimeoutTests(TestCase): 32 | # noinspection PyMissingOrEmptyDocstring 33 | stack_name: str 34 | 35 | def setUp(self) -> None: 36 | with open(EXECUTED_FILE, "r") as f: 37 | self.stack_name = f.read().strip() 38 | self.stack = cfn_resource.Stack(self.stack_name) 39 | 40 | def test_bypass_execution(self) -> None: 41 | resource = self.stack.Resource("BypassExecution") 42 | self.assertEqual(resource.resource_status, "CREATE_COMPLETE") 43 | 44 | def test_no_connect_execution(self) -> None: 45 | resource = self.stack.Resource("NoConnectExecution") 46 | self.assertEqual(resource.resource_status, "CREATE_FAILED") 47 | 48 | def test_success_execution(self) -> None: 49 | resource = self.stack.Resource("SuccessExecution") 50 | self.assertEqual(resource.resource_status, "CREATE_COMPLETE") 51 | 52 | def test_timeout_execution(self) -> None: 53 | resource = self.stack.Resource("TimeoutExecution") 54 | self.assertEqual(resource.resource_status, "CREATE_FAILED") 55 | 56 | def test_invalid_properties_execution(self) -> None: 57 | resource = self.stack.Resource("InvalidPropertiesExecution") 58 | self.assertEqual(resource.resource_status, "CREATE_FAILED") 59 | 60 | 61 | if __name__ == "__main__": 62 | ut_main() 63 | -------------------------------------------------------------------------------- /tests/int/timeout.deploy.yaml: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | AWSTemplateFormatVersion: 2010-09-09 5 | Description: > 6 | This template deploys accustom using a function that will timeout before it completes. 7 | Resources: 8 | SuccessFunction: 9 | Type: AWS::Lambda::Function 10 | Properties: 11 | Code: timeout.zip 12 | Handler: timeout.handler 13 | MemorySize: 256 14 | Role: !GetAtt FullRole.Arn 15 | Runtime: python3.10 16 | Timeout: 40 17 | 18 | BypassFunction: 19 | Type: AWS::Lambda::Function 20 | Properties: 21 | Code: timeout.zip 22 | Handler: timeout.handler 23 | MemorySize: 256 24 | Role: !GetAtt MissingRole.Arn 25 | Runtime: python3.10 26 | Timeout: 40 27 | 28 | TimeoutFunction: 29 | Type: AWS::Lambda::Function 30 | Properties: 31 | Code: timeout.zip 32 | Handler: timeout.handler 33 | MemorySize: 256 34 | Role: !GetAtt FullRole.Arn 35 | Runtime: python3.10 36 | Timeout: 20 37 | 38 | NoConnectFunction: 39 | Type: AWS::Lambda::Function 40 | DependsOn: 41 | - S3Endpoint 42 | - RouteTableAssociation 43 | - LogsEndpoint 44 | Properties: 45 | Code: timeout.zip 46 | Handler: timeout.handler 47 | MemorySize: 256 48 | Role: !GetAtt FullRole.Arn 49 | Runtime: python3.10 50 | Timeout: 40 51 | VpcConfig: 52 | SecurityGroupIds: 53 | - !GetAtt SecurityGroup.GroupId 54 | SubnetIds: 55 | - !Ref Subnet 56 | 57 | FullRole: 58 | Type: AWS::IAM::Role 59 | Properties: 60 | AssumeRolePolicyDocument: 61 | Version: 2012-10-17 62 | Statement: 63 | - Effect: Allow 64 | Principal: 65 | Service: 66 | - lambda.amazonaws.com 67 | Action: 68 | - sts:AssumeRole 69 | Path: / 70 | Policies: 71 | - PolicyName: invokeFunction 72 | PolicyDocument: 73 | Version: 2012-10-17 74 | Statement: 75 | - Effect: Allow 76 | Action: 77 | - lambda:InvokeFunction 78 | - logs:* 79 | - ec2:* 80 | Resource: '*' 81 | MissingRole: 82 | Type: AWS::IAM::Role 83 | Properties: 84 | AssumeRolePolicyDocument: 85 | Version: 2012-10-17 86 | Statement: 87 | - Effect: Allow 88 | Principal: 89 | Service: 90 | - lambda.amazonaws.com 91 | Action: 92 | - sts:AssumeRole 93 | Path: / 94 | Policies: 95 | - PolicyName: invokeFunction 96 | PolicyDocument: 97 | Version: 2012-10-17 98 | Statement: 99 | - Effect: Allow 100 | Action: 101 | - logs:* 102 | - ec2:* 103 | Resource: '*' 104 | 105 | VPC: 106 | Type: AWS::EC2::VPC 107 | Properties: 108 | EnableDnsSupport: True 109 | EnableDnsHostnames: True 110 | CidrBlock: 10.0.0.0/16 111 | 112 | Subnet: 113 | Type: AWS::EC2::Subnet 114 | Properties: 115 | CidrBlock: 10.0.0.0/20 116 | MapPublicIpOnLaunch: False 117 | VpcId: !Ref VPC 118 | 119 | RouteTable: 120 | Type: AWS::EC2::RouteTable 121 | Properties: 122 | VpcId: !Ref VPC 123 | 124 | RouteTableAssociation: 125 | Type: AWS::EC2::SubnetRouteTableAssociation 126 | Properties: 127 | RouteTableId: !Ref RouteTable 128 | SubnetId: !Ref Subnet 129 | 130 | S3Endpoint: 131 | Type: AWS::EC2::VPCEndpoint 132 | Properties: 133 | RouteTableIds: 134 | - !Ref RouteTable 135 | ServiceName: !Sub com.amazonaws.${AWS::Region}.s3 136 | VpcEndpointType: Gateway 137 | VpcId: !Ref VPC 138 | 139 | LogsEndpoint: 140 | Type: AWS::EC2::VPCEndpoint 141 | Properties: 142 | PrivateDnsEnabled: True 143 | SecurityGroupIds: 144 | - !GetAtt SecurityGroup.GroupId 145 | ServiceName: !Sub com.amazonaws.${AWS::Region}.logs 146 | SubnetIds: 147 | - !Ref Subnet 148 | VpcEndpointType: Interface 149 | VpcId: !Ref VPC 150 | 151 | SecurityGroup: 152 | Type: AWS::EC2::SecurityGroup 153 | Properties: 154 | GroupDescription: AllowAllVPCTraffic 155 | SecurityGroupIngress: 156 | - CidrIp: 10.0.0.0/16 157 | IpProtocol: -1 158 | VpcId: !Ref VPC 159 | 160 | 161 | Outputs: 162 | SuccessFunctionArn: 163 | Value: !GetAtt SuccessFunction.Arn 164 | Export: 165 | Name: ST-SuccessFunction 166 | BypassFunctionArn: 167 | Value: !GetAtt BypassFunction.Arn 168 | Export: 169 | Name: ST-BypassFunction 170 | TimeoutFunctionArn: 171 | Value: !GetAtt TimeoutFunction.Arn 172 | Export: 173 | Name: ST-TimeoutFunction 174 | NoConnectFunctionArn: 175 | Value: !GetAtt NoConnectFunction.Arn 176 | Export: 177 | Name: ST-NoConnectFunction 178 | -------------------------------------------------------------------------------- /tests/int/timeout.execute.yaml: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | AWSTemplateFormatVersion: "2010-09-09" 5 | Description: > 6 | This template tests the deployed function and executes it. It should fail to complete in time and not timeout. 7 | Resources: 8 | SuccessExecution: 9 | Type: Custom::SuccessFunction 10 | Properties: 11 | ServiceToken: !ImportValue ST-SuccessFunction 12 | TestProperty: TestValue 13 | BypassExecution: 14 | Type: Custom::BypassFunction 15 | Properties: 16 | ServiceToken: !ImportValue ST-BypassFunction 17 | TestProperty: TestValue 18 | TimeoutExecution: 19 | DependsOn: 20 | - BypassExecution 21 | - SuccessExecution 22 | Type: Custom::TimeoutFunction 23 | Properties: 24 | ServiceToken: !ImportValue ST-TimeoutFunction 25 | TestProperty: TestValue 26 | NoConnectExecution: 27 | Type: Custom::NoConnectFunction 28 | DeletionPolicy: Retain 29 | UpdateReplacePolicy: Retain 30 | Properties: 31 | ServiceToken: !ImportValue ST-NoConnectFunction 32 | TestProperty: TestValue 33 | InvalidPropertiesExecution: 34 | Type: Custom::InvalidProperties 35 | Properties: 36 | ServiceToken: !ImportValue ST-SuccessFunction 37 | NotTestProperty: NotTestValue 38 | -------------------------------------------------------------------------------- /tests/int/timeout.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | """ 4 | Lambda Code for Testing Timeout and Chaining Functionality 5 | """ 6 | 7 | import logging 8 | from time import sleep 9 | 10 | from accustom import sdecorator as sdecorator 11 | 12 | logging.getLogger().setLevel(logging.DEBUG) 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | # noinspection PyUnusedLocal 18 | @sdecorator(hideResourceDeleteFailure=True, decoratorHandleDelete=True, expectedProperties=["TestProperty"]) 19 | def handler(event, context) -> None: 20 | """ 21 | Stab handler for this Lambda Function 22 | 23 | :param event: Event Input 24 | :param context: Lambda Context 25 | :return: Nothing 26 | """ 27 | logger.info("Sleeping for 30 seconds") 28 | sleep(30) 29 | -------------------------------------------------------------------------------- /tests/unit/test_constants.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | """ 5 | Testing of the "constant" library 6 | """ 7 | from unittest import TestCase 8 | from unittest import main as ut_main 9 | 10 | from accustom import RedactMode, RequestType, Status 11 | 12 | 13 | class StatusTests(TestCase): 14 | def test_success(self) -> None: 15 | self.assertEqual("SUCCESS", Status.SUCCESS) 16 | 17 | def test_failed(self) -> None: 18 | self.assertEqual("FAILED", Status.FAILED) 19 | 20 | 21 | class RequestTypeTests(TestCase): 22 | def test_create(self) -> None: 23 | self.assertEqual("Create", RequestType.CREATE) 24 | 25 | def test_update(self) -> None: 26 | self.assertEqual("Update", RequestType.UPDATE) 27 | 28 | def test_delete(self) -> None: 29 | self.assertEqual("Delete", RequestType.DELETE) 30 | 31 | 32 | class RedactModeTests(TestCase): 33 | def test_blocklist(self) -> None: 34 | self.assertEqual("block", RedactMode.BLOCKLIST) 35 | 36 | def test_allowlist(self) -> None: 37 | self.assertEqual("allow", RedactMode.ALLOWLIST) 38 | 39 | def test_blacklist(self) -> None: 40 | self.assertEqual("black", RedactMode.BLACKLIST) 41 | 42 | def test_whitelist(self) -> None: 43 | self.assertEqual("white", RedactMode.WHITELIST) 44 | 45 | 46 | if __name__ == "__main__": 47 | ut_main() 48 | -------------------------------------------------------------------------------- /tests/unit/test_redaction.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | """ 5 | Testing of "redaction" library 6 | """ 7 | import logging 8 | from typing import Any, Dict 9 | from unittest import TestCase 10 | from unittest import main as ut_main 11 | 12 | from accustom import RedactionConfig, RedactionRuleSet, RedactMode, StandaloneRedactionConfig 13 | from accustom.Exceptions import CannotApplyRuleToStandaloneRedactionConfig 14 | 15 | REDACTED_STRING = "[REDACTED]" 16 | NOT_REDACTED_STRING = "NotRedacted" 17 | 18 | 19 | class RedactionRuleSetTests(TestCase): 20 | # noinspection PyMissingOrEmptyDocstring 21 | def setUp(self) -> None: 22 | self.ruleSet = RedactionRuleSet() 23 | 24 | def test_init(self) -> None: 25 | # This test ignores the setUp resources 26 | rs = RedactionRuleSet("^Custom::Test$") 27 | self.assertEqual("^Custom::Test$", rs.resourceRegex) 28 | 29 | def test_invalid_init(self) -> None: 30 | # This test ignores the setUp Resources 31 | with self.assertRaises(TypeError): 32 | RedactionRuleSet(0) # type: ignore 33 | 34 | def test_default_regex(self) -> None: 35 | self.assertEqual("^.*$", self.ruleSet.resourceRegex) 36 | 37 | def test_adding_regex(self) -> None: 38 | self.ruleSet.add_property_regex("^Test$") 39 | self.assertIn("^Test$", self.ruleSet._properties) 40 | 41 | def test_adding_invalid_regex(self) -> None: 42 | with self.assertRaises(TypeError): 43 | # noinspection PyTypeChecker 44 | self.ruleSet.add_property_regex(0) # type: ignore 45 | 46 | def test_adding_property(self) -> None: 47 | self.ruleSet.add_property("Test") 48 | self.assertIn("^Test$", self.ruleSet._properties) 49 | 50 | def test_adding_invalid_property(self) -> None: 51 | with self.assertRaises(TypeError): 52 | # noinspection PyTypeChecker 53 | self.ruleSet.add_property(0) # type: ignore 54 | 55 | 56 | # noinspection DuplicatedCode,PyUnusedLocal 57 | class RedactionConfigTests(TestCase): 58 | # noinspection PyMissingOrEmptyDocstring 59 | def setUp(self) -> None: 60 | self.ruleSetDefault = RedactionRuleSet() 61 | self.ruleSetDefault.add_property_regex("^Test$") 62 | self.ruleSetDefault.add_property("Example") 63 | 64 | self.ruleSetCustom = RedactionRuleSet("^Custom::Test$") 65 | self.ruleSetCustom.add_property("Custom") 66 | self.ruleSetCustom.add_property_regex("^DeleteMe.*$") 67 | 68 | def test_defaults(self) -> None: 69 | rc = RedactionConfig() 70 | self.assertEqual(rc.redactMode, RedactMode.BLOCKLIST) 71 | self.assertFalse(rc.redactResponseURL) 72 | 73 | def test_input_values(self) -> None: 74 | rc = RedactionConfig(redactMode=RedactMode.ALLOWLIST, redactResponseURL=True) 75 | self.assertEqual(rc.redactMode, RedactMode.ALLOWLIST) 76 | self.assertTrue(rc.redactResponseURL) 77 | 78 | def test_whitelist_deprecated(self) -> None: 79 | with self.assertLogs(level=logging.WARNING) as captured: 80 | rc = RedactionConfig(redactMode=RedactMode.WHITELIST) # noqa: F841 81 | 82 | self.assertEqual(1, len(captured.records)) 83 | self.assertEqual( 84 | "The usage of RedactMode.WHITELIST is deprecated, please change to use RedactMode.ALLOWLIST", 85 | captured.records[0].getMessage(), 86 | ) 87 | 88 | def test_blacklist_deprecated(self) -> None: 89 | with self.assertLogs(level=logging.WARNING) as captured: 90 | rc = RedactionConfig(redactMode=RedactMode.BLACKLIST) # noqa: F841 91 | 92 | self.assertEqual(1, len(captured.records), 1) 93 | self.assertEqual( 94 | "The usage of RedactMode.BLACKLIST is deprecated, please change to use RedactMode.BLOCKLIST", 95 | captured.records[0].getMessage(), 96 | ) 97 | 98 | def test_invalid_input_values(self) -> None: 99 | with self.assertRaises(TypeError): 100 | RedactionConfig(redactMode="somestring") # type: ignore 101 | with self.assertRaises(TypeError): 102 | # noinspection PyTypeChecker 103 | RedactionConfig(redactMode=0) # type: ignore 104 | with self.assertRaises(TypeError): 105 | # noinspection PyTypeChecker 106 | RedactionConfig(redactResponseURL=0) # type: ignore 107 | 108 | def test_structure(self) -> None: 109 | rc = RedactionConfig() 110 | rc.add_rule_set(self.ruleSetDefault) 111 | rc.add_rule_set(self.ruleSetCustom) 112 | 113 | self.assertIn("^.*$", rc._redactProperties) 114 | self.assertIn("^Custom::Test$", rc._redactProperties) 115 | self.assertIn("^Test$", rc._redactProperties["^.*$"]) 116 | self.assertIn("^Example$", rc._redactProperties["^.*$"]) 117 | self.assertIn("^DeleteMe.*$", rc._redactProperties["^Custom::Test$"]) 118 | self.assertIn("^Custom$", rc._redactProperties["^Custom::Test$"]) 119 | 120 | def test_redactResponseURL(self) -> None: 121 | rc = RedactionConfig(redactResponseURL=True) 122 | event: Dict[str, Any] = { 123 | "RequestType": "Create", 124 | "RequestId": "abcded", 125 | "ResponseURL": "https://localhost", 126 | "StackId": "arn:...", 127 | "LogicalResourceId": "Test", 128 | "ResourceType": "Custom::Test", 129 | } 130 | revent = rc._redact(event) # type: ignore 131 | 132 | self.assertIn("ResponseURL", event) 133 | self.assertNotIn("ResponseURL", revent) 134 | 135 | def test_blocklist1(self) -> None: 136 | rc = RedactionConfig() 137 | rc.add_rule_set(self.ruleSetDefault) 138 | rc.add_rule_set(self.ruleSetCustom) 139 | event: Dict[str, Any] = { 140 | "RequestType": "Create", 141 | "RequestId": "abcded", 142 | "ResponseURL": "https://localhost", 143 | "StackId": "arn:...", 144 | "LogicalResourceId": "Test", 145 | "ResourceType": "Custom::Test", 146 | "ResourceProperties": { 147 | "Test": NOT_REDACTED_STRING, 148 | "Example": NOT_REDACTED_STRING, 149 | "Custom": NOT_REDACTED_STRING, 150 | "DeleteMe1": NOT_REDACTED_STRING, 151 | "DeleteMe2": NOT_REDACTED_STRING, 152 | "DoNotDelete": NOT_REDACTED_STRING, 153 | }, 154 | } 155 | revent = rc._redact(event) # type: ignore 156 | 157 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Test"]) 158 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["Test"]) 159 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Example"]) 160 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["Example"]) 161 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Custom"]) 162 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["Custom"]) 163 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe1"]) 164 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["DeleteMe1"]) 165 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe2"]) 166 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["DeleteMe2"]) 167 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DoNotDelete"]) 168 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["DoNotDelete"]) 169 | 170 | def test_blocklist2(self) -> None: 171 | rc = RedactionConfig() 172 | rc.add_rule_set(self.ruleSetDefault) 173 | rc.add_rule_set(self.ruleSetCustom) 174 | event: Dict[str, Any] = { 175 | "RequestType": "Create", 176 | "RequestId": "abcded", 177 | "ResponseURL": "https://localhost", 178 | "StackId": "arn:...", 179 | "LogicalResourceId": "Test", 180 | "ResourceType": "Custom::Hello", 181 | "ResourceProperties": { 182 | "Test": NOT_REDACTED_STRING, 183 | "Example": NOT_REDACTED_STRING, 184 | "Custom": NOT_REDACTED_STRING, 185 | "DeleteMe1": NOT_REDACTED_STRING, 186 | "DeleteMe2": NOT_REDACTED_STRING, 187 | "DoNotDelete": NOT_REDACTED_STRING, 188 | }, 189 | } 190 | revent = rc._redact(event) # type: ignore 191 | 192 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Test"]) 193 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["Test"]) 194 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Example"]) 195 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["Example"]) 196 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Custom"]) 197 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["Custom"]) 198 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe1"]) 199 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["DeleteMe1"]) 200 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe2"]) 201 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["DeleteMe2"]) 202 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DoNotDelete"]) 203 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["DoNotDelete"]) 204 | 205 | def test_allowlist1(self) -> None: 206 | rc = RedactionConfig(redactMode=RedactMode.ALLOWLIST) 207 | rc.add_rule_set(self.ruleSetDefault) 208 | rc.add_rule_set(self.ruleSetCustom) 209 | event: Dict[str, Any] = { 210 | "RequestType": "Create", 211 | "RequestId": "abcded", 212 | "ResponseURL": "https://localhost", 213 | "StackId": "arn:...", 214 | "LogicalResourceId": "Test", 215 | "ResourceType": "Custom::Test", 216 | "ResourceProperties": { 217 | "Test": NOT_REDACTED_STRING, 218 | "Example": NOT_REDACTED_STRING, 219 | "Custom": NOT_REDACTED_STRING, 220 | "DeleteMe1": NOT_REDACTED_STRING, 221 | "DeleteMe2": NOT_REDACTED_STRING, 222 | "DoNotDelete": NOT_REDACTED_STRING, 223 | }, 224 | } 225 | revent = rc._redact(event) # type: ignore 226 | 227 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Test"]) 228 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["Test"]) 229 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Example"]) 230 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["Example"]) 231 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Custom"]) 232 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["Custom"]) 233 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe1"]) 234 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["DeleteMe1"]) 235 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe2"]) 236 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["DeleteMe2"]) 237 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DoNotDelete"]) 238 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["DoNotDelete"]) 239 | 240 | def test_allowlist2(self) -> None: 241 | rc = RedactionConfig(redactMode=RedactMode.ALLOWLIST) 242 | rc.add_rule_set(self.ruleSetDefault) 243 | rc.add_rule_set(self.ruleSetCustom) 244 | event: Dict[str, Any] = { 245 | "RequestType": "Create", 246 | "RequestId": "abcded", 247 | "ResponseURL": "https://localhost", 248 | "StackId": "arn:...", 249 | "LogicalResourceId": "Test", 250 | "ResourceType": "Custom::Hello", 251 | "ResourceProperties": { 252 | "Test": NOT_REDACTED_STRING, 253 | "Example": NOT_REDACTED_STRING, 254 | "Custom": NOT_REDACTED_STRING, 255 | "DeleteMe1": NOT_REDACTED_STRING, 256 | "DeleteMe2": NOT_REDACTED_STRING, 257 | "DoNotDelete": NOT_REDACTED_STRING, 258 | }, 259 | } 260 | revent = rc._redact(event) # type: ignore 261 | 262 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Test"]) 263 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["Test"]) 264 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Example"]) 265 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["Example"]) 266 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Custom"]) 267 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["Custom"]) 268 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe1"]) 269 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["DeleteMe1"]) 270 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe2"]) 271 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["DeleteMe2"]) 272 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DoNotDelete"]) 273 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["DoNotDelete"]) 274 | 275 | def test_oldproperties1(self) -> None: 276 | rc = RedactionConfig() 277 | rc.add_rule_set(self.ruleSetDefault) 278 | rc.add_rule_set(self.ruleSetCustom) 279 | event: Dict[str, Any] = { 280 | "RequestType": "Update", 281 | "RequestId": "abcded", 282 | "ResponseURL": "https://localhost", 283 | "StackId": "arn:...", 284 | "LogicalResourceId": "Test", 285 | "PhysicalResourceId": "Test", 286 | "ResourceType": "Custom::Hello", 287 | "ResourceProperties": { 288 | "Test": NOT_REDACTED_STRING, 289 | "Example": NOT_REDACTED_STRING, 290 | "Custom": NOT_REDACTED_STRING, 291 | "DeleteMe1": NOT_REDACTED_STRING, 292 | "DeleteMe2": NOT_REDACTED_STRING, 293 | "DoNotDelete": NOT_REDACTED_STRING, 294 | }, 295 | "OldResourceProperties": { 296 | "Test": NOT_REDACTED_STRING, 297 | "Example": NOT_REDACTED_STRING, 298 | "Custom": NOT_REDACTED_STRING, 299 | "DeleteMe1": NOT_REDACTED_STRING, 300 | "DeleteMe2": NOT_REDACTED_STRING, 301 | "DoNotDelete": NOT_REDACTED_STRING, 302 | }, 303 | } 304 | revent = rc._redact(event) # type: ignore 305 | 306 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Test"]) 307 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["Test"]) 308 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Example"]) 309 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["Example"]) 310 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Custom"]) 311 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["Custom"]) 312 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe1"]) 313 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["DeleteMe1"]) 314 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe2"]) 315 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["DeleteMe2"]) 316 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DoNotDelete"]) 317 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["DoNotDelete"]) 318 | 319 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["Test"]) 320 | self.assertEqual(REDACTED_STRING, revent["OldResourceProperties"]["Test"]) 321 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["Example"]) 322 | self.assertEqual(REDACTED_STRING, revent["OldResourceProperties"]["Example"]) 323 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["Custom"]) 324 | self.assertEqual(NOT_REDACTED_STRING, revent["OldResourceProperties"]["Custom"]) 325 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["DeleteMe1"]) 326 | self.assertEqual(NOT_REDACTED_STRING, revent["OldResourceProperties"]["DeleteMe1"]) 327 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["DeleteMe2"]) 328 | self.assertEqual(NOT_REDACTED_STRING, revent["OldResourceProperties"]["DeleteMe2"]) 329 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["DoNotDelete"]) 330 | self.assertEqual(NOT_REDACTED_STRING, revent["OldResourceProperties"]["DoNotDelete"]) 331 | 332 | def test_oldproperties2(self) -> None: 333 | rc = RedactionConfig(redactMode=RedactMode.ALLOWLIST) 334 | rc.add_rule_set(self.ruleSetDefault) 335 | rc.add_rule_set(self.ruleSetCustom) 336 | event: Dict[str, Any] = { 337 | "RequestType": "Update", 338 | "RequestId": "abcded", 339 | "ResponseURL": "https://localhost", 340 | "StackId": "arn:...", 341 | "LogicalResourceId": "Test", 342 | "PhysicalResourceId": "Test", 343 | "ResourceType": "Custom::Hello", 344 | "ResourceProperties": { 345 | "Test": NOT_REDACTED_STRING, 346 | "Example": NOT_REDACTED_STRING, 347 | "Custom": NOT_REDACTED_STRING, 348 | "DeleteMe1": NOT_REDACTED_STRING, 349 | "DeleteMe2": NOT_REDACTED_STRING, 350 | "DoNotDelete": NOT_REDACTED_STRING, 351 | }, 352 | "OldResourceProperties": { 353 | "Test": NOT_REDACTED_STRING, 354 | "Example": NOT_REDACTED_STRING, 355 | "Custom": NOT_REDACTED_STRING, 356 | "DeleteMe1": NOT_REDACTED_STRING, 357 | "DeleteMe2": NOT_REDACTED_STRING, 358 | "DoNotDelete": NOT_REDACTED_STRING, 359 | }, 360 | } 361 | revent = rc._redact(event) # type: ignore 362 | 363 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Test"]) 364 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["Test"]) 365 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Example"]) 366 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["Example"]) 367 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Custom"]) 368 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["Custom"]) 369 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe1"]) 370 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["DeleteMe1"]) 371 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe2"]) 372 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["DeleteMe2"]) 373 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DoNotDelete"]) 374 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["DoNotDelete"]) 375 | 376 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["Test"]) 377 | self.assertEqual(NOT_REDACTED_STRING, revent["OldResourceProperties"]["Test"]) 378 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["Example"]) 379 | self.assertEqual(NOT_REDACTED_STRING, revent["OldResourceProperties"]["Example"]) 380 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["Custom"]) 381 | self.assertEqual(REDACTED_STRING, revent["OldResourceProperties"]["Custom"]) 382 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["DeleteMe1"]) 383 | self.assertEqual(REDACTED_STRING, revent["OldResourceProperties"]["DeleteMe1"]) 384 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["DeleteMe2"]) 385 | self.assertEqual(REDACTED_STRING, revent["OldResourceProperties"]["DeleteMe2"]) 386 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["DoNotDelete"]) 387 | self.assertEqual(REDACTED_STRING, revent["OldResourceProperties"]["DoNotDelete"]) 388 | 389 | 390 | # noinspection DuplicatedCode,PyUnusedLocal 391 | class StandaloneRedactionConfigTests(TestCase): 392 | # noinspection PyMissingOrEmptyDocstring 393 | def setUp(self) -> None: 394 | self.ruleSetDefault = RedactionRuleSet() 395 | self.ruleSetDefault.add_property_regex("^Test$") 396 | self.ruleSetDefault.add_property("Example") 397 | 398 | self.ruleSetCustom = RedactionRuleSet("^Custom::Test$") 399 | self.ruleSetCustom.add_property("Custom") 400 | self.ruleSetCustom.add_property_regex("^DeleteMe.*$") 401 | 402 | def test_defaults(self) -> None: 403 | rc = StandaloneRedactionConfig(self.ruleSetDefault) 404 | self.assertEqual(RedactMode.BLOCKLIST, rc.redactMode) 405 | self.assertFalse(rc.redactResponseURL) 406 | 407 | def test_input_values(self) -> None: 408 | rc = StandaloneRedactionConfig(self.ruleSetDefault, redactMode=RedactMode.ALLOWLIST, redactResponseURL=True) 409 | self.assertEqual(RedactMode.ALLOWLIST, rc.redactMode) 410 | self.assertTrue(rc.redactResponseURL) 411 | 412 | def test_whitelist_deprecated(self) -> None: 413 | with self.assertLogs(level=logging.WARNING) as captured: 414 | rc = StandaloneRedactionConfig(self.ruleSetDefault, redactMode=RedactMode.WHITELIST) # noqa: F841 415 | 416 | self.assertEqual(1, len(captured.records)) 417 | self.assertEqual( 418 | "The usage of RedactMode.WHITELIST is deprecated, please change to use RedactMode.ALLOWLIST", 419 | captured.records[0].getMessage(), 420 | ) 421 | 422 | def test_blacklist_deprecated(self) -> None: 423 | with self.assertLogs(level=logging.WARNING) as captured: 424 | rc = StandaloneRedactionConfig(self.ruleSetDefault, redactMode=RedactMode.BLACKLIST) # noqa: F841 425 | 426 | self.assertEqual(1, len(captured.records), 1) 427 | self.assertEqual( 428 | "The usage of RedactMode.BLACKLIST is deprecated, please change to use RedactMode.BLOCKLIST", 429 | captured.records[0].getMessage(), 430 | ) 431 | 432 | def test_invalid_input_values(self) -> None: 433 | with self.assertRaises(TypeError): 434 | StandaloneRedactionConfig(self.ruleSetDefault, redactMode="somestring") # type: ignore 435 | with self.assertRaises(TypeError): 436 | # noinspection PyTypeChecker 437 | StandaloneRedactionConfig(self.ruleSetDefault, redactMode=0) # type: ignore 438 | with self.assertRaises(TypeError): 439 | # noinspection PyTypeChecker 440 | StandaloneRedactionConfig(self.ruleSetDefault, redactResponseURL=0) # type: ignore 441 | with self.assertRaises(CannotApplyRuleToStandaloneRedactionConfig): 442 | rc = StandaloneRedactionConfig(self.ruleSetDefault) 443 | rc.add_rule_set(self.ruleSetCustom) 444 | 445 | def test_structure(self) -> None: 446 | rc = StandaloneRedactionConfig(self.ruleSetDefault) 447 | 448 | self.assertIn("^.*$", rc._redactProperties) 449 | self.assertIn("^Test$", rc._redactProperties["^.*$"]) 450 | self.assertIn("^Example$", rc._redactProperties["^.*$"]) 451 | 452 | def test_redactResponseURL(self) -> None: 453 | rc = StandaloneRedactionConfig(self.ruleSetDefault, redactResponseURL=True) 454 | event: Dict[str, Any] = { 455 | "RequestType": "Create", 456 | "RequestId": "abcded", 457 | "ResponseURL": "https://localhost", 458 | "StackId": "arn:...", 459 | "LogicalResourceId": "Test", 460 | "ResourceType": "Custom::Test", 461 | } 462 | revent = rc._redact(event) # type: ignore 463 | 464 | self.assertIn("ResponseURL", event) 465 | self.assertNotIn("ResponseURL", revent) 466 | 467 | def test_blocklist(self) -> None: 468 | rc = StandaloneRedactionConfig(self.ruleSetDefault) 469 | event: Dict[str, Any] = { 470 | "RequestType": "Create", 471 | "RequestId": "abcded", 472 | "ResponseURL": "https://localhost", 473 | "StackId": "arn:...", 474 | "LogicalResourceId": "Test", 475 | "ResourceType": "Custom::Test", 476 | "ResourceProperties": { 477 | "Test": NOT_REDACTED_STRING, 478 | "Example": NOT_REDACTED_STRING, 479 | "Custom": NOT_REDACTED_STRING, 480 | "DeleteMe1": NOT_REDACTED_STRING, 481 | "DeleteMe2": NOT_REDACTED_STRING, 482 | "DoNotDelete": NOT_REDACTED_STRING, 483 | }, 484 | } 485 | revent = rc._redact(event) # type: ignore 486 | 487 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Test"]) 488 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["Test"]) 489 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Example"]) 490 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["Example"]) 491 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Custom"]) 492 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["Custom"]) 493 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe1"]) 494 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["DeleteMe1"]) 495 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe2"]) 496 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["DeleteMe2"]) 497 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DoNotDelete"]) 498 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["DoNotDelete"]) 499 | 500 | def test_allowlist(self) -> None: 501 | rc = StandaloneRedactionConfig(self.ruleSetDefault, redactMode=RedactMode.ALLOWLIST) 502 | event: Dict[str, Any] = { 503 | "RequestType": "Create", 504 | "RequestId": "abcded", 505 | "ResponseURL": "https://localhost", 506 | "StackId": "arn:...", 507 | "LogicalResourceId": "Test", 508 | "ResourceType": "Custom::Hello", 509 | "ResourceProperties": { 510 | "Test": NOT_REDACTED_STRING, 511 | "Example": NOT_REDACTED_STRING, 512 | "Custom": NOT_REDACTED_STRING, 513 | "DeleteMe1": NOT_REDACTED_STRING, 514 | "DeleteMe2": NOT_REDACTED_STRING, 515 | "DoNotDelete": NOT_REDACTED_STRING, 516 | }, 517 | } 518 | revent = rc._redact(event) # type: ignore 519 | 520 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Test"]) 521 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["Test"]) 522 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Example"]) 523 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["Example"]) 524 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Custom"]) 525 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["Custom"]) 526 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe1"]) 527 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["DeleteMe1"]) 528 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe2"]) 529 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["DeleteMe2"]) 530 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DoNotDelete"]) 531 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["DoNotDelete"]) 532 | 533 | def test_oldproperties(self) -> None: 534 | rc = StandaloneRedactionConfig(self.ruleSetDefault, redactMode=RedactMode.ALLOWLIST) 535 | event: Dict[str, Any] = { 536 | "RequestType": "Update", 537 | "RequestId": "abcded", 538 | "ResponseURL": "https://localhost", 539 | "StackId": "arn:...", 540 | "LogicalResourceId": "Test", 541 | "PhysicalResourceId": "Test", 542 | "ResourceType": "Custom::Hello", 543 | "ResourceProperties": { 544 | "Test": NOT_REDACTED_STRING, 545 | "Example": NOT_REDACTED_STRING, 546 | "Custom": NOT_REDACTED_STRING, 547 | "DeleteMe1": NOT_REDACTED_STRING, 548 | "DeleteMe2": NOT_REDACTED_STRING, 549 | "DoNotDelete": NOT_REDACTED_STRING, 550 | }, 551 | "OldResourceProperties": { 552 | "Test": NOT_REDACTED_STRING, 553 | "Example": NOT_REDACTED_STRING, 554 | "Custom": NOT_REDACTED_STRING, 555 | "DeleteMe1": NOT_REDACTED_STRING, 556 | "DeleteMe2": NOT_REDACTED_STRING, 557 | "DoNotDelete": NOT_REDACTED_STRING, 558 | }, 559 | } 560 | revent = rc._redact(event) # type: ignore 561 | 562 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Test"]) 563 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["Test"]) 564 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Example"]) 565 | self.assertEqual(NOT_REDACTED_STRING, revent["ResourceProperties"]["Example"]) 566 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["Custom"]) 567 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["Custom"]) 568 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe1"]) 569 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["DeleteMe1"]) 570 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DeleteMe2"]) 571 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["DeleteMe2"]) 572 | self.assertEqual(NOT_REDACTED_STRING, event["ResourceProperties"]["DoNotDelete"]) 573 | self.assertEqual(REDACTED_STRING, revent["ResourceProperties"]["DoNotDelete"]) 574 | 575 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["Test"]) 576 | self.assertEqual(NOT_REDACTED_STRING, revent["OldResourceProperties"]["Test"]) 577 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["Example"]) 578 | self.assertEqual(NOT_REDACTED_STRING, revent["OldResourceProperties"]["Example"]) 579 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["Custom"]) 580 | self.assertEqual(REDACTED_STRING, revent["OldResourceProperties"]["Custom"]) 581 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["DeleteMe1"]) 582 | self.assertEqual(REDACTED_STRING, revent["OldResourceProperties"]["DeleteMe1"]) 583 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["DeleteMe2"]) 584 | self.assertEqual(REDACTED_STRING, revent["OldResourceProperties"]["DeleteMe2"]) 585 | self.assertEqual(NOT_REDACTED_STRING, event["OldResourceProperties"]["DoNotDelete"]) 586 | self.assertEqual(REDACTED_STRING, revent["OldResourceProperties"]["DoNotDelete"]) 587 | 588 | 589 | if __name__ == "__main__": 590 | ut_main() 591 | -------------------------------------------------------------------------------- /tests/unit/test_response.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | """ 5 | Testing of "response" library 6 | """ 7 | 8 | from unittest import TestCase 9 | from unittest import main as ut_main 10 | 11 | from accustom import RequestType, collapse_data, is_valid_event 12 | 13 | 14 | class ValidEventTests(TestCase): 15 | def test_missing_field(self) -> None: 16 | event = { 17 | "RequestType": RequestType.CREATE, 18 | "ResponseURL": "https://test.url", 19 | "StackId": None, 20 | "RequestId": None, 21 | "ResourceType": None, 22 | } 23 | self.assertFalse(is_valid_event(event)) # type: ignore 24 | 25 | def test_no_valid_request_type(self) -> None: 26 | event = { 27 | "RequestType": "DESTROY", 28 | "ResponseURL": "https://test.url", 29 | "StackId": None, 30 | "RequestId": None, 31 | "ResourceType": None, 32 | "LogicalResourceId": None, 33 | } 34 | self.assertFalse(is_valid_event(event)) # type: ignore 35 | 36 | def test_invalid_url(self) -> None: 37 | event = { 38 | "RequestType": RequestType.CREATE, 39 | "ResponseURL": "ftp://test.url", 40 | "StackId": None, 41 | "RequestId": None, 42 | "ResourceType": None, 43 | "LogicalResourceId": None, 44 | } 45 | self.assertFalse(is_valid_event(event)) # type: ignore 46 | 47 | def test_missing_physical(self) -> None: 48 | event = { 49 | "RequestType": RequestType.UPDATE, 50 | "ResponseURL": "https://test.url", 51 | "StackId": None, 52 | "RequestId": None, 53 | "ResourceType": None, 54 | "LogicalResourceId": None, 55 | } 56 | self.assertFalse(is_valid_event(event)) # type: ignore 57 | 58 | def test_included_physical(self) -> None: 59 | event = { 60 | "RequestType": RequestType.DELETE, 61 | "ResponseURL": "https://test.url", 62 | "StackId": None, 63 | "RequestId": None, 64 | "ResourceType": None, 65 | "LogicalResourceId": None, 66 | "PhysicalResourceId": None, 67 | } 68 | self.assertTrue(is_valid_event(event)) # type: ignore 69 | 70 | 71 | class CollapseDataTests(TestCase): 72 | def test_collapse1(self) -> None: 73 | data = {"Address": {"Street": "Apple Street"}} 74 | expected_data = {"Address.Street": "Apple Street"} 75 | collapsed_data = collapse_data(data) 76 | self.assertEqual(expected_data, collapsed_data) 77 | 78 | def test_collapse2(self) -> None: 79 | data = {"Address": {"Street": "Apple Street", "City": "Fakeville"}} 80 | expected_data = {"Address.Street": "Apple Street", "Address.City": "Fakeville"} 81 | collapsed_data = collapse_data(data) 82 | self.assertEqual(expected_data, collapsed_data) 83 | 84 | def test_collapse3(self) -> None: 85 | data = {"Address": {"Street": "Apple Street", "City": "Fakeville", "Number": {"House": 3, "Unit": 18}}} 86 | expected_data = { 87 | "Address.Street": "Apple Street", 88 | "Address.City": "Fakeville", 89 | "Address.Number.House": 3, 90 | "Address.Number.Unit": 18, 91 | } 92 | collapsed_data = collapse_data(data) 93 | self.assertEqual(expected_data, collapsed_data) 94 | 95 | def test_collapse4(self) -> None: 96 | data = { 97 | "Address": {"Street": "Apple Street", "City": "Fakeville", "Number": {"House": 3, "Unit": 18}}, 98 | "Name": "Bob", 99 | } 100 | expected_data = { 101 | "Address.Street": "Apple Street", 102 | "Address.City": "Fakeville", 103 | "Address.Number.House": 3, 104 | "Address.Number.Unit": 18, 105 | "Name": "Bob", 106 | } 107 | collapsed_data = collapse_data(data) 108 | self.assertEqual(expected_data, collapsed_data) 109 | 110 | def test_collapse5(self) -> None: 111 | data = { 112 | "Address": {"Street": "Apple Street", "City": "Fakeville", "Number": {"House": 3, "Unit": 18}}, 113 | "Address.City": "NotFakeville", 114 | } 115 | expected_data = { 116 | "Address.Street": "Apple Street", 117 | "Address.City": "NotFakeville", 118 | "Address.Number.House": 3, 119 | "Address.Number.Unit": 18, 120 | } 121 | collapsed_data = collapse_data(data) 122 | self.assertEqual(expected_data, collapsed_data) 123 | 124 | 125 | if __name__ == "__main__": 126 | ut_main() 127 | --------------------------------------------------------------------------------