├── .github ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ └── python-package.yml ├── .gitignore ├── .pylintrc ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── NOTICE ├── README.md ├── buildspec.yaml ├── dev-requirements.txt ├── doc ├── _templates │ └── autosummary │ │ └── module.rst ├── conf.py └── index.rst ├── requirements.txt ├── setup.cfg ├── setup.py ├── src └── aws_secretsmanager_caching │ ├── __init__.py │ ├── cache │ ├── __init__.py │ ├── items.py │ ├── lru.py │ └── secret_cache_hook.py │ ├── config.py │ ├── decorators.py │ └── secret_cache.py ├── test ├── integ │ ├── __init__.py │ └── test_aws_secretsmanager_caching.py └── unit │ ├── __init__.py │ ├── test_aws_secretsmanager_caching.py │ ├── test_cache_hook.py │ ├── test_config.py │ ├── test_decorators.py │ ├── test_items.py │ └── test_lru.py └── tox.ini /.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 my contribution is made under the terms of the Apache 2.0 license. 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | groups: 13 | dependencies: 14 | applies-to: version-updates 15 | dependency-type: production 16 | update-types: 17 | - minor 18 | - patch 19 | - package-ecosystem: "github-actions" 20 | directory: "/" 21 | schedule: 22 | interval: "weekly" 23 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install -r requirements.txt -r dev-requirements.txt 31 | pip install -e . 32 | - name: Lint with flake8 33 | run: | 34 | # stop the build if there are Python syntax errors or undefined names 35 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 36 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 37 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 38 | - name: Lint with PyLint 39 | run: pylint --rcfile=.pylintrc src/aws_secretsmanager_caching 40 | - name: Check formatting with Ruff 41 | uses: astral-sh/ruff-action@v3 42 | - name: Test with pytest 43 | run: | 44 | pytest test/unit/ 45 | - name: Upload coverage to Codecov 46 | uses: codecov/codecov-action@v5 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *# 3 | *.swp 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | *.egg-info/ 8 | /.coverage 9 | /.coverage.* 10 | /.cache 11 | /doc/_autosummary/ 12 | /build 13 | *.iml 14 | dist/ 15 | .eggs/ 16 | .pytest_cache/ 17 | .python-version 18 | .tox/ 19 | venv/ 20 | env/ 21 | .vscode/ 22 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | [MESSAGES CONTROL] 4 | disable = I0011, # locally-disabled 5 | R0903, # too-few-public-methods 6 | C0413, # wrong-import-position (isort is handling this) 7 | C0412, # ungrouped-imports (isort is handling this) 8 | 9 | [REPORTS] 10 | # Set the output format. Available formats are text, parseable, colorized, msvs 11 | # (visual studio) and html 12 | output-format=colorized 13 | 14 | 15 | [FORMAT] 16 | # Maximum number of characters on a single line. 17 | max-line-length=120 18 | # Maximum number of lines in a module 19 | #max-module-lines=1000 20 | 21 | [BASIC] 22 | good-names= 23 | i, 24 | e, 25 | logger 26 | 27 | [SIMILARITIES] 28 | # Minimum lines number of a similarity. 29 | min-similarity-lines=5 30 | # Ignore comments when computing similarities. 31 | ignore-comments=yes 32 | # Ignore docstrings when computing similarities. 33 | ignore-docstrings=yes 34 | 35 | [VARIABLES] 36 | # Tells whether we should check for unused import in __init__ files. 37 | init-import=yes 38 | 39 | [LOGGING] 40 | # Apply logging string format checks to calls on these modules. 41 | logging-modules= 42 | logging 43 | 44 | [TYPECHECK] 45 | ignored-modules= 46 | distutils 47 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | README.md @ecraw-amzn 2 | * @aws/aws-secrets-manager-pr-br 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check [existing open](https://github.com/aws/aws-secretsmanager-caching-python/issues), or [recently closed](https://github.com/aws/aws-secretsmanager-caching-python/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/aws/aws-secretsmanager-caching-python/labels/help%20wanted) issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](https://github.com/aws/aws-secretsmanager-caching-python/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | 61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 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: -------------------------------------------------------------------------------- 1 | AWS Secrets Manager Python Caching Client 2 | Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## AWS Secrets Manager Python caching client 2 | 3 | [![Build](https://github.com/aws/aws-secretsmanager-caching-python/actions/workflows/python-package.yml/badge.svg?event=push)](https://github.com/aws/aws-secretsmanager-caching-python/actions/workflows/python-package.yml) 4 | [![codecov](https://codecov.io/github/aws/aws-secretsmanager-caching-python/branch/master/graph/badge.svg?token=DkTHUP8lv5)](https://codecov.io/github/aws/aws-secretsmanager-caching-python) 5 | 6 | The AWS Secrets Manager Python caching client enables in-process caching of secrets for Python applications. 7 | 8 | ## Getting Started 9 | 10 | ### Required Prerequisites 11 | 12 | To use this client you must have: 13 | 14 | - Python 3.8 or newer. Use of Python versions 3.7 or older are not supported. 15 | - An Amazon Web Services (AWS) account to access secrets stored in AWS Secrets Manager. 16 | 17 | - **To create an AWS account**, go to [Sign In or Create an AWS Account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) and then choose **I am a new user.** Follow the instructions to create an AWS account. 18 | 19 | - **To create a secret in AWS Secrets Manager**, go to [Creating Secrets](https://docs.aws.amazon.com/secretsmanager/latest/userguide/manage_create-basic-secret.html) and follow the instructions on that page. 20 | 21 | - This library makes use of botocore, the low-level core functionality of the boto3 SDK. For more information on boto3 and botocore, please review the [AWS SDK for Python](https://aws.amazon.com/sdk-for-python/) and [Botocore](https://botocore.amazonaws.com/v1/documentation/api/latest/index.html) documentation. 22 | 23 | ### Dependencies 24 | 25 | This library requires the following standard dependencies: 26 | 27 | - botocore 28 | - setuptools_scm 29 | - setuptools 30 | 31 | For development and testing purposes, this library requires the following additional dependencies: 32 | 33 | - pytest 34 | - pytest-cov 35 | - pytest-sugar 36 | - codecov 37 | - pylint 38 | - sphinx 39 | - flake8 40 | - tox 41 | 42 | Please review the `requirements.txt` and `dev-requirements.txt` file for specific version requirements. 43 | 44 | ### Installation 45 | 46 | Installing the latest release via **pip**: 47 | 48 | ```bash 49 | $ pip install aws-secretsmanager-caching 50 | ``` 51 | 52 | Installing the latest development release: 53 | 54 | ```bash 55 | $ git clone https://github.com/aws/aws-secretsmanager-caching-python.git 56 | $ cd aws-secretsmanager-caching-python 57 | $ python setup.py install 58 | ``` 59 | 60 | ### Development 61 | 62 | #### Getting Started 63 | 64 | Assuming that you have Python and virtualenv installed, set up your environment and install the required dependencies like this instead of the `pip install aws_secretsmanager_caching` defined above: 65 | 66 | ```bash 67 | $ git clone https://github.com/aws/aws-secretsmanager-caching-python.git 68 | $ cd aws-secretsmanager-caching-python 69 | $ virtualenv venv 70 | ... 71 | $ . venv/bin/activate 72 | $ pip install -r requirements.txt -r dev-requirements.txt 73 | $ pip install -e . 74 | ``` 75 | 76 | **NOTE:** Please use [Ruff](https://docs.astral.sh/ruff/formatter/) for formatting. 77 | 78 | #### Running Tests 79 | 80 | You can run tests in all supported Python versions using tox. By default, it will run all of the unit and integration tests, but you can also specify your own arguments to past to `pytest`. 81 | 82 | ```bash 83 | $ tox # runs integ/unit tests, flake8 tests and pylint tests 84 | $ tox -- test/unit/test_decorators.py # runs specific test file 85 | $ tox -e py37 -- test/integ/ # runs specific test directory 86 | ``` 87 | 88 | #### Documentation 89 | 90 | You can locally-generate the Sphinx-based documentation via: 91 | 92 | ```bash 93 | $ tox -e docs 94 | ``` 95 | 96 | Which will subsequently be viewable at `file://${CLONE_DIR}/.tox/docs_out/index.html` 97 | 98 | ### Usage 99 | 100 | Using the client consists of the following steps: 101 | 102 | 1. Instantiate the client while optionally passing in a `SecretCacheConfig()` object to the `config` parameter. You can also pass in an existing `botocore.client.BaseClient` client to the client parameter. 103 | 2. Request the secret from the client instance. 104 | 105 | ```python 106 | import botocore 107 | import botocore.session 108 | from aws_secretsmanager_caching import SecretCache, SecretCacheConfig 109 | 110 | client = botocore.session.get_session().create_client('secretsmanager') 111 | cache_config = SecretCacheConfig() # See below for defaults 112 | cache = SecretCache(config=cache_config, client=client) 113 | 114 | secret = cache.get_secret_string('mysecret') 115 | ``` 116 | 117 | #### Cache Configuration 118 | 119 | You can configure the cache config object with the following parameters: 120 | 121 | - `max_cache_size` - The maximum number of secrets to cache. The default value is `1024`. 122 | - `exception_retry_delay_base` - The number of seconds to wait after an exception is encountered and before retrying the request. The default value is `1`. 123 | - `exception_retry_growth_factor` - The growth factor to use for calculating the wait time between retries of failed requests. The default value is `2`. 124 | - `exception_retry_delay_max` - The maximum amount of time in seconds to wait between failed requests. The default value is `3600`. 125 | - `default_version_stage` - The default version stage to request. The default value is `'AWSCURRENT'` 126 | - `secret_refresh_interval` - The number of seconds to wait between refreshing cached secret information. The default value is `3600.0`. 127 | - `secret_cache_hook` - An implementation of the SecretCacheHook abstract class. The default value is `None`. 128 | 129 | #### Decorators 130 | 131 | The library also includes several decorator functions to wrap existing function calls with SecretString-based secrets: 132 | 133 | - `@InjectedKeywordedSecretString` - This decorator expects the secret id and cache as the first and second arguments, with subsequent arguments mapping a parameter key from the function that is being wrapped to a key in the secret. The secret being retrieved from the cache must contain a SecretString and that string must be JSON-based. 134 | - `@InjectSecretString` - This decorator also expects the secret id and cache as the first and second arguments. However, this decorator simply returns the result of the cache lookup directly to the first argument of the wrapped function. The secret does not need to be JSON-based but it must contain a SecretString. 135 | 136 | ```python 137 | from aws_secretsmanager_caching import SecretCache 138 | from aws_secretsmanager_caching import InjectKeywordedSecretString, InjectSecretString 139 | 140 | cache = SecretCache() 141 | 142 | @InjectKeywordedSecretString(secret_id='mysecret', cache=cache, func_username='username', func_password='password') 143 | def function_to_be_decorated(func_username, func_password): 144 | print('Something cool is being done with the func_username and func_password arguments here') 145 | ... 146 | 147 | @InjectSecretString('mysimplesecret', cache) 148 | def function_to_be_decorated(arg1, arg2, arg3): 149 | # arg1 contains the cache lookup result of the 'mysimplesecret' secret. 150 | # arg2 and arg3, in this example, must still be passed when calling function_to_be_decorated(). 151 | ``` 152 | 153 | ## Getting Help 154 | 155 | Please use these community resources for getting help: 156 | 157 | - Ask a question on [Stack Overflow](https://stackoverflow.com/) and tag it with [aws-secrets-manager](https://stackoverflow.com/questions/tagged/aws-secrets-manager). 158 | - Open a support ticket with [AWS Support](https://console.aws.amazon.com/support/home#/) 159 | - If it turns out that you may have found a bug, or have a feature request, please [open an issue](https://github.com/aws/aws-secretsmanager-caching-python/issues/new). 160 | 161 | ## License 162 | 163 | This library is licensed under the Apache 2.0 License. 164 | -------------------------------------------------------------------------------- /buildspec.yaml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | phases: 3 | install: 4 | commands: 5 | - apt-get update 6 | - apt-get install -y python3-pip 7 | - pip install --upgrade tox 8 | build: 9 | commands: 10 | - tox -v 11 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest>=8 2 | pytest-cov>=5 3 | pytest-sugar>=1 4 | codecov>=1.4.0 5 | pylint>1.9.4 6 | sphinx>=1.8.4 7 | tox>=4 8 | flake8>=7 9 | -------------------------------------------------------------------------------- /doc/_templates/autosummary/module.rst: -------------------------------------------------------------------------------- 1 | {{ fullname }} 2 | {{ underline }} 3 | 4 | .. automodule:: {{ fullname }} 5 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import version 2 | from datetime import datetime 3 | import os 4 | import shutil 5 | 6 | project = u'AWS Secrets Manager Python Caching Client' 7 | 8 | # If you use autosummary, this ensures that any stale autogenerated files are 9 | # cleaned up first. 10 | if os.path.exists('_autosummary'): 11 | print("cleaning up stale autogenerated files...") 12 | shutil.rmtree('_autosummary') 13 | 14 | # Add any Sphinx extension module names here, as strings. They can be extensions 15 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 16 | extensions = [ 17 | 'sphinx.ext.autodoc', 18 | 'sphinx.ext.autosummary', 19 | 'sphinx.ext.coverage', 20 | 'sphinx.ext.doctest', 21 | 'sphinx.ext.napoleon', 22 | 'sphinx.ext.todo', 23 | ] 24 | 25 | # Add any paths that contain templates here, relative to this directory. 26 | templates_path = ['_templates'] 27 | 28 | source_suffix = '.rst' # The suffix of source filenames. 29 | master_doc = 'index' # The master toctree document. 30 | 31 | copyright = u'%s, Amazon.com' % datetime.now().year 32 | 33 | # The full version, including alpha/beta/rc tags. 34 | release = version('aws_secretsmanager_caching') 35 | 36 | # List of directories, relative to source directory, that shouldn't be searched 37 | # for source files. 38 | exclude_trees = ['_build', '_templates'] 39 | 40 | pygments_style = 'sphinx' 41 | 42 | autoclass_content = "both" 43 | autodoc_default_flags = ['show-inheritance', 'members', 'undoc-members'] 44 | autodoc_member_order = 'bysource' 45 | 46 | html_theme = 'haiku' 47 | html_static_path = ['_static'] 48 | htmlhelp_basename = '%sdoc' % project 49 | 50 | # autosummary 51 | autosummary_generate = True 52 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | AWS Secrets Manager Python Caching Client 2 | ========================================= 3 | 4 | This package provides a client-side caching implementation for AWS Secrets Manager 5 | 6 | Modules 7 | _______ 8 | 9 | .. autosummary:: 10 | :toctree: _autosummary 11 | 12 | .. Add/replace module names you want documented here 13 | aws_secretsmanager_caching 14 | 15 | 16 | 17 | Indices and tables 18 | __________________ 19 | 20 | * :ref:`genindex` 21 | * :ref:`modindex` 22 | * :ref:`search` 23 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | botocore>=1.12 2 | setuptools_scm>=3.2 3 | setuptools>=69 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | xfail_strict = true 3 | addopts = 4 | --verbose 5 | --doctest-modules 6 | --cov aws_secretsmanager_caching 7 | --cov-fail-under 90 8 | --cov-report term-missing 9 | --ignore doc/ 10 | 11 | [aliases] 12 | test=pytest 13 | 14 | [metadata] 15 | description-file = README.md 16 | license_file = LICENSE 17 | 18 | [flake8] 19 | max-line-length = 127 20 | select = C,E,F,W,B 21 | # C812, W503 clash with black 22 | ignore = C812,W503 23 | exclude = venv,.venv,.tox,dist,doc,build,*.egg 24 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setup( 7 | name="aws_secretsmanager_caching", 8 | description="Client-side AWS Secrets Manager caching library", 9 | url="https://github.com/aws/aws-secretsmanager-caching-python", 10 | author="Amazon Web Services", 11 | author_email="aws-secretsmanager-dev@amazon.com", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | packages=find_packages(where="src", exclude=("test",)), 15 | package_dir={"": "src"}, 16 | classifiers=[ 17 | 'Development Status :: 5 - Production/Stable', 18 | 'Intended Audience :: Developers', 19 | 'License :: OSI Approved :: Apache Software License', 20 | 'Programming Language :: Python :: 3.6', 21 | 'Programming Language :: Python :: 3.7' 22 | ], 23 | keywords='secretsmanager secrets manager development cache caching client', 24 | use_scm_version=True, 25 | python_requires='>=3.8', 26 | install_requires=['botocore'], 27 | setup_requires=['pytest-runner', 'setuptools-scm'], 28 | tests_require=['pytest', 'pytest-cov', 'pytest-sugar', 'codecov'] 29 | 30 | ) 31 | -------------------------------------------------------------------------------- /src/aws_secretsmanager_caching/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | """High level AWS Secrets Manager caching client.""" 14 | from aws_secretsmanager_caching.config import SecretCacheConfig 15 | from aws_secretsmanager_caching.decorators import InjectKeywordedSecretString, InjectSecretString 16 | from aws_secretsmanager_caching.secret_cache import SecretCache 17 | 18 | __all__ = ["SecretCache", "SecretCacheConfig", "InjectSecretString", "InjectKeywordedSecretString"] 19 | -------------------------------------------------------------------------------- /src/aws_secretsmanager_caching/cache/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | """Internal Implementation Details 14 | .. warning:: 15 | No guarantee is provided on the modules and APIs within this 16 | namespace staying consistent. Directly reference at your own risk. 17 | """ 18 | from aws_secretsmanager_caching.cache.items import SecretCacheItem, SecretCacheObject, SecretCacheVersion 19 | from aws_secretsmanager_caching.cache.lru import LRUCache 20 | 21 | __all__ = ["SecretCacheObject", "SecretCacheItem", "SecretCacheVersion", "LRUCache"] 22 | -------------------------------------------------------------------------------- /src/aws_secretsmanager_caching/cache/items.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | """Secret cache items""" 14 | # pylint: disable=super-with-arguments 15 | 16 | import threading 17 | import time 18 | from abc import ABCMeta, abstractmethod 19 | from copy import deepcopy 20 | from datetime import datetime, timedelta, timezone 21 | from random import randint 22 | 23 | from .lru import LRUCache 24 | 25 | 26 | class SecretCacheObject: # pylint: disable=too-many-instance-attributes 27 | """Secret cache object that handles the common refresh logic.""" 28 | # Jitter max for refresh now 29 | FORCE_REFRESH_JITTER_SLEEP = 5000 30 | __metaclass__ = ABCMeta 31 | 32 | def __init__(self, config, client, secret_id): 33 | """Construct the secret cache object. 34 | 35 | :type config: aws_secretsmanager_caching.SecretCacheConfig 36 | :param config: Configuration for the cache. 37 | 38 | :type client: botocore.client.BaseClient 39 | :param client: The 'secretsmanager' boto client. 40 | 41 | :type secret_id: str 42 | :param secret_id: The secret identifier to cache. 43 | """ 44 | self._lock = threading.RLock() 45 | self._config = config 46 | self._client = client 47 | self._secret_id = secret_id 48 | self._result = None 49 | self._exception = None 50 | self._exception_count = 0 51 | self._refresh_needed = True 52 | self._next_retry_time = None 53 | 54 | def _is_refresh_needed(self): 55 | """Determine if the cached object should be refreshed. 56 | 57 | :rtype: bool 58 | :return: True if the object should be refreshed. 59 | """ 60 | if self._refresh_needed: 61 | return True 62 | if self._exception is None: 63 | return False 64 | if self._next_retry_time is None: 65 | return False 66 | return self._next_retry_time <= datetime.now(timezone.utc) 67 | 68 | @abstractmethod 69 | def _execute_refresh(self): 70 | """Perform the refresh of the cached object. 71 | 72 | :rtype: object 73 | :return: The cached result of the refresh. 74 | """ 75 | 76 | @abstractmethod 77 | def _get_version(self, version_stage): 78 | """Get a cached secret version based on the given stage. 79 | 80 | :type version_stage: str 81 | :param version_stage: The version stage being requested. 82 | 83 | :rtype: object 84 | :return: The associated cached secret version. 85 | """ 86 | 87 | def __refresh(self): 88 | """Refresh the cached object when needed. 89 | 90 | :rtype: None 91 | :return: None 92 | """ 93 | if not self._is_refresh_needed(): 94 | return 95 | self._refresh_needed = False 96 | try: 97 | self._set_result(self._execute_refresh()) 98 | self._exception = None 99 | self._exception_count = 0 100 | except Exception as e: # pylint: disable=broad-except 101 | self._exception = e 102 | delay = self._config.exception_retry_delay_base * ( 103 | self._config.exception_retry_growth_factor ** self._exception_count 104 | ) 105 | self._exception_count += 1 106 | delay = min(delay, self._config.exception_retry_delay_max) 107 | self._next_retry_time = datetime.now(timezone.utc) + timedelta(milliseconds=delay) 108 | 109 | def get_secret_value(self, version_stage=None): 110 | """Get the cached secret value for the given version stage. 111 | 112 | :type version_stage: str 113 | :param version_stage: The requested secret version stage. 114 | 115 | :rtype: object 116 | :return: The cached secret value. 117 | """ 118 | if not version_stage: 119 | version_stage = self._config.default_version_stage 120 | with self._lock: 121 | self.__refresh() 122 | value = self._get_version(version_stage) 123 | if not value and self._exception: 124 | raise self._exception 125 | return deepcopy(value) 126 | 127 | def refresh_secret_now(self): 128 | """Force a refresh of the cached secret. 129 | :rtype: None 130 | :return: None 131 | """ 132 | self._refresh_needed = True 133 | 134 | # Generate a random number to have a sleep jitter to not get stuck in a retry loop 135 | sleep = randint(int(self.FORCE_REFRESH_JITTER_SLEEP / 2), self.FORCE_REFRESH_JITTER_SLEEP + 1) 136 | 137 | if self._exception is not None: 138 | current_time_millis = int(datetime.now(timezone.utc).timestamp() * 1000) 139 | exception_sleep = self._next_retry_time - current_time_millis 140 | sleep = max(exception_sleep, sleep) 141 | 142 | # Divide by 1000 for millis 143 | time.sleep(sleep / 1000) 144 | 145 | self._execute_refresh() 146 | 147 | def _get_result(self): 148 | """Get the stored result using a hook if present""" 149 | if self._config.secret_cache_hook is None: 150 | return self._result 151 | 152 | return self._config.secret_cache_hook.get(self._result) 153 | 154 | def _set_result(self, result): 155 | """Store the given result using a hook if present""" 156 | if self._config.secret_cache_hook is None: 157 | self._result = result 158 | return 159 | 160 | self._result = self._config.secret_cache_hook.put(result) 161 | 162 | 163 | class SecretCacheItem(SecretCacheObject): 164 | """The secret cache item that maintains a cache of secret versions.""" 165 | 166 | def __init__(self, config, client, secret_id): 167 | """Construct a secret cache item. 168 | 169 | 170 | :type config: aws_secretsmanager_caching.SecretCacheConfig 171 | :param config: Configuration for the cache. 172 | 173 | :type client: botocore.client.BaseClient 174 | :param client: The 'secretsmanager' boto client. 175 | 176 | :type secret_id: str 177 | :param secret_id: The secret identifier to cache. 178 | """ 179 | super(SecretCacheItem, self).__init__(config, client, secret_id) 180 | self._versions = LRUCache(10) 181 | self._next_refresh_time = datetime.now(timezone.utc) 182 | 183 | def _is_refresh_needed(self): 184 | """Determine if the cached item should be refreshed. 185 | 186 | :rtype: bool 187 | :return: True if a refresh is needed. 188 | """ 189 | if super(SecretCacheItem, self)._is_refresh_needed(): 190 | return True 191 | if self._exception: 192 | return False 193 | return self._next_refresh_time <= datetime.now(timezone.utc) 194 | 195 | @staticmethod 196 | def _get_version_id(result, version_stage): 197 | """Get the version id for the given version stage. 198 | 199 | :type: dict 200 | :param result: The result of the DescribeSecret request. 201 | 202 | :type version_stage: str 203 | :param version_stage: The version stage being requested. 204 | 205 | :rtype: str 206 | :return: The associated version id. 207 | """ 208 | if not result: 209 | return None 210 | if "VersionIdsToStages" not in result: 211 | return None 212 | ids = [key for (key, value) in result["VersionIdsToStages"].items() if version_stage in value] 213 | if not ids: 214 | return None 215 | return ids[0] 216 | 217 | def _execute_refresh(self): 218 | """Perform the actual refresh of the cached secret information. 219 | 220 | :rtype: dict 221 | :return: The result of the DescribeSecret request. 222 | """ 223 | result = self._client.describe_secret(SecretId=self._secret_id) 224 | ttl = self._config.secret_refresh_interval 225 | self._next_refresh_time = datetime.now(timezone.utc) + timedelta(seconds=randint(round(ttl / 2), ttl)) 226 | return result 227 | 228 | def _get_version(self, version_stage): 229 | """Get the version associated with the given stage. 230 | 231 | :type version_stage: str 232 | :param version_stage: The version stage being requested. 233 | 234 | :rtype: dict 235 | :return: The cached secret for the given version stage. 236 | """ 237 | version_id = self._get_version_id(self._get_result(), version_stage) 238 | if not version_id: 239 | return None 240 | version = self._versions.get(version_id) 241 | if version: 242 | return version.get_secret_value() 243 | self._versions.put_if_absent(version_id, SecretCacheVersion(self._config, self._client, self._secret_id, 244 | version_id)) 245 | return self._versions.get(version_id).get_secret_value() 246 | 247 | 248 | class SecretCacheVersion(SecretCacheObject): 249 | """Secret cache object for a secret version.""" 250 | 251 | def __init__(self, config, client, secret_id, version_id): 252 | """Construct the cache object for a secret version. 253 | 254 | :type config: aws_secretsmanager_caching.SecretCacheConfig 255 | :param config: Configuration for the cache. 256 | 257 | :type client: botocore.client.BaseClient 258 | :param client: The 'secretsmanager' boto client. 259 | 260 | :type secret_id: str 261 | :param secret_id: The secret identifier to cache. 262 | 263 | :type version_id: str 264 | :param version_id: The version identifier. 265 | """ 266 | super(SecretCacheVersion, self).__init__(config, client, secret_id) 267 | self._version_id = version_id 268 | 269 | def _execute_refresh(self): 270 | """Perform the actual refresh of the cached secret version. 271 | 272 | :rtype: dict 273 | :return: The result of GetSecretValue for the version. 274 | """ 275 | return self._client.get_secret_value(SecretId=self._secret_id, VersionId=self._version_id) 276 | 277 | def _get_version(self, version_stage): 278 | """Get the cached version information for the given stage. 279 | 280 | :type version_stage: str 281 | :param version_stage: The version stage being requested. 282 | 283 | :rtype: dict 284 | :return: The cached GetSecretValue result. 285 | """ 286 | return self._get_result() 287 | -------------------------------------------------------------------------------- /src/aws_secretsmanager_caching/cache/lru.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | """LRU cache""" 14 | 15 | import threading 16 | 17 | 18 | class LRUCache: 19 | """Least recently used cache""" 20 | 21 | def __init__(self, max_size=1024): 22 | """Construct a new instance of the LRU cache 23 | 24 | :type max_size: int 25 | :param max_size: The maximum number of elements to store in the cache 26 | """ 27 | self._lock = threading.RLock() 28 | self._cache = {} 29 | self._head = None 30 | self._tail = None 31 | self._max_size = max_size 32 | self._size = 0 33 | 34 | def get(self, key): 35 | """Get the cached item for the given key 36 | 37 | :type key: object 38 | :param key: Key of the cached item 39 | 40 | :rtype: object 41 | :return: The cached item associated with the key 42 | """ 43 | with self._lock: 44 | if key not in self._cache: 45 | return None 46 | item = self._cache[key] 47 | self._update_head(item) 48 | return item.data 49 | 50 | def put_if_absent(self, key, data): 51 | """Associate the given item with the key if the key is not already associated with an item. 52 | 53 | :type key: object 54 | :param key: The key for the item to cache. 55 | 56 | :type data: object 57 | :param data: The item to cache if the key is not already in use. 58 | 59 | :rtype: bool 60 | :return: True if the given data was mapped to the given key. 61 | """ 62 | with self._lock: 63 | if key in self._cache: 64 | return False 65 | item = LRUItem(key=key, data=data) 66 | self._cache[key] = item 67 | self._size += 1 68 | self._update_head(item) 69 | if self._size > self._max_size: 70 | del self._cache[self._tail.key] 71 | self._unlink(self._tail) 72 | self._size -= 1 73 | return True 74 | 75 | def _update_head(self, item): 76 | """Update the head item in the list to be the given item. 77 | 78 | :type item: object 79 | :param item: The item that should be updated as the head item. 80 | 81 | :rtype: None 82 | :return: None 83 | """ 84 | if item is self._head: 85 | return 86 | self._unlink(item) 87 | item.next = self._head 88 | if self._head is not None: 89 | self._head.prev = item 90 | self._head = item 91 | if self._tail is None: 92 | self._tail = item 93 | 94 | def _unlink(self, item): 95 | """Unlink the given item from the linked list. 96 | 97 | :type item: object 98 | :param item: The item to unlink from the linked list. 99 | 100 | :rtype: None 101 | :return: None 102 | """ 103 | if item is self._head: 104 | self._head = item.next 105 | if item is self._tail: 106 | self._tail = item.prev 107 | if item.prev is not None: 108 | item.prev.next = item.next 109 | if item.next is not None: 110 | item.next.prev = item.prev 111 | item.next = None 112 | item.prev = None 113 | 114 | 115 | class LRUItem: 116 | """An item for use in the LRU cache.""" 117 | 118 | def __init__(self, key, data=None, prev=None, nxt=None): 119 | """Construct an item for use within the LRU cache. 120 | 121 | :type key: object 122 | :param key: The key associated with the item. 123 | 124 | :type data: object 125 | :param data: The associated data for the key/item. 126 | 127 | :type prev: LRUItem 128 | :param prev: The previous item in the linked list. 129 | 130 | :type nxt: LRUItem 131 | :param nxt: The next item in the linked list. 132 | """ 133 | self.key = key 134 | self.next = nxt 135 | self.prev = prev 136 | self.data = data 137 | -------------------------------------------------------------------------------- /src/aws_secretsmanager_caching/cache/secret_cache_hook.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | """Secret cache hook""" 14 | 15 | from abc import ABCMeta, abstractmethod 16 | 17 | 18 | class SecretCacheHook: # pylint: disable=too-many-instance-attributes 19 | """Interface to hook the local in-memory cache. This interface will allow 20 | for clients to perform actions on the items being stored in the in-memory 21 | cache. One example would be encrypting/decrypting items stored in the 22 | in-memory cache.""" 23 | 24 | __metaclass__ = ABCMeta 25 | 26 | def __init__(self): 27 | """Construct the secret cache hook.""" 28 | 29 | @abstractmethod 30 | def put(self, obj): 31 | """Prepare the object for storing in the cache""" 32 | 33 | @abstractmethod 34 | def get(self, cached_obj): 35 | """Derive the object from the cached object.""" 36 | -------------------------------------------------------------------------------- /src/aws_secretsmanager_caching/config.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | """Secret cache configuration object.""" 14 | 15 | from copy import deepcopy 16 | 17 | 18 | class SecretCacheConfig: 19 | 20 | """Advanced configuration for SecretCache clients. 21 | 22 | :type max_cache_size: int 23 | :param max_cache_size: The maximum number of secrets to cache. 24 | 25 | :type exception_retry_delay_base: int 26 | :param exception_retry_delay_base: The number of seconds to wait 27 | after an exception is encountered and before retrying the request. 28 | 29 | :type exception_retry_growth_factor: int 30 | :param exception_retry_growth_factor: The growth factor to use for 31 | calculating the wait time between retries of failed requests. 32 | 33 | :type exception_retry_delay_max: int 34 | :param exception_retry_delay_max: The maximum amount of time in 35 | seconds to wait between failed requests. 36 | 37 | :type default_version_stage: str 38 | :param default_version_stage: The default version stage to request. 39 | 40 | :type secret_refresh_interval: int 41 | :param secret_refresh_interval: The number of seconds to wait between 42 | refreshing cached secret information. 43 | 44 | :type secret_cache_hook: SecretCacheHook 45 | :param secret_cache_hook: An implementation of the SecretCacheHook abstract 46 | class 47 | 48 | """ 49 | 50 | OPTION_DEFAULTS = { 51 | "max_cache_size": 1024, 52 | "exception_retry_delay_base": 1, 53 | "exception_retry_growth_factor": 2, 54 | "exception_retry_delay_max": 3600, 55 | "default_version_stage": "AWSCURRENT", 56 | "secret_refresh_interval": 3600, 57 | "secret_cache_hook": None 58 | } 59 | 60 | def __init__(self, **kwargs): 61 | options = deepcopy(self.OPTION_DEFAULTS) 62 | 63 | # Set config options based on given values 64 | if kwargs: 65 | for key, value in kwargs.items(): 66 | if key in options: 67 | options[key] = value 68 | # The key must exist in the available options 69 | else: 70 | raise TypeError(f"Unexpected keyword argument {key}") 71 | 72 | # Set the attributes based on the config options 73 | for key, value in options.items(): 74 | setattr(self, key, value) 75 | -------------------------------------------------------------------------------- /src/aws_secretsmanager_caching/decorators.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | """Decorators for use with caching library""" 14 | 15 | import json 16 | from functools import wraps 17 | 18 | 19 | class InjectSecretString: 20 | """Decorator implementing high-level Secrets Manager caching client""" 21 | 22 | def __init__(self, secret_id, cache): 23 | """ 24 | Constructs a decorator to inject a single non-keyworded argument from a cached secret for a given function. 25 | 26 | :type secret_id: str 27 | :param secret_id: The secret identifier 28 | 29 | :type cache: aws_secretsmanager_caching.SecretCache 30 | :param cache: Secret cache 31 | """ 32 | 33 | self.cache = cache 34 | self.secret_id = secret_id 35 | 36 | def __call__(self, func): 37 | """ 38 | Return a function with cached secret injected as first argument. 39 | 40 | :type func: object 41 | :param func: The function for injecting a single non-keyworded argument too. 42 | :return The function with the injected argument. 43 | """ 44 | 45 | # Using functools.wraps preserves the metadata of the wrapped function 46 | @wraps(func) 47 | def _wrapped_func(*args, **kwargs): 48 | """ 49 | Internal function to execute wrapped function 50 | """ 51 | secret = self.cache.get_secret_string(secret_id=self.secret_id) 52 | 53 | # Prevent clobbering self arg in class methods 54 | if args and hasattr(args[0].__class__, func.__name__): 55 | new_args = (args[0], secret) + args[1:] 56 | else: 57 | new_args = (secret,) + args 58 | 59 | return func(*new_args, **kwargs) 60 | 61 | return _wrapped_func 62 | 63 | 64 | class InjectKeywordedSecretString: 65 | """Decorator implementing high-level Secrets Manager caching client using JSON-based secrets""" 66 | 67 | def __init__(self, secret_id, cache, **kwargs): 68 | """ 69 | Construct a decorator to inject a variable list of keyword arguments to a given function with resolved values 70 | from a cached secret. 71 | 72 | :type kwargs: dict 73 | :param kwargs: dictionary mapping original keyword argument of wrapped function to JSON-encoded secret key 74 | 75 | :type secret_id: str 76 | :param secret_id: The secret identifier 77 | 78 | :type cache: aws_secretsmanager_caching.SecretCache 79 | :param cache: Secret cache 80 | """ 81 | 82 | self.cache = cache 83 | self.kwarg_map = kwargs 84 | self.secret_id = secret_id 85 | 86 | def __call__(self, func): 87 | """ 88 | Return a function with injected keyword arguments from a cached secret. 89 | 90 | :type func: object 91 | :param func: function for injecting keyword arguments. 92 | :return The original function with injected keyword arguments 93 | """ 94 | 95 | @wraps(func) 96 | def _wrapped_func(*args, **kwargs): 97 | """ 98 | Internal function to execute wrapped function 99 | """ 100 | try: 101 | secret = json.loads( 102 | self.cache.get_secret_string(secret_id=self.secret_id) 103 | ) 104 | except json.decoder.JSONDecodeError: 105 | raise RuntimeError("Cached secret is not valid JSON") from None 106 | 107 | resolved_kwargs = {} 108 | for orig_kwarg, secret_key in self.kwarg_map.items(): 109 | try: 110 | resolved_kwargs[orig_kwarg] = secret[secret_key] 111 | except KeyError: 112 | raise RuntimeError( 113 | f"Cached secret does not contain key {secret_key}" 114 | ) from None 115 | 116 | return func(*args, **resolved_kwargs, **kwargs) 117 | 118 | return _wrapped_func 119 | -------------------------------------------------------------------------------- /src/aws_secretsmanager_caching/secret_cache.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | """High level AWS Secrets Manager caching client.""" 14 | from copy import deepcopy 15 | 16 | from importlib.metadata import version, PackageNotFoundError 17 | import botocore.config 18 | import botocore.session 19 | 20 | from .cache import LRUCache, SecretCacheItem 21 | from .config import SecretCacheConfig 22 | 23 | 24 | class SecretCache: 25 | """Secret Cache client for AWS Secrets Manager secrets""" 26 | 27 | try: 28 | __version__ = version('aws_secretsmanager_caching') 29 | except PackageNotFoundError: 30 | __version__ = '0.0.0' 31 | 32 | def __init__(self, config=SecretCacheConfig(), client=None): 33 | """Construct a secret cache using the given configuration and 34 | AWS Secrets Manager boto client. 35 | 36 | :type config: aws_secretsmanager_caching.SecretCacheConfig 37 | :param config: Secret cache configuration 38 | 39 | :type client: botocore.client.BaseClient 40 | :param client: botocore 'secretsmanager' client 41 | """ 42 | 43 | self._client = client 44 | self._config = deepcopy(config) 45 | self._cache = LRUCache(max_size=self._config.max_cache_size) 46 | boto_config = botocore.config.Config(**{ 47 | "user_agent_extra": f"AwsSecretCache/{SecretCache.__version__}", 48 | }) 49 | if self._client is None: 50 | self._client = botocore.session.get_session().create_client("secretsmanager", config=boto_config) 51 | 52 | def _get_cached_secret(self, secret_id): 53 | """Get a cached secret for the given secret identifier. 54 | 55 | :type secret_id: str 56 | :param secret_id: The secret identifier 57 | 58 | :rtype: aws_secretsmanager_caching.cache.SecretCacheItem 59 | :return: The associated cached secret item 60 | """ 61 | secret = self._cache.get(secret_id) 62 | if secret is not None: 63 | return secret 64 | self._cache.put_if_absent( 65 | secret_id, SecretCacheItem(config=self._config, client=self._client, secret_id=secret_id) 66 | ) 67 | return self._cache.get(secret_id) 68 | 69 | def get_secret_string(self, secret_id, version_stage=None): 70 | """Get the secret string value from the cache. 71 | 72 | :type secret_id: str 73 | :param secret_id: The secret identifier 74 | 75 | :type version_stage: str 76 | :param version_stage: The stage for the requested version. 77 | 78 | :rtype: str 79 | :return: The associated secret string value 80 | """ 81 | secret = self._get_cached_secret(secret_id).get_secret_value(version_stage) 82 | if secret is None: 83 | return secret 84 | return secret.get("SecretString") 85 | 86 | def get_secret_binary(self, secret_id, version_stage=None): 87 | """Get the secret binary value from the cache. 88 | 89 | :type secret_id: str 90 | :param secret_id: The secret identifier 91 | 92 | :type version_stage: str 93 | :param version_stage: The stage for the requested version. 94 | 95 | :rtype: bytes 96 | :return: The associated secret binary value 97 | """ 98 | secret = self._get_cached_secret(secret_id).get_secret_value(version_stage) 99 | if secret is None: 100 | return secret 101 | return secret.get("SecretBinary") 102 | 103 | def refresh_secret_now(self, secret_id): 104 | """Immediately refresh the secret in the cache. 105 | 106 | :type secret_id: str 107 | :param secret_id: The secret identifier 108 | """ 109 | self._get_cached_secret(secret_id).refresh_secret_now() 110 | -------------------------------------------------------------------------------- /test/integ/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws/aws-secretsmanager-caching-python/c2107d77b8ae1a7a4d5012a5933ae5beb44847b6/test/integ/__init__.py -------------------------------------------------------------------------------- /test/integ/test_aws_secretsmanager_caching.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | import inspect 14 | import logging 15 | import time 16 | from datetime import datetime, timedelta 17 | from uuid import uuid4 18 | 19 | import botocore 20 | import botocore.session 21 | import pytest 22 | from aws_secretsmanager_caching.config import SecretCacheConfig 23 | from aws_secretsmanager_caching.secret_cache import SecretCache 24 | from botocore.exceptions import ClientError, HTTPClientError, NoCredentialsError 25 | 26 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | class TestAwsSecretsManagerCachingInteg: 31 | fixture_prefix = 'python_caching_integ_test_' 32 | uuid_suffix = uuid4().hex 33 | 34 | @pytest.fixture(scope='module') 35 | def client(self): 36 | yield botocore.session.get_session().create_client('secretsmanager') 37 | 38 | @pytest.fixture(scope='module', autouse=True) 39 | def pre_test_cleanup(self, client): 40 | logger.info('Starting cleanup operation of previous test secrets...') 41 | old_secrets = [] 42 | two_days_ago = datetime.now() - timedelta(days=2) 43 | 44 | paginator = client.get_paginator('list_secrets') 45 | paginator_config = {'PageSize': 10, 'StartingToken': None} 46 | iterator = paginator.paginate(PaginationConfig=paginator_config) 47 | try: 48 | for page in iterator: 49 | logger.info('Fetching results from ListSecretValue...') 50 | for secret in page['SecretList']: 51 | if secret['Name'].startswith(TestAwsSecretsManagerCachingInteg.fixture_prefix) and \ 52 | (secret['LastChangedDate'] > two_days_ago) and (secret['LastAccessedDate'] > two_days_ago): 53 | old_secrets.append(secret) 54 | try: 55 | paginator_config['StartingToken'] = page['NextToken'] 56 | except KeyError: 57 | logger.info('reached end of list') 58 | break 59 | time.sleep(0.5) 60 | except ClientError as e: 61 | logger.error("Got ClientError {0} while calling ListSecrets".format(e.response['Error']['Code'])) 62 | except HTTPClientError: 63 | logger.error("Got HTTPClientError while calling ListSecrets") 64 | except NoCredentialsError: 65 | logger.fatal("Got NoCredentialsError while calling ListSecrets.") 66 | raise 67 | 68 | if len(old_secrets) == 0: 69 | logger.info("No previously configured test secrets found") 70 | 71 | for secret in old_secrets: 72 | logger.info("Scheduling deletion of secret {}".format(secret['Name'])) 73 | try: 74 | client.delete_secret(SecretId=secret['Name']) 75 | except ClientError as e: 76 | logger.error("Got ClientError {0} while calling " 77 | "DeleteSecret for secret {1}".format(e.response['Error']['Code'], secret['Name'])) 78 | except HTTPClientError: 79 | logger.error("Got HTTPClientError while calling DeleteSecret for secret {0}".format(secret['Name'])) 80 | time.sleep(0.5) 81 | 82 | yield None 83 | 84 | @pytest.fixture 85 | def secret_string(self, request, client): 86 | name = "{0}{1}{2}".format(TestAwsSecretsManagerCachingInteg.fixture_prefix, request.function.__name__, 87 | TestAwsSecretsManagerCachingInteg.uuid_suffix) 88 | 89 | secret = client.create_secret(Name=name, SecretString='test') 90 | yield secret 91 | client.delete_secret(SecretId=secret['ARN'], ForceDeleteWithoutRecovery=True) 92 | 93 | def test_get_secret_string(self, client, secret_string): 94 | cache = SecretCache(client=client) 95 | secret = client.get_secret_value(SecretId=secret_string['ARN'])['SecretString'] 96 | 97 | for _ in range(10): 98 | assert cache.get_secret_string("{0}{1}{2}".format(TestAwsSecretsManagerCachingInteg.fixture_prefix, 99 | inspect.stack()[0][3], 100 | TestAwsSecretsManagerCachingInteg.uuid_suffix)) == secret 101 | 102 | def test_get_secret_string_refresh(self, client, secret_string): 103 | cache = SecretCache(config=SecretCacheConfig(secret_refresh_interval=1), 104 | client=client) 105 | secret = client.get_secret_value(SecretId=secret_string['ARN'])['SecretString'] 106 | 107 | for _ in range(10): 108 | assert cache.get_secret_string("{0}{1}{2}".format(TestAwsSecretsManagerCachingInteg.fixture_prefix, 109 | inspect.stack()[0][3], 110 | TestAwsSecretsManagerCachingInteg.uuid_suffix)) == secret 111 | 112 | client.put_secret_value(SecretId=secret_string['ARN'], 113 | SecretString='test2', VersionStages=['AWSCURRENT']) 114 | 115 | time.sleep(2) 116 | secret = client.get_secret_value(SecretId=secret_string['ARN'])['SecretString'] 117 | for _ in range(10): 118 | assert cache.get_secret_string("{0}{1}{2}".format(TestAwsSecretsManagerCachingInteg.fixture_prefix, 119 | inspect.stack()[0][3], 120 | TestAwsSecretsManagerCachingInteg.uuid_suffix)) == secret 121 | 122 | def test_get_secret_binary_empty(self, client, secret_string): 123 | cache = SecretCache(client=client) 124 | assert cache.get_secret_binary("{0}{1}{2}".format(TestAwsSecretsManagerCachingInteg.fixture_prefix, 125 | inspect.stack()[0][3], 126 | TestAwsSecretsManagerCachingInteg.uuid_suffix)) is None 127 | 128 | @pytest.fixture 129 | def secret_string_stage(self, request, client): 130 | name = "{0}{1}{2}".format(TestAwsSecretsManagerCachingInteg.fixture_prefix, request.function.__name__, 131 | TestAwsSecretsManagerCachingInteg.uuid_suffix) 132 | 133 | secret = client.create_secret(Name=name, SecretString='test') 134 | client.put_secret_value(SecretId=secret['ARN'], SecretString='test2', 135 | VersionStages=['AWSCURRENT']) 136 | 137 | yield client.describe_secret(SecretId=secret['ARN']) 138 | client.delete_secret(SecretId=secret['ARN'], ForceDeleteWithoutRecovery=True) 139 | 140 | def test_get_secret_string_stage(self, client, secret_string_stage): 141 | cache = SecretCache(client=client) 142 | secret = client.get_secret_value(SecretId=secret_string_stage['ARN'], 143 | VersionStage='AWSPREVIOUS')['SecretString'] 144 | 145 | for _ in range(10): 146 | assert cache.get_secret_string("{0}{1}{2}".format(TestAwsSecretsManagerCachingInteg.fixture_prefix, 147 | inspect.stack()[0][3], 148 | TestAwsSecretsManagerCachingInteg.uuid_suffix), 149 | 'AWSPREVIOUS') == secret 150 | 151 | @pytest.fixture 152 | def secret_binary(self, request, client): 153 | name = "{0}{1}{2}".format(TestAwsSecretsManagerCachingInteg.fixture_prefix, request.function.__name__, 154 | TestAwsSecretsManagerCachingInteg.uuid_suffix) 155 | 156 | secret = client.create_secret(Name=name, SecretBinary=b'01010101') 157 | yield secret 158 | client.delete_secret(SecretId=secret['ARN'], ForceDeleteWithoutRecovery=True) 159 | 160 | def test_get_secret_binary(self, client, secret_binary): 161 | cache = SecretCache(client=client) 162 | secret = client.get_secret_value(SecretId=secret_binary['ARN'])['SecretBinary'] 163 | 164 | for _ in range(10): 165 | assert cache.get_secret_binary("{0}{1}{2}".format(TestAwsSecretsManagerCachingInteg.fixture_prefix, 166 | inspect.stack()[0][3], 167 | TestAwsSecretsManagerCachingInteg.uuid_suffix)) == secret 168 | 169 | def test_get_secret_string_empty(self, client, secret_binary): 170 | cache = SecretCache(client=client) 171 | assert cache.get_secret_string("{0}{1}{2}".format(TestAwsSecretsManagerCachingInteg.fixture_prefix, 172 | inspect.stack()[0][3], 173 | TestAwsSecretsManagerCachingInteg.uuid_suffix)) is None 174 | -------------------------------------------------------------------------------- /test/unit/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | -------------------------------------------------------------------------------- /test/unit/test_aws_secretsmanager_caching.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | """ 14 | Unit test suite for high-level functions in aws_secretsmanager_caching 15 | """ 16 | import unittest 17 | 18 | import botocore 19 | import pytest 20 | from botocore.exceptions import ClientError, NoRegionError 21 | from botocore.stub import Stubber 22 | 23 | from aws_secretsmanager_caching.config import SecretCacheConfig 24 | from aws_secretsmanager_caching.secret_cache import SecretCache 25 | 26 | pytestmark = [pytest.mark.unit, pytest.mark.local] 27 | 28 | 29 | class TestAwsSecretsManagerCaching(unittest.TestCase): 30 | 31 | def setUp(self): 32 | pass 33 | 34 | def get_client(self, response={}, versions=None, version_response=None): 35 | client = botocore.session.get_session().create_client( 36 | 'secretsmanager', region_name='us-west-2') 37 | 38 | stubber = Stubber(client) 39 | expected_params = {'SecretId': 'test'} 40 | if versions: 41 | response['VersionIdsToStages'] = versions 42 | stubber.add_response('describe_secret', response, expected_params) 43 | if version_response is not None: 44 | stubber.add_response('get_secret_value', version_response) 45 | stubber.activate() 46 | return client 47 | 48 | def tearDown(self): 49 | pass 50 | 51 | def test_default_session(self): 52 | try: 53 | cache = SecretCache() 54 | user_agent_extra = f"AwsSecretCache/{cache.__version__}" 55 | user_agent = cache._client.meta.config.user_agent 56 | 57 | self.assertTrue(user_agent.find(user_agent_extra) > 0, 58 | f"User agent: {user_agent} ; \ 59 | does not include: {user_agent_extra}") 60 | except NoRegionError: 61 | pass 62 | 63 | def test_client_stub(self): 64 | SecretCache(client=self.get_client()) 65 | 66 | def test_get_secret_string_none(self): 67 | cache = SecretCache(client=self.get_client()) 68 | self.assertIsNone(cache.get_secret_string('test')) 69 | 70 | def test_get_secret_string_missing(self): 71 | response = {} 72 | versions = { 73 | '01234567890123456789012345678901': ['AWSCURRENT'] 74 | } 75 | version_response = {'Name': 'test'} 76 | cache = SecretCache(client=self.get_client(response, 77 | versions, 78 | version_response)) 79 | self.assertIsNone(cache.get_secret_string('test')) 80 | 81 | def test_get_secret_string_no_current(self): 82 | response = {} 83 | versions = { 84 | '01234567890123456789012345678901': ['NOTCURRENT'] 85 | } 86 | version_response = {'Name': 'test'} 87 | cache = SecretCache(client=self.get_client(response, 88 | versions, 89 | version_response)) 90 | self.assertIsNone(cache.get_secret_string('test')) 91 | 92 | def test_get_secret_string_no_versions(self): 93 | response = {'Name': 'test'} 94 | cache = SecretCache(client=self.get_client(response)) 95 | self.assertIsNone(cache.get_secret_string('test')) 96 | 97 | def test_get_secret_string_empty(self): 98 | response = {} 99 | versions = { 100 | '01234567890123456789012345678901': ['AWSCURRENT'] 101 | } 102 | version_response = {} 103 | cache = SecretCache(client=self.get_client(response, 104 | versions, 105 | version_response)) 106 | self.assertIsNone(cache.get_secret_string('test')) 107 | 108 | def test_get_secret_string(self): 109 | secret = 'mysecret' 110 | response = {} 111 | versions = { 112 | '01234567890123456789012345678901': ['AWSCURRENT'] 113 | } 114 | version_response = {'SecretString': secret} 115 | cache = SecretCache(client=self.get_client(response, 116 | versions, 117 | version_response)) 118 | for _ in range(10): 119 | self.assertEqual(secret, cache.get_secret_string('test')) 120 | 121 | def test_get_secret_string_refresh(self): 122 | secret = 'mysecret' 123 | response = {} 124 | versions = { 125 | '01234567890123456789012345678901': ['AWSCURRENT'] 126 | } 127 | version_response = {'SecretString': secret} 128 | cache = SecretCache( 129 | config=SecretCacheConfig(secret_refresh_interval=1), 130 | client=self.get_client(response, 131 | versions, 132 | version_response)) 133 | for _ in range(10): 134 | self.assertEqual(secret, cache.get_secret_string('test')) 135 | 136 | def test_get_secret_string_stage(self): 137 | secret = 'mysecret' 138 | response = {} 139 | versions = { 140 | '01234567890123456789012345678901': ['AWSCURRENT'] 141 | } 142 | version_response = {'SecretString': secret} 143 | cache = SecretCache(client=self.get_client(response, 144 | versions, 145 | version_response)) 146 | for _ in range(10): 147 | self.assertEqual(secret, cache.get_secret_string('test', 148 | 'AWSCURRENT')) 149 | 150 | def test_get_secret_string_multiple(self): 151 | cache = SecretCache(client=self.get_client()) 152 | for _ in range(100): 153 | self.assertIsNone(cache.get_secret_string('test')) 154 | 155 | def test_get_secret_binary(self): 156 | secret = b'01010101' 157 | response = {} 158 | versions = { 159 | '01234567890123456789012345678901': ['AWSCURRENT'] 160 | } 161 | version_response = {'SecretBinary': secret} 162 | cache = SecretCache(client=self.get_client(response, 163 | versions, 164 | version_response)) 165 | for _ in range(10): 166 | self.assertEqual(secret, cache.get_secret_binary('test')) 167 | 168 | def test_get_secret_binary_no_versions(self): 169 | cache = SecretCache(client=self.get_client()) 170 | self.assertIsNone(cache.get_secret_binary('test')) 171 | 172 | def test_refresh_secret_now(self): 173 | secret = 'mysecret' 174 | response = {} 175 | versions = { 176 | '01234567890123456789012345678901': ['AWSCURRENT'] 177 | } 178 | version_response = {'SecretString': secret} 179 | cache = SecretCache(client=self.get_client(response, 180 | versions, 181 | version_response)) 182 | secret = cache._get_cached_secret('test') 183 | self.assertIsNotNone(secret) 184 | 185 | old_refresh_time = secret._next_refresh_time 186 | 187 | secret = cache._get_cached_secret('test') 188 | self.assertTrue(old_refresh_time == secret._next_refresh_time) 189 | 190 | cache.refresh_secret_now('test') 191 | 192 | secret = cache._get_cached_secret('test') 193 | 194 | new_refresh_time = secret._next_refresh_time 195 | self.assertTrue(new_refresh_time > old_refresh_time) 196 | 197 | def test_get_secret_string_exception(self): 198 | client = botocore.session.get_session().create_client( 199 | 'secretsmanager', region_name='us-west-2') 200 | 201 | stubber = Stubber(client) 202 | cache = SecretCache(client=client) 203 | for _ in range(3): 204 | stubber.add_client_error('describe_secret') 205 | stubber.activate() 206 | self.assertRaises(ClientError, cache.get_secret_binary, 'test') 207 | -------------------------------------------------------------------------------- /test/unit/test_cache_hook.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | """ 14 | Unit test suite for items module 15 | """ 16 | import unittest 17 | 18 | import botocore 19 | from aws_secretsmanager_caching.cache.secret_cache_hook import SecretCacheHook 20 | from aws_secretsmanager_caching.config import SecretCacheConfig 21 | from aws_secretsmanager_caching.secret_cache import SecretCache 22 | from botocore.stub import Stubber 23 | 24 | 25 | class DummySecretCacheHook(SecretCacheHook): 26 | """A dummy implementation of the SecretCacheHook abstract class for testing""" 27 | 28 | dict = {} 29 | 30 | def put(self, obj): 31 | if 'SecretString' in obj: 32 | obj['SecretString'] = obj['SecretString'] + "+hook_put" 33 | 34 | if 'SecretBinary' in obj: 35 | obj['SecretBinary'] = obj['SecretBinary'] + b'11111111' 36 | 37 | key = len(self.dict) 38 | self.dict[key] = obj 39 | return key 40 | 41 | def get(self, cached_obj): 42 | obj = self.dict[cached_obj] 43 | 44 | if 'SecretString' in obj: 45 | obj['SecretString'] = obj['SecretString'] + "+hook_get" 46 | 47 | if 'SecretBinary' in obj: 48 | obj['SecretBinary'] = obj['SecretBinary'] + b'00000000' 49 | 50 | return obj 51 | 52 | 53 | class TestSecretCacheHook(unittest.TestCase): 54 | 55 | def setUp(self): 56 | pass 57 | 58 | def get_client(self, response={}, versions=None, version_response=None): 59 | client = botocore.session.get_session().create_client( 60 | 'secretsmanager', region_name='us-west-2') 61 | 62 | stubber = Stubber(client) 63 | expected_params = {'SecretId': 'test'} 64 | if versions: 65 | response['VersionIdsToStages'] = versions 66 | stubber.add_response('describe_secret', response, expected_params) 67 | if version_response is not None: 68 | stubber.add_response('get_secret_value', version_response) 69 | stubber.activate() 70 | return client 71 | 72 | def tearDown(self): 73 | pass 74 | 75 | def test_calls_hook_string(self): 76 | secret = 'mysecret' 77 | hooked_secret = secret + "+hook_put+hook_get" 78 | response = {} 79 | versions = { 80 | '01234567890123456789012345678901': ['AWSCURRENT'] 81 | } 82 | version_response = {'SecretString': secret} 83 | 84 | hook = DummySecretCacheHook() 85 | config = SecretCacheConfig(secret_cache_hook=hook) 86 | 87 | cache = SecretCache(config=config, client=self.get_client(response, 88 | versions, 89 | version_response)) 90 | 91 | for _ in range(10): 92 | fetched_secret = cache.get_secret_string('test') 93 | self.assertTrue(fetched_secret.startswith(hooked_secret)) 94 | 95 | def test_calls_hook_binary(self): 96 | secret = b'01010101' 97 | hooked_secret = secret + b'1111111100000000' 98 | response = {} 99 | versions = { 100 | '01234567890123456789012345678901': ['AWSCURRENT'] 101 | } 102 | version_response = {'SecretBinary': secret} 103 | 104 | hook = DummySecretCacheHook() 105 | config = SecretCacheConfig(secret_cache_hook=hook) 106 | 107 | cache = SecretCache(config=config, client=self.get_client(response, 108 | versions, 109 | version_response)) 110 | 111 | for _ in range(10): 112 | self.assertEqual(hooked_secret, cache.get_secret_binary('test')[0:24]) 113 | -------------------------------------------------------------------------------- /test/unit/test_config.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | """ 14 | Unit test suite for items module 15 | """ 16 | import unittest 17 | 18 | from aws_secretsmanager_caching.config import SecretCacheConfig 19 | 20 | 21 | class TestSecretCacheConfig(unittest.TestCase): 22 | 23 | def setUp(self): 24 | pass 25 | 26 | def tearDown(self): 27 | pass 28 | 29 | def test_simple_config(self): 30 | self.assertRaises(TypeError, SecretCacheConfig, no='one') 31 | 32 | def test_config_default_version_stage(self): 33 | stage = 'nothing' 34 | config = SecretCacheConfig(default_version_stage=stage) 35 | self.assertEqual(config.default_version_stage, stage) 36 | 37 | def test_default_secret_refresh_interval_typing(self): 38 | config = SecretCacheConfig() 39 | self.assertIsInstance(config.secret_refresh_interval, int) 40 | -------------------------------------------------------------------------------- /test/unit/test_decorators.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | """ 14 | Unit test suite for decorators module 15 | """ 16 | import json 17 | import unittest 18 | 19 | import botocore 20 | from aws_secretsmanager_caching.decorators import InjectKeywordedSecretString, InjectSecretString 21 | from aws_secretsmanager_caching.secret_cache import SecretCache 22 | from botocore.stub import Stubber 23 | 24 | 25 | class TestAwsSecretsManagerCachingInjectKeywordedSecretStringDecorator(unittest.TestCase): 26 | 27 | def get_client(self, response={}, versions=None, version_response=None): 28 | client = botocore.session.get_session().create_client('secretsmanager', region_name='us-west-2') 29 | stubber = Stubber(client) 30 | expected_params = {'SecretId': 'test'} 31 | if versions: 32 | response['VersionIdsToStages'] = versions 33 | stubber.add_response('describe_secret', response, expected_params) 34 | if version_response is not None: 35 | stubber.add_response('get_secret_value', version_response) 36 | stubber.activate() 37 | return client 38 | 39 | def test_valid_json(self): 40 | secret = { 41 | 'username': 'secret_username', 42 | 'password': 'secret_password' 43 | } 44 | 45 | secret_string = json.dumps(secret) 46 | 47 | response = {} 48 | versions = { 49 | '01234567890123456789012345678901': ['AWSCURRENT'] 50 | } 51 | version_response = {'SecretString': secret_string} 52 | cache = SecretCache(client=self.get_client(response, versions, version_response)) 53 | 54 | @InjectKeywordedSecretString(secret_id='test', cache=cache, func_username='username', func_password='password') 55 | def function_to_be_decorated(func_username, func_password, keyworded_argument='foo'): 56 | self.assertEqual(secret['username'], func_username) 57 | self.assertEqual(secret['password'], func_password) 58 | self.assertEqual(keyworded_argument, 'foo') 59 | return 'OK' 60 | 61 | self.assertEqual(function_to_be_decorated(), 'OK') 62 | 63 | def test_valid_json_with_mixed_args(self): 64 | secret = { 65 | 'username': 'secret_username', 66 | 'password': 'secret_password' 67 | } 68 | 69 | secret_string = json.dumps(secret) 70 | 71 | response = {} 72 | versions = { 73 | '01234567890123456789012345678901': ['AWSCURRENT'] 74 | } 75 | version_response = {'SecretString': secret_string} 76 | cache = SecretCache(client=self.get_client(response, versions, version_response)) 77 | 78 | @InjectKeywordedSecretString(secret_id='test', cache=cache, arg2='username', arg3='password') 79 | def function_to_be_decorated(arg1, arg2, arg3, arg4='bar'): 80 | self.assertEqual(arg1, 'foo') 81 | self.assertEqual(secret['username'], arg2) 82 | self.assertEqual(secret['password'], arg3) 83 | self.assertEqual(arg4, 'bar') 84 | 85 | function_to_be_decorated('foo') 86 | 87 | def test_valid_json_with_no_secret_kwarg(self): 88 | secret = { 89 | 'username': 'secret_username', 90 | 'password': 'secret_password' 91 | } 92 | 93 | secret_string = json.dumps(secret) 94 | 95 | response = {} 96 | versions = { 97 | '01234567890123456789012345678901': ['AWSCURRENT'] 98 | } 99 | version_response = {'SecretString': secret_string} 100 | cache = SecretCache(client=self.get_client(response, versions, version_response)) 101 | 102 | @InjectKeywordedSecretString('test', cache=cache, func_username='username', func_password='password') 103 | def function_to_be_decorated(func_username, func_password, keyworded_argument='foo'): 104 | self.assertEqual(secret['username'], func_username) 105 | self.assertEqual(secret['password'], func_password) 106 | self.assertEqual(keyworded_argument, 'foo') 107 | 108 | function_to_be_decorated() 109 | 110 | def test_invalid_json(self): 111 | secret = 'not json' 112 | response = {} 113 | versions = { 114 | '01234567890123456789012345678901': ['AWSCURRENT'] 115 | } 116 | version_response = {'SecretString': secret} 117 | cache = SecretCache(client=self.get_client(response, versions, version_response)) 118 | 119 | with self.assertRaises((RuntimeError, json.decoder.JSONDecodeError)): 120 | @InjectKeywordedSecretString(secret_id='test', cache=cache, func_username='username', 121 | func_passsword='password') 122 | def function_to_be_decorated(func_username, func_password, keyworded_argument='foo'): 123 | return 124 | 125 | function_to_be_decorated() 126 | 127 | def test_missing_key(self): 128 | secret = {'username': 'secret_username'} 129 | secret_string = json.dumps(secret) 130 | response = {} 131 | versions = { 132 | '01234567890123456789012345678901': ['AWSCURRENT'] 133 | } 134 | version_response = {'SecretString': secret_string} 135 | cache = SecretCache(client=self.get_client(response, versions, version_response)) 136 | 137 | with self.assertRaises((RuntimeError, ValueError)): 138 | @InjectKeywordedSecretString(secret_id='test', cache=cache, func_username='username', 139 | func_passsword='password') 140 | def function_to_be_decorated(func_username, func_password, keyworded_argument='foo'): 141 | return 142 | 143 | function_to_be_decorated() 144 | 145 | 146 | class TestAwsSecretsManagerCachingInjectSecretStringDecorator(unittest.TestCase): 147 | 148 | def get_client(self, response={}, versions=None, version_response=None): 149 | client = botocore.session.get_session().create_client('secretsmanager', region_name='us-west-2') 150 | stubber = Stubber(client) 151 | expected_params = {'SecretId': 'test'} 152 | if versions: 153 | response['VersionIdsToStages'] = versions 154 | stubber.add_response('describe_secret', response, expected_params) 155 | if version_response is not None: 156 | stubber.add_response('get_secret_value', version_response) 157 | stubber.activate() 158 | return client 159 | 160 | def test_string(self): 161 | secret = 'not json' 162 | response = {} 163 | versions = { 164 | '01234567890123456789012345678901': ['AWSCURRENT'] 165 | } 166 | version_response = {'SecretString': secret} 167 | cache = SecretCache(client=self.get_client(response, versions, version_response)) 168 | 169 | @InjectSecretString('test', cache) 170 | def function_to_be_decorated(arg1, arg2, arg3): 171 | self.assertEqual(arg1, secret) 172 | self.assertEqual(arg2, 'foo') 173 | self.assertEqual(arg3, 'bar') 174 | return 'OK' 175 | 176 | self.assertEqual(function_to_be_decorated('foo', 'bar'), 'OK') 177 | 178 | def test_string_with_additional_kwargs(self): 179 | secret = 'not json' 180 | response = {} 181 | versions = { 182 | '01234567890123456789012345678901': ['AWSCURRENT'] 183 | } 184 | version_response = {'SecretString': secret} 185 | cache = SecretCache(client=self.get_client(response, versions, version_response)) 186 | 187 | @InjectSecretString('test', cache) 188 | def function_to_be_decorated(arg1, arg2, arg3): 189 | self.assertEqual(arg1, secret) 190 | self.assertEqual(arg2, 'foo') 191 | self.assertEqual(arg3, 'bar') 192 | 193 | function_to_be_decorated(arg2='foo', arg3='bar') 194 | 195 | def test_string_with_class_method(self): 196 | secret = 'not json' 197 | response = {} 198 | versions = { 199 | '01234567890123456789012345678901': ['AWSCURRENT'] 200 | } 201 | version_response = {'SecretString': secret} 202 | cache = SecretCache(client=self.get_client(response, versions, version_response)) 203 | 204 | class TestClass(unittest.TestCase): 205 | @InjectSecretString('test', cache) 206 | def class_method(self, arg1, arg2, arg3): 207 | self.assertEqual(arg1, secret) 208 | self.assertEqual(arg2, 'foo') 209 | self.assertEqual(arg3, 'bar') 210 | 211 | t = TestClass() 212 | t.class_method(arg2="foo", arg3="bar") 213 | -------------------------------------------------------------------------------- /test/unit/test_items.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | """ 14 | Unit test suite for items module 15 | """ 16 | import unittest 17 | from datetime import timezone, datetime, timedelta 18 | from unittest.mock import Mock 19 | 20 | from aws_secretsmanager_caching.cache.items import SecretCacheObject, SecretCacheItem 21 | from aws_secretsmanager_caching.config import SecretCacheConfig 22 | 23 | 24 | class TestSecretCacheObject(unittest.TestCase): 25 | 26 | def setUp(self): 27 | pass 28 | 29 | def tearDown(self): 30 | pass 31 | 32 | class TestObject(SecretCacheObject): 33 | 34 | def __init__(self, config, client, secret_id): 35 | super(TestSecretCacheObject.TestObject, self).__init__(config, client, secret_id) 36 | 37 | def _execute_refresh(self): 38 | super(TestSecretCacheObject.TestObject, self)._execute_refresh() 39 | 40 | def _get_version(self, version_stage): 41 | return super(TestSecretCacheObject.TestObject, self)._get_version(version_stage) 42 | 43 | def test_simple(self): 44 | sco = TestSecretCacheObject.TestObject(SecretCacheConfig(), None, None) 45 | self.assertIsNone(sco.get_secret_value()) 46 | 47 | def test_simple_2(self): 48 | sco = TestSecretCacheObject.TestObject(SecretCacheConfig(), None, None) 49 | self.assertIsNone(sco.get_secret_value()) 50 | sco._exception = Exception("test") 51 | self.assertRaises(Exception, sco.get_secret_value) 52 | 53 | def test_refresh_now(self): 54 | config = SecretCacheConfig() 55 | 56 | client_mock = Mock() 57 | client_mock.describe_secret = Mock() 58 | client_mock.describe_secret.return_value = "test" 59 | secret_cache_item = SecretCacheItem(config, client_mock, None) 60 | secret_cache_item._next_refresh_time = datetime.now(timezone.utc) + timedelta(days=30) 61 | secret_cache_item._refresh_needed = False 62 | self.assertFalse(secret_cache_item._is_refresh_needed()) 63 | 64 | old_refresh_time = secret_cache_item._next_refresh_time 65 | self.assertTrue(old_refresh_time > datetime.now(timezone.utc) + timedelta(days=29)) 66 | 67 | secret_cache_item.refresh_secret_now() 68 | new_refresh_time = secret_cache_item._next_refresh_time 69 | 70 | ttl = config.secret_refresh_interval 71 | 72 | # New refresh time will use the ttl and will be less than the old refresh time that was artificially set a month ahead 73 | # The new refresh time will be between now + ttl and now + (ttl / 2) if the secret was immediately refreshed 74 | self.assertTrue(new_refresh_time < old_refresh_time and new_refresh_time < datetime.now(timezone.utc) + timedelta(ttl)) 75 | 76 | def test_datetime_fix_is_refresh_needed(self): 77 | secret_cached_object = TestSecretCacheObject.TestObject(SecretCacheConfig(), None, None) 78 | 79 | # Variable values set in order to be able to test modified line with assert statement (False is not None) 80 | secret_cached_object._next_retry_time = datetime.now(tz=timezone.utc) 81 | secret_cached_object._refresh_needed = False 82 | secret_cached_object._exception = not None 83 | 84 | self.assertTrue(secret_cached_object._is_refresh_needed()) 85 | 86 | def test_datetime_fix_refresh(self): 87 | exp_factor = 11 88 | 89 | secret_cached_object = SecretCacheObject( 90 | SecretCacheConfig(exception_retry_delay_base=1, exception_retry_growth_factor=2), 91 | None, None 92 | ) 93 | secret_cached_object._set_result = Mock(side_effect=Exception("exception used for test")) 94 | secret_cached_object._refresh_needed = True 95 | secret_cached_object._exception_count = exp_factor # delay = min(1*(2^exp_factor) = 2048, 3600) 96 | 97 | t_before = datetime.now(tz=timezone.utc) 98 | secret_cached_object._SecretCacheObject__refresh() 99 | t_after = datetime.now(tz=timezone.utc) 100 | 101 | t_before_delay = t_before + timedelta( 102 | milliseconds=secret_cached_object._config.exception_retry_delay_base * ( 103 | secret_cached_object._config.exception_retry_growth_factor ** exp_factor 104 | ) 105 | ) 106 | self.assertLessEqual(t_before_delay, secret_cached_object._next_retry_time) 107 | 108 | t_after_delay = t_after + timedelta( 109 | milliseconds=secret_cached_object._config.exception_retry_delay_base * ( 110 | secret_cached_object._config.exception_retry_growth_factor ** exp_factor 111 | ) 112 | ) 113 | self.assertGreaterEqual(t_after_delay, secret_cached_object._next_retry_time) 114 | 115 | 116 | class TestSecretCacheItem(unittest.TestCase): 117 | def setUp(self): 118 | pass 119 | 120 | def tearDown(self): 121 | pass 122 | 123 | def test_datetime_fix_SCI_init(self): 124 | config = SecretCacheConfig() 125 | t_before = datetime.now(tz=timezone.utc) 126 | secret_cache_item = SecretCacheItem(config, None, None) 127 | t_after = datetime.now(tz=timezone.utc) 128 | 129 | self.assertGreaterEqual(secret_cache_item._next_refresh_time, t_before) 130 | self.assertLessEqual(secret_cache_item._next_refresh_time, t_after) 131 | 132 | def test_datetime_fix_refresh_needed(self): 133 | config = SecretCacheConfig() 134 | secret_cache_item = SecretCacheItem(config, None, None) 135 | 136 | # Variable values set in order to be able to test modified line with assert statement (False is not None) 137 | secret_cache_item._refresh_needed = False 138 | secret_cache_item._exception = False 139 | secret_cache_item._next_retry_time = None 140 | 141 | self.assertTrue(secret_cache_item._is_refresh_needed()) 142 | 143 | def test_datetime_fix_execute_refresh(self): 144 | client_mock = Mock() 145 | client_mock.describe_secret = Mock() 146 | client_mock.describe_secret.return_value = "test" 147 | 148 | config = SecretCacheConfig() 149 | secret_cache_item = SecretCacheItem(config, client_mock, None) 150 | 151 | t_before = datetime.now(tz=timezone.utc) 152 | secret_cache_item._execute_refresh() 153 | t_after = datetime.now(tz=timezone.utc) 154 | 155 | # Make sure that the timezone is correctly set 156 | self.assertEqual(secret_cache_item._next_refresh_time.tzinfo, timezone.utc) 157 | 158 | # Check that secret_refresh_interval addition works as intended 159 | self.assertGreaterEqual(secret_cache_item._next_refresh_time, t_before) 160 | t_max_after = t_after + timedelta(seconds=config.secret_refresh_interval) 161 | self.assertLessEqual(secret_cache_item._next_refresh_time, t_max_after) 162 | -------------------------------------------------------------------------------- /test/unit/test_lru.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | """ 14 | Unit test suite for high-level functions in aws_secretsmanager_caching 15 | """ 16 | import unittest 17 | 18 | import pytest 19 | from aws_secretsmanager_caching.cache.lru import LRUCache 20 | 21 | pytestmark = [pytest.mark.unit, pytest.mark.local] 22 | 23 | 24 | class TestLRUCache(unittest.TestCase): 25 | 26 | def test_lru_cache_max(self): 27 | cache = LRUCache(max_size=10) 28 | for n in range(100): 29 | cache.put_if_absent(n, n) 30 | for n in range(90): 31 | self.assertIsNone(cache.get(n)) 32 | for n in range(91, 100): 33 | self.assertIsNotNone(cache.get(n)) 34 | 35 | def test_lru_cache_none(self): 36 | cache = LRUCache(max_size=10) 37 | self.assertIsNone(cache.get(1)) 38 | 39 | def test_lru_cache_recent(self): 40 | cache = LRUCache(max_size=10) 41 | for n in range(100): 42 | cache.put_if_absent(n, n) 43 | cache.get(0) 44 | for n in range(1, 91): 45 | self.assertIsNone(cache.get(n)) 46 | for n in range(92, 100): 47 | self.assertIsNotNone(cache.get(n)) 48 | self.assertIsNotNone(cache.get(0)) 49 | 50 | def test_lru_cache_zero(self): 51 | cache = LRUCache(max_size=0) 52 | for n in range(100): 53 | cache.put_if_absent(n, n) 54 | self.assertIsNone(cache.get(n)) 55 | for n in range(100): 56 | self.assertIsNone(cache.get(n)) 57 | 58 | def test_lru_cache_one(self): 59 | cache = LRUCache(max_size=1) 60 | for n in range(100): 61 | cache.put_if_absent(n, n) 62 | self.assertEqual(cache.get(n), n) 63 | for n in range(99): 64 | self.assertIsNone(cache.get(n)) 65 | self.assertEqual(cache.get(99), 99) 66 | 67 | def test_lru_cache_if_absent(self): 68 | cache = LRUCache(max_size=1) 69 | for n in range(100): 70 | cache.put_if_absent(1000, 1000) 71 | self.assertIsNone(cache.get(n)) 72 | self.assertEqual(cache.get(1000), 1000) 73 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | 7 | [tox] 8 | envlist = py38, py39, py310, py311, py312, flake8, pylint 9 | skip_missing_interpreters = true 10 | 11 | [testenv] 12 | passenv = AWS_ACCESS_KEY_ID 13 | AWS_SECRET_ACCESS_KEY 14 | AWS_SESSION_TOKEN 15 | AWS_REGION 16 | AWS_DEFAULT_REGION 17 | AWS_CONTAINER_CREDENTIALS_RELATIVE_URI 18 | SETUPTOOLS_SCM_PRETEND_VERSION 19 | setenv = PYTHONPATH = {toxinidir}/src 20 | deps = -r{toxinidir}/requirements.txt 21 | -r{toxinidir}/dev-requirements.txt 22 | commands= {[testenv:pytest]commands} 23 | 24 | [flake8] 25 | max-line-length = 120 26 | select = C,E,F,W,B 27 | # C812, W503 clash with black 28 | ignore = C812,W503 29 | 30 | [testenv:flake8] 31 | commands = flake8 32 | deps = flake8 33 | 34 | [testenv:pytest] 35 | commands=pytest {posargs} 36 | 37 | 38 | [testenv:pylint] 39 | commands = pylint --rcfile=.pylintrc src/aws_secretsmanager_caching 40 | 41 | 42 | [testenv:docs] 43 | changedir = {toxinidir} 44 | description = invoke sphinx-build to build the HTML docs 45 | commands = sphinx-build -d "{toxworkdir}/docs_doctree" doc "{toxworkdir}/docs_out" --color -bhtml {posargs} 46 | python -c 'import pathlib; print("documentation available under file://\{0\}".format(pathlib.Path(r"{toxworkdir}") / "docs_out" / "index.html"))' 47 | 48 | --------------------------------------------------------------------------------