├── .devcontainer └── devcontainer.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── main.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── changelog.md ├── doc-logo.svg ├── favicon.png ├── getting-started │ ├── concepts.md │ ├── installation.md │ └── quickstart.md ├── guides │ ├── api-gateway.md │ ├── dynamo-db.md │ ├── lambda.md │ ├── linking.md │ └── project-structure.md ├── index.md └── overrides │ └── partials │ └── integrations │ └── analytics │ └── custom.html ├── mkdocs.yml ├── pulumi-tmpl ├── .gitignore ├── Pulumi.yaml ├── __main__.py ├── requirements.txt └── stlv_app.py ├── pyproject.toml ├── stelvio ├── __init__.py ├── app.py ├── aws │ ├── __init__.py │ ├── _packaging │ │ ├── __init__.py │ │ └── dependencies.py │ ├── api_gateway.py │ ├── dynamo_db.py │ ├── function │ │ ├── __init__.py │ │ ├── config.py │ │ ├── constants.py │ │ ├── dependencies.py │ │ ├── iam.py │ │ ├── naming.py │ │ ├── packaging.py │ │ └── resources_codegen.py │ ├── layer.py │ ├── permission.py │ └── types.py ├── component.py ├── link.py └── project.py ├── tests ├── __init__.py ├── aws │ ├── __init__.py │ ├── _packaging │ │ ├── __init__.py │ │ └── test_dependencies.py │ ├── api_gateway │ │ ├── __init__.py │ │ ├── test_api.py │ │ ├── test_api_helper_functions.py │ │ ├── test_api_route.py │ │ └── test_api_route_dataclass.py │ ├── dynamo_db │ │ ├── __init__.py │ │ └── test_dynamodb_table.py │ ├── function │ │ ├── __init__.py │ │ ├── test_function.py │ │ ├── test_function_config.py │ │ └── test_function_init.py │ ├── pulumi_mocks.py │ ├── sample_test_project │ │ ├── functions │ │ │ ├── folder │ │ │ │ ├── handler.py │ │ │ │ └── handler2.py │ │ │ ├── folder2 │ │ │ │ └── handler.py │ │ │ ├── orders.py │ │ │ ├── simple.py │ │ │ ├── simple2.py │ │ │ └── users.py │ │ └── stlv_app.py │ ├── test_layer.py │ └── test_permission.py ├── conftest.py ├── test_component.py └── test_link.py └── uv.lock /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/python 3 | { 4 | "name": "Stelvio.dev (py3.12-bookworm)", 5 | "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm", 6 | "features": { 7 | "ghcr.io/devcontainers/features/aws-cli:1": {}, 8 | "ghcr.io/devcontainers/features/node:1": {}, 9 | "ghcr.io/devcontainers-extra/features/pulumi:1": {}, 10 | "ghcr.io/devcontainers/features/github-cli:1": {}, 11 | "ghcr.io/va-h/devcontainers-features/uv:1": {} 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: michal-stlv 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots or other outputs** 20 | If applicable, add screenshots or outputs to help explain your problem. 21 | 22 | **Environment (please complete the following information):** 23 | - OS 24 | - Version [e.g. 0.1.0a2] 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: michal-stlv 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | tests-and-ruff: 7 | name: Tests & Ruff 8 | timeout-minutes: 5 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Install uv 13 | uses: astral-sh/setup-uv@v5 14 | with: 15 | version: "0.7.3" 16 | enable-cache: true 17 | cache-dependency-glob: "uv.lock" 18 | - name: Install the project 19 | run: uv sync --dev 20 | - name: Run ruff format 21 | run: uv run ruff format 22 | - name: Run ruff check 23 | run: uv run ruff check 24 | - name: Run pytest 25 | run: uv run pytest tests -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # PyPI configuration file 171 | .pypirc 172 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Be Nice, Stay On Topic 4 | 5 | This project has a simple code of conduct: 6 | 7 | 1. **Be respectful** and constructive in all interactions 8 | 2. Keep discussions on-topic and focused on the project 9 | 3. The project maintainer has final say on all project decisions and can remove disruptive participants if necessary 10 | 11 | That's it. Let's build good software together. 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Stelvio 2 | 3 | First off, thank you for considering contributing to Stelvio! Contributions of all kinds are welcome and valued, from code improvements to documentation updates. Every contribution, no matter how small, helps make Stelvio better for everyone. 4 | 5 | ## Code of Conduct 6 | 7 | By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). It's very short and simple - basically just be nice and stay on topic. 8 | 9 | ## Contributor License Agreement 10 | 11 | Stelvio uses a Contributor License Agreement (CLA) to ensure that the project has the necessary rights to use your contributions. When you submit your first PR, our CLA Assistant bot will guide you through the signing process. This is a one-time process for all your future contributions. 12 | 13 | The CLA protects both contributors and the project by clearly defining the terms under which code is contributed. 14 | 15 | ## Getting Started 16 | 17 | The quickest way to get started with development is to: 18 | 19 | 1. Fork the repository 20 | 2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/stelvio.git` 21 | 3. Add the original repo as upstream: `git remote add upstream https://github.com/michal-stlv/stelvio.git` 22 | 4. Install UV if not already installed (see [UV installation docs](https://github.com/astral-sh/uv?tab=readme-ov-file#installation)) 23 | 5. Set up the development environment: `uv sync` 24 | 6. Run tests to make sure everything works: `uv run pytest` 25 | 7. Run docs locally with: `uv run mkdocs serve` 26 | 8. Format code with Ruff: `uv run ruff format` 27 | 9. Check code with Ruff: `uv run ruff check` 28 | 29 | For a more detailed guide on using Stelvio, please refer to our [Quick Start Guide](https://docs.stelvio.dev/getting-started/quickstart/). 30 | 31 | ## Contribution Process 32 | 33 | 1. Create a branch for your work: `git checkout -b feature/your-feature-name` 34 | 2. Make your changes 35 | 3. Write tests for any code you add or modify 36 | 4. Update documentation if needed 37 | 5. Ensure all tests pass: `uv run pytest` 38 | 6. Commit your changes with descriptive messages 39 | 7. Push to your fork: `git push origin feature/your-feature-name` 40 | 8. Create a Pull Request to the `main` branch of the original repository 41 | 42 | ## Pull Request Guidelines 43 | 44 | - Every code change should include appropriate tests 45 | - Update documentation for any user-facing changes 46 | - Keep PRs focused on a single change or feature 47 | - Follow the existing code style 48 | - Format your code with Ruff: `uv run ruff format` 49 | - Ensure all linting checks pass: `uv run ruff check` 50 | - Ensure all tests pass before submitting: `uv run pytest` 51 | - Provide a clear description of the changes in your PR 52 | 53 | ## Communication 54 | 55 | Have questions or suggestions? Here are the best ways to reach out: 56 | 57 | - [GitHub Issues](https://github.com/michal-stlv/stelvio/issues) for bug reports and feature requests 58 | - [GitHub Discussions](https://github.com/michal-stlv/stelvio/discussions) for general questions and discussions 59 | - Email: michal@stelvio.dev 60 | - Twitter: [@michal_stlv](https://twitter.com/michal_stlv) 61 | 62 | ## Issue Reporting 63 | 64 | When reporting issues, please include: 65 | 66 | - A clear and descriptive title 67 | - A detailed description of the issue 68 | - Steps to reproduce the behavior 69 | - What you expected to happen 70 | - What actually happened 71 | - Your environment (OS, Python version, Stelvio version) 72 | 73 | ## Thank You! 74 | 75 | Your contributions to open source, no matter how small, are greatly appreciated. Even if it's just fixing a typo in the documentation, it helps make Stelvio better for everyone. 76 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this wdocument. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 Michal Martinka 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stelvio 2 | 3 | _**AWS for Python devs - made simple.**_ 4 | 5 | [**Documentation**](https://docs.stelvio.dev/getting-started/quickstart/) - 6 | [**Why I'm building Stelvio**](https://blog.stelvio.dev/why-i-am-building-stelvio/) - [**Intro article with quickstart**](https://blog.stelvio.dev/introducing-stelvio/) 7 | 8 | ## What is Stelvio? 9 | 10 | Stelvio is a Python library that simplifies AWS cloud infrastructure management and deployment. It lets you define your cloud infrastructure using pure Python, with smart defaults that handle complex configuration automatically. 11 | 12 | ### Key Features 13 | 14 | - **Developer-First**: Built specifically for Python developers, not infrastructure experts 15 | - **Python-Native Infrastructure**: Define your cloud resources using familiar Python code 16 | - **Smart Defaults**: Automatic configuration of IAM roles, networking, and security 17 | - **Clean Separation**: Keep your infrastructure code separate from application code 18 | 19 | ### Currently Supported 20 | 21 | - [AWS Lambda](https://docs.stelvio.dev/guides/lambda/) 22 | - [Amazon DynamoDB](https://docs.stelvio.dev/guides/dynamo-db/) 23 | - [API Gateway](https://docs.stelvio.dev/guides/api-gateway/) 24 | - [Linking - automated IAM](https://docs.stelvio.dev/guides/linking/) 25 | 26 | Support for additional AWS services is planned. See [**Roadmap**](https://github.com/michal-stlv/stelvio/wiki/Roadmap). 27 | 28 | ## Quick Start 29 | 30 | Go to our [Quick Start Guide](https://docs.stelvio.dev/getting-started/quickstart/) to start. 31 | 32 | ## Why Stelvio? 33 | 34 | Unlike generic infrastructure tools like Terraform, Pulumi, or AWS CDK, Stelvio is: 35 | 36 | - Built specifically for Python developers 37 | - Focused on developer productivity, not infrastructure complexity 38 | - Designed to minimize boilerplate through intelligent defaults 39 | - Maintained in pure Python without mixing application and infrastructure code 40 | 41 | For detailed explanation see [Why I'm building Stelvio](https://blog.stelvio.dev/why-i-am-building-stelvio/) blog post. 42 | 43 | ## Project Status 44 | 45 | Stelvio is currently in active development as a side project. 46 | 47 | ⚠️ It is in Early alpha state - Not production ready - Only for experimentation" 48 | 49 | ## Contributing 50 | 51 | Best way to contribute now is to play with it and report any issues. 52 | 53 | I'm also happy to gather any feedback or feature requests. 54 | 55 | Use GitHub Issues or email me directly at michal@stelvio.dev 56 | 57 | I'm focused on building a solid foundation before accepting code contributions. 58 | This will make future collaboration easier and more enjoyable. 59 | But don't hesitate to email me if you want to contribute before everything is ready. 60 | 61 | ## License 62 | 63 | This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.2.0a4 (2025-05-14) 4 | 5 | - Lambda Function dependencies 6 | - Lambda Layers 7 | - More tests for faster future progress 8 | 9 | ## 0.1.0a2 (2025-02-14) 10 | 11 | - Maintenance release 12 | - Fixed bug when route couldn't be created if it had just default config 13 | - Added better checks so Stelvio informs you if there's route conflicts 14 | - Added tests 15 | 16 | 17 | 18 | ## 0.1.0a1 (2025-01-31) 19 | 20 | - Initial release 21 | - Very basic support for: 22 | 23 | - AWS Lambda 24 | - Dynamo DB Table 25 | - API Gateway 26 | -------------------------------------------------------------------------------- /docs/doc-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michal-stlv/stelvio/73f245fd4bb0ae011afec7af21568c4c40f9b1e8/docs/favicon.png -------------------------------------------------------------------------------- /docs/getting-started/concepts.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michal-stlv/stelvio/73f245fd4bb0ae011afec7af21568c4c40f9b1e8/docs/getting-started/concepts.md -------------------------------------------------------------------------------- /docs/getting-started/installation.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michal-stlv/stelvio/73f245fd4bb0ae011afec7af21568c4c40f9b1e8/docs/getting-started/installation.md -------------------------------------------------------------------------------- /docs/getting-started/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quick Start Guide 2 | 3 | Welcome to Stelvio! 4 | 5 | First of all, thank you for taking a time to try it. 6 | 7 | While Stelvio is in **very early development stage (alpha - Developer Preview)** I hope 8 | it shows how it simplifies AWS infrastructure for Python devs. 9 | 10 | In this guide, we'll go through basics so you can see how Stelvio makes AWS easy. 11 | 12 | 13 | ## Prerequisites 14 | 15 | Before we begin, you'll need: 16 | 17 | - Python 3.12 or newer installed 18 | - An AWS account where you can deploy with credentials configured 19 | - Basic familiarity with Python and AWS concepts 20 | - _Pulumi CLI_ - [installation instructions here](https://www.pulumi.com/docs/iac/download-install/) 21 | 22 | !!! note "This way of setting up a project and the need to manually install Pulumi CLI is temporary" 23 | I understand that manually installing another tool and setting up project in this 24 | specific way is far from ideal. I assure you this is temporary and Stelvio will have 25 | its own CLI which will make things much easier. However to gather early feedback 26 | I had to cut many features from Stelvio's early releases and this is one of them. 27 | 28 | ## Setting up a project 29 | 30 | ### 1. AWS keys and envars 31 | First we need to make sure you configure your AWS credentials correctly. 32 | 33 | Make sure you have configured AWS account that has programmatic access with rights to 34 | deploy infrastructure. If you have installed and configured the AWS CLI before Stelvio 35 | will use those configs. If not you can configure it by setting AWS keys with envars: 36 | 37 | ```bash 38 | export AWS_ACCESS_KEY_ID="" 39 | export AWS_SECRET_ACCESS_KEY="" 40 | ``` 41 | or AWS profile: 42 | 43 | ```bash 44 | export AWS_PROFILE="" 45 | ``` 46 | 47 | ### 2. Project 48 | 49 | First let's create a project folder and go inside it. 50 | 51 | ```bash 52 | mkdir stelvio-app 53 | cd stelvio-app 54 | ``` 55 | 56 | We need to tell Pulumi to use local backend to make sure that Pulumi will keep state of 57 | our infrastructure on our computer rather then sending it to their cloud or S3 bucket ([see docs on this](https://www.pulumi.com/docs/iac/concepts/state-and-backends/)). 58 | 59 | ```bash 60 | pulumi login --local 61 | ``` 62 | Ok now that we're inside our folder we can init new project. 63 | 64 | When you run the command below it will ask you to create a passphrase. Put any 65 | passphrase you like but _remember it_ because you'll be asked for it every time you'll 66 | deploy. 67 | You can also set it to `PULUMI_CONFIG_PASSPHRASE` envar and then you'll not be asked 68 | for it. 69 | 70 | We use name `stelvio-app` but you can choose whatever name you like. 71 | It will also use a default region which is `us-east-1`. If you want to use other 72 | region you can specify it by adding `-c aws:region=YOUR_REGION` to the command. 73 | 74 | By default Pulumi will use **pip** to to create a virtual environment. You can change 75 | this to **poetry** or **uv** on the last line - highlighted. 76 | 77 | ```bash hl_lines="6" 78 | pulumi new https://github.com/michal-stlv/stelvio/tree/main/pulumi-tmpl \ 79 | --name stelvio-app \ 80 | --stack dev \ 81 | --force \ 82 | --yes 83 | --runtime-options toolchain=pip # (1)! 84 | ``` 85 | 86 | 1. You can choose `pip`, `poetry` or `uv`. 87 | 88 | By running this command Pulumi CLI has created a Pulumi project for us using Stelvio 89 | template. 90 | 91 | It did a few things for us: 92 | 93 | - created a Pulumi project `stelvio-app`. 94 | - created a stack named `dev` ([stack](https://www.pulumi.com/docs/iac/concepts/stacks/) 95 | is an instance of Pulumi program). 96 | - created Python virtual env named `venv` inside our project folder. 97 | - created requirements.txt file which has only one thing in it - `stelvio`. 98 | - ran `pip install requirements.txt` to install it. `stelvio` has dependency on `pulumi` 99 | and `pulumi-aw`s so those were installed as well. 100 | - created `stlv_app.py` - main Stelvio file which contains StelvioApp 101 | - created `__main__.py` which Pulumi runs - this file just imports our Stelvio file `stlv_app.py` 102 | - created Pulumi.yaml - root file that Pulumi requires 103 | - created Pulumi.dev.yaml - Pulumi's config file for stack dev 104 | 105 | You can run `pulumi preview` to check all is working. 106 | 107 | When you run `pulumi preview` or `pulumi up` Pulumi is automatically activating virtual 108 | env it created for us. 109 | 110 | !!! note 111 | All of this setup will go away once Stelvio has its own CLI. Then we'll not have 112 | to have Pulumi yaml files nor `__main__.py` file. 113 | 114 | ## Simple project using Stelvio 115 | 116 | 117 | ### Project structure 118 | 119 | In Stelvio, you have complete flexibility in 120 | [how you organize your project](../guides/project-structure.md) and where are 121 | your Stelvio infrastructure files located. But for this quickstart guide, we'll 122 | keep things super simple and keep our infra definitions only in the main `stvl_app.py` 123 | file so our project structure will look like this: 124 | 125 | ``` 126 | stelvio-app/ 127 | ├── stlv_app.py # Infrastructure configuration 128 | └── functions/ # Lambda functions 129 | └── todos.py # Our function code 130 | ``` 131 | 132 | 133 | Open `stlv_app.py`, it will look like this: 134 | 135 | ```python 136 | from stelvio.app import StelvioApp 137 | 138 | app = StelvioApp( 139 | name="Stelvio app", 140 | modules=[ 141 | # Need these in specific order? Just list them 142 | # "infra.base", 143 | # Don't care about the rest? Glob it! 144 | "*/infra/*.py", 145 | "**/*stlv.py", 146 | ], 147 | ) 148 | 149 | app.run() 150 | ``` 151 | 152 | ### Define our infrastructure 153 | We need to put any infrastructure definitions before `app.run()` but after `app = StelvioApp(...)` 154 | 155 | Let's create a simple API to create and list todos. 156 | 157 | First create a DynamoDB table: 158 | 159 | ```python 160 | from stelvio.aws.dynamo_db import AttributeType, DynamoTable 161 | 162 | table = DynamoTable( 163 | name="todos", 164 | fields={ 165 | "username": AttributeType.STRING, 166 | "created": AttributeType.STRING, 167 | }, 168 | partition_key="username", 169 | sort_key='created' 170 | ) 171 | ``` 172 | 173 | The above will create 174 | [DynamoDB table](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/WorkingWithTables.Basics.html) 175 | with partition key `username`, sort key `created` and billing mode `PAY_PER_REQUEST`. 176 | 177 | Now lets create an API and routes: 178 | 179 | ```python 180 | from stelvio.aws.api_gateway import Api 181 | 182 | api = Api("todo-api") 183 | api.route("POST", "/todos", handler="functions/todos.post", links=[table]) 184 | api.route("GET", "/todos/{username}", handler="functions/todos.get") 185 | 186 | ``` 187 | 188 | The above will create: 189 | - An API Gateway REST API 190 | - API resources (e.g., `/todos`, `/todos/{username}`) 191 | - API methods (GET and POST) 192 | - A Lambda function with code from `functions/todos.py` file with: 193 | - properly configured env vars containing table name and arn 194 | - generated routing code to properly route requests to proper functions 195 | - lambda integration between methods and lambda 196 | - IAM (roles, policies, etc.) 197 | - stage 198 | - deployment 199 | - log groups 200 | 201 | So our complete `app_stlv.py` now looks like this: 202 | 203 | ```python 204 | from stelvio.app import StelvioApp 205 | from stelvio.aws.api_gateway import Api # Import the Api component 206 | from stelvio.aws.dynamo_db import AttributeType, DynamoTable 207 | 208 | app = StelvioApp( 209 | name="Stelvio app", 210 | modules=[ 211 | # Need these in specific order? Just list them 212 | # "infra.base", 213 | # Don't care about the rest? Glob it! 214 | "*/infra/*.py", 215 | "**/*stlv.py", 216 | ], 217 | ) 218 | 219 | table = DynamoTable( 220 | name="todos", 221 | fields={ 222 | "username": AttributeType.STRING, 223 | "created": AttributeType.STRING, 224 | }, 225 | partition_key="username", 226 | sort_key='created' 227 | ) 228 | 229 | api = Api("todo-api") 230 | api.route("POST", "/todos", handler="functions/todos.post", links=[table]) 231 | api.route("GET", "/todos/{username}", handler="functions/todos.get") 232 | 233 | app.run() 234 | ``` 235 | 236 | ### Lambda code 237 | 238 | Now we can write code for our `functions/todos.py`: 239 | 240 | ```python 241 | import json 242 | from datetime import datetime 243 | 244 | import boto3 245 | from boto3.dynamodb.conditions import Key 246 | 247 | from stlv_resources import Resources 248 | 249 | dynamodb = boto3.resource('dynamodb') 250 | table = dynamodb.Table(Resources.todos.table_name) 251 | 252 | 253 | def post(event, context): 254 | # Parse the request body 255 | body = json.loads(event.get('body', '{}')) 256 | 257 | # Create item 258 | item = { 259 | 'username': body.get('username'), 260 | 'created': datetime.utcnow().isoformat(), 261 | 'title': body.get('title'), 262 | 'done': False 263 | } 264 | # Save to DynamoDB 265 | table.put_item(Item=item) 266 | return { 267 | 'statusCode': 201, 268 | 'body': json.dumps(item) 269 | } 270 | 271 | def get(event, context): 272 | # Get username from query parameters 273 | username = event.get('pathParameters', {}).get('username') 274 | 275 | # Query DynamoDB 276 | response = table.query( 277 | KeyConditionExpression=Key('username').eq(username) 278 | ) 279 | 280 | return { 281 | 'statusCode': 200, 282 | 'body': json.dumps({ 283 | 'todos': response['Items'] 284 | }) 285 | } 286 | ``` 287 | 288 | 289 | ### Preview our infra 290 | 291 | Now we're ready to deploy. First let's try to preview what we've got - run `pulumi preview`. 292 | 293 | It should print something like this: 294 | ```bash 295 | Previewing update (dev): 296 | Type Name Plan Info 297 | + pulumi:pulumi:Stack stelvio-app-dev create 5 messages 298 | + ├─ aws:apigateway:RestApi todos-api create 299 | + ├─ aws:dynamodb:Table todos create 300 | + ├─ aws:iam:Role api-gateway-role create 301 | + ├─ aws:apigateway:Deployment todos-api-deployment create 302 | + ├─ aws:apigateway:Resource resource-todos create 303 | + ├─ aws:iam:RolePolicyAttachment api-gateway-role-logs-policy-attachment create 304 | + ├─ aws:apigateway:Account api-gateway-account create 305 | + ├─ aws:iam:Role functions-todos-Role create 306 | + ├─ aws:iam:Policy functions-todos-Policy create 307 | + ├─ aws:apigateway:Integration integration-POST-/todos create 308 | + ├─ aws:apigateway:Integration integration-GET-/todos/{username} create 309 | + ├─ aws:apigateway:Resource resource-todos-username create 310 | + ├─ aws:lambda:Function functions-todos create 311 | + ├─ aws:iam:RolePolicyAttachment functions-todos-DefaultRolePolicyAttachment create 312 | + ├─ aws:iam:RolePolicyAttachment functions-todos-BasicExecutionRolePolicyAttachment create 313 | + ├─ aws:apigateway:Method method-POST-todos create 314 | + ├─ aws:apigateway:Method method-GET-todos-username create 315 | + ├─ aws:lambda:Permission todos-api-functions-todos-policy-statement create 316 | + └─ aws:apigateway:Stage todos-api-v1 create 317 | 318 | Diagnostics: 319 | pulumi:pulumi:Stack (stelvio-app-dev): 320 | todos 321 | todos-api 322 | todos 323 | todos-api 324 | functions-todos 325 | 326 | Outputs: 327 | dynamo_todos_arn : output 328 | invoke_url_for_restapi_todos-api: output 329 | lambda_functions-todos_arn : output 330 | restapi_todos-api_arn : output 331 | 332 | Resources: 333 | + 20 to create 334 | ``` 335 | 336 | It shows you all resources that will be created. But it has one side effect - when you run 337 | preview or deploy Stelvio will create `stlv_resources.py` which contains type safe 338 | definitions of our lambda environment variables which we an use in our lambda code. 339 | 340 | You can see it above in our lambda code: 341 | ```python 342 | from stlv_resources import Resources # <--- importing Resources class from stlv_resources.py 343 | ... 344 | table = dynamodb.Table(Resources.todos.table_name) ## <--- getting our table's name 345 | ``` 346 | ### Deploy 347 | 348 | Now to deploy we run need to `pulumi up`. It will ask you to confirm deployment. 349 | Select _yes_ and it will create our infrastructure. 350 | 351 | During deployment it will print what resources it creates. But when it finishes it should 352 | print something like this at the end: 353 | ```bash 354 | Outputs: 355 | dynamo_todos_arn : "arn:aws:dynamodb:us-east-1:482403859050:table/todos-4442577" 356 | invoke_url_for_restapi_todos-api: "https://somerandomstring.execute-api.us-east-1.amazonaws.com/v1" 357 | lambda_functions-todos_arn : "arn:aws:lambda:us-east-1:482403859050:function:functions-todos-fbe96ae" 358 | restapi_todos-api_arn : "arn:aws:apigateway:us-east-1::/restapis/en4kl5pn23" 359 | 360 | Resources: 361 | + 20 created 362 | 363 | Duration: 57s 364 | ``` 365 | 366 | In Outputs there is one called `invoke_url_for_restapi_todos-api`. This contains URL of our todos API. 367 | Copy it and we can test our API. 368 | 369 | ## Testing Your API 370 | 371 | We'll use curl to create a todo item: 372 | 373 | ```bash 374 | curl -X POST https://YOUR_API_URL/todos/ \ 375 | -d '{"username": "john", "title": "Buy milk"}' 376 | ``` 377 | 378 | And now we can list todos: 379 | 380 | ```bash 381 | curl https://YOUR_API_URL/todos/john 382 | ``` 383 | 384 | ### Understanding What We've Built 385 | 386 | Let's take a moment to appreciate what we've accomplished with just a few files: 387 | 388 | - Set up a database 389 | - Created lambda function 390 | - Created a serverless API 391 | - Deployed everything to AWS 392 | 393 | Most importantly, we did this while writing clean, maintainable Python code. No YAML 394 | files, no clicking through consoles, and no complex configuration. 395 | 396 | That's it for this quickstart. Hope you give Stelvio a chance. I encourage you to play 397 | around and let me know any feedback on GitHub or michal@stelvio.dev 398 | 399 | 400 | ## Next Steps 401 | 402 | - [Working with Lambda Functions](../guides/lambda.md) - Learn more about how to work with Lambda functions 403 | - [Working with API Gateway](../guides/api-gateway.md) - Learn how to create APIs 404 | - [Working with DynamoDB](../guides/dynamo-db.md) - Learn how to create DynamoDB tables 405 | - [Linking](../guides/linking.md) - Learn how linking automates IAM, permissions, envars and more 406 | - [Project Structure](../guides/project-structure.md) - Discover patterns for organizing your Stelvio applications 407 | -------------------------------------------------------------------------------- /docs/guides/api-gateway.md: -------------------------------------------------------------------------------- 1 | # Working with API Gateway in Stelvio 2 | 3 | This guide explains how to create and manage API endpoints with Stelvio. You'll learn 4 | how to define routes, connect them to Lambda functions, and understand the different 5 | organizational patterns available to you. 6 | 7 | ## Creating an API 8 | 9 | Creating an API Gateway in Stelvio is straightforward. You start by defining your API 10 | instance: 11 | 12 | ```python 13 | from stelvio.aws.apigateway import Api 14 | 15 | api = Api('my-api') 16 | ``` 17 | 18 | The name you provide will be used as part of your API's URL and for identifying it in 19 | the AWS console. 20 | 21 | ## Defining Routes 22 | 23 | Stelvio provides a clean, intuitive way to define API routes. The basic pattern is: 24 | 25 | ```python 26 | api.route(http_method, path, handler) 27 | ``` 28 | 29 | Let's look at each component: 30 | 31 | - `http_method`: The HTTP verb for this route ('GET', 'POST', etc.) 32 | - `path`: The URL path for this endpoint ('/users', '/orders/{id}', etc.) 33 | - `handler`: Lambda function handler or path to it 34 | 35 | Here's a complete example: 36 | 37 | ```python 38 | from stelvio.aws.apigateway import Api 39 | 40 | api = Api('my-api') 41 | 42 | # Basic route 43 | api.route('GET', '/users', 'functions/users.index') 44 | 45 | # Route with path parameter 46 | api.route('GET', '/users/{id}', 'functions/users.get') 47 | 48 | # Route with different HTTP method 49 | api.route('POST', '/users', 'functions/users.create') 50 | 51 | # Deployment happens automatically when routes or configurations change. 52 | ``` 53 | 54 | ### HTTP Methods 55 | 56 | Stelvio supports all standard HTTP methods. You can specify them in several ways: 57 | 58 | ```python 59 | 60 | from stelvio.aws.apigateway import Api 61 | 62 | api = Api('my-api') 63 | 64 | # Single method (case insensitive) 65 | api.route('GET', '/users', 'functions/users.index') 66 | api.route('get', '/users', 'functions/users.index') 67 | 68 | # Multiple methods for one endpoint 69 | api.route(['GET', 'POST'], '/users', 'functions/users.handler') 70 | 71 | # Any HTTP method 72 | api.route('ANY', '/users', 'functions/users.handler') 73 | api.route('*', '/users', 'functions/users.handler') # Alternative syntax 74 | ``` 75 | 76 | ## Lambda function Integration 77 | 78 | Stelvio offers flexible ways to connect your routes to Lambda functions. The handler 79 | path in your route definition can have two formats: 80 | 81 | 1. For [Single-File Functions](lambda.md#single-file-lambda-functions) use a simple path 82 | convention: 83 | 84 | ``` 85 | folder/file.function_name 86 | ``` 87 | 88 | 2. [Folder-Based Functions](lambda.md#folder-based-lambda-functions) (when you need to 89 | package multiple files) use this format: 90 | 91 | ``` 92 | folder/path::file.function_name 93 | ``` 94 | Where everything before `::` is the path to the folder of your lambda function, and 95 | everything after is the relative path to file and function name within that folder. 96 | 97 | Examples: 98 | ```python 99 | # Single-file function 100 | api.route('GET', '/users', 'functions/users.index') 101 | 102 | # Folder-based function 103 | api.route('GET', '/orders', 'functions/orders::handler.process_order') 104 | ``` 105 | 106 | Stelvio will create lambda automatically from your source file. 107 | 108 | When multiple routes point to the same Lambda Function (whether it's a single file or 109 | folder-based function), Stelvio automatically generates and includes routing code in the 110 | Lambda package. This routing code ensures each route calls the correct Python function 111 | as defined in your routes. 112 | 113 | ```python 114 | # These routes share one Lambda function 115 | # Stelvio will generate routing code to call correct function based on the route 116 | api.route('GET', '/users', 'functions/users.index') 117 | api.route('POST', '/users', 'functions/users.create_user') 118 | 119 | # This route uses a different Lambda function 120 | api.route('GET', '/orders', 'functions/orders.index') 121 | ``` 122 | 123 | ### Lambda Configuration 124 | 125 | The above samples will create functions with default configuration. If you want to 126 | customize Lambda function settings like memory size, timeout or 127 | runtime settings, you have several options: 128 | 129 | 1. Through `FunctionConfig` class 130 | 131 | ```python 132 | # In this example we configure custom memory size and timeout 133 | api.route( 134 | "GET", 135 | "/users", 136 | FunctionConfig( 137 | handler="functions/users.index", 138 | memory=512, 139 | timeout=30, 140 | ), 141 | ) 142 | ``` 143 | 144 | 2. Through dictionary `FunctionConfigDict`. 145 | 146 | `FunctionConfigDict()` is typed dict so all your keys and values will be typed checked 147 | if you use IDE or mypy or other type checking tool. 148 | 149 | ```python 150 | # In this example we configure custom memory size and timeout 151 | api.route( 152 | "GET", 153 | "/users", 154 | { 155 | "handler": "functions/users.index", 156 | "memory":512, 157 | "timeout":30, 158 | }, 159 | ) 160 | ``` 161 | 162 | 3. Through keyword arguments 163 | ```python 164 | # In this example we configure custom memory size and timeout 165 | api.route( 166 | "GET", 167 | "/users", 168 | "functions/users.index", 169 | memory=512, 170 | timeout=30, 171 | ) 172 | ``` 173 | 174 | 4. Passing function instance as a handler: 175 | 176 | You can create lambda function yourself and pass it to the route as a handler. 177 | 178 | ```python 179 | # Defined in separate variable. 180 | users_fn = Function( 181 | "users-function", 182 | handler="functions/users.index", 183 | memory=512, 184 | ) 185 | 186 | api.route("GET", "/users", users_fn) 187 | 188 | # Inline. 189 | api.route( 190 | "GET", 191 | "/orders", 192 | Function( 193 | "orders-function", 194 | folder="functions/orders", 195 | handler="handler.index", 196 | ), 197 | ) 198 | ``` 199 | !!! warning 200 | When you create function yourself Stelvio will not generate any routing code for 201 | you, you're responsible for it. 202 | 203 | !!! note "Remember" 204 | Each instance of `Function` creates new lambda function so if you want to 205 | use one function as a handler for multiple routes 206 | you need to store it in a variable first. 207 | 208 | 209 | !!! warning "Only One Configuration per Function" 210 | When multiple routes use same function (identified 211 | by the same file for [Single-File Functions](lambda.md#single-file-lambda-functions) 212 | and by the same folder (`src`) for 213 | [Folder-Based Functions](lambda.md#folder-based-lambda-functions)), the function 214 | should be configured only once. If other route uses same function it shares config 215 | from the route that has config. 216 | 217 | If you provide configuration in multiple places for the same function , Stelvio will 218 | fail with an error message. This ensures clear and predictable behavior. 219 | 220 | To configure a shared function, either configure it on its first use or create a 221 | separate `Function` instance and reuse it across routes. (As shown above in point 4.) 222 | 223 | ??? note "A note about handler format for Folder-based functions" 224 | The `::` format (`folder/path::file.function_name`) for folder-based functions is a 225 | convenient shorthand specific to API Gateway routes. However, you can still create 226 | folder-based functions using configuration options. Here are all the ways to define 227 | a folder-based function: 228 | 229 | ```python 230 | # Using FunctionConfig class 231 | api.route( 232 | "POST", 233 | "/orders", 234 | FunctionConfig( 235 | folder="functions/orders", 236 | handler="function.handler", 237 | ), 238 | ) 239 | 240 | # Using configuration dictionary 241 | api.route( 242 | "POST", 243 | "/orders", 244 | { 245 | "src": 'functions/orders', 246 | "handler": "function.handler", 247 | }, 248 | ) 249 | 250 | # Using keyword arguments 251 | api.route( 252 | "POST", 253 | "/orders", 254 | folder="functions/orders", 255 | handler="function.handler", 256 | ) 257 | 258 | # Using Function instance 259 | api.route( 260 | "GET", 261 | "/orders", 262 | Function( 263 | "orders-function", 264 | folder="functions/orders", 265 | handler="handler.index", 266 | ), 267 | ) 268 | ``` 269 | 270 | ## Advanced Features 271 | 272 | ### Authorization 273 | 274 | TBD 275 | 276 | ### CORS 277 | 278 | TBD 279 | 280 | ### Custom Domains 281 | 282 | TBD 283 | 284 | 285 | ## Next Steps 286 | 287 | Now that you understand API Gateway basics, you might want to explore: 288 | 289 | - [Working with Lambda Functions](lambda.md) - Learn more about how to work with Lambda functions 290 | - [Working with DynamoDB](dynamo-db.md) - Learn how to create DynamoDB tables 291 | - [Linking](linking.md) - Learn how linking automates IAM, permissions, envars and more 292 | - [Project Structure](project-structure.md) - Discover patterns for organizing your Stelvio applications 293 | -------------------------------------------------------------------------------- /docs/guides/dynamo-db.md: -------------------------------------------------------------------------------- 1 | # Working with DynamoDB in Stelvio 2 | 3 | This guide explains how to create and manage DynamoDB tables with Stelvio. Currently 4 | support is limited and all you can do is to create table and define partition and sort 5 | keys. 6 | 7 | ## Creating a DynamoDB table 8 | 9 | Creating a DynamoDB table in Stelvio is straightforward. 10 | 11 | ```python 12 | from stelvio.aws.dynamo_db import AttributeType, DynamoTable 13 | 14 | todos_dynamo = DynamoTable( 15 | name="orders", 16 | fields={ 17 | "customer_id": AttributeType.STRING, 18 | "order_date": AttributeType.STRING, 19 | }, 20 | partition_key="customer_id", 21 | sort_key='order_date' 22 | ) 23 | ``` 24 | 25 | That's it! 26 | 27 | 28 | 29 | 30 | ## Indexes 31 | 32 | Support for indexes is coming soon. 33 | 34 | TBD 35 | 36 | ## Next Steps 37 | 38 | Now that you understand DynamoDB basics, you might want to explore: 39 | 40 | - [Working with Lambda Functions](lambda.md) - Learn more about how to work with Lambda functions 41 | - [Working with API Gateway](api-gateway.md) - Learn how to create APIs 42 | - [Linking](linking.md) - Learn how linking automates IAM, permissions, envars and more 43 | - [Project Structure](project-structure.md) - Discover patterns for organizing your Stelvio applications 44 | -------------------------------------------------------------------------------- /docs/guides/linking.md: -------------------------------------------------------------------------------- 1 | # Linking in Stelvio 2 | 3 | Linking in Stelvio is a powerful concept that automates IAM permission management and environment variable configuration between AWS resources. 4 | 5 | ## What is a Link? 6 | 7 | A Link represents a connection between two resources in your infrastructure. It defines: 8 | 9 | 1. **Permissions**: The IAM policies that allow one resource to interact with another (such as a Lambda function accessing a DynamoDB table) 10 | 2. **Properties**: Key-value pairs that one resource shares with another (like environment variables passed to a Lambda function) 11 | 12 | Links make it easy to establish secure, properly configured connections between resources without manually setting up complex IAM policies or environment configurations. 13 | 14 | ## How Linking Works 15 | 16 | Resources that can be linked implement the `Linkable` protocol, which requires a `link()` method that returns a `Link` object. This object contains default permissions and properties appropriate for that resource type. 17 | 18 | ## Using Links 19 | 20 | ### Basic Linking 21 | 22 | ```python 23 | from stelvio.aws.dynamo_db import AttributeType, DynamoTable 24 | from stelvio.aws.function import Function 25 | 26 | # Create a DynamoDB table 27 | table = DynamoTable( 28 | name="todos", 29 | fields={"username": AttributeType.STRING}, 30 | partition_key="username" 31 | ) 32 | 33 | # Link the table to a Lambda function 34 | fn = Function( 35 | handler="users/handler.process", 36 | links=[table] # Link the table to the function 37 | ) 38 | ``` 39 | 40 | When you link a DynamoDB table to a Lambda function, Stelvio automatically: 41 | 1. Creates IAM permissions allowing the Lambda to perform operations on the table 42 | 2. Passes the table ARN and name as environment variables to the Lambda 43 | 44 | ### Linking with API Routes 45 | 46 | You can also link resources to API routes: 47 | 48 | ```python 49 | from stelvio.aws.api_gateway import Api 50 | 51 | api = Api("todo-api") 52 | api.route("POST", "/todos", handler="functions/todos.post", links=[table]) 53 | ``` 54 | 55 | ### Creating Custom Links 56 | 57 | You can also create standalone links with custom properties and permissions: 58 | 59 | ```python 60 | from stelvio.link import Link 61 | from stelvio.aws.permission import AwsPermission 62 | 63 | # Create a custom link with specific properties 64 | custom_link = Link( 65 | name="custom-config", 66 | properties={"api_url": "https://example.com/api", "timeout": "30"}, 67 | permissions=None 68 | ) 69 | 70 | # Link with specific permissions to an S3 bucket 71 | s3_link = Link( 72 | name="logs-bucket", 73 | properties={"bucket_name": "my-logs-bucket"}, 74 | permissions=[ 75 | AwsPermission( 76 | actions=["s3:GetObject", "s3:PutObject"], 77 | resources=["arn:aws:s3:::my-logs-bucket/*"] 78 | ) 79 | ] 80 | ) 81 | 82 | # Use these custom links with a function 83 | fn = Function( 84 | handler="functions/process.handler", 85 | links=[custom_link, s3_link] 86 | ) 87 | ``` 88 | 89 | ### Customizing Links 90 | 91 | You can customize links using various methods which all return a new Link instance (the original link remains unchanged): 92 | 93 | - `with_properties()` - Replace all properties 94 | - `with_permissions()` - Replace all permissions 95 | - `add_properties()` - Add to existing properties 96 | - `add_permissions()` - Add to existing permissions 97 | - `remove_properties()` - Remove specific properties 98 | 99 | Example: 100 | 101 | ```python 102 | # Create a read-only link to the table (creates a new Link object) 103 | read_only_link = table.link().with_permissions( 104 | AwsPermission( 105 | actions=["dynamodb:GetItem", "dynamodb:Query", "dynamodb:Scan"], 106 | resources=[table.arn] 107 | ) 108 | ) 109 | 110 | # Link with custom properties (creates a new Link object) 111 | fn = Function( 112 | handler="users/handler.process", 113 | links=[table.link().add_properties(table_region="us-west-2")] 114 | ) 115 | ``` 116 | 117 | ## Overriding Default Link Creation 118 | 119 | You can override how links are created for specific component types by providing custom link configurations to your Stelvio application: 120 | 121 | ```python 122 | from stelvio.app import StelvioApp 123 | from pulumi_aws.dynamodb import Table 124 | from stelvio.aws.dynamo_db import DynamoTable 125 | from stelvio.link import LinkConfig 126 | from stelvio.aws.permission import AwsPermission 127 | 128 | # Define a custom link creation function 129 | def read_only_dynamo_link(table: Table) -> LinkConfig: 130 | return LinkConfig( 131 | properties={"table_arn": table.arn, "table_name": table.name}, 132 | permissions=[ 133 | AwsPermission( 134 | actions=["dynamodb:GetItem", "dynamodb:Query", "dynamodb:Scan"], 135 | resources=[table.arn] 136 | ) 137 | ] 138 | ) 139 | 140 | # Initialize app with custom link configs 141 | app = StelvioApp( 142 | name="my-app", 143 | link_configs={ 144 | DynamoTable: read_only_dynamo_link # Override default DynamoTable link creation 145 | } 146 | ) 147 | ``` 148 | 149 | ## Next Steps 150 | 151 | Now that you understand linking, you might want to explore: 152 | 153 | - [Working with Lambda Functions](lambda.md) - Learn more about how to work with Lambda functions 154 | - [Working with API Gateway](api-gateway.md) - Learn how to create APIs 155 | - [Working with DynamoDB](dynamo-db.md) - Learn how to create DynamoDB tables 156 | - [Project Structure](project-structure.md) - Discover patterns for organizing your Stelvio applications 157 | -------------------------------------------------------------------------------- /docs/guides/project-structure.md: -------------------------------------------------------------------------------- 1 | # Project Structure 2 | 3 | This guide explains how to structure your Stelvio project and how Stelvio finds and loads your infrastructure code. 4 | 5 | ## Important Considerations 6 | 7 | ### Installation 8 | Since Stelvio (and its dependency Pulumi) are used for infrastructure deployment rather than application runtime, you might want to install it as a development or CI dependency: 9 | 10 | ```toml 11 | # pyproject.toml 12 | [tool.poetry.group.dev.dependencies] 13 | stelvio = "^0.1.0a1" 14 | 15 | # or in Pipfile 16 | [dev-packages] 17 | stelvio = ">=0.1.0a1" 18 | ``` 19 | 20 | ### Entry Point 21 | Currently, Stelvio requires a `__main__.py` file in your project root due to Pulumi CLI requirements. This will be removed once Stelvio has its own CLI: 22 | 23 | ```python 24 | # __main__.py 25 | from importlib import import_module 26 | 27 | import_module("stlv_app") # Loads stlv_app.py which contains StelvioApp configuration 28 | ``` 29 | 30 | ### Critical: StelvioApp Initialization 31 | 32 | StelvioApp must be created before any Stelvio resources are imported. You have two options: 33 | 34 | #### Option 1: Direct Imports 35 | 36 | ```python 37 | # stlv_app.py 38 | from stelvio import StelvioApp 39 | 40 | app = StelvioApp( 41 | name="my-project", 42 | stage="dev" 43 | ) 44 | 45 | # Now you can import and use resources 46 | from my_project.infra.tables import users_table 47 | from my_project.infra.api import api_gateway 48 | 49 | # Run your infrastructure code 50 | app.run() 51 | ``` 52 | 53 | #### Option 2: Using Modules List 54 | 55 | If you prefer not to manage imports manually, you can let Stelvio find and load your resources: 56 | 57 | ```python 58 | # stlv_app.py 59 | from stelvio import StelvioApp 60 | 61 | app = StelvioApp( 62 | name="my-project", 63 | modules=[ 64 | # Explicit modules first (if order matters) 65 | "infra.base", 66 | "infra.auth", 67 | # Then patterns to find the rest 68 | "*/infra/*.py", 69 | "**/*_stlv.py" 70 | ] 71 | ) 72 | 73 | # Run your infrastructure code 74 | app.run() 75 | ``` 76 | 77 | ❌ In either case, don't import resources before StelvioApp: 78 | 79 | ```python 80 | # stlv_app.py 81 | from my_project.infra.tables import users_table # Don't import resources before StelvioApp! 82 | from stelvio import StelvioApp 83 | 84 | app = StelvioApp( 85 | name="my-project", 86 | ) 87 | ``` 88 | 89 | ### Importing Between Infrastructure Files 90 | 91 | Of course, you can and import between your infrastructure files: 92 | 93 | ```python 94 | # infra/functions.py 95 | from stelvio.aws.function import Function 96 | from infra.storage.users import users_table # Importing from other infrastructure files 97 | 98 | users_func = Function( 99 | name="process-users", 100 | handler='functions/users.process', 101 | links=[users_table] 102 | ) 103 | ``` 104 | 105 | This allows you to organize your infrastructure in different files. 106 | 107 | ## Project Organization 108 | 109 | Stelvio is flexible about how you organize your code. Here are some common patterns: 110 | 111 | ### Separate Infrastructure Folder 112 | ``` 113 | my-project/ 114 | ├── __main__.py 115 | ├── stlv_app.py 116 | ├── infrastructure/ 117 | │ ├── base.py 118 | │ ├── storage.py 119 | │ └── api.py 120 | └── app/ 121 | └── *.py 122 | ``` 123 | 124 | ### Co-located with Features 125 | ``` 126 | my-project/ 127 | ├── __main__.py 128 | ├── stlv_app.py 129 | └── services/ 130 | ├── users/ 131 | │ ├── infra/ 132 | │ │ ├── tables.py 133 | │ │ └── api.py 134 | │ └── handler.py 135 | └── orders/ 136 | ├── infra/ 137 | │ └── queues.py 138 | └── handler.py 139 | ``` 140 | 141 | ### Using File Patterns 142 | ``` 143 | my-project/ 144 | ├── __main__.py 145 | ├── stlv_app.py 146 | └── services/ 147 | ├── users/ 148 | │ ├── stlv.py # Any file names works as far as it's defined in modules 149 | │ └── handler.py 150 | └── orders/ 151 | └── stlv.py 152 | └── handler.py 153 | ``` 154 | 155 | ## Project Organization Tips 156 | 157 | To avoid conflicts with your application code and frameworks: 158 | 159 | 1. Keep infrastructure code separate from application code 160 | 2. Be mindful of framework auto-loaders that might scan all .py files 161 | 3. Consider adding infrastructure paths to framework exclude lists 162 | 163 | 164 | ## Next Steps 165 | 166 | Now that you understand project structure in Stelvio, you might want to explore: 167 | 168 | - [Working with Lambda Functions](lambda.md) - Learn more about how to work with Lambda functions 169 | - [Working with API Gateway](api-gateway.md) - Learn how to create APIs 170 | - [Working with DynamoDB](dynamo-db.md) - Learn how to create DynamoDB tables 171 | - [Linking](linking.md) - Learn how linking automates IAM, permissions, envars and more 172 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to Stelvio 2 | 3 | Stelvio is a Python library that makes AWS development simple for for Python devs. 4 | 5 | It lets you build and deploy AWS applications using pure Python code, without dealing 6 | with complex infrastructure tools. 7 | 8 | **Head over to the _[Quick Start](getting-started/quickstart.md)_ guide to get started.** 9 | 10 | !!! warning "Stelvio is in Early alpha state - Not production ready - Only for experimentation - API unstable" 11 | Stelvio is currently in active development as a side project. 12 | 13 | It supports basic Lambda, Dynamo DB and API Gateway setup. 14 | 15 | ## Why I Built This 16 | 17 | As a Python developer working with AWS, I got tired of: 18 | 19 | - Switching between YAML, JSON, and other config formats 20 | - Figuring out IAM roles and permissions 21 | - Managing infrastructure separately from my code 22 | - Clicking through endless AWS console screens 23 | - Writing and maintaining complex infrastructure code 24 | 25 | I wanted to focus on building applications, not fighting with infrastructure. That's 26 | why I created Stelvio. 27 | 28 | ## How It Works 29 | 30 | Here's how simple it can be to create an API (Gateway) with Stelvio: 31 | 32 | ```py 33 | from stelvio import Api 34 | 35 | api = Api('my-api') 36 | api.route('GET', '/users', 'users/handler.index') 37 | api.route('POST', '/users', 'users/handler.create') 38 | api.deploy() 39 | ``` 40 | 41 | Stelvio takes care of everything else: 42 | 43 | - Creates Lambda functions 44 | - Sets up API Gateway 45 | - Handles IAM roles and permissions 46 | - Makes sure your resources can talk to each other 47 | - Deploys everything properly 48 | 49 | ## What Makes It Different 50 | 51 | ### Just Python 52 | Write everything in Python. No new tools or languages to learn. If you know Python, 53 | you know how to use Stelvio. 54 | 55 | ### Smart Defaults That Make Sense 56 | Start simple with sensible defaults. Add configuration only when you need it. Simple 57 | things stay simple, but you still have full control when you need it. 58 | 59 | ### Type Safety That Actually Helps 60 | Get IDE support and type checking for all your AWS resources. No more guessing about 61 | environment variables or resource configurations. 62 | 63 | ### Works Your Way 64 | Keep your infrastructure code wherever makes sense: 65 | 66 | - Next to your application code 67 | - In a separate folder 68 | - Or mix both approaches 69 | 70 | ## Ready to Try It? 71 | 72 | Head over to the [Quick Start](getting-started/quickstart.md) guide to get started. 73 | 74 | ## What I Believe In 75 | 76 | I built Stelvio believing that: 77 | 78 | 1. Infrastructure should feel natural in your Python code 79 | 2. You shouldn't need to become an AWS expert 80 | 3. Simple things should be simple 81 | 4. Your IDE should help you catch problems early 82 | 5. Good defaults beat endless options 83 | 84 | ## Let's Talk 85 | 86 | - Found a bug or want a feature? [Open an issue](https://github.com/michal-stlv/stelvio/issues) 87 | - Have questions? [Join the discussion](https://github.com/michal-stlv/stelvio/discussions) 88 | - Want updates and tips? [Follow me on Twitter](https://twitter.com/michal_stlv) 89 | 90 | ## License 91 | 92 | Stelvio is released under the Apache 2.0 License. See the LICENSE file for details. 93 | -------------------------------------------------------------------------------- /docs/overrides/partials/integrations/analytics/custom.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: stelvio.dev 2 | theme: 3 | name: material 4 | custom_dir: docs/overrides 5 | palette: 6 | # Palette toggle for automatic mode 7 | - media: "(prefers-color-scheme)" 8 | primary: cyan 9 | accent: light blue 10 | toggle: 11 | icon: material/brightness-auto 12 | name: Switch to light mode 13 | 14 | # Palette toggle for light mode 15 | - media: "(prefers-color-scheme: light)" 16 | scheme: default 17 | primary: cyan 18 | accent: light blue 19 | toggle: 20 | icon: material/brightness-7 21 | name: Switch to dark mode 22 | 23 | # Palette toggle for dark mode 24 | - media: "(prefers-color-scheme: dark)" 25 | scheme: slate 26 | primary: teal 27 | accent: light green 28 | toggle: 29 | icon: material/brightness-4 30 | name: Switch to system preference 31 | 32 | features: 33 | - content.tabs.link 34 | - content.code.annotate 35 | - content.code.copy 36 | - content.tooltips 37 | - navigation.tabs 38 | - navigation.sections 39 | - navigation.expand 40 | - announce.dismiss 41 | - navigation.tabs 42 | - navigation.instant 43 | - navigation.instant.prefetch 44 | - navigation.instant.preview 45 | - navigation.instant.progress 46 | - navigation.path 47 | - navigation.sections 48 | - navigation.top 49 | - navigation.tracking 50 | - search.suggest 51 | - toc.follow 52 | logo: 'doc-logo.svg' 53 | favicon: 'favicon.png' 54 | 55 | repo_name: michal-stlv/stelvio 56 | repo_url: https://github.com/michal-stlv/stelvio 57 | 58 | nav: 59 | - Home: 60 | - Welcome to Stelvio: index.md 61 | - Changelog: changelog.md 62 | - Getting Started: 63 | - Quick Start: getting-started/quickstart.md 64 | - Guides: 65 | - API Gateway: guides/api-gateway.md 66 | - Lambda Functions: guides/lambda.md 67 | - Dynamo DB: guides/dynamo-db.md 68 | - Linking: guides/linking.md 69 | - Project Structure: guides/project-structure.md 70 | 71 | markdown_extensions: 72 | - pymdownx.highlight: 73 | anchor_linenums: true 74 | line_spans: __span 75 | pygments_lang_class: true 76 | - pymdownx.inlinehilite 77 | - pymdownx.snippets 78 | - pymdownx.superfences 79 | - admonition 80 | - pymdownx.details 81 | - pymdownx.critic 82 | - pymdownx.caret 83 | - pymdownx.keys 84 | - pymdownx.mark 85 | - pymdownx.tilde 86 | 87 | extra: 88 | analytics: 89 | provider: custom -------------------------------------------------------------------------------- /pulumi-tmpl/.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | venv/ 3 | -------------------------------------------------------------------------------- /pulumi-tmpl/Pulumi.yaml: -------------------------------------------------------------------------------- 1 | name: ${PROJECT} 2 | description: A minimal Stelvio AWS program 3 | runtime: 4 | name: python 5 | template: 6 | important: true 7 | config: 8 | aws:region: 9 | description: The AWS region to deploy into 10 | default: us-east-1 11 | -------------------------------------------------------------------------------- /pulumi-tmpl/__main__.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | 3 | import_module("stlv_app") 4 | -------------------------------------------------------------------------------- /pulumi-tmpl/requirements.txt: -------------------------------------------------------------------------------- 1 | stelvio -------------------------------------------------------------------------------- /pulumi-tmpl/stlv_app.py: -------------------------------------------------------------------------------- 1 | from stelvio.app import StelvioApp 2 | 3 | app = StelvioApp( 4 | name="Stelvio app", 5 | modules=[ 6 | # Need these in specific order? Just list them 7 | # "infra.base", 8 | # Don't care about the rest? Glob it! 9 | "*/infra/*.py", 10 | "**/*stlv.py", 11 | ], 12 | ) 13 | 14 | app.run() 15 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "stelvio" 3 | version = "0.2.0a4" 4 | description = "AWS for Python devs made simple." 5 | license = { text = "Apache-2.0" } 6 | readme = "README.md" 7 | requires-python = ">=3.12" 8 | authors = [{ name = "Michal", email = "michal@stelvio.dev" }] 9 | maintainers = [{ name = "Michal", email = "michal@stelvio.dev" }] 10 | keywords = ["aws", "infrastructure"] 11 | 12 | dependencies = [ 13 | "pulumi (>=3.156.0,<4.0.0)", 14 | "pulumi-aws (>=6.72.0,<7.0.0)", 15 | ] 16 | 17 | [dependency-groups] 18 | dev = [ 19 | "pytest >=8.3.4", 20 | "pytest-cov >=6.0.0", 21 | "ruff >=0.11.0", 22 | "mkdocs-material >=9.5.49" 23 | ] 24 | 25 | [project.urls] 26 | homepage = "https://stelvio.dev/" 27 | repository = "https://github.com/michal-stlv/stelvio" 28 | documentation = "https://docs.stelvio.dev" 29 | "Bug Tracker" = "https://github.com/michal-stlv/stelvio/issues" 30 | 31 | [build-system] 32 | requires = ['hatchling'] 33 | build-backend = 'hatchling.build' 34 | 35 | [tool.hatch.build.targets.sdist] 36 | include = [ 37 | "/stelvio", 38 | "/pyproject.toml", 39 | "/README.md", 40 | "/LICENSE", 41 | ] 42 | 43 | [tool.coverage.report] 44 | omit = ["*/tests/*"] 45 | 46 | [tool.ruff] 47 | line-length = 99 48 | target-version = "py312" 49 | exclude = ['pulumi-tmpl'] 50 | 51 | # Configure linting 52 | [tool.ruff.lint] 53 | select = ["E", "F", "I", "B", "N", "UP", "C4", "A", "S", "ARG", "LOG", "G", "PIE", "T20", "PYI", 54 | "PT", "Q", "RSE", "RET", "SLF", "SIM", "SLOT", "TID", "TC", "PTH", "FLY", "C90", "PERF", "W", 55 | "PGH", "PL", "FURB", "RUF", "TRY", "INP", "ANN" 56 | # "D" - will need, defo need to work on more docs 57 | ] 58 | ignore = ['TRY003'] 59 | # Allow autofix for all enabled rules (when `--fix` is passed) 60 | fixable = ["ALL"] 61 | unfixable = [] 62 | 63 | [tool.ruff.lint.flake8-annotations] 64 | mypy-init-return = true 65 | 66 | # Sort imports 67 | [tool.ruff.lint.isort] 68 | known-first-party = ["stelvio"] 69 | 70 | [tool.ruff.lint.per-file-ignores] 71 | "tests/**/*.py" = [ 72 | "S101", # asserts allowed in tests 73 | "T20", # asserts allowed in tests 74 | "SLF", # protected access allowed in tests 75 | "TID", # allow relative imports in tests 76 | "ARG", # Unused function args -> fixtures nevertheless are functionally relevant 77 | "D", # No need to check docstrings in tests 78 | "PLR2004", 79 | "ANN" 80 | ] 81 | "tests/aws/sample_test_project/**/*.py" = ["INP"] 82 | 83 | -------------------------------------------------------------------------------- /stelvio/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /stelvio/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections.abc import Callable 3 | from importlib import import_module 4 | from pathlib import Path 5 | from typing import TypeVar, final 6 | 7 | from pulumi import Resource as PulumiResource 8 | from pulumi import ResourceOptions, dynamic 9 | from pulumi.dynamic import CreateResult 10 | 11 | # Import cleanup functions for both functions and layers 12 | from stelvio.aws.function.dependencies import ( 13 | clean_function_active_dependencies_caches_file, 14 | clean_function_stale_dependency_caches, 15 | ) 16 | from stelvio.aws.layer import ( 17 | clean_layer_active_dependencies_caches_file, 18 | clean_layer_stale_dependency_caches, 19 | ) 20 | from stelvio.component import Component, ComponentRegistry 21 | from stelvio.link import LinkConfig 22 | 23 | from .aws.function import Function 24 | from .project import get_project_root 25 | 26 | T = TypeVar("T", bound=PulumiResource) 27 | 28 | logging.basicConfig(level=logging.DEBUG) 29 | 30 | logger = logging.getLogger(__name__) 31 | 32 | 33 | class PostDeploymentProvider(dynamic.ResourceProvider): 34 | def create(self, props: dict) -> CreateResult: 35 | return dynamic.CreateResult(id_="stlv-post-deployment", outs=props) 36 | 37 | 38 | class PostDeploymentResource(dynamic.Resource): 39 | def __init__(self, name: str, props: dict, opts: ResourceOptions): 40 | logger.debug("Cleaning up stale dependency caches post-deployment") 41 | clean_function_stale_dependency_caches() 42 | clean_layer_stale_dependency_caches() 43 | provider = PostDeploymentProvider() 44 | super().__init__(provider, name, props or {}, opts) 45 | 46 | 47 | @final 48 | class StelvioApp: 49 | def __init__( 50 | self, 51 | name: str, 52 | modules: list[str], 53 | link_configs: dict[type[Component[T]], Callable[[T], LinkConfig]] | None = None, 54 | ): 55 | self.name = name 56 | self._modules = modules 57 | if link_configs: 58 | for component_type, fn in link_configs.items(): 59 | self.set_user_link_for(component_type, fn) 60 | 61 | @staticmethod 62 | def set_user_link_for( 63 | component_type: type[Component[T]], func: Callable[[T], LinkConfig] 64 | ) -> None: 65 | """Register a user-defined link creator that overrides defaults""" 66 | ComponentRegistry.register_user_link_creator(component_type, func) 67 | 68 | def run(self) -> None: 69 | self.drive() 70 | 71 | def drive(self) -> None: 72 | # Clean active cache tracking files at the start of the run 73 | clean_function_active_dependencies_caches_file() 74 | clean_layer_active_dependencies_caches_file() 75 | 76 | self._load_modules(self._modules, get_project_root()) 77 | # Brm brm, vroooom through those infrastructure deployments 78 | # like an Alfa Romeo through those Stelvio hairpins 79 | for i in ComponentRegistry.all_instances(): 80 | _ = i.resources 81 | 82 | # This is temporary until we move to automation api 83 | all_functions_components = list(ComponentRegistry.instances_of(Function)) 84 | all_pulumi_functions = [f.resources.function for f in all_functions_components] 85 | PostDeploymentResource( 86 | "stlv-post-deployment", props={}, opts=ResourceOptions(depends_on=all_pulumi_functions) 87 | ) 88 | 89 | def _load_modules(self, modules: list[str], project_root: Path) -> None: 90 | exclude_dirs = {"__pycache__", "build", "dist", "node_modules", ".egg-info"} 91 | for pattern in modules: 92 | # Direct module import 93 | if "." in pattern and not any(c in pattern for c in "/*?[]"): 94 | import_module(pattern) 95 | continue 96 | 97 | # Glob pattern 98 | files = project_root.rglob(pattern) 99 | 100 | for file in files: 101 | path = Path(file) 102 | 103 | # Skip hidden folders (any part starts with .) 104 | if any(part.startswith(".") for part in path.parts): 105 | continue 106 | 107 | if path.suffix == ".py" and not any( 108 | excluded in path.parts for excluded in exclude_dirs 109 | ): 110 | parts = list(path.with_suffix("").parts) 111 | if all(part.isidentifier() for part in parts): 112 | module_path = ".".join(parts) 113 | import_module(module_path) 114 | -------------------------------------------------------------------------------- /stelvio/aws/__init__.py: -------------------------------------------------------------------------------- 1 | """AWS components for Stelvio.""" 2 | -------------------------------------------------------------------------------- /stelvio/aws/_packaging/__init__.py: -------------------------------------------------------------------------------- 1 | """Internal packaging utilities for AWS Lambda.""" 2 | -------------------------------------------------------------------------------- /stelvio/aws/_packaging/dependencies.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import logging 3 | import re 4 | import shutil 5 | import subprocess 6 | from collections.abc import Generator, Mapping 7 | from collections.abc import Set as AbstractSet 8 | from dataclasses import dataclass 9 | from pathlib import Path 10 | from typing import Final 11 | 12 | from pulumi import Archive, Asset 13 | 14 | from stelvio.project import get_dot_stelvio_dir 15 | 16 | type PulumiAssets = Mapping[str, Asset | Archive] 17 | 18 | _ACTIVE_CACHE_FILENAME: Final[str] = "active_caches.txt" 19 | _FILE_REFERENCE_PATTERN: Final[re.Pattern] = re.compile(r"^\s*-[rc]\s+(\S+)", re.MULTILINE) 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | @dataclass(frozen=True) 25 | class RequirementsSpec: 26 | content: str | None = None 27 | path_from_root: Path | None = None 28 | 29 | 30 | def get_or_install_dependencies( # noqa: PLR0913 31 | requirements_source: RequirementsSpec, 32 | runtime: str, 33 | architecture: str, 34 | project_root: Path, 35 | cache_subdirectory: str, 36 | log_context: str, 37 | ) -> Path: 38 | """ 39 | Checks cache, installs dependencies if needed using uv or pip, and returns 40 | the absolute path to the populated cache directory. 41 | 42 | Args: 43 | requirements_source: The source of requirements (content and path_from_root). 44 | runtime: The target Python runtime (e.g., "python3.12"). 45 | architecture: The target architecture (e.g., "x86_64"). 46 | project_root: Absolute path to the project root. 47 | cache_subdirectory: Subdirectory within .stelvio/lambda_dependencies 48 | (e.g., "functions", "layers"). 49 | log_context: A string identifier for logging (e.g., function or layer name). 50 | 51 | Returns: 52 | Absolute path to the cache directory containing installed dependencies. 53 | 54 | Raises: 55 | RuntimeError: If installation fails or required tools (uv/pip) are missing. 56 | FileNotFoundError: If a referenced requirements file cannot be found. 57 | ValueError: If requirements paths resolve outside the project root. 58 | """ 59 | py_version = runtime[6:] # Assumes format like "python3.12" 60 | 61 | cache_key = _calculate_cache_key(requirements_source, architecture, py_version, project_root) 62 | 63 | dependencies_dir = _get_lambda_dependencies_dir(cache_subdirectory) 64 | cache_dir = dependencies_dir / cache_key 65 | 66 | _mark_cache_dir_as_active(cache_key, cache_subdirectory) 67 | 68 | if cache_dir.is_dir(): 69 | logger.info("[%s] Cache hit for key '%s'.", log_context, cache_key) 70 | return cache_dir 71 | 72 | logger.info("[%s] Cache miss for key '%s'. Installing dependencies.", log_context, cache_key) 73 | 74 | cache_dir.mkdir(parents=True, exist_ok=True) 75 | installer_cmd, install_flags = _get_installer_command(architecture, py_version) 76 | input_ = None 77 | if requirements_source.path_from_root is None: 78 | # Inline requirements: use '-r -' and pass content via stdin 79 | r_parameter_value = "-" 80 | input_ = requirements_source.content 81 | logger.debug("[%s] Using stdin for inline requirements.", log_context) 82 | else: 83 | # File-based requirements: use '-r ' 84 | r_parameter_value = str((project_root / requirements_source.path_from_root).resolve()) 85 | logger.debug("[%s] Using requirements file: %s", log_context, r_parameter_value) 86 | 87 | cmd = [ 88 | *installer_cmd, 89 | "install", 90 | "-r", 91 | r_parameter_value, 92 | "--target", 93 | str(cache_dir), 94 | *install_flags, 95 | ] 96 | logger.info("[%s] Running dependency installation command: %s", log_context, " ".join(cmd)) 97 | 98 | success = _run_install_command(cmd, input_, log_context) 99 | 100 | if success: 101 | logger.info("[%s] Dependencies installed into %s.", log_context, cache_dir) 102 | else: 103 | shutil.rmtree(cache_dir, ignore_errors=True) 104 | logger.error( 105 | "[%s] Installation failed. Cleaning up cache directory: %s", log_context, cache_dir 106 | ) 107 | raise RuntimeError( 108 | f"Stelvio: [{log_context}] Failed to install dependencies. Check logs for details." 109 | ) 110 | return cache_dir 111 | 112 | 113 | def _resolve_requirements_from_path( 114 | req_path_str: str, project_root: Path, log_context: str 115 | ) -> RequirementsSpec: 116 | logger.debug("[%s] Requirements specified via path: %s", log_context, req_path_str) 117 | source_path_relative = Path(req_path_str) 118 | abs_path = _get_abs_requirements_path(source_path_relative, project_root) 119 | logger.info("[%s] Reading requirements from specified file: %s", log_context, abs_path) 120 | return RequirementsSpec(content=None, path_from_root=source_path_relative) 121 | 122 | 123 | def _resolve_requirements_from_list( 124 | requirements_list: list[str], log_context: str 125 | ) -> RequirementsSpec | None: 126 | logger.debug("[%s] Requirements specified via inline list.", log_context) 127 | # Filter out empty/whitespace 128 | valid_requirements = [r for r in requirements_list if r and r.strip()] 129 | if not valid_requirements: 130 | logger.info("[%s] Inline list contains no valid requirement strings.", log_context) 131 | return None 132 | content = "\n".join(valid_requirements) 133 | return RequirementsSpec(content=content, path_from_root=None) 134 | 135 | 136 | def _normalize_requirements( 137 | content: str, 138 | current_file_path_relative: Path | None = None, 139 | project_root: Path | None = None, 140 | visited_paths: AbstractSet[Path] | None = None, 141 | ) -> Generator[str, None, None]: 142 | """ 143 | Normalizes requirements content for consistent hashing. 144 | - Strips leading/trailing whitespace from each line. 145 | - Removes empty lines. 146 | - Removes comment lines (starting with '#'). 147 | - If current_file_path_relative is provided, resolves paths in '-r'/'c' lines 148 | relative to the project root and recursively expands their content. 149 | - Sorts. 150 | """ 151 | if visited_paths is None: 152 | visited_paths = set() 153 | 154 | if current_file_path_relative in visited_paths: 155 | logger.warning( 156 | "Circular dependency detected: skipping already visited %s", current_file_path_relative 157 | ) 158 | return 159 | 160 | visited_paths.add(current_file_path_relative) 161 | 162 | lines = content.splitlines() 163 | 164 | # Determine the directory context for resolving relative paths within this content 165 | current_dir_abs = None 166 | if current_file_path_relative and project_root: 167 | current_dir_abs = (project_root / current_file_path_relative).parent 168 | 169 | for line in lines: 170 | stripped_line = line.strip() 171 | if not stripped_line or stripped_line.startswith("#"): 172 | continue 173 | 174 | line_no_comment = stripped_line.split("#", 1)[0].strip() 175 | if not line_no_comment: 176 | continue 177 | 178 | match = _FILE_REFERENCE_PATTERN.match(line_no_comment) 179 | # Only attempt to resolve if we work with requirements file 180 | if match and current_dir_abs: 181 | reference_path_str = match.group(1) 182 | # Resolve the referenced path relative to the current file's directory 183 | reference_path_abs = (current_dir_abs / reference_path_str).resolve() 184 | try: 185 | reference_path_project_relative = reference_path_abs.relative_to(project_root) 186 | except ValueError: 187 | raise ValueError( 188 | f"Requirements file '{reference_path_str}' " 189 | f"referenced from {current_file_path_relative} " 190 | f"resolves to '{reference_path_abs}', " 191 | f"which is outside the project root '{project_root}'." 192 | ) from None 193 | _validate_is_file_exists(reference_path_abs) 194 | reference_content = _get_requirements_content( 195 | reference_path_project_relative, project_root 196 | ) 197 | yield from _normalize_requirements( 198 | reference_content, reference_path_project_relative, project_root, visited_paths 199 | ) 200 | else: 201 | yield line_no_comment 202 | 203 | 204 | def _get_requirements_content(relative_path: Path, project_root: Path) -> str: 205 | abs_path = _get_abs_requirements_path(relative_path, project_root) 206 | return abs_path.read_text(encoding="utf-8") 207 | 208 | 209 | def _get_abs_requirements_path(relative_path: Path, project_root: Path) -> Path: 210 | abs_path = (project_root / relative_path).resolve() 211 | 212 | _validate_is_file_exists(abs_path) 213 | 214 | try: 215 | abs_path.relative_to(project_root) 216 | except ValueError: 217 | raise ValueError( 218 | f"Requirements file '{relative_path}' resolves to '{abs_path}', " 219 | f"which is outside the project root '{project_root}'." 220 | ) from None 221 | return abs_path 222 | 223 | 224 | def _validate_is_file_exists(abs_path: Path) -> None: 225 | if not abs_path.is_file(): 226 | if not abs_path.exists(): 227 | raise FileNotFoundError(f"Requirements file not found: {abs_path}") 228 | raise ValueError(f"Requirements path is not a file: {abs_path}") 229 | 230 | 231 | def _calculate_cache_key( 232 | source: RequirementsSpec, architecture: str, py_version: str, project_root: Path 233 | ) -> str: 234 | """ 235 | Calculates a unique cache key based on requirements content, architecture, and Python version. 236 | """ 237 | if source.path_from_root is None: 238 | if bool(_FILE_REFERENCE_PATTERN.search(source.content)): 239 | raise ValueError( 240 | "'-r' or '-c' references are not allowed when providing requirements as list. " 241 | ) 242 | normalized_requirements = _normalize_requirements(source.content) 243 | elif isinstance(source.path_from_root, Path): 244 | content = _get_requirements_content(source.path_from_root, project_root) 245 | normalized_requirements = _normalize_requirements( 246 | content, source.path_from_root, project_root 247 | ) 248 | else: 249 | raise TypeError(f"Unexpected source identifier type: {type(source.path_from_root)}") 250 | normalized_requirements_str = "\n".join(sorted(normalized_requirements)) 251 | final_hash = hashlib.sha256(normalized_requirements_str.encode("utf-8")).hexdigest() 252 | 253 | return f"{architecture}__{py_version}__{final_hash[:16]}" 254 | 255 | 256 | def _run_install_command(cmd: list[str], input_: str, log_context: str) -> bool: 257 | try: 258 | result = subprocess.run(cmd, input=input_, capture_output=True, check=True, text=True) # noqa: S603 259 | logger.debug("[%s] Installation successful. Stdout:\n%s", log_context, result.stdout) 260 | except subprocess.CalledProcessError as e: 261 | # TODO: test manually to see what error in console 262 | logger.exception( 263 | "[%s] Installation command failed.\nCommand: %s\nStderr:\n%s", 264 | log_context, 265 | " ".join(e.cmd), 266 | e.stderr, 267 | ) 268 | return False 269 | else: 270 | return True 271 | 272 | 273 | def _get_installer_command(architecture: str, py_version: str) -> tuple[list[str], list[str]]: 274 | """Determines the installer command (uv or pip) and necessary flags.""" 275 | uv_path = shutil.which("uv") 276 | platform_arch = "aarch64" if architecture == "arm64" else "x86_64" 277 | 278 | if uv_path: 279 | logger.info("Using 'uv' for dependency installation.") 280 | installer_cmd = [uv_path, "pip"] 281 | platform_flags = ["--python-platform", f"{platform_arch}-manylinux2014"] 282 | implementation_flags = [] 283 | else: 284 | pip_path = shutil.which("pip") 285 | if not pip_path: 286 | raise RuntimeError( 287 | "Could not find 'pip' or 'uv'. Please ensure one is installed and in your PATH." 288 | ) 289 | logger.info("Using 'pip' for dependency installation.") 290 | installer_cmd = [pip_path] 291 | manylinux_tag = f"manylinux2014_{platform_arch}" 292 | platform_flags = ["--platform", manylinux_tag] 293 | implementation_flags = ["--implementation", "cp"] 294 | 295 | install_flags = [ 296 | *implementation_flags, 297 | *platform_flags, 298 | "--python-version", 299 | py_version, 300 | "--only-binary=:all:", 301 | ] 302 | return installer_cmd, install_flags 303 | 304 | 305 | def _get_lambda_dependencies_dir(cache_subdirectory: str) -> Path: 306 | return get_dot_stelvio_dir() / "lambda_dependencies" / cache_subdirectory 307 | 308 | 309 | def clean_active_dependencies_caches_file(cache_subdirectory: str) -> None: 310 | active_file = _get_lambda_dependencies_dir(cache_subdirectory) / _ACTIVE_CACHE_FILENAME 311 | active_file.unlink(missing_ok=True) 312 | 313 | 314 | def _mark_cache_dir_as_active(cache_key: str, cache_subdirectory: str) -> None: 315 | dependencies_dir = _get_lambda_dependencies_dir(cache_subdirectory) 316 | dependencies_dir.mkdir(parents=True, exist_ok=True) 317 | active_file = dependencies_dir / _ACTIVE_CACHE_FILENAME 318 | with active_file.open("a", encoding="utf-8") as f: 319 | f.write(f"{cache_key}\n") 320 | 321 | 322 | def clean_stale_dependency_caches(cache_subdirectory: str) -> None: 323 | dependencies_dir = _get_lambda_dependencies_dir(cache_subdirectory) 324 | if not dependencies_dir.is_dir(): 325 | return 326 | 327 | active_file = dependencies_dir / _ACTIVE_CACHE_FILENAME 328 | 329 | if not active_file.is_file(): 330 | return 331 | 332 | active_caches = set(active_file.read_text(encoding="utf-8").splitlines()) 333 | 334 | for item in dependencies_dir.iterdir(): 335 | if item.is_dir() and item.name not in active_caches: 336 | shutil.rmtree(item) 337 | -------------------------------------------------------------------------------- /stelvio/aws/dynamo_db.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from typing import final 4 | 5 | import pulumi 6 | from pulumi import Output 7 | from pulumi_aws.dynamodb import Table 8 | 9 | from stelvio.aws.permission import AwsPermission 10 | from stelvio.component import Component, ComponentRegistry, link_config_creator 11 | from stelvio.link import Link, Linkable, LinkConfig 12 | 13 | 14 | class AttributeType(Enum): 15 | STRING = "S" 16 | NUMBER = "N" 17 | BINARY = "B" 18 | 19 | 20 | @dataclass(frozen=True) 21 | class DynamoTableResources: 22 | table: Table 23 | 24 | 25 | @final 26 | class DynamoTable(Component[DynamoTableResources], Linkable): 27 | def __init__( 28 | self, 29 | name: str, 30 | *, 31 | fields: dict[str, AttributeType], 32 | partition_key: str, 33 | sort_key: str | None = None, 34 | ): 35 | super().__init__(name) 36 | self._fields = fields 37 | self._partition_key = partition_key 38 | self._sort_key = sort_key 39 | 40 | if self._partition_key not in self.fields: 41 | raise ValueError(f"partition_key '{self._partition_key}' not in fields list") 42 | 43 | if self._sort_key and self.sort_key not in self.fields: 44 | raise ValueError(f"sort_key '{self.sort_key}' not in fields list") 45 | 46 | self._resources = None 47 | 48 | @property 49 | def fields(self) -> dict[str, AttributeType]: 50 | return dict(self._fields) # Return a copy to prevent modification 51 | 52 | @property 53 | def partition_key(self) -> str: 54 | return self._partition_key 55 | 56 | @property 57 | def sort_key(self) -> str | None: 58 | return self._sort_key 59 | 60 | @property 61 | def arn(self) -> Output[str]: 62 | return self.resources.table.arn 63 | 64 | def _create_resources(self) -> DynamoTableResources: 65 | table = Table( 66 | self.name, 67 | billing_mode="PAY_PER_REQUEST", 68 | hash_key=self.partition_key, 69 | range_key=self.sort_key, 70 | attributes=[{"name": k, "type": v.value} for k, v in self.fields.items()], 71 | ) 72 | pulumi.export(f"dynamotable_{self.name}_arn", table.arn) 73 | pulumi.export(f"dynamotable_{self.name}_name", table.name) 74 | return DynamoTableResources(table) 75 | 76 | # we can also provide other predefined links e.g read only, index etc. 77 | def link(self) -> Link: 78 | link_creator_ = ComponentRegistry.get_link_config_creator(type(self)) 79 | 80 | link_config = link_creator_(self._resources.table) 81 | return Link(self.name, link_config.properties, link_config.permissions) 82 | 83 | 84 | @link_config_creator(DynamoTable) 85 | def default_dynamo_table_link(table: Table) -> LinkConfig: 86 | # https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_lambda-access-dynamodb.html 87 | return LinkConfig( 88 | properties={"table_arn": table.arn, "table_name": table.name}, 89 | permissions=[ 90 | AwsPermission( 91 | actions=[ 92 | "dynamodb:Scan", 93 | "dynamodb:Query", 94 | "dynamodb:GetItem", 95 | "dynamodb:PutItem", 96 | "dynamodb:UpdateItem", 97 | "dynamodb:DeleteItem", 98 | ], 99 | resources=[table.arn], 100 | ) 101 | ], 102 | ) 103 | -------------------------------------------------------------------------------- /stelvio/aws/function/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections.abc import Iterable, Mapping, Sequence 3 | from dataclasses import dataclass 4 | from pathlib import Path 5 | from typing import ClassVar, Unpack, final 6 | 7 | import pulumi 8 | from pulumi import Asset, Input, Output 9 | from pulumi_aws import lambda_ 10 | from pulumi_aws.iam import Policy, Role 11 | 12 | from stelvio.component import Component 13 | from stelvio.link import Link, Linkable 14 | from stelvio.project import get_project_root 15 | 16 | from .config import FunctionConfig, FunctionConfigDict # Import for re-export 17 | from .constants import DEFAULT_ARCHITECTURE, DEFAULT_MEMORY, DEFAULT_RUNTIME, DEFAULT_TIMEOUT 18 | from .iam import _attach_role_policies, _create_function_policy, _create_lambda_role 19 | from .naming import _envar_name 20 | from .packaging import _create_lambda_archive 21 | from .resources_codegen import _create_stlv_resource_file, create_stlv_resource_file_content 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | __all__ = ["Function", "FunctionConfig", "FunctionConfigDict", "FunctionResources"] 27 | 28 | 29 | @dataclass(frozen=True) 30 | class FunctionResources: 31 | function: lambda_.Function 32 | role: Role 33 | policy: Policy | None 34 | 35 | 36 | @final 37 | class Function(Component[FunctionResources]): 38 | """AWS Lambda function component with automatic resource discovery. 39 | 40 | Generated environment variables follow pattern: STLV_RESOURCENAME_PROPERTYNAME 41 | 42 | Args: 43 | name: Function name 44 | config: Complete function configuration as FunctionConfig or dict 45 | **opts: Individual function configuration parameters 46 | 47 | You can configure the function in two ways: 48 | - Provide complete config: 49 | function = Function( 50 | name="process-user", 51 | config={"handler": "functions/orders.index", "timeout": 30} 52 | ) 53 | - Provide individual parameters: 54 | function = Function( 55 | name="process-user", 56 | handler="functions/orders.index", 57 | links=[table.default_link(), bucket.readonly_link()] 58 | ) 59 | 60 | """ 61 | 62 | _config: FunctionConfig 63 | 64 | def __init__( 65 | self, 66 | name: str, 67 | config: None | FunctionConfig | FunctionConfigDict = None, 68 | **opts: Unpack[FunctionConfigDict], 69 | ): 70 | super().__init__(name) 71 | 72 | self._config = self._parse_config(config, opts) 73 | 74 | @staticmethod 75 | def _parse_config( 76 | config: None | FunctionConfig | FunctionConfigDict, opts: FunctionConfigDict 77 | ) -> FunctionConfig: 78 | if not config and not opts: 79 | raise ValueError( 80 | "Missing function handler: must provide either a complete configuration via " 81 | "'config' parameter or at least the 'handler' option" 82 | ) 83 | if config and opts: 84 | raise ValueError( 85 | "Invalid configuration: cannot combine 'config' parameter with additional options " 86 | "- provide all settings either in 'config' or as separate options" 87 | ) 88 | if config is None: 89 | return FunctionConfig(**opts) 90 | if isinstance(config, FunctionConfig): 91 | return config 92 | if isinstance(config, dict): 93 | return FunctionConfig(**config) 94 | 95 | raise TypeError( 96 | f"Invalid config type: expected FunctionConfig or dict, got {type(config).__name__}" 97 | ) 98 | 99 | @property 100 | def config(self) -> FunctionConfig: 101 | return self._config 102 | 103 | @property 104 | def invoke_arn(self) -> Output[str]: 105 | return self.resources.function.invoke_arn 106 | 107 | @property 108 | def function_name(self) -> Output[str]: 109 | return self.resources.function.name 110 | 111 | def _create_resources(self) -> FunctionResources: 112 | logger.debug("Creating resources for function '%s'", self.name) 113 | iam_statements = _extract_links_permissions(self._config.links) 114 | function_policy = _create_function_policy(self.name, iam_statements) 115 | 116 | lambda_role = _create_lambda_role(self.name) 117 | _attach_role_policies(self.name, lambda_role, function_policy) 118 | 119 | folder_path = self.config.folder_path or str(Path(self.config.handler_file_path).parent) 120 | 121 | links_props = _extract_links_property_mappings(self._config.links) 122 | lambda_resource_file_content = create_stlv_resource_file_content(links_props) 123 | LinkPropertiesRegistry.add(folder_path, links_props) 124 | 125 | ide_resource_file_content = create_stlv_resource_file_content( 126 | LinkPropertiesRegistry.get_link_properties_map(folder_path) 127 | ) 128 | 129 | _create_stlv_resource_file(get_project_root() / folder_path, ide_resource_file_content) 130 | extra_assets_map = FunctionAssetsRegistry.get_assets_map(self) 131 | handler = self.config.handler_format 132 | if "stlv_routing_handler.py" in extra_assets_map: 133 | handler = "stlv_routing_handler.lambda_handler" 134 | 135 | # Determine effective runtime and architecture for the function 136 | function_runtime = self.config.runtime or DEFAULT_RUNTIME 137 | function_architecture = self.config.architecture or DEFAULT_ARCHITECTURE 138 | function_resource = lambda_.Function( 139 | self.name, 140 | role=lambda_role.arn, 141 | architectures=[function_architecture], 142 | runtime=function_runtime, 143 | code=_create_lambda_archive( 144 | self.config, lambda_resource_file_content, extra_assets_map 145 | ), 146 | handler=handler, 147 | environment={"variables": _extract_links_env_vars(self._config.links)}, 148 | memory_size=self.config.memory or DEFAULT_MEMORY, 149 | timeout=self.config.timeout or DEFAULT_TIMEOUT, 150 | layers=[layer.arn for layer in self.config.layers] if self.config.layers else None, 151 | ) 152 | pulumi.export(f"function_{self.name}_arn", function_resource.arn) 153 | pulumi.export(f"function_{self.name}_name", function_resource.name) 154 | pulumi.export(f"function_{self.name}_role_arn", lambda_role.arn) 155 | pulumi.export(f"function_{self.name}_role_name", lambda_role.name) 156 | return FunctionResources(function_resource, lambda_role, function_policy) 157 | 158 | 159 | class LinkPropertiesRegistry: 160 | _folder_links_properties_map: ClassVar[dict[str, dict[str, list[str]]]] = {} 161 | 162 | @classmethod 163 | def add(cls, folder: str, link_properties_map: dict[str, list[str]]) -> None: 164 | cls._folder_links_properties_map.setdefault(folder, {}).update(link_properties_map) 165 | 166 | @classmethod 167 | def get_link_properties_map(cls, folder: str) -> dict[str, list[str]]: 168 | return cls._folder_links_properties_map.get(folder, {}) 169 | 170 | 171 | class FunctionAssetsRegistry: 172 | _functions_assets_map: ClassVar[dict[Function, dict[str, Asset]]] = {} 173 | 174 | @classmethod 175 | def add(cls, function_: Function, assets_map: dict[str, Asset]) -> None: 176 | cls._functions_assets_map.setdefault(function_, {}).update(assets_map) 177 | 178 | @classmethod 179 | def get_assets_map(cls, function_: Function) -> dict[str, Asset]: 180 | return cls._functions_assets_map.get(function_, {}).copy() 181 | 182 | 183 | def _extract_links_permissions(linkables: Sequence[Link | Linkable]) -> list[Mapping | Iterable]: 184 | """Extracts IAM statements from permissions for function's IAM policy""" 185 | return [ 186 | permission.to_provider_format() 187 | for linkable in linkables 188 | if linkable.link().permissions 189 | for permission in linkable.link().permissions 190 | ] 191 | 192 | 193 | def _extract_links_env_vars(linkables: Sequence[Link | Linkable]) -> dict[str, Input[str]]: 194 | """Creates environment variables with STLV_ prefix for runtime resource discovery. 195 | The STLV_ prefix in environment variables ensures no conflicts with other env vars 196 | and makes it clear which variables are managed by Stelvio. 197 | """ 198 | link_objects = [item.link() for item in linkables] 199 | return { 200 | _envar_name(link.name, prop_name): value 201 | for link in link_objects 202 | if link.properties 203 | for prop_name, value in link.properties.items() 204 | } 205 | 206 | 207 | def _extract_links_property_mappings(linkables: Sequence[Link | Linkable]) -> dict[str, list[str]]: 208 | """Maps resource properties to Python class names for code generation of resource 209 | access classes. 210 | """ 211 | link_objects = [item.link() for item in linkables] 212 | return {link.name: list(link.properties) for link in link_objects} 213 | -------------------------------------------------------------------------------- /stelvio/aws/function/config.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | from dataclasses import MISSING, Field, dataclass, field, fields 3 | from typing import Literal, TypedDict 4 | 5 | from stelvio.aws.function.constants import DEFAULT_ARCHITECTURE, DEFAULT_RUNTIME, MAX_LAMBDA_LAYERS 6 | from stelvio.aws.layer import Layer 7 | from stelvio.aws.types import AwsArchitecture, AwsLambdaRuntime 8 | from stelvio.link import Link, Linkable 9 | 10 | 11 | class FunctionConfigDict(TypedDict, total=False): 12 | handler: str 13 | folder: str 14 | links: list[Link | Linkable] 15 | memory: int 16 | timeout: int 17 | environment: dict[str, str] 18 | architecture: AwsArchitecture 19 | runtime: AwsLambdaRuntime 20 | requirements: str | list[str] | Literal[False] | None 21 | layers: list[Layer] | None 22 | 23 | 24 | @dataclass(frozen=True, kw_only=True) 25 | class FunctionConfig: 26 | # handler is mandatory but rest defaults to None. Default values will be configured 27 | # elsewhere not here so they can be configurable and so they don't cause trouble 28 | # in api Gateway where we check for conflicting configurations 29 | handler: str 30 | folder: str | None = None 31 | links: list[Link | Linkable] = field(default_factory=list) 32 | memory: int | None = None 33 | timeout: int | None = None 34 | environment: dict[str, str] = field(default_factory=dict) 35 | architecture: AwsArchitecture | None = None 36 | runtime: AwsLambdaRuntime | None = None 37 | requirements: str | list[str] | Literal[False] | None = None 38 | layers: list[Layer] = field(default_factory=list) 39 | 40 | def __post_init__(self) -> None: 41 | handler_parts = self.handler.split("::") 42 | 43 | if len(handler_parts) > 2: # noqa: PLR2004 44 | raise ValueError("Handler can only contain one :: separator") 45 | 46 | if len(handler_parts) == 2: # noqa: PLR2004 47 | if self.folder is not None: 48 | raise ValueError("Cannot specify both 'folder' and use '::' in handler") 49 | folder_path, handler_path = handler_parts 50 | if "." in folder_path: 51 | raise ValueError("Folder path should not contain dots") 52 | else: 53 | if self.folder is not None and "." in self.folder: 54 | raise ValueError("Folder path should not contain dots") 55 | handler_path = self.handler 56 | 57 | if "." not in handler_path: 58 | raise ValueError( 59 | "Handler must contain a dot separator between file path and function name" 60 | ) 61 | 62 | file_path, function_name = handler_path.rsplit(".", 1) 63 | if not file_path or not function_name: 64 | raise ValueError("Both file path and function name must be non-empty") 65 | 66 | if "." in file_path: 67 | raise ValueError("File path part should not contain dots") 68 | 69 | self._validate_requirements() 70 | self._validate_layers( 71 | function_runtime=self.runtime or DEFAULT_RUNTIME, 72 | function_architecture=self.architecture or DEFAULT_ARCHITECTURE, 73 | ) 74 | 75 | def _validate_requirements(self) -> None: 76 | """Validates the 'requirements' property against allowed types and values.""" 77 | if self.requirements is None: 78 | return 79 | 80 | if isinstance(self.requirements, str): 81 | if not self.requirements.strip(): 82 | raise ValueError("If 'requirements' is a string (path), it cannot be empty.") 83 | elif isinstance(self.requirements, list): 84 | if not all(isinstance(item, str) for item in (self.requirements)): 85 | raise TypeError("If 'requirements' is a list, all its elements must be strings.") 86 | elif isinstance(self.requirements, bool): 87 | if self.requirements is not False: 88 | raise ValueError( 89 | "If 'requirements' is a boolean, it must be False (to disable). " 90 | "True is not allowed." 91 | ) 92 | else: 93 | raise TypeError( 94 | f"'requirements' must be a string (path), list of strings, False, or None. " 95 | f"Got type: {type(self.requirements).__name__}." 96 | ) 97 | 98 | def _validate_layers(self, function_runtime: str, function_architecture: str) -> None: 99 | """ 100 | Validates the 'layers' property against types, duplicates, limits, 101 | and compatibility with the function's runtime/architecture. 102 | """ 103 | if not self.layers: 104 | return 105 | 106 | if not isinstance(self.layers, list): 107 | raise TypeError( 108 | f"Expected 'layers' to be a list of Layer objects, " 109 | f"but got {type(self.layers).__name__}." 110 | ) 111 | 112 | if len(self.layers) > MAX_LAMBDA_LAYERS: 113 | raise ValueError( 114 | f"A function cannot have more than {MAX_LAMBDA_LAYERS} layers. " 115 | f"Found {len(self.layers)}." 116 | ) 117 | 118 | layer_names = [layer.name for layer in self.layers if isinstance(layer, Layer)] 119 | name_counts = Counter(layer_names) 120 | duplicates = [name for name, count in name_counts.items() if count > 1] 121 | if duplicates: 122 | raise ValueError( 123 | f"Duplicate layer names found: {', '.join(duplicates)}. " 124 | f"Layer names must be unique for a function." 125 | ) 126 | 127 | for index, layer in enumerate(self.layers): 128 | if not isinstance(layer, Layer): 129 | raise TypeError( 130 | f"Item at index {index} in 'layers' list is not a Layer instance. " 131 | f"Got {type(layer).__name__}." 132 | ) 133 | 134 | layer_runtime = layer.runtime or DEFAULT_RUNTIME 135 | layer_architecture = layer.architecture or DEFAULT_ARCHITECTURE 136 | if function_runtime != layer_runtime: 137 | raise ValueError( 138 | f"Function runtime '{function_runtime}' is incompatible " 139 | f"with Layer '{layer.name}' runtime '{layer_runtime}'." 140 | ) 141 | 142 | if function_architecture != layer_architecture: 143 | raise ValueError( 144 | f"Function architecture '{function_architecture}' is incompatible " 145 | f"with Layer '{layer.name}' architecture '{layer_architecture}'." 146 | ) 147 | 148 | @property 149 | def folder_path(self) -> str | None: 150 | return self.folder or (self.handler.split("::")[0] if "::" in self.handler else None) 151 | 152 | @property 153 | def _handler_part(self) -> str: 154 | return self.handler.split("::")[1] if "::" in self.handler else self.handler 155 | 156 | @property 157 | def handler_file_path(self) -> str: 158 | return self._handler_part.rsplit(".", 1)[0] 159 | 160 | @property 161 | def local_handler_file_path(self) -> str: 162 | return self.handler_format.rsplit(".", 1)[0] 163 | 164 | @property 165 | def handler_function_name(self) -> str: 166 | return self._handler_part.rsplit(".", 1)[1] 167 | 168 | @property 169 | def handler_format(self) -> str: 170 | return self._handler_part if self.folder_path else self.handler.split("/")[-1] 171 | 172 | @property 173 | def has_only_defaults(self) -> bool: 174 | ignore_fields = {"handler", "folder"} 175 | 176 | def _field_has_default_value(info: Field) -> bool: 177 | if info.name in ignore_fields: 178 | return True 179 | 180 | current_value = getattr(self, info.name) 181 | 182 | if info.default_factory is not MISSING: 183 | return current_value == info.default_factory() 184 | if info.default is not MISSING: 185 | return current_value == info.default 186 | # Field has no default, so it cannot fail the 'is default' check 187 | return True 188 | 189 | return all(_field_has_default_value(info) for info in fields(self)) 190 | -------------------------------------------------------------------------------- /stelvio/aws/function/constants.py: -------------------------------------------------------------------------------- 1 | DEFAULT_RUNTIME = "python3.12" 2 | DEFAULT_ARCHITECTURE = "x86_64" 3 | DEFAULT_MEMORY = 128 4 | DEFAULT_TIMEOUT = 60 5 | LAMBDA_EXCLUDED_FILES = ["stlv.py", ".DS_Store"] # exact file matches 6 | LAMBDA_EXCLUDED_DIRS = ["__pycache__"] 7 | LAMBDA_EXCLUDED_EXTENSIONS = [".pyc"] 8 | MAX_LAMBDA_LAYERS = 5 9 | # "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole", 10 | LAMBDA_BASIC_EXECUTION_ROLE = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" 11 | NUMBER_WORDS = { 12 | "0": "Zero", 13 | "1": "One", 14 | "2": "Two", 15 | "3": "Three", 16 | "4": "Four", 17 | "5": "Five", 18 | "6": "Six", 19 | "7": "Seven", 20 | "8": "Eight", 21 | "9": "Nine", 22 | } 23 | -------------------------------------------------------------------------------- /stelvio/aws/function/dependencies.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from typing import Final 4 | 5 | from pulumi import FileArchive 6 | 7 | from stelvio.aws._packaging.dependencies import ( 8 | PulumiAssets, 9 | RequirementsSpec, 10 | _resolve_requirements_from_list, 11 | _resolve_requirements_from_path, 12 | clean_active_dependencies_caches_file, 13 | clean_stale_dependency_caches, 14 | get_or_install_dependencies, 15 | ) 16 | from stelvio.aws.function.config import FunctionConfig 17 | from stelvio.aws.function.constants import DEFAULT_ARCHITECTURE, DEFAULT_RUNTIME 18 | from stelvio.project import get_project_root 19 | 20 | # Constants specific to function dependency resolution 21 | _REQUIREMENTS_FILENAME: Final[str] = "requirements.txt" 22 | _FUNCTION_CACHE_SUBDIR: Final[str] = "functions" 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | def _get_function_packages(function_config: FunctionConfig) -> PulumiAssets | None: 28 | """ 29 | Resolves, installs (via shared logic), and packages Lambda function dependencies. 30 | 31 | Args: 32 | function_config: The configuration object for the Lambda function. 33 | 34 | Returns: 35 | A dictionary mapping filenames/paths to Pulumi Assets/Archives for the 36 | dependencies, or None if no requirements are specified or found. 37 | """ 38 | project_root = get_project_root() 39 | log_context = f"Function: {function_config.handler}" # Use handler for context 40 | logger.debug("[%s] Starting dependency resolution", log_context) 41 | 42 | # 1. Resolve requirements source 43 | source = _resolve_requirements_source(function_config, project_root, log_context) 44 | if source is None: 45 | logger.debug("[%s] No requirements source found or requirements disabled.", log_context) 46 | return None 47 | 48 | # 2. Determine runtime, architecture, and source context path for shared functions 49 | runtime = function_config.runtime or DEFAULT_RUNTIME 50 | architecture = function_config.architecture or DEFAULT_ARCHITECTURE 51 | 52 | # 3. Ensure Dependencies are Installed (using shared function) 53 | cache_dir = get_or_install_dependencies( 54 | requirements_source=source, 55 | runtime=runtime, 56 | architecture=architecture, 57 | project_root=project_root, 58 | cache_subdirectory=_FUNCTION_CACHE_SUBDIR, 59 | log_context=log_context, 60 | ) 61 | 62 | # 4. Package dependencies from the cache directory (using shared function) 63 | return {"": FileArchive(str(cache_dir))} 64 | 65 | 66 | def _handle_requirements_none( 67 | config: FunctionConfig, project_root: Path, log_context: str 68 | ) -> RequirementsSpec | None: 69 | """Handle the case where requirements=None (default lookup).""" 70 | logger.debug( 71 | "[%s] Requirements option is None, looking for default %s", 72 | log_context, 73 | _REQUIREMENTS_FILENAME, 74 | ) 75 | if config.folder_path: # Folder-based: look inside the folder 76 | base_folder_relative = Path(config.folder_path) 77 | else: # Single file lambda: relative to the handler file's directory 78 | base_folder_relative = Path(config.handler_file_path).parent 79 | 80 | # Path relative to project root 81 | source_path_relative = base_folder_relative / _REQUIREMENTS_FILENAME 82 | abs_path = project_root / source_path_relative 83 | logger.debug("[%s] Checking for default requirements file at: %s", log_context, abs_path) 84 | 85 | if abs_path.is_file(): 86 | logger.info("[%s] Found default requirements file: %s", log_context, abs_path) 87 | return RequirementsSpec(content=None, path_from_root=source_path_relative) 88 | logger.debug("[%s] Default %s not found.", log_context, _REQUIREMENTS_FILENAME) 89 | return None 90 | 91 | 92 | def _resolve_requirements_source( 93 | config: FunctionConfig, project_root: Path, log_context: str 94 | ) -> RequirementsSpec | None: 95 | """ 96 | Determines the source and content of requirements based on FunctionConfig. 97 | 98 | Returns: 99 | A RequirementsSource, or None if no requirements are applicable. 100 | Raises: 101 | FileNotFoundError: If an explicitly specified requirements file is not found. 102 | ValueError: If an explicitly specified path is not a file. 103 | """ 104 | requirements = config.requirements 105 | logger.debug("[%s] Resolving requirements source with option: %r", log_context, requirements) 106 | 107 | if requirements is False or requirements == []: 108 | logger.info( 109 | "[%s] Requirements handling explicitly disabled or empty list provided.", log_context 110 | ) 111 | return None 112 | 113 | if requirements is None: 114 | return _handle_requirements_none(config, project_root, log_context) 115 | 116 | if isinstance(requirements, str): 117 | return _resolve_requirements_from_path(requirements, project_root, log_context) 118 | 119 | if isinstance(requirements, list): 120 | return _resolve_requirements_from_list(requirements, log_context) 121 | 122 | # Should be caught by FunctionConfig validation, but raise defensively 123 | raise TypeError( 124 | f"[{log_context}] Unexpected type for requirements configuration: {type(requirements)}" 125 | ) 126 | 127 | 128 | def clean_function_active_dependencies_caches_file() -> None: 129 | clean_active_dependencies_caches_file(_FUNCTION_CACHE_SUBDIR) 130 | 131 | 132 | def clean_function_stale_dependency_caches() -> None: 133 | clean_stale_dependency_caches(_FUNCTION_CACHE_SUBDIR) 134 | -------------------------------------------------------------------------------- /stelvio/aws/function/iam.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from pulumi_aws.iam import ( 4 | GetPolicyDocumentStatementArgs, 5 | GetPolicyDocumentStatementPrincipalArgs, 6 | Policy, 7 | Role, 8 | RolePolicyAttachment, 9 | get_policy_document, 10 | ) 11 | 12 | from .constants import LAMBDA_BASIC_EXECUTION_ROLE 13 | 14 | 15 | def _create_function_policy(name: str, statements: list[dict[str, Any]]) -> Policy | None: 16 | """Create IAM policy for Lambda if there are any statements.""" 17 | if not statements: 18 | return None 19 | 20 | policy_document = get_policy_document(statements=statements) 21 | return Policy(f"{name}-Policy", path="/", policy=policy_document.json) 22 | 23 | 24 | def _create_lambda_role(name: str) -> Role: 25 | """Create basic execution role for Lambda.""" 26 | assume_role_policy = get_policy_document( 27 | statements=[ 28 | GetPolicyDocumentStatementArgs( 29 | actions=["sts:AssumeRole"], 30 | principals=[ 31 | GetPolicyDocumentStatementPrincipalArgs( 32 | identifiers=["lambda.amazonaws.com"], type="Service" 33 | ) 34 | ], 35 | ) 36 | ] 37 | ) 38 | return Role(f"{name}-role", assume_role_policy=assume_role_policy.json) 39 | 40 | 41 | def _attach_role_policies(name: str, role: Role, function_policy: Policy | None) -> None: 42 | """Attach required policies to Lambda role.""" 43 | RolePolicyAttachment( 44 | f"{name}-BasicExecutionRolePolicyAttachment", 45 | role=role.name, 46 | policy_arn=LAMBDA_BASIC_EXECUTION_ROLE, 47 | ) 48 | if function_policy: 49 | RolePolicyAttachment( 50 | f"{name}-DefaultRolePolicyAttachment", role=role.name, policy_arn=function_policy.arn 51 | ) 52 | -------------------------------------------------------------------------------- /stelvio/aws/function/naming.py: -------------------------------------------------------------------------------- 1 | from .constants import NUMBER_WORDS 2 | 3 | 4 | def _envar_name(link_name: str, prop_name: str) -> str: 5 | cleaned_link_name = "".join(c if c.isalnum() else "_" for c in link_name) 6 | 7 | if (first_char := cleaned_link_name[0]) and first_char.isdigit(): 8 | cleaned_link_name = NUMBER_WORDS[first_char] + cleaned_link_name[1:] 9 | 10 | return f"STLV_{cleaned_link_name.upper()}_{prop_name.upper()}" 11 | -------------------------------------------------------------------------------- /stelvio/aws/function/packaging.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pulumi import Archive, Asset, AssetArchive, FileAsset, StringAsset 4 | 5 | from stelvio.project import get_project_root 6 | 7 | from .config import FunctionConfig 8 | from .constants import LAMBDA_EXCLUDED_DIRS, LAMBDA_EXCLUDED_EXTENSIONS, LAMBDA_EXCLUDED_FILES 9 | from .dependencies import _get_function_packages 10 | 11 | 12 | def _create_lambda_archive( 13 | function_config: FunctionConfig, 14 | resource_file_content: str | None, 15 | extra_assets_map: dict[str, Asset], 16 | ) -> AssetArchive: 17 | """Create an AssetArchive for Lambda function based on configuration. 18 | Handles both single file and folder-based Lambdas. 19 | """ 20 | 21 | project_root = get_project_root() 22 | 23 | assets: dict[str, Asset | Archive] = extra_assets_map 24 | handler_file = str(Path(function_config.handler_file_path).with_suffix(".py")) 25 | if function_config.folder_path: 26 | # Handle folder-based Lambda 27 | full_folder_path = project_root / function_config.folder_path 28 | if not full_folder_path.exists(): 29 | raise ValueError(f"Folder not found: {full_folder_path}") 30 | 31 | # Check if handler file exists in the folder 32 | if handler_file not in extra_assets_map: 33 | absolute_handler_file = full_folder_path / handler_file 34 | if not absolute_handler_file.exists(): 35 | raise ValueError(f"Handler file not found in folder: {absolute_handler_file}.py") 36 | 37 | # Recursively collect all files from the folder 38 | assets |= { 39 | str(file_path.relative_to(full_folder_path)): FileAsset(file_path) 40 | for file_path in full_folder_path.rglob("*") 41 | if not ( 42 | file_path.is_dir() 43 | or file_path.name in LAMBDA_EXCLUDED_FILES 44 | or file_path.parent.name in LAMBDA_EXCLUDED_DIRS 45 | or file_path.suffix in LAMBDA_EXCLUDED_EXTENSIONS 46 | ) 47 | } 48 | # Handle single file Lambda 49 | elif handler_file not in extra_assets_map: 50 | absolute_handler_file = project_root / handler_file 51 | if not absolute_handler_file.exists(): 52 | raise ValueError(f"Handler file not found: {absolute_handler_file}") 53 | assets[absolute_handler_file.name] = FileAsset(absolute_handler_file) 54 | 55 | if resource_file_content: 56 | assets["stlv_resources.py"] = StringAsset(resource_file_content) 57 | 58 | function_packages_archives = _get_function_packages(function_config) 59 | if function_packages_archives: 60 | assets |= function_packages_archives 61 | return AssetArchive(assets) 62 | -------------------------------------------------------------------------------- /stelvio/aws/function/resources_codegen.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from .constants import NUMBER_WORDS 4 | from .naming import _envar_name 5 | 6 | 7 | def _create_stlv_resource_file(folder: Path, content: str | None) -> None: 8 | """Create resource access file with supplied content.""" 9 | path = folder / "stlv_resources.py" 10 | # Delete file if no content 11 | if not content: 12 | path.unlink(missing_ok=True) 13 | return 14 | with Path.open(path, "w") as f: 15 | f.write(content) 16 | 17 | 18 | def create_stlv_resource_file_content(link_properties_map: dict[str, list[str]]) -> str | None: 19 | """Generate resource access file content with classes for linked resources.""" 20 | # Return None if no properties to generate 21 | if not any(link_properties_map.values()): 22 | return None 23 | 24 | lines = [ 25 | "import os", 26 | "from dataclasses import dataclass", 27 | "from typing import Final", 28 | "from functools import cached_property\n\n", 29 | ] 30 | 31 | for link_name, properties in link_properties_map.items(): 32 | if not properties: 33 | continue 34 | lines.extend(_create_link_resource_class(link_name, properties)) 35 | 36 | lines.extend(["@dataclass(frozen=True)", "class LinkedResources:"]) 37 | 38 | # and this 39 | for link_name in link_properties_map: 40 | cls_name = _to_valid_python_class_name(link_name) 41 | lines.append( 42 | f" {_pascal_to_camel(cls_name)}: Final[{cls_name}Resource] = {cls_name}Resource()" 43 | ) 44 | lines.extend(["\n", "Resources: Final = LinkedResources()"]) 45 | 46 | return "\n".join(lines) 47 | 48 | 49 | def _create_link_resource_class(link_name: str, properties: list[str]) -> list[str] | None: 50 | if not properties: 51 | return None 52 | class_name = _to_valid_python_class_name(link_name) 53 | lines = [ 54 | "@dataclass(frozen=True)", 55 | f"class {class_name}Resource:", 56 | ] 57 | for prop in properties: 58 | lines.extend( 59 | [ 60 | " @cached_property", 61 | f" def {prop}(self) -> str:", 62 | f' return os.getenv("{_envar_name(link_name, prop)}")\n', 63 | ] 64 | ) 65 | lines.append("") 66 | return lines 67 | 68 | 69 | def _to_valid_python_class_name(aws_name: str) -> str: 70 | # Split and clean 71 | words = aws_name.replace("-", " ").replace(".", " ").replace("_", " ").split() 72 | cleaned_words = ["".join(c for c in word if c.isalnum()) for word in words] 73 | class_name = "".join(word.capitalize() for word in cleaned_words) 74 | 75 | # Convert only first digit if name starts with number 76 | if class_name and class_name[0].isdigit(): 77 | first_digit = NUMBER_WORDS[class_name[0]] 78 | class_name = first_digit + class_name[1:] 79 | 80 | return class_name 81 | 82 | 83 | def _pascal_to_camel(pascal_str: str) -> str: 84 | """Convert Pascal case to camel case. 85 | Example: PascalCase -> pascalCase, XMLParser -> xmlParser 86 | """ 87 | if not pascal_str: 88 | return pascal_str 89 | i = 1 90 | while i < len(pascal_str) and pascal_str[i].isupper(): 91 | i += 1 92 | return pascal_str[:i].lower() + pascal_str[i:] 93 | -------------------------------------------------------------------------------- /stelvio/aws/layer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | from pathlib import Path 4 | from typing import Final, final 5 | 6 | import pulumi 7 | from pulumi import Archive, Asset, AssetArchive, FileArchive, Output 8 | from pulumi_aws.lambda_ import LayerVersion 9 | 10 | from stelvio.aws._packaging.dependencies import ( 11 | RequirementsSpec, 12 | _resolve_requirements_from_list, 13 | _resolve_requirements_from_path, 14 | clean_active_dependencies_caches_file, 15 | clean_stale_dependency_caches, 16 | get_or_install_dependencies, 17 | ) 18 | from stelvio.aws.function.constants import DEFAULT_ARCHITECTURE, DEFAULT_RUNTIME 19 | from stelvio.aws.types import AwsArchitecture, AwsLambdaRuntime 20 | from stelvio.component import Component 21 | from stelvio.project import get_project_root 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | _LAYER_CACHE_SUBDIR: Final[str] = "layers" 26 | 27 | 28 | __all__ = ["Layer", "LayerResources"] 29 | 30 | 31 | @dataclass(frozen=True) 32 | class LayerResources: 33 | """Represents the AWS resources created for a Stelvio Layer component.""" 34 | 35 | layer_version: LayerVersion 36 | 37 | 38 | @final 39 | class Layer(Component[LayerResources]): 40 | """ 41 | Represents an AWS Lambda Layer, enabling code and dependency sharing. 42 | 43 | This component manages the creation and versioning of an AWS Lambda LayerVersion 44 | based on the provided code and/or requirements. Stelvio automatically handles 45 | packaging according to AWS standards (e.g., placing code under 'python/'). 46 | 47 | Args: 48 | name: The logical name of the layer component within the Stelvio application. 49 | code: Path (relative to project root) to the directory containing the layer's 50 | Python code (e.g., "src/my_utils"). The directory itself (e.g., "my_utils") 51 | will be placed under 'python/' in the layer archive, making it importable 52 | (e.g., `from my_utils import ...`). If None, the layer contains only dependencies. 53 | requirements: Specifies Python package dependencies. Accepts a path string to a 54 | requirements file (relative to project root), a list of 55 | requirement strings (e.g., `["requests", "boto3"]`), or 56 | `False` (to explicitly disable). If `None` (default), no 57 | dependencies are installed. No default file lookup occurs. 58 | runtime: The compatible Lambda runtime identifier (e.g., "python3.12"). 59 | Defaults to the project's default runtime if None. 60 | architecture: The compatible instruction set architecture (e.g., "x86_64"). 61 | Defaults to the project's default architecture if None. 62 | """ 63 | 64 | _code: str | None 65 | _requirements: str | list[str] | bool | None 66 | _architecture: AwsArchitecture | None 67 | _runtime: AwsLambdaRuntime | None 68 | 69 | def __init__( 70 | self, 71 | name: str, 72 | *, 73 | code: str | None = None, 74 | requirements: str | list[str] | bool | None = None, 75 | runtime: AwsLambdaRuntime | None = None, 76 | architecture: AwsArchitecture | None = None, 77 | ): 78 | super().__init__(name) 79 | self._code = code 80 | self._requirements = requirements 81 | self._runtime = runtime 82 | self._architecture = architecture 83 | 84 | if not self._code and not self._requirements: 85 | raise ValueError(f"Layer '{name}' must specify 'code' and/or 'requirements'.") 86 | self._validate_requirements() 87 | # TODO: validate arch and runtime values 88 | 89 | def _validate_requirements(self) -> None: 90 | if not self._requirements: 91 | return 92 | 93 | if isinstance(self._requirements, list): 94 | if not all(isinstance(item, str) for item in self._requirements): 95 | raise TypeError("If 'requirements' is a list, all its elements must be strings.") 96 | elif not isinstance(self._requirements, str): 97 | raise TypeError( 98 | f"'requirements' must be a string (path), list of strings, or None. " 99 | f"Got type: {type(self._requirements).__name__}." 100 | ) 101 | 102 | @property 103 | def runtime(self) -> str | None: 104 | return self._runtime 105 | 106 | @property 107 | def architecture(self) -> str | None: 108 | return self._architecture 109 | 110 | @property 111 | def arn(self) -> Output[str]: 112 | return self.resources.layer_version.arn 113 | 114 | def _create_resources(self) -> LayerResources: 115 | logger.debug("Creating resources for Layer '%s'", self.name) 116 | log_context = f"Layer: {self.name}" 117 | 118 | runtime = self._runtime or DEFAULT_RUNTIME 119 | architecture = self._architecture or DEFAULT_ARCHITECTURE 120 | 121 | assets = _gather_layer_assets( 122 | code=self._code, 123 | requirements=self._requirements, 124 | log_context=log_context, 125 | runtime=runtime, 126 | architecture=architecture, 127 | ) 128 | 129 | if not assets: 130 | raise ValueError( 131 | f"[{log_context}] Layer must contain code or requirements, " 132 | f"but resulted in an empty package." 133 | ) 134 | 135 | asset_archive = AssetArchive(assets) 136 | 137 | layer_version_resource = LayerVersion( 138 | self.name, 139 | layer_name=self.name, 140 | code=asset_archive, 141 | compatible_runtimes=[runtime], 142 | compatible_architectures=[architecture], 143 | ) 144 | 145 | pulumi.export(f"layer_{self.name}_name", layer_version_resource.layer_name) 146 | pulumi.export(f"layer_{self.name}_version_arn", layer_version_resource.arn) 147 | 148 | return LayerResources(layer_version=layer_version_resource) 149 | 150 | 151 | def _resolve_requirements_source( 152 | requirements: str | list[str] | bool | None, project_root: Path, log_context: str 153 | ) -> RequirementsSpec | None: 154 | logger.debug("[%s] Resolving requirements source with option: %r", log_context, requirements) 155 | 156 | if requirements is None or requirements is False or requirements == []: 157 | logger.debug("[%s] Requirements explicitly disabled or not provided.", log_context) 158 | return None 159 | 160 | if isinstance(requirements, str): 161 | return _resolve_requirements_from_path(requirements, project_root, log_context) 162 | 163 | if isinstance(requirements, list): 164 | return _resolve_requirements_from_list(requirements, log_context) 165 | 166 | raise TypeError( 167 | f"[{log_context}] Unexpected type for requirements configuration: {type(requirements)}" 168 | ) 169 | 170 | 171 | def _gather_layer_assets( 172 | code: str | None, 173 | requirements: str | list[str] | bool | None, 174 | log_context: str, 175 | runtime: str, 176 | architecture: str, 177 | ) -> dict[str, Asset | Archive]: 178 | assets: dict[str, Asset | Archive] = {} 179 | project_root = get_project_root() 180 | if code: 181 | code_path_relative = Path(code) 182 | code_path_abs = (project_root / code_path_relative).resolve() 183 | 184 | try: 185 | _ = code_path_abs.relative_to(project_root) 186 | except ValueError: 187 | raise ValueError( 188 | f"Code path '{code_path_relative}' resolves to '{code_path_abs}', " 189 | f"which is outside the project root '{project_root}'." 190 | ) from None 191 | 192 | if not code_path_abs.is_dir(): 193 | raise ValueError(f"[{log_context}] Code path '{code_path_abs}' is not a directory.") 194 | 195 | code_dir_name = code_path_abs.name 196 | archive_code_path = f"python/{code_dir_name}" 197 | logger.debug( 198 | "[%s] Packaging code directory '%s' into archive path '%s'", 199 | log_context, 200 | code_path_abs, 201 | archive_code_path, 202 | ) 203 | assets[archive_code_path] = FileArchive(str(code_path_abs)) 204 | 205 | source = _resolve_requirements_source(requirements, project_root, log_context) 206 | 207 | if source: 208 | logger.debug("[%s] Requirements source identified, ensuring installation.", log_context) 209 | cache_dir = get_or_install_dependencies( 210 | requirements_source=source, 211 | runtime=runtime, 212 | architecture=architecture, 213 | project_root=project_root, 214 | cache_subdirectory=_LAYER_CACHE_SUBDIR, 215 | log_context=log_context, 216 | ) 217 | # Package the entire cache directory into the standard layer path 218 | dep_archive_path = f"python/lib/{runtime}/site-packages" 219 | logger.debug( 220 | "[%s] Packaging dependency cache '%s' into archive path '%s'", 221 | log_context, 222 | cache_dir, 223 | dep_archive_path, 224 | ) 225 | # Only add if cache_dir actually exists and has content 226 | if cache_dir.exists() and any(cache_dir.iterdir()): 227 | assets[dep_archive_path] = FileArchive(str(cache_dir)) 228 | else: 229 | logger.warning( 230 | "[%s] Dependency cache directory '%s' is empty or missing after " 231 | "installation attempt. No dependencies will be added to the layer.", 232 | log_context, 233 | cache_dir, 234 | ) 235 | return assets 236 | 237 | 238 | def clean_layer_active_dependencies_caches_file() -> None: 239 | """Removes the tracking file for active layer dependency caches.""" 240 | clean_active_dependencies_caches_file(_LAYER_CACHE_SUBDIR) 241 | 242 | 243 | def clean_layer_stale_dependency_caches() -> None: 244 | """Removes stale cached dependency directories specific to layers.""" 245 | clean_stale_dependency_caches(_LAYER_CACHE_SUBDIR) 246 | -------------------------------------------------------------------------------- /stelvio/aws/permission.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from pulumi import Input 4 | from pulumi_aws.iam import GetPolicyDocumentStatementArgsDict 5 | 6 | 7 | @dataclass(frozen=True) 8 | class AwsPermission: 9 | actions: list[str] | Input[str] 10 | resources: list[Input[str]] | Input[str] 11 | 12 | def to_provider_format(self) -> GetPolicyDocumentStatementArgsDict: 13 | return GetPolicyDocumentStatementArgsDict(actions=self.actions, resources=self.resources) 14 | -------------------------------------------------------------------------------- /stelvio/aws/types.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | # Type alias for supported AWS Lambda instruction set architectures 4 | type AwsArchitecture = Literal["x86_64", "arm64"] 5 | 6 | # Type alias for supported AWS Lambda Python runtimes 7 | type AwsLambdaRuntime = Literal["python3.12", "python3.13"] 8 | -------------------------------------------------------------------------------- /stelvio/component.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from collections.abc import Callable, Iterator 3 | from functools import wraps 4 | from typing import Any, ClassVar 5 | 6 | from pulumi import Resource as PulumiResource 7 | 8 | from stelvio.link import LinkConfig 9 | 10 | 11 | class Component[ResourcesT](ABC): 12 | _name: str 13 | _resources: ResourcesT | None 14 | 15 | def __init__(self, name: str): 16 | self._name = name 17 | self._resources = None 18 | ComponentRegistry.add_instance(self) 19 | 20 | @property 21 | def name(self) -> str: 22 | return self._name 23 | 24 | @property 25 | def resources(self) -> ResourcesT: 26 | if not self._resources: 27 | self._resources = self._create_resources() 28 | return self._resources 29 | 30 | @abstractmethod 31 | def _create_resources(self) -> ResourcesT: 32 | """Implement actual resource creation logic""" 33 | raise NotImplementedError 34 | 35 | 36 | class ComponentRegistry: 37 | _instances: ClassVar[dict[type[Component], list[Component]]] = {} 38 | _registered_names: ClassVar[set[str]] = set() 39 | 40 | # Two-tier registry for link creators 41 | _default_link_creators: ClassVar[dict[type, Callable]] = {} 42 | _user_link_creators: ClassVar[dict[type, Callable]] = {} 43 | 44 | @classmethod 45 | def add_instance(cls, instance: Component[Any]) -> None: 46 | if instance.name in cls._registered_names: 47 | raise ValueError( 48 | f"Duplicate Stelvio component name detected: '{instance.name}'. " 49 | "Component names must be unique across all component types." 50 | ) 51 | cls._registered_names.add(instance.name) 52 | if type(instance) not in cls._instances: 53 | cls._instances[type(instance)] = [] 54 | cls._instances[type(instance)].append(instance) 55 | 56 | @classmethod 57 | def register_default_link_creator[T: PulumiResource]( 58 | cls, component_type: type[Component[T]], creator_fn: Callable[[T], LinkConfig] 59 | ) -> None: 60 | """Register a default link creator, which will be used if no user-defined creator exists""" 61 | cls._default_link_creators[component_type] = creator_fn 62 | 63 | @classmethod 64 | def register_user_link_creator[T: PulumiResource]( 65 | cls, component_type: type[Component[T]], creator_fn: Callable[[T], LinkConfig] 66 | ) -> None: 67 | """Register a user-defined link creator, which takes precedence over defaults""" 68 | cls._user_link_creators[component_type] = creator_fn 69 | 70 | @classmethod 71 | def get_link_config_creator[T: PulumiResource]( 72 | cls, component_type: type[Component] 73 | ) -> Callable[[T], LinkConfig] | None: 74 | """Get the link creator for a component type, prioritizing user-defined over defaults""" 75 | # First check user-defined creators, then fall back to defaults 76 | return cls._user_link_creators.get(component_type) or cls._default_link_creators.get( 77 | component_type 78 | ) 79 | 80 | @classmethod 81 | def all_instances(cls) -> Iterator[Component[Any]]: 82 | instances = cls._instances.copy() 83 | for k in instances: 84 | yield from instances[k] 85 | 86 | @classmethod 87 | def instances_of[T: Component](cls, component_type: type[T]) -> Iterator[T]: 88 | yield from cls._instances[component_type] 89 | 90 | 91 | def link_config_creator[T: PulumiResource]( 92 | component_type: type[Component], 93 | ) -> Callable[[Callable[[T], LinkConfig]], Callable[[T], LinkConfig]]: 94 | """Decorator to register a default link creator for a component type""" 95 | 96 | def decorator(func: Callable[[T], LinkConfig]) -> Callable[[T], LinkConfig]: 97 | @wraps(func) 98 | def wrapper(resource: T) -> LinkConfig: 99 | return func(resource) 100 | 101 | ComponentRegistry.register_default_link_creator(component_type, func) 102 | return wrapper 103 | 104 | return decorator 105 | -------------------------------------------------------------------------------- /stelvio/link.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable, Iterable, Mapping 2 | from dataclasses import dataclass 3 | from typing import TYPE_CHECKING, Any, Optional, Protocol, final 4 | 5 | from pulumi import Input 6 | 7 | if TYPE_CHECKING: 8 | from stelvio.component import Component 9 | 10 | 11 | @dataclass 12 | class Permission(Protocol): 13 | def to_provider_format(self) -> Mapping | Iterable: 14 | """Convert permission to provider-specific format.""" 15 | ... 16 | 17 | 18 | type ConfigureLink = Callable[[Any], tuple[dict, list[Permission] | Permission]] 19 | 20 | 21 | # Link has permissions, and each permission has actions and resources 22 | # so permission represents part of statement 23 | @final 24 | @dataclass(frozen=True) 25 | class LinkConfig: 26 | properties: dict[str, Input[str]] | None = None 27 | permissions: list[Permission] | None = None 28 | 29 | 30 | @final 31 | @dataclass(frozen=True) 32 | class Link: 33 | name: str 34 | properties: dict[str, Input[str]] | None 35 | permissions: list[Permission] | None 36 | component: Optional["Component"] = None 37 | 38 | def link(self) -> "Link": 39 | return self 40 | 41 | def with_config( 42 | self, 43 | *, 44 | properties: dict[str, Input[str]] | None = None, 45 | permissions: list[Permission] | None = None, 46 | ) -> "Link": 47 | """Replace both properties and permissions at once.""" 48 | return Link( 49 | name=self.name, 50 | properties=properties, 51 | permissions=permissions, 52 | component=self.component, 53 | ) 54 | 55 | def with_properties(self, **props: Input[str]) -> "Link": 56 | """Replace all properties.""" 57 | return Link( 58 | name=self.name, 59 | properties=props, 60 | permissions=self.permissions, 61 | component=self.component, 62 | ) 63 | 64 | def with_permissions(self, *permissions: Permission) -> "Link": 65 | """Replace all permissions.""" 66 | return Link( 67 | name=self.name, 68 | properties=self.properties, 69 | permissions=list(permissions), 70 | component=self.component, 71 | ) 72 | 73 | def add_properties(self, **extra_props: Input[str]) -> "Link": 74 | """Add to existing properties.""" 75 | new_props = {**(self.properties or {}), **extra_props} 76 | return self.with_properties(**new_props) 77 | 78 | def add_permissions(self, *extra_permissions: Permission) -> "Link": 79 | """Add to existing permissions.""" 80 | current = self.permissions or [] 81 | return self.with_permissions(*(current + list(extra_permissions))) 82 | 83 | def remove_properties(self, *keys: str) -> "Link": 84 | """Remove specific properties by key.""" 85 | if not self.properties: 86 | return self 87 | 88 | new_props = {k: v for k, v in self.properties.items() if k not in keys} 89 | return self.with_properties(**new_props) 90 | 91 | 92 | class Linkable(Protocol): 93 | def link(self) -> Link: 94 | raise NotImplementedError 95 | -------------------------------------------------------------------------------- /stelvio/project.py: -------------------------------------------------------------------------------- 1 | from functools import cache 2 | from pathlib import Path 3 | 4 | 5 | @cache 6 | def get_project_root() -> Path: 7 | """Find and cache the project root by looking for stlv_app.py. 8 | Raises ValueError if not found. 9 | """ 10 | start_path = Path.cwd().resolve() 11 | 12 | current = start_path 13 | while current != current.parent: 14 | if (current / "stlv_app.py").exists(): 15 | return current 16 | current = current.parent 17 | 18 | raise ValueError("Could not find project root: no stlv_app.py found in parent directories") 19 | 20 | 21 | def get_dot_stelvio_dir() -> Path: 22 | return get_project_root() / ".stelvio" 23 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michal-stlv/stelvio/73f245fd4bb0ae011afec7af21568c4c40f9b1e8/tests/__init__.py -------------------------------------------------------------------------------- /tests/aws/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michal-stlv/stelvio/73f245fd4bb0ae011afec7af21568c4c40f9b1e8/tests/aws/__init__.py -------------------------------------------------------------------------------- /tests/aws/_packaging/__init__.py: -------------------------------------------------------------------------------- 1 | # This file makes this directory a Python package. 2 | -------------------------------------------------------------------------------- /tests/aws/api_gateway/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michal-stlv/stelvio/73f245fd4bb0ae011afec7af21568c4c40f9b1e8/tests/aws/api_gateway/__init__.py -------------------------------------------------------------------------------- /tests/aws/api_gateway/test_api_route.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from stelvio.aws.api_gateway import Api 4 | from stelvio.aws.function import Function, FunctionConfig 5 | 6 | 7 | def test_api_route_basic(): 8 | """Test that a basic route can be added.""" 9 | api = Api("test-api") 10 | api.route("GET", "/users", "users.handler") 11 | assert len(api._routes) == 1 12 | route = api._routes[0] 13 | assert route.methods == ["GET"] 14 | assert route.path == "/users" 15 | assert isinstance(route.handler, FunctionConfig) 16 | assert route.handler.handler == "users.handler" 17 | 18 | 19 | def test_api_route_with_function(): 20 | """Test that a route can be added with a Function instance.""" 21 | api = Api("test-api") 22 | fn = Function("users-function", handler="users.handler") 23 | api.route("GET", "/users", fn) 24 | assert len(api._routes) == 1 25 | route = api._routes[0] 26 | assert route.handler == fn 27 | 28 | 29 | def test_api_route_with_function_config(): 30 | """Test that a route can be added with a FunctionConfig.""" 31 | api = Api("test-api") 32 | config = FunctionConfig(handler="users.handler", memory=256) 33 | api.route("GET", "/users", config) 34 | assert len(api._routes) == 1 35 | route = api._routes[0] 36 | assert isinstance(route.handler, FunctionConfig) 37 | assert route.handler.memory == 256 38 | 39 | 40 | def test_api_route_with_config_dict(): 41 | """Test that a route can be added with a config dictionary.""" 42 | api = Api("test-api") 43 | config = {"handler": "users.handler", "memory": 256} 44 | api.route("GET", "/users", config) 45 | assert len(api._routes) == 1 46 | route = api._routes[0] 47 | assert isinstance(route.handler, FunctionConfig) 48 | assert route.handler.handler == "users.handler" 49 | assert route.handler.memory == 256 50 | 51 | 52 | @pytest.mark.parametrize( 53 | ("handler", "opts", "expected_error"), 54 | [ 55 | # Missing handler in both places 56 | ( 57 | None, 58 | {}, 59 | "Missing handler configuration: when handler argument is None, 'handler' option must " 60 | "be provided", 61 | ), 62 | # Handler in both places 63 | ( 64 | "users.index", 65 | {"handler": "users.other"}, 66 | "Ambiguous handler configuration: handler is specified both as positional argument " 67 | "and in options", 68 | ), 69 | # Complete config with additional options 70 | ( 71 | {"handler": "users.index"}, 72 | {"memory": 256}, 73 | "Invalid configuration: cannot combine complete handler configuration with additional " 74 | "options", 75 | ), 76 | ( 77 | FunctionConfig(handler="users.index"), 78 | {"memory": 256}, 79 | "Invalid configuration: cannot combine complete handler configuration with additional " 80 | "options", 81 | ), 82 | ( 83 | Function("test-1", handler="users.index"), 84 | {"memory": 256}, 85 | "Invalid configuration: cannot combine complete handler configuration with additional " 86 | "options", 87 | ), 88 | ], 89 | ) 90 | def test_api_create_route_validation(handler, opts, expected_error): 91 | """Test validation in _create_route static method.""" 92 | api = Api("test-api") 93 | with pytest.raises(ValueError, match=expected_error): 94 | api._create_route("GET", "/users", handler, opts) 95 | 96 | 97 | @pytest.mark.parametrize( 98 | ("handler", "expected_type", "expected_handler"), 99 | [ 100 | # String handler converted to FunctionConfig 101 | ("users.index", FunctionConfig, "users.index"), 102 | # Dict converted to FunctionConfig 103 | ({"handler": "users.index"}, FunctionConfig, "users.index"), 104 | # FunctionConfig stays FunctionConfig 105 | (FunctionConfig(handler="users.index"), FunctionConfig, "users.index"), 106 | # Function instance stays Function 107 | (Function("test", handler="users.index"), Function, "users.index"), 108 | ], 109 | ) 110 | def test_api_create_route_handler_types(handler, expected_type, expected_handler): 111 | """Test that _create_route handles different handler types correctly.""" 112 | api = Api("test-api") 113 | route = api._create_route("GET", "/users", handler, {}) 114 | assert isinstance(route.handler, expected_type) 115 | if isinstance(route.handler, Function): 116 | assert route.handler.config.handler == expected_handler 117 | else: # Must be FunctionConfig 118 | assert route.handler.handler == expected_handler 119 | 120 | 121 | def test_api_create_route_with_opts(): 122 | """Test that _create_route correctly combines handler with options.""" 123 | api = Api("test-api") 124 | route = api._create_route("GET", "/users", "users.index", {"memory": 256}) 125 | assert isinstance(route.handler, FunctionConfig) 126 | assert route.handler.handler == "users.index" 127 | assert route.handler.memory == 256 128 | 129 | 130 | @pytest.mark.parametrize( 131 | ("first_route", "second_route"), 132 | [ 133 | # Same file, both trying to configure 134 | ( 135 | ("GET", "/users", {"handler": "users.index", "memory": 256}), 136 | ("POST", "/users", {"handler": "users.index", "timeout": 30}), 137 | ), 138 | # Using FunctionConfig instead of dict 139 | ( 140 | ("GET", "/users", FunctionConfig(handler="users.index", memory=256)), 141 | ("POST", "/users", FunctionConfig(handler="users.index", timeout=30)), 142 | ), 143 | # Same folder, both trying to configure 144 | ( 145 | ("GET", "/users", {"handler": "users::handler.index", "memory": 256}), 146 | ("POST", "/users", {"handler": "users::handler.create", "timeout": 30}), 147 | ), 148 | # Using FunctionConfig instead of dict 149 | ( 150 | ("GET", "/users", FunctionConfig(handler="users::handler.index", memory=256)), 151 | ("POST", "/users", FunctionConfig(handler="users::handler.create", timeout=30)), 152 | ), 153 | ], 154 | ) 155 | def test_api_route_conflicts(first_route, second_route): 156 | """Test that only one route can configure a shared function.""" 157 | api = Api("test-api") 158 | api.route(first_route[0], first_route[1], first_route[2]) 159 | api.route(second_route[0], second_route[1], second_route[2]) 160 | 161 | # The actual check is in _get_group_config_map during _create_resource. 162 | # Let's simulate that here. 163 | # Maybe we should check during route()? 164 | from stelvio.aws.api_gateway import _get_group_config_map, _group_routes_by_lambda 165 | 166 | grouped_routes = _group_routes_by_lambda(api._routes) 167 | # This should raise when we try to process the API routes 168 | with pytest.raises( 169 | ValueError, match="Multiple routes trying to configure the same lambda function" 170 | ): 171 | _get_group_config_map(grouped_routes) 172 | 173 | 174 | @pytest.mark.parametrize( 175 | ("first_method", "second_method", "should_conflict"), 176 | [ 177 | # Exact same method - should conflict 178 | ("GET", "GET", True), 179 | # Different methods - should not conflict 180 | ("GET", "POST", False), 181 | # List of methods with overlap - should conflict 182 | (["GET", "POST"], "GET", True), 183 | # List of methods without overlap - should not conflict 184 | (["GET", "POST"], "PUT", False), 185 | # ANY method should conflict with everything 186 | ("ANY", "GET", True), 187 | ("GET", "ANY", True), 188 | # Wildcard (*) method should conflict with everything 189 | ("*", "POST", True), 190 | ("DELETE", "*", True), 191 | ], 192 | ) 193 | def test_route_method_path_conflicts(first_method, second_method, should_conflict): 194 | """Test that routes with the same path and overlapping methods conflict.""" 195 | api = Api("test-api") 196 | 197 | # Add the first route 198 | api.route(first_method, "/users", "users.handler") 199 | 200 | if should_conflict: 201 | # If methods overlap, adding the second route should raise a conflict error 202 | with pytest.raises(ValueError, match="Route conflict"): 203 | api.route(second_method, "/users", "users.handler2") 204 | else: 205 | # If methods don't overlap, adding the second route should succeed 206 | api.route(second_method, "/users", "users.handler2") 207 | 208 | # Verify both routes were added 209 | assert len(api._routes) == 2 210 | -------------------------------------------------------------------------------- /tests/aws/api_gateway/test_api_route_dataclass.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | from stelvio.aws.api_gateway import HTTPMethod, _ApiRoute 6 | from stelvio.aws.function import Function, FunctionConfig 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ("path", "expected_error"), 11 | [ 12 | ("", "Path must start with '/'"), 13 | ("/" + "x" * 8192, "Path too long"), 14 | ("/users/{}/orders", "Empty path parameters not allowed"), 15 | ("/".join(f"/{{{i}}}" for i in range(11)), "Maximum of 10 path parameters allowed"), 16 | ("/users/{id}{name}", "Adjacent path parameters not allowed"), 17 | ("/users/{id}/orders/{id}", "Duplicate path parameters not allowed"), 18 | ("/users/{123-id}", "Invalid parameter name: 123-id"), 19 | ("/users/{proxy+}/orders", "Greedy parameter must be at the end of the path"), 20 | ("/users/{path+}", re.escape("Only {proxy+} is supported for greedy paths")), 21 | ], 22 | ) 23 | def test_api_route_path_validation(path, expected_error): 24 | """Test various path validation scenarios.""" 25 | with pytest.raises(ValueError, match=expected_error): 26 | _ApiRoute("GET", path, FunctionConfig(handler="handler.main")) 27 | 28 | 29 | @pytest.mark.parametrize( 30 | ("method", "expected_error"), 31 | [ 32 | ("INVALID", "Invalid HTTP method: INVALID"), 33 | (["GET", "INVALID"], "Invalid HTTP method: INVALID"), 34 | (["GET", "ANY"], re.escape("ANY and * not allowed in method list")), 35 | (["GET", "*"], re.escape("ANY and * not allowed in method list")), 36 | ([], "Method list cannot be empty"), 37 | ], 38 | ) 39 | def test_api_route_invalid_methods(method, expected_error): 40 | """Test that invalid HTTP methods raise ValueError.""" 41 | with pytest.raises(ValueError, match=expected_error): 42 | _ApiRoute(method, "/users", FunctionConfig(handler="handler.main")) 43 | 44 | 45 | @pytest.mark.parametrize( 46 | ("method", "expected_error"), 47 | [ 48 | ([123], "Invalid method type in list: "), 49 | ([[str]], "Invalid method type in list: "), 50 | ([3.14], "Invalid method type in list: "), 51 | ], 52 | ) 53 | def test_api_route_invalid_method_type(method, expected_error): 54 | """Test that invalid HTTP methods raise ValueError.""" 55 | with pytest.raises(TypeError, match=expected_error): 56 | _ApiRoute(method, "/users", FunctionConfig(handler="handler.main")) 57 | 58 | 59 | @pytest.mark.parametrize( 60 | ("method", "expected_methods"), 61 | [ 62 | # Single string methods (case insensitive) 63 | ("GET", ["GET"]), 64 | ("get", ["GET"]), 65 | ("POST", ["POST"]), 66 | ("post", ["POST"]), 67 | ("PUT", ["PUT"]), 68 | ("PATCH", ["PATCH"]), 69 | ("DELETE", ["DELETE"]), 70 | ("HEAD", ["HEAD"]), 71 | ("OPTIONS", ["OPTIONS"]), 72 | # ANY/* 73 | ("ANY", ["ANY"]), 74 | ("*", ["ANY"]), 75 | # Single HTTPMethod enum 76 | (HTTPMethod.GET, ["GET"]), 77 | (HTTPMethod.POST, ["POST"]), 78 | (HTTPMethod.PUT, ["PUT"]), 79 | (HTTPMethod.PATCH, ["PATCH"]), 80 | (HTTPMethod.DELETE, ["DELETE"]), 81 | (HTTPMethod.HEAD, ["HEAD"]), 82 | (HTTPMethod.OPTIONS, ["OPTIONS"]), 83 | (HTTPMethod.ANY, ["ANY"]), 84 | # Multiple methods - strings 85 | (["GET", "POST"], ["GET", "POST"]), 86 | (["get", "post"], ["GET", "POST"]), 87 | (["GET", "POST", "PUT"], ["GET", "POST", "PUT"]), 88 | (["get", "POST", "Put"], ["GET", "POST", "PUT"]), 89 | # Multiple methods - enums 90 | ([HTTPMethod.GET, HTTPMethod.POST], ["GET", "POST"]), 91 | ([HTTPMethod.GET, HTTPMethod.POST, HTTPMethod.PUT], ["GET", "POST", "PUT"]), 92 | # Mixed string and enum 93 | (["GET", HTTPMethod.POST], ["GET", "POST"]), 94 | ([HTTPMethod.GET, "post", HTTPMethod.PUT], ["GET", "POST", "PUT"]), 95 | ], 96 | ) 97 | def test_api_route_methods(method, expected_methods): 98 | """Test that HTTP methods are normalized correctly in all valid combinations.""" 99 | route = _ApiRoute(method, "/users", FunctionConfig(handler="handler.main")) 100 | assert route.methods == expected_methods 101 | 102 | 103 | @pytest.mark.parametrize( 104 | ("handler", "expected_type"), 105 | [ 106 | # FunctionConfig 107 | (FunctionConfig(handler="users.handler"), FunctionConfig), 108 | # Function instance 109 | (Function("test-2", handler="users.handler"), Function), 110 | ], 111 | ) 112 | def test_api_route_valid_handler_configurations(handler, expected_type): 113 | """Check we accept only FunctionConfig or Function as handler.""" 114 | route = _ApiRoute("GET", "/users", handler) 115 | assert isinstance(route.handler, expected_type) 116 | assert route.handler == handler 117 | 118 | 119 | def test_api_route_invalid_handler_type(): 120 | """Test that invalid handler types are rejected.""" 121 | invalid_handlers = [ 122 | "string_handler", # String (should be processed by _create_route, not directly) 123 | {"handler": "dict_handler"}, # Dict (should be converted to FunctionConfig) 124 | 123, # Integer 125 | None, # None 126 | [], # List 127 | ] 128 | 129 | for handler in invalid_handlers: 130 | with pytest.raises(TypeError, match="Handler must be FunctionConfig or Function"): 131 | _ApiRoute("GET", "/users", handler) 132 | 133 | 134 | @pytest.mark.parametrize( 135 | ("path", "expected_parts"), 136 | [ 137 | # Basic paths 138 | ("/users", ["users"]), 139 | ("/users/", ["users"]), 140 | ("/users/orders", ["users", "orders"]), 141 | # Paths with parameters 142 | ("/users/{id}", ["users", "{id}"]), 143 | ("/users/{id}/orders", ["users", "{id}", "orders"]), 144 | # Path with greedy parameter 145 | ("/users/{proxy+}", ["users", "{proxy+}"]), 146 | # Root path 147 | ("/", []), 148 | ], 149 | ) 150 | def test_api_route_path_parts(path, expected_parts): 151 | """Test that path_parts property correctly parses and filters path segments.""" 152 | route = _ApiRoute("GET", path, FunctionConfig(handler="handler.main")) 153 | assert route.path_parts == expected_parts 154 | -------------------------------------------------------------------------------- /tests/aws/dynamo_db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michal-stlv/stelvio/73f245fd4bb0ae011afec7af21568c4c40f9b1e8/tests/aws/dynamo_db/__init__.py -------------------------------------------------------------------------------- /tests/aws/dynamo_db/test_dynamodb_table.py: -------------------------------------------------------------------------------- 1 | import pulumi 2 | import pytest 3 | from pulumi.runtime import set_mocks 4 | 5 | from stelvio.aws.dynamo_db import AttributeType, DynamoTable 6 | from stelvio.aws.permission import AwsPermission 7 | 8 | from ..pulumi_mocks import ACCOUNT_ID, DEFAULT_REGION, PulumiTestMocks, tn 9 | 10 | TABLE_ARN_TEMPLATE = f"arn:aws:dynamodb:{DEFAULT_REGION}:{ACCOUNT_ID}:table/{{name}}" 11 | 12 | 13 | @pytest.fixture 14 | def pulumi_mocks(): 15 | mocks = PulumiTestMocks() 16 | set_mocks(mocks) 17 | return mocks 18 | 19 | 20 | @pulumi.runtime.test 21 | def test_table_properties(pulumi_mocks): 22 | # Arrange 23 | table = DynamoTable("test-table", fields={"id": AttributeType.STRING}, partition_key="id") 24 | # Act 25 | _ = table.resources 26 | 27 | # Assert 28 | def check_resources(args): 29 | table_id, arn = args 30 | assert table_id == "test-table-test-id" 31 | assert arn == TABLE_ARN_TEMPLATE.format(name=tn("test-table")) 32 | 33 | pulumi.Output.all(table.resources.table.id, table.arn).apply(check_resources) 34 | 35 | 36 | @pulumi.runtime.test 37 | def test_dynamo_table_basic(pulumi_mocks): 38 | # Arrange 39 | table = DynamoTable("test-table", fields={"id": AttributeType.STRING}, partition_key="id") 40 | 41 | # Act 42 | _ = table.resources 43 | 44 | # Assert 45 | def check_resources(_): 46 | tables = pulumi_mocks.created_dynamo_tables("test-table") 47 | assert len(tables) == 1 48 | create_table = tables[0] 49 | assert create_table.inputs["billingMode"] == "PAY_PER_REQUEST" 50 | assert create_table.inputs["attributes"] == [{"type": "S", "name": "id"}] 51 | assert create_table.inputs["hashKey"] == "id" 52 | 53 | table.resources.table.id.apply(check_resources) 54 | 55 | 56 | @pulumi.runtime.test 57 | def test_dynamo_table_partition_key_and_sort_key(pulumi_mocks): 58 | # Arrange 59 | table = DynamoTable( 60 | "test-table", 61 | fields={"category": AttributeType.STRING, "order": AttributeType.NUMBER}, 62 | partition_key="category", 63 | sort_key="order", 64 | ) 65 | 66 | # Act 67 | _ = table.resources 68 | 69 | # Assert 70 | def check_resources(_): 71 | tables = pulumi_mocks.created_dynamo_tables("test-table") 72 | assert len(tables) == 1 73 | create_table = tables[0] 74 | assert create_table.inputs["billingMode"] == "PAY_PER_REQUEST" 75 | assert create_table.inputs["attributes"] == [ 76 | {"type": "S", "name": "category"}, 77 | {"type": "N", "name": "order"}, 78 | ] 79 | assert create_table.inputs["hashKey"] == "category" 80 | assert create_table.inputs["rangeKey"] == "order" 81 | 82 | table.resources.table.id.apply(check_resources) 83 | 84 | 85 | def test_partition_key_not_in_fields(pulumi_mocks): 86 | with pytest.raises(ValueError, match="partition_key 'non_existent_key' not in fields list"): 87 | DynamoTable( 88 | "test-table", fields={"id": AttributeType.STRING}, partition_key="non_existent_key" 89 | ) 90 | 91 | 92 | def test_sort_key_not_in_fields(pulumi_mocks): 93 | with pytest.raises(ValueError, match="sort_key 'non_existent_key' not in fields list"): 94 | DynamoTable( 95 | "test-table", 96 | fields={"id": AttributeType.STRING}, 97 | partition_key="id", 98 | sort_key="non_existent_key", 99 | ) 100 | 101 | 102 | def test_partition_sort_key_properties(pulumi_mocks): 103 | # Arrange 104 | table = DynamoTable( 105 | "test-table", 106 | fields={"id": AttributeType.STRING, "timestamp": AttributeType.NUMBER}, 107 | partition_key="id", 108 | sort_key="timestamp", 109 | ) 110 | 111 | # Assert 112 | assert table.partition_key == "id" 113 | assert table.sort_key == "timestamp" 114 | 115 | 116 | def test_fields_property_immutability(pulumi_mocks): 117 | # Arrange 118 | original_fields = {"id": AttributeType.STRING, "count": AttributeType.NUMBER} 119 | table = DynamoTable("test-table", fields=original_fields, partition_key="id") 120 | 121 | # Act - Attempt to modify the fields after table creation 122 | fields_copy = table.fields 123 | fields_copy["new_field"] = AttributeType.BINARY 124 | 125 | # Assert - Original fields in the table should remain unchanged 126 | assert "new_field" not in table.fields 127 | assert len(table.fields) == 2 128 | assert table.fields == {"id": AttributeType.STRING, "count": AttributeType.NUMBER} 129 | 130 | # Also ensure original dictionary hasn't been modified 131 | assert original_fields == {"id": AttributeType.STRING, "count": AttributeType.NUMBER} 132 | 133 | 134 | @pulumi.runtime.test 135 | def test_dynamo_table_link(pulumi_mocks): 136 | # Arrange 137 | table = DynamoTable("test-table", fields={"id": AttributeType.STRING}, partition_key="id") 138 | 139 | # Create the resource so we have the table output 140 | _ = table.resources 141 | 142 | # Act - Get the link from the table 143 | link = table.link() 144 | 145 | # Assert - Check link properties and permissions 146 | def check_link(args): 147 | properties, permissions = args 148 | 149 | expected_properties = { 150 | "table_name": "test-table-test-name", 151 | "table_arn": TABLE_ARN_TEMPLATE.format(name=tn("test-table")), 152 | } 153 | assert properties == expected_properties 154 | 155 | assert len(permissions) == 1 156 | assert isinstance(permissions[0], AwsPermission) 157 | 158 | expected_actions = [ 159 | "dynamodb:Scan", 160 | "dynamodb:Query", 161 | "dynamodb:GetItem", 162 | "dynamodb:PutItem", 163 | "dynamodb:UpdateItem", 164 | "dynamodb:DeleteItem", 165 | ] 166 | assert sorted(permissions[0].actions) == sorted(expected_actions) 167 | 168 | # For resources which are Pulumi Outputs, we need to use .apply() 169 | def check_resource(resource): 170 | assert resource == TABLE_ARN_TEMPLATE.format(name=tn("test-table")) 171 | 172 | # Check we have exactly 1 resource with the expected ARN 173 | assert len(permissions[0].resources) == 1 174 | permissions[0].resources[0].apply(check_resource) 175 | 176 | # We use Output.all and .apply because Link properties and permissions contain 177 | # Pulumi Output objects (like table.arn) 178 | pulumi.Output.all(link.properties, link.permissions).apply(check_link) 179 | -------------------------------------------------------------------------------- /tests/aws/function/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michal-stlv/stelvio/73f245fd4bb0ae011afec7af21568c4c40f9b1e8/tests/aws/function/__init__.py -------------------------------------------------------------------------------- /tests/aws/function/test_function_config.py: -------------------------------------------------------------------------------- 1 | # Place these imports at the top of your test file if they aren't already there 2 | import re 3 | import typing 4 | from dataclasses import fields 5 | from types import UnionType 6 | from typing import Union, get_args, get_origin, get_type_hints 7 | 8 | import pytest 9 | 10 | from stelvio.aws.function import ( 11 | DEFAULT_ARCHITECTURE, 12 | DEFAULT_RUNTIME, 13 | FunctionConfig, 14 | FunctionConfigDict, 15 | ) 16 | from stelvio.aws.layer import Layer 17 | 18 | NoneType = type(None) 19 | 20 | 21 | def normalize_type(type_hint: type) -> type: 22 | """ 23 | Normalizes a type hint by removing 'NoneType' from its Union representation, 24 | if applicable. Keeps other Union members intact. 25 | 26 | Examples: 27 | Union[str, None] -> str 28 | Union[str, list[str], None] -> Union[str, list[str]] 29 | Union[Literal["a", "b"], None] -> Literal["a", "b"] 30 | str -> str 31 | Union[str, int] -> Union[str, int] 32 | NoneType -> NoneType 33 | Union[NoneType] -> NoneType 34 | """ 35 | origin = get_origin(type_hint) 36 | 37 | if origin is Union or origin is UnionType: 38 | args = get_args(type_hint) 39 | 40 | non_none_args = tuple(arg for arg in args if arg is not NoneType) 41 | 42 | if not non_none_args: 43 | return NoneType 44 | if len(non_none_args) == 1: 45 | return non_none_args[0] 46 | return typing.Union[non_none_args] # noqa: UP007 47 | 48 | return type_hint 49 | 50 | 51 | def test_function_config_dict_has_same_fields_as_function_config(): 52 | """Tests that the FunctionConfigDict matches the FunctionConfig dataclass.""" 53 | # noinspection PyTypeChecker 54 | dataclass_fields = {f.name: f.type for f in fields(FunctionConfig)} 55 | typeddict_fields = get_type_hints(FunctionConfigDict) 56 | assert set(dataclass_fields.keys()) == set(typeddict_fields.keys()), ( 57 | "FunctionConfigDict and FunctionConfig dataclass have different fields." 58 | ) 59 | 60 | for field_name, dataclass_type in dataclass_fields.items(): 61 | if field_name not in typeddict_fields: 62 | continue 63 | 64 | typeddict_type = typeddict_fields[field_name] 65 | 66 | normalized_dataclass_type = normalize_type(dataclass_type) 67 | normalized_typeddict_type = normalize_type(typeddict_type) 68 | 69 | assert normalized_dataclass_type == normalized_typeddict_type, ( 70 | f"Type mismatch for field '{field_name}':\n" 71 | f" Dataclass (original): {dataclass_type}\n" 72 | f" TypedDict (original): {typeddict_type}\n" 73 | f" Dataclass (normalized): {normalized_dataclass_type}\n" 74 | f" TypedDict (normalized): {normalized_typeddict_type}\n" 75 | f" Comparison Failed: {normalized_dataclass_type} != {normalized_typeddict_type}" 76 | ) 77 | 78 | 79 | @pytest.mark.parametrize( 80 | ("handler", "match"), 81 | [ 82 | ( 83 | "missing_dot", 84 | "Handler must contain a dot separator between file path and function name", 85 | ), 86 | ("file.", "Both file path and function name must be non-empty"), 87 | ("file..", "Both file path and function name must be non-empty"), 88 | (".function", "Both file path and function name must be non-empty"), 89 | ("file..function", "File path part should not contain dots"), 90 | ("two::doublecolon::separators", "Handler can only contain one :: separator"), 91 | ("one.two::file.function", "Folder path should not contain dots"), 92 | ], 93 | ) 94 | def test_function_config_invalid_handler_format(handler, match): 95 | with pytest.raises(ValueError, match=match): 96 | FunctionConfig(handler=handler) 97 | 98 | 99 | def test_function_config_folder_handler_conflict(): 100 | with pytest.raises(ValueError, match="Cannot specify both 'folder' and use '::' in handler"): 101 | FunctionConfig(handler="folder::file.function", folder="another_folder") 102 | 103 | 104 | def test_function_config_invalid_folder_path(): 105 | with pytest.raises(ValueError, match="Folder path should not contain dots"): 106 | FunctionConfig(handler="file.function", folder="path.with.dots") 107 | 108 | 109 | @pytest.mark.parametrize( 110 | ("handler", "folder", "expected_folder_path"), 111 | [ 112 | ("file.function", None, None), 113 | ("file.function", "my_folder", "my_folder"), 114 | ("file.function", "my_folder/subfolder", "my_folder/subfolder"), 115 | ("my_folder::file.function", None, "my_folder"), 116 | ("my_folder/subfolder::file.function", None, "my_folder/subfolder"), 117 | ("path/to/folder::file.function", None, "path/to/folder"), 118 | ], 119 | ) 120 | def test_function_config_folder_path(handler, folder, expected_folder_path): 121 | """Tests that the folder_path property returns the correct value.""" 122 | if folder and "::" in handler: 123 | pytest.skip("This combination would raise a validation error") 124 | 125 | config = FunctionConfig(handler=handler, folder=folder) 126 | assert config.folder_path == expected_folder_path, ( 127 | f"folder_path incorrect for handler='{handler}', folder='{folder}'. " 128 | f"Expected '{expected_folder_path}', got '{config.folder_path}'" 129 | ) 130 | 131 | 132 | @pytest.mark.parametrize( 133 | ("handler", "folder"), 134 | [ 135 | ("file.function", None), 136 | ("folder::file.function", None), 137 | ("folder/subfolder::file.function", None), 138 | ("file.function", "my_folder"), 139 | ("file.function", "my_folder/and_subfolder"), 140 | ("subfolder/file.function", "my_folder"), 141 | ("sub_subfolder/file.function", "my_folder/subfolder"), 142 | ], 143 | ) 144 | def test_function_config_valid_config(handler, folder): 145 | FunctionConfig(handler=handler, folder=folder) 146 | 147 | 148 | @pytest.mark.parametrize( 149 | ("handler", "expected_file_path"), 150 | [ 151 | ("file.function", "file"), 152 | ("folder::file.function", "file"), 153 | ("path/to/file.function", "path/to/file"), 154 | ], 155 | ) 156 | def test_function_config_handler_file_path(handler, expected_file_path): 157 | config = FunctionConfig(handler=handler) 158 | assert config.handler_file_path == expected_file_path 159 | 160 | 161 | @pytest.mark.parametrize( 162 | ("handler", "expected_function_name"), 163 | [("file.function", "function"), ("folder::file.function", "function")], 164 | ) 165 | def test_function_config_handler_function_name(handler, expected_function_name): 166 | config = FunctionConfig(handler=handler) 167 | assert config.handler_function_name == expected_function_name 168 | 169 | 170 | @pytest.mark.parametrize( 171 | ("handler", "expected_handler_format"), 172 | [ 173 | ("file.function", "file.function"), 174 | ("folder/file.function", "file.function"), 175 | ("folder::file.function", "file.function"), 176 | ("folder::subfolder/file.function", "subfolder/file.function"), 177 | ], 178 | ) 179 | def test_function_config_handler_format(handler, expected_handler_format): 180 | config = FunctionConfig(handler=handler) 181 | assert config.handler_format == expected_handler_format 182 | 183 | 184 | def test_function_config_default_values(): 185 | config = FunctionConfig(handler="file.function") 186 | assert config.memory is None 187 | assert config.timeout is None 188 | 189 | 190 | def test_function_config_immutability(): 191 | config = FunctionConfig(handler="file.function") 192 | with pytest.raises(AttributeError): 193 | # noinspection PyDataclass 194 | config.handler = "another.function" 195 | 196 | 197 | def create_layer(name=None, runtime=None, arch=None): 198 | return Layer( 199 | name=name or "mock-layer-1", 200 | requirements=["dummy-req"], 201 | runtime=runtime or DEFAULT_RUNTIME, 202 | architecture=arch or DEFAULT_ARCHITECTURE, 203 | ) 204 | 205 | 206 | @pytest.mark.parametrize( 207 | ("test_id", "layers_input_generator", "error_type", "error_match", "opts"), # Use tuple 208 | [ 209 | ( 210 | "invalid_type_in_list", 211 | lambda: [create_layer(), "not-a-layer"], 212 | TypeError, 213 | "Item at index 1 in 'layers' list is not a Layer instance. Got str.", 214 | None, 215 | ), 216 | ( 217 | "not_a_list", 218 | lambda: create_layer(), 219 | TypeError, 220 | "Expected 'layers' to be a list of Layer objects, but got Layer.", 221 | None, 222 | ), 223 | ( 224 | "duplicate_names", 225 | # Use one layer twice 226 | lambda: [create_layer()] * 2, 227 | ValueError, 228 | "Duplicate layer names found: mock-layer-1. " 229 | "Layer names must be unique for a function.", 230 | None, 231 | ), 232 | ( 233 | "too_many_layers", 234 | # Generate 6 layers 235 | lambda: [Layer(name=f"l{i}", requirements=["req"]) for i in range(6)], 236 | ValueError, 237 | "A function cannot have more than 5 layers. Found 6.", 238 | None, 239 | ), 240 | ( 241 | "incompatible_runtime", 242 | lambda: [create_layer(name="mock-layer-py313", runtime="python3.13")], 243 | ValueError, 244 | f"Function runtime '{DEFAULT_RUNTIME}' is incompatible with " 245 | f"Layer 'mock-layer-py313' runtime 'python3.13'.", 246 | None, 247 | ), 248 | ( 249 | "incompatible_architecture", 250 | lambda: [create_layer(name="mock-layer-arm", arch="arm64")], 251 | ValueError, 252 | f"Function architecture '{DEFAULT_ARCHITECTURE}' is incompatible with " 253 | f"Layer 'mock-layer-arm' architecture 'arm64'.", 254 | None, 255 | ), 256 | ( 257 | "incompatible_architecture_explicit_func", 258 | lambda: [create_layer()], 259 | ValueError, 260 | "Function architecture 'arm64' is incompatible with " 261 | "Layer 'mock-layer-1' architecture 'x86_64'.", 262 | {"architecture": "arm64"}, 263 | ), 264 | ( 265 | "incompatible_runtime_explicit_func", 266 | lambda: [create_layer()], 267 | ValueError, 268 | "Function runtime 'python3.13' is incompatible with " 269 | "Layer 'mock-layer-1' runtime 'python3.12'.", 270 | {"runtime": "python3.13"}, 271 | ), 272 | ], 273 | ids=lambda x: x if isinstance(x, str) else "", 274 | ) 275 | def test_function_layer_validation(test_id, layers_input_generator, error_type, error_match, opts): 276 | with pytest.raises(error_type, match=error_match): 277 | FunctionConfig( 278 | handler="functions/simple.handler", layers=layers_input_generator(), **opts or {} 279 | ) 280 | 281 | 282 | @pytest.mark.parametrize( 283 | ("opts", "error_type", "error_match"), 284 | [ 285 | ( 286 | {"requirements": [1, True]}, 287 | TypeError, 288 | "If 'requirements' is a list, all its elements must be strings.", 289 | ), 290 | ( 291 | {"requirements": {}}, 292 | TypeError, 293 | re.escape("'requirements' must be a string (path), list of strings, False, or None."), 294 | ), 295 | ( 296 | {"requirements": True}, 297 | ValueError, 298 | re.escape( 299 | "If 'requirements' is a boolean, it must be False (to disable). " 300 | "True is not allowed." 301 | ), 302 | ), 303 | ], 304 | ids=[ 305 | "list_not_strings", 306 | "not_list_or_str_or_false_or_none", 307 | "true_not_allowed_if_bool", 308 | ], 309 | ) 310 | def test_function_config_raises_when_requirements___(opts, error_type, error_match): 311 | # Act & Assert 312 | with pytest.raises(error_type, match=error_match): 313 | FunctionConfig(handler="functions/simple.handler", **opts) 314 | -------------------------------------------------------------------------------- /tests/aws/function/test_function_init.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | 5 | from stelvio.aws.function import Function, FunctionConfig 6 | 7 | 8 | @pytest.mark.parametrize( 9 | ("name", "config", "opts", "expected_error"), 10 | [ 11 | ( 12 | "my_function", 13 | None, 14 | {}, 15 | "Missing function handler: must provide either a complete configuration " 16 | "via 'config' parameter or at least the 'handler' option", 17 | ), 18 | ( 19 | "my_function", 20 | {"handler": "functions/handler.main"}, 21 | {"handler": "functions/handler.main"}, 22 | "Invalid configuration: cannot combine 'config' parameter with additional " 23 | "options - provide all settings either in 'config' or as separate options", 24 | ), 25 | ( 26 | "my_function", 27 | {"handler": "functions/handler.main"}, 28 | {"memory": 256}, 29 | "Invalid configuration: cannot combine 'config' parameter with additional " 30 | "options - provide all settings either in 'config' or as separate options", 31 | ), 32 | ( 33 | "my_function", 34 | {"folder": "functions/handler"}, 35 | {"memory": 256}, 36 | "Invalid configuration: cannot combine 'config' parameter with additional " 37 | "options - provide all settings either in 'config' or as separate options", 38 | ), 39 | ], 40 | ) 41 | def test_invalid_config(name: str, config: Any, opts: dict, expected_error: str): 42 | """Test that Function raises ValueError with invalid configurations.""" 43 | with pytest.raises(ValueError, match=expected_error): 44 | Function(name, config=config, **opts) 45 | 46 | 47 | @pytest.mark.parametrize( 48 | ("name", "config", "opts", "wrong_type"), 49 | [ 50 | ("my_function", 123, {}, "int"), 51 | ("my_function", "hello", {}, "str"), 52 | ("my_function", [4, 5, 6], {}, "list"), 53 | ], 54 | ) 55 | def test_invalid_config_type_error(name: str, config: Any, opts: dict, wrong_type: str): 56 | """Test that Function raises ValueError with invalid configurations.""" 57 | with pytest.raises( 58 | TypeError, match=f"Invalid config type: expected FunctionConfig or dict, got {wrong_type}" 59 | ): 60 | Function(name, config=config, **opts) 61 | 62 | 63 | def test_function_config_property(): 64 | """Test that the config property returns the correct FunctionConfig object.""" 65 | config = {"handler": "functions/handler.main", "memory": 128} 66 | function = Function("my_function", config=config) 67 | assert isinstance(function.config, FunctionConfig) 68 | assert function.config.handler == "functions/handler.main" 69 | assert function.config.memory == 128 70 | 71 | 72 | @pytest.mark.parametrize( 73 | ("name", "config", "opts"), 74 | [ 75 | ("my_function", {"handler": "folder::handler.main"}, {}), 76 | ("my_function", None, {"handler": "folder::handler.main"}), 77 | ("my_function", FunctionConfig(handler="folder::handler.main"), {}), 78 | ], 79 | ) 80 | def test_valid_folder_config(name: str, config: Any, opts: dict): 81 | """Test that Function initializes correctly with valid folder-based configurations.""" 82 | try: 83 | Function(name, config=config, **opts) 84 | except Exception as e: 85 | pytest.fail(f"Function initialization failed with valid config: {e}") 86 | 87 | 88 | @pytest.mark.parametrize( 89 | ("name", "config", "opts", "expected_error"), 90 | [ 91 | ( 92 | "my_function", 93 | {"handler": "invalid.handler.format"}, 94 | {}, 95 | "File path part should not contain dots", 96 | ), 97 | ( 98 | "my_function", 99 | {"handler": "handler."}, 100 | {}, 101 | "Both file path and function name must be non-empty", 102 | ), 103 | ( 104 | "my_function", 105 | {"handler": ".handler"}, 106 | {}, 107 | "Both file path and function name must be non-empty", 108 | ), 109 | ], 110 | ) 111 | def test_invalid_handler_format(name: str, config: Any, opts: dict, expected_error: str): 112 | """Test that Function raises ValueError with invalid handler formats. 113 | This is already thoroughly tested in FunctionConfig tests, there we just do simple 114 | sanity tests. 115 | """ 116 | with pytest.raises(ValueError, match=expected_error): 117 | Function(name, config=config, **opts) 118 | -------------------------------------------------------------------------------- /tests/aws/pulumi_mocks.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | 4 | from pulumi.runtime import MockCallArgs, MockResourceArgs, Mocks 5 | 6 | ROOT_RESOURCE_ID = "root-resource-id" 7 | DEFAULT_REGION = "us-east-1" 8 | ACCOUNT_ID = "123456789012" 9 | TEST_USER = "test-user" 10 | SAMPLE_API_ID = "12345abcde" 11 | 12 | 13 | # test id 14 | def tid(name: str) -> str: 15 | return name + "-test-id" 16 | 17 | 18 | # test name 19 | def tn(name: str) -> str: 20 | return name + "-test-name" 21 | 22 | 23 | class PulumiTestMocks(Mocks): 24 | """Base Pulumi test mocks for all AWS resource testing.""" 25 | 26 | def __init__(self): 27 | super().__init__() 28 | self.created_resources: list[MockResourceArgs] = [] 29 | 30 | def new_resource(self, args: MockResourceArgs) -> tuple[str, dict[str, Any]]: 31 | # print(f"NEW RESOURCE: {args.name} -- {args.typ} -- {args.inputs}\n") 32 | 33 | self.created_resources.append(args) 34 | resource_id = tid(args.name) 35 | name = tn(args.name) 36 | output_props = args.inputs | {"name": name} 37 | 38 | region = DEFAULT_REGION 39 | account_id = ACCOUNT_ID 40 | 41 | # Lambda resources 42 | if args.typ == "aws:lambda/function:Function": 43 | arn = f"arn:aws:lambda:{region}:{account_id}:function:{name}" 44 | output_props["arn"] = arn 45 | output_props["invoke_arn"] = ( 46 | f"arn:aws:apigateway:{region}:lambda:path/2015-03-31/functions/{arn}/invocations" 47 | ) 48 | # IAM resources 49 | elif args.typ == "aws:iam/role:Role": 50 | output_props["arn"] = f"arn:aws:iam::{account_id}:role/{name}" 51 | elif args.typ == "aws:iam/policy:Policy": 52 | output_props["arn"] = f"arn:aws:iam::{account_id}:policy/{name}" 53 | # API Gateway resources 54 | elif args.typ == "aws:apigateway/restApi:RestApi": 55 | output_props["arn"] = f"arn:aws:apigateway:us-east-1::/restapis/{SAMPLE_API_ID}" 56 | output_props["execution_arn"] = ( 57 | f"arn:aws:execute-api:{region}:{account_id}:{SAMPLE_API_ID}" 58 | ) 59 | output_props["root_resource_id"] = ROOT_RESOURCE_ID 60 | elif args.typ == "aws:apigateway/stage:Stage": 61 | output_props["invokeUrl"] = ( 62 | f"https://{args.inputs['restApi']}.execute-api.{region}.amazonaws.com/{args.inputs['stageName']}" 63 | ) 64 | elif args.typ == "aws:apigateway/resource:Resource": 65 | output_props["id"] = f"resource-{args.name}" 66 | elif args.typ == "aws:apigateway/account:Account": 67 | ... 68 | elif args.typ == "aws:dynamodb/table:Table": 69 | output_props["arn"] = f"arn:aws:dynamodb:{region}:{account_id}:table/{name}" 70 | # LayerVersion resource 71 | elif args.typ == "aws:lambda/layerVersion:LayerVersion": 72 | # LayerVersion ARN includes the name and version number (mocked as 1) 73 | output_props["arn"] = f"arn:aws:lambda:{region}:{account_id}:layer:{name}:1" 74 | output_props["layer_arn"] = f"arn:aws:lambda:{region}:{account_id}:layer:{name}" 75 | output_props["version"] = "1" 76 | 77 | return resource_id, output_props 78 | 79 | def call(self, args: MockCallArgs) -> tuple[dict, list[tuple[str, str]] | None]: 80 | # print(f"CALL: {args.token} {args.args}\n") 81 | if args.token == "aws:iam/getPolicyDocument:getPolicyDocument": # noqa: S105 82 | statements_str = json.dumps(args.args["statements"]) 83 | return {"json": statements_str}, [] 84 | if args.token == "aws:index/getCallerIdentity:getCallerIdentity": # noqa: S105 85 | return { 86 | "accountId": ACCOUNT_ID, 87 | "arn": f"arn:aws:iam::{ACCOUNT_ID}:user/{TEST_USER}", 88 | "userId": f"{TEST_USER}-id", 89 | }, [] 90 | if args.token == "aws:index/getRegion:getRegion": # noqa: S105 91 | return {"name": "us-east-1", "description": "US East (N. Virginia)"}, [] 92 | 93 | return {}, [] 94 | 95 | def _filter_created(self, typ: str, name: str | None = None) -> list[MockResourceArgs]: 96 | return [r for r in self.created_resources if r.typ == typ and (not name or r.name == name)] 97 | 98 | # Lambda resource helpers 99 | def created_functions(self, name: str | None = None) -> list[MockResourceArgs]: 100 | return self._filter_created("aws:lambda/function:Function", name) 101 | 102 | def created_role_policy_attachments(self, name: str | None = None) -> list[MockResourceArgs]: 103 | return self._filter_created("aws:iam/rolePolicyAttachment:RolePolicyAttachment", name) 104 | 105 | def created_roles(self, name: str | None = None) -> list[MockResourceArgs]: 106 | return self._filter_created("aws:iam/role:Role", name) 107 | 108 | def created_policies(self, name: str | None = None) -> list[MockResourceArgs]: 109 | return self._filter_created("aws:iam/policy:Policy", name) 110 | 111 | # API Gateway resource helpers 112 | def created_rest_apis(self, name: str | None = None) -> list[MockResourceArgs]: 113 | return self._filter_created("aws:apigateway/restApi:RestApi", name) 114 | 115 | def created_api_resources(self, name: str | None = None) -> list[MockResourceArgs]: 116 | return self._filter_created("aws:apigateway/resource:Resource", name) 117 | 118 | def created_methods(self, name: str | None = None) -> list[MockResourceArgs]: 119 | return self._filter_created("aws:apigateway/method:Method", name) 120 | 121 | def created_integrations(self, name: str | None = None) -> list[MockResourceArgs]: 122 | return self._filter_created("aws:apigateway/integration:Integration", name) 123 | 124 | def created_deployments(self, name: str | None = None) -> list[MockResourceArgs]: 125 | return self._filter_created("aws:apigateway/deployment:Deployment", name) 126 | 127 | def created_stages(self, name: str | None = None) -> list[MockResourceArgs]: 128 | return self._filter_created("aws:apigateway/stage:Stage", name) 129 | 130 | def created_permissions(self, name: str | None = None) -> list[MockResourceArgs]: 131 | return self._filter_created("aws:lambda/permission:Permission", name) 132 | 133 | def created_api_accounts(self, name: str | None = None) -> list[MockResourceArgs]: 134 | return self._filter_created("aws:apigateway/account:Account", name) 135 | 136 | def created_dynamo_tables(self, name: str | None = None) -> list[MockResourceArgs]: 137 | return self._filter_created("aws:dynamodb/table:Table", name) 138 | 139 | # Layer resource helper 140 | def created_layer_versions(self, name: str | None = None) -> list[MockResourceArgs]: 141 | return self._filter_created("aws:lambda/layerVersion:LayerVersion", name) 142 | -------------------------------------------------------------------------------- /tests/aws/sample_test_project/functions/folder/handler.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michal-stlv/stelvio/73f245fd4bb0ae011afec7af21568c4c40f9b1e8/tests/aws/sample_test_project/functions/folder/handler.py -------------------------------------------------------------------------------- /tests/aws/sample_test_project/functions/folder/handler2.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michal-stlv/stelvio/73f245fd4bb0ae011afec7af21568c4c40f9b1e8/tests/aws/sample_test_project/functions/folder/handler2.py -------------------------------------------------------------------------------- /tests/aws/sample_test_project/functions/folder2/handler.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michal-stlv/stelvio/73f245fd4bb0ae011afec7af21568c4c40f9b1e8/tests/aws/sample_test_project/functions/folder2/handler.py -------------------------------------------------------------------------------- /tests/aws/sample_test_project/functions/orders.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michal-stlv/stelvio/73f245fd4bb0ae011afec7af21568c4c40f9b1e8/tests/aws/sample_test_project/functions/orders.py -------------------------------------------------------------------------------- /tests/aws/sample_test_project/functions/simple.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michal-stlv/stelvio/73f245fd4bb0ae011afec7af21568c4c40f9b1e8/tests/aws/sample_test_project/functions/simple.py -------------------------------------------------------------------------------- /tests/aws/sample_test_project/functions/simple2.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michal-stlv/stelvio/73f245fd4bb0ae011afec7af21568c4c40f9b1e8/tests/aws/sample_test_project/functions/simple2.py -------------------------------------------------------------------------------- /tests/aws/sample_test_project/functions/users.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michal-stlv/stelvio/73f245fd4bb0ae011afec7af21568c4c40f9b1e8/tests/aws/sample_test_project/functions/users.py -------------------------------------------------------------------------------- /tests/aws/sample_test_project/stlv_app.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michal-stlv/stelvio/73f245fd4bb0ae011afec7af21568c4c40f9b1e8/tests/aws/sample_test_project/stlv_app.py -------------------------------------------------------------------------------- /tests/aws/test_layer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import shutil 4 | from pathlib import Path 5 | from unittest.mock import patch 6 | 7 | import pulumi 8 | import pytest 9 | from pulumi import AssetArchive, FileArchive 10 | from pulumi.runtime import set_mocks 11 | 12 | from stelvio.aws._packaging.dependencies import RequirementsSpec 13 | from stelvio.aws.function.constants import DEFAULT_ARCHITECTURE, DEFAULT_RUNTIME 14 | from stelvio.aws.layer import _LAYER_CACHE_SUBDIR, Layer 15 | 16 | from .pulumi_mocks import PulumiTestMocks 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | @pytest.fixture 22 | def pulumi_mocks(): 23 | mocks = PulumiTestMocks() 24 | set_mocks(mocks) 25 | return mocks 26 | 27 | 28 | @pytest.fixture 29 | def project_cwd(monkeypatch, pytestconfig, tmp_path): 30 | rootpath = pytestconfig.rootpath 31 | source_project_dir = rootpath / "tests" / "aws" / "sample_test_project" 32 | temp_project_dir = tmp_path / "sample_project_copy" 33 | 34 | shutil.copytree(source_project_dir, temp_project_dir, dirs_exist_ok=True) 35 | monkeypatch.chdir(temp_project_dir) 36 | 37 | with patch("stelvio.aws.layer.get_project_root", return_value=temp_project_dir): 38 | yield temp_project_dir 39 | 40 | 41 | @pytest.fixture 42 | def mock_cache_fs(tmp_path, monkeypatch): 43 | dot_stelvio = tmp_path / ".stelvio" 44 | layer_cache_base = dot_stelvio / "lambda_dependencies" / _LAYER_CACHE_SUBDIR 45 | layer_cache_base.mkdir(parents=True, exist_ok=True) 46 | 47 | monkeypatch.setattr( 48 | "stelvio.aws._packaging.dependencies._get_lambda_dependencies_dir", 49 | lambda subdir: dot_stelvio / "lambda_dependencies" / subdir, 50 | ) 51 | monkeypatch.setattr("stelvio.project.get_dot_stelvio_dir", lambda: dot_stelvio) 52 | 53 | return layer_cache_base 54 | 55 | 56 | @pytest.mark.parametrize( 57 | ("code", "requirements", "arch", "runtime"), 58 | [ 59 | ("src/my_layer_code", None, None, None), 60 | (None, ["requests", "boto3"], None, None), 61 | (None, "src/layer_requirements.txt", None, None), 62 | ("src/my_layer_code", ["requests", "boto3"], None, None), 63 | ("src/my_layer_code", "src/layer_requirements.txt", "python3.13", "arm64"), 64 | ], 65 | ids=[ 66 | "code_only", 67 | "requirements_only_as_list", 68 | "requirements_only_as_file", 69 | "code_and_requirements_as_list", 70 | "code_and_requirements_as_file_custom_runtime_and_arch", 71 | ], 72 | ) 73 | @pulumi.runtime.test 74 | def test_layer_with__( # noqa: PLR0913 75 | pulumi_mocks, 76 | project_cwd, 77 | mock_cache_fs, 78 | mock_get_or_install_dependencies_layer, 79 | code, 80 | requirements, 81 | arch, 82 | runtime, 83 | ): 84 | # Arrange 85 | layer_name = "my-layer" 86 | if isinstance(requirements, str): 87 | requirements_abs = project_cwd / requirements 88 | requirements_abs.parent.mkdir(parents=True, exist_ok=True) 89 | requirements_abs.touch() 90 | if code: 91 | (project_cwd / code).mkdir(parents=True, exist_ok=True) 92 | 93 | # Act 94 | layer = Layer( 95 | layer_name, code=code, requirements=requirements, runtime=runtime, architecture=arch 96 | ) 97 | 98 | # Assert 99 | def check_resources(_): 100 | layer_versions = pulumi_mocks.created_layer_versions(layer_name) 101 | assert len(layer_versions) == 1 102 | layer_args = layer_versions[0] 103 | assert layer_args.inputs["layerName"] == layer_name 104 | assert layer_args.inputs["compatibleRuntimes"] == [runtime or DEFAULT_RUNTIME] 105 | assert layer_args.inputs["compatibleArchitectures"] == [arch or DEFAULT_ARCHITECTURE] 106 | code_archive: AssetArchive = layer_args.inputs["code"] 107 | assert isinstance(code_archive, AssetArchive) 108 | assert len(code_archive.assets) == bool(code) + bool(requirements) 109 | 110 | # Check code archive 111 | if code: 112 | code_dir_name = Path(code).name 113 | expected_code_key = f"python/{code_dir_name}" 114 | assert expected_code_key in code_archive.assets 115 | code_archive_asset = code_archive.assets[expected_code_key] 116 | assert isinstance(code_archive_asset, FileArchive) 117 | assert code_archive_asset.path == str(project_cwd / code) 118 | 119 | if not requirements: 120 | mock_get_or_install_dependencies_layer.assert_not_called() 121 | return 122 | 123 | mock_get_or_install_dependencies_layer.assert_called_once_with( 124 | requirements_source=RequirementsSpec( 125 | content="\n".join(requirements) if isinstance(requirements, list) else None, 126 | path_from_root=Path(requirements) if isinstance(requirements, str) else None, 127 | ), 128 | runtime=runtime or DEFAULT_RUNTIME, 129 | architecture=arch or DEFAULT_ARCHITECTURE, 130 | project_root=project_cwd, 131 | log_context=f"Layer: {layer_name}", 132 | cache_subdirectory=_LAYER_CACHE_SUBDIR, 133 | ) 134 | 135 | # Check dependencies archive 136 | expected_depencencies_key = f"python/lib/{runtime or DEFAULT_RUNTIME}/site-packages" 137 | assert expected_depencencies_key in code_archive.assets 138 | dependencies_archive_asset = code_archive.assets[expected_depencencies_key] 139 | assert isinstance(dependencies_archive_asset, FileArchive) 140 | assert dependencies_archive_asset.path == str( 141 | mock_get_or_install_dependencies_layer.return_value 142 | ) 143 | 144 | layer.arn.apply(check_resources) 145 | 146 | 147 | @pytest.mark.parametrize( 148 | ("opts", "error_type", "error_match"), 149 | [ 150 | ({}, ValueError, "must specify 'code' and/or 'requirements'"), 151 | ( 152 | {"requirements": [1, True]}, 153 | TypeError, 154 | "If 'requirements' is a list, all its elements must be strings.", 155 | ), 156 | ( 157 | {"requirements": True}, 158 | TypeError, 159 | re.escape("'requirements' must be a string (path), list of strings, or None."), 160 | ), 161 | ({"requirements": "nonexistent.txt"}, FileNotFoundError, "Requirements file not found"), 162 | ({"requirements": "functions"}, ValueError, "Requirements path is not a file"), 163 | ({"code": "functions/simple.py"}, ValueError, "is not a directory"), 164 | ({"code": "src/non-existent-folder"}, ValueError, "is not a directory"), 165 | ({"code": "../outside-folder/"}, ValueError, "which is outside the project root"), 166 | ( 167 | {"requirements": "../outside-folder/file.txt"}, 168 | ValueError, 169 | "which is outside the project root", 170 | ), 171 | ], 172 | ids=[ 173 | "no_code_or_requirements", 174 | "requirements_list_not_strings", 175 | "requirements_not_list_or_str_or_none", 176 | "requirements_path_does_not_exist", 177 | "requirements_path_is_folder", 178 | "code_path_is_not_a_folder", 179 | "code_path_does_not_exist", 180 | "code_path_outside_of_project_root", 181 | "requirements_path_outside_of_project_root", 182 | ], 183 | ) 184 | def test_layer_raises_when__(project_cwd, opts, error_type, error_match): 185 | # Arrange 186 | outside_folder = project_cwd / "../outside-folder" 187 | outside_folder.mkdir(parents=True) 188 | outside_file = outside_folder / "file.txt" 189 | outside_file.touch() 190 | # Act & Assert 191 | with pytest.raises(error_type, match=error_match): 192 | _ = Layer(name="my-layer", **opts).resources 193 | -------------------------------------------------------------------------------- /tests/aws/test_permission.py: -------------------------------------------------------------------------------- 1 | from stelvio.aws.permission import AwsPermission 2 | 3 | 4 | def test_init_with_simple_values(): 5 | actions = ["s3:GetObject", "s3:PutObject"] 6 | resources = ["arn:aws:s3:::my-bucket/*"] 7 | 8 | permission = AwsPermission(actions=actions, resources=resources) 9 | 10 | assert permission.actions == actions 11 | assert permission.resources == resources 12 | 13 | 14 | def test_init_with_single_string(): 15 | permission = AwsPermission( 16 | actions="dynamodb:GetItem", 17 | resources="arn:aws:dynamodb:us-east-1:123456789012:table/my-table", 18 | ) 19 | 20 | assert permission.actions == "dynamodb:GetItem" 21 | assert permission.resources == "arn:aws:dynamodb:us-east-1:123456789012:table/my-table" 22 | 23 | 24 | def test_to_provider_format(): 25 | actions = ["dynamodb:GetItem", "dynamodb:PutItem"] 26 | resources = ["arn:aws:dynamodb:us-east-1:123456789012:table/my-table"] 27 | 28 | permission = AwsPermission(actions=actions, resources=resources) 29 | provider_format = permission.to_provider_format() 30 | 31 | # We can't check the type directly since GetPolicyDocumentStatementArgsDict is a TypedDict 32 | # Instead check that it's a dict with the expected structure 33 | assert isinstance(provider_format, dict) 34 | 35 | # Check that values are passed through correctly 36 | assert provider_format["actions"] == actions 37 | assert provider_format["resources"] == resources 38 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from pathlib import Path 3 | from unittest.mock import patch 4 | 5 | import pytest 6 | 7 | from stelvio.aws.function import FunctionAssetsRegistry, LinkPropertiesRegistry 8 | from stelvio.component import ComponentRegistry 9 | 10 | 11 | @pytest.fixture(autouse=True) 12 | def clean_registries(): 13 | LinkPropertiesRegistry._folder_links_properties_map.clear() 14 | ComponentRegistry._instances.clear() 15 | ComponentRegistry._registered_names.clear() 16 | ComponentRegistry._user_link_creators.clear() 17 | FunctionAssetsRegistry._functions_assets_map.clear() 18 | 19 | 20 | def mock_get_or_install_dependencies(path: str): 21 | with patch(path) as mock_ensure: 22 | # Simulate get_or_install_dependencies returning a valid cache path 23 | # Use a unique path per test potentially, or ensure cleanup 24 | mock_cache_path = Path("mock_cache_dir_for_fixture").resolve() 25 | mock_cache_path.mkdir(parents=True, exist_ok=True) 26 | # Add a dummy file to simulate non-empty cache after install 27 | (mock_cache_path / "dummy_installed_package").touch() 28 | mock_ensure.return_value = mock_cache_path 29 | yield mock_ensure 30 | # Clean up the dummy cache dir after test 31 | shutil.rmtree(mock_cache_path, ignore_errors=True) 32 | 33 | 34 | @pytest.fixture 35 | def mock_get_or_install_dependencies_layer(): 36 | yield from mock_get_or_install_dependencies("stelvio.aws.layer.get_or_install_dependencies") 37 | 38 | 39 | @pytest.fixture 40 | def mock_get_or_install_dependencies_function(): 41 | yield from mock_get_or_install_dependencies( 42 | "stelvio.aws.function.dependencies.get_or_install_dependencies" 43 | ) 44 | -------------------------------------------------------------------------------- /tests/test_component.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import pytest 4 | 5 | from stelvio.component import Component, ComponentRegistry, link_config_creator 6 | from stelvio.link import LinkConfig 7 | 8 | 9 | # Mock Pulumi resource for testing 10 | class MockResource: 11 | def __init__(self, name="test-resource"): 12 | self.name = name 13 | self.id = f"{name}-id" 14 | 15 | 16 | @dataclass(frozen=True) 17 | class MockComponentResources: 18 | mock_resource: MockResource 19 | 20 | 21 | # Concrete implementation of Component for testing 22 | class MockComponent(Component[MockComponentResources]): 23 | def __init__(self, name: str, resource: MockResource = None): 24 | super().__init__(name) 25 | self._mock_resource = resource or MockResource(name) 26 | # Track if _create_resource was called 27 | self.create_resources_called = False 28 | 29 | def _create_resources(self) -> MockComponentResources: 30 | self.create_resources_called = True 31 | return MockComponentResources(self._mock_resource) 32 | 33 | 34 | @pytest.fixture 35 | def clear_registry(): 36 | """Clear the component registry before and after tests.""" 37 | # Save old state 38 | old_instances = ComponentRegistry._instances.copy() 39 | old_default_creators = ComponentRegistry._default_link_creators.copy() 40 | old_user_creators = ComponentRegistry._user_link_creators.copy() 41 | 42 | # Clear registries 43 | ComponentRegistry._instances = {} 44 | ComponentRegistry._default_link_creators = {} 45 | ComponentRegistry._user_link_creators = {} 46 | 47 | yield 48 | # We need to do this because otherwise we get: 49 | # Task was destroyed but it is pending! 50 | # task: . 51 | # is_value_known() running at ~/Library/Caches/pypoetry/virtualenvs/ 52 | # stelvio-wXLVHIoC-py3.12/lib/python3.12/site-packages/pulumi/output.py:127> 53 | # wait_for=> 54 | 55 | # Restore old state 56 | ComponentRegistry._instances = old_instances 57 | ComponentRegistry._default_link_creators = old_default_creators 58 | ComponentRegistry._user_link_creators = old_user_creators 59 | 60 | 61 | # Component base class tests 62 | 63 | 64 | def test_component_initialization(clear_registry): 65 | """Test that component is initialized and registered correctly.""" 66 | component = MockComponent("test-component") 67 | 68 | # Verify name property 69 | assert component.name == "test-component" 70 | 71 | # Verify it was added to the registry 72 | assert type(component) in ComponentRegistry._instances 73 | assert component in ComponentRegistry._instances[type(component)] 74 | 75 | 76 | def test_resources_stores_created_resources(clear_registry): 77 | test_resource = MockResource("test-resource") 78 | component = MockComponent("test-component", test_resource) 79 | 80 | # First access - creates the resource 81 | resources1 = component.resources 82 | assert component.create_resources_called 83 | assert resources1.mock_resource is test_resource 84 | 85 | # Reset flag to test caching 86 | component.create_resources_called = False 87 | 88 | # Second access should use cached resource from registry 89 | resources2 = component.resources 90 | assert not component.create_resources_called # Should not call create again 91 | assert resources2.mock_resource is test_resource # Should get same resource 92 | 93 | 94 | # ComponentRegistry tests 95 | 96 | 97 | def test_add_and_get_instance(clear_registry): 98 | """Test adding and retrieving component instances.""" 99 | 100 | # Create multiple components of different types 101 | class ComponentA(MockComponent): 102 | pass 103 | 104 | class ComponentB(MockComponent): 105 | pass 106 | 107 | comp_a1 = ComponentA("a1") 108 | comp_a2 = ComponentA("a2") 109 | comp_b = ComponentB("b") 110 | 111 | # Verify they're in the registry 112 | assert ComponentA in ComponentRegistry._instances 113 | assert len(ComponentRegistry._instances[ComponentA]) == 2 114 | assert comp_a1 in ComponentRegistry._instances[ComponentA] 115 | assert comp_a2 in ComponentRegistry._instances[ComponentA] 116 | 117 | assert ComponentB in ComponentRegistry._instances 118 | assert len(ComponentRegistry._instances[ComponentB]) == 1 119 | assert comp_b in ComponentRegistry._instances[ComponentB] 120 | 121 | 122 | def test_all_instances(clear_registry): 123 | """Test iterating through all component instances.""" 124 | 125 | # Create components of different types 126 | class ComponentA(MockComponent): 127 | pass 128 | 129 | class ComponentB(MockComponent): 130 | pass 131 | 132 | comp_a1 = ComponentA("a1") 133 | comp_a2 = ComponentA("a2") 134 | comp_b = ComponentB("b") 135 | 136 | # Get all instances 137 | all_instances = list(ComponentRegistry.all_instances()) 138 | 139 | # Verify all components are in the list 140 | assert len(all_instances) == 3 141 | assert comp_a1 in all_instances 142 | assert comp_a2 in all_instances 143 | assert comp_b in all_instances 144 | 145 | 146 | def test_link_creator_decorator(clear_registry): 147 | """Test that the decorator correctly registers and wraps the function.""" 148 | 149 | # Define a test function and decorate it 150 | @link_config_creator(MockComponent) 151 | def test_creator(r): 152 | return LinkConfig(properties={"name": r.name}) 153 | 154 | # Get the registered creator 155 | creator = ComponentRegistry.get_link_config_creator(MockComponent) 156 | 157 | # Create a mock resource 158 | resource = MockResource("test") 159 | 160 | # Test the registered function 161 | # noinspection PyTypeChecker 162 | config = creator(resource) 163 | 164 | # Verify it returns expected result 165 | assert isinstance(config, LinkConfig) 166 | assert config.properties == {"name": "test"} 167 | 168 | # Test that the wrapper preserves function metadata 169 | assert creator.__name__ == test_creator.__name__ 170 | -------------------------------------------------------------------------------- /tests/test_link.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pulumi.runtime import set_mocks 3 | 4 | from stelvio.component import Component, ComponentRegistry, link_config_creator 5 | from stelvio.link import Link, Linkable, LinkConfig, Permission 6 | 7 | from .aws.pulumi_mocks import PulumiTestMocks 8 | 9 | 10 | class MockPermission(Permission): 11 | def __init__(self, id_, actions=None, resources=None): 12 | self.id = id_ 13 | self.actions = actions or [] 14 | self.resources = resources or [] 15 | 16 | def to_provider_format(self): 17 | return {"id": self.id, "actions": self.actions, "resources": self.resources} 18 | 19 | def __eq__(self, other): 20 | if not isinstance(other, MockPermission): 21 | return False 22 | return self.id == other.id 23 | 24 | def __hash__(self): 25 | # Make hashable for use in sets 26 | return hash(self.id) 27 | 28 | 29 | class MockResource: 30 | def __init__(self, name="test-resource"): 31 | self.name = name 32 | self.arn = f"arn:aws:mock:::{name}" 33 | 34 | 35 | class MockComponent(Component[MockResource], Linkable): 36 | def __init__(self, name): 37 | super().__init__(name) 38 | self._mock_resource = MockResource(name) 39 | 40 | def _create_resource(self) -> MockResource: 41 | return self._mock_resource 42 | 43 | @property 44 | def _resource(self) -> MockResource: 45 | return self._mock_resource 46 | 47 | def link(self) -> Link: 48 | """Implementation of Linkable protocol.""" 49 | link_creator = ComponentRegistry.get_link_config_creator(type(self)) 50 | if not link_creator: 51 | return Link(self.name, {}, []) 52 | 53 | link_config = link_creator(self._resource) 54 | return Link(self.name, link_config.properties, link_config.permissions) 55 | 56 | 57 | @pytest.fixture 58 | def clear_registry(): 59 | """Clear the component registry before and after tests.""" 60 | # Clear before test 61 | ComponentRegistry._default_link_creators = {} 62 | ComponentRegistry._user_link_creators = {} 63 | 64 | yield 65 | 66 | # Clear after test 67 | ComponentRegistry._default_link_creators = {} 68 | ComponentRegistry._user_link_creators = {} 69 | 70 | 71 | @pytest.fixture 72 | def pulumi_mocks(): 73 | mocks = PulumiTestMocks() 74 | set_mocks(mocks) 75 | return mocks 76 | 77 | 78 | # Link class tests 79 | 80 | 81 | def test_link_properties(): 82 | properties = {"key1": "value1", "key2": "value2"} 83 | permissions = [MockPermission("test-perm")] 84 | 85 | link = Link("test-link", properties, permissions) 86 | 87 | assert link.name == "test-link" 88 | assert link.properties == properties 89 | assert link.permissions == permissions 90 | 91 | 92 | def test_link_method(): 93 | link = Link("test-link", {}, []) 94 | assert link.link() is link 95 | 96 | 97 | def test_with_config(): 98 | link = Link("test-link", {"old": "value"}, [MockPermission("old")]) 99 | 100 | new_props = {"new": "value"} 101 | new_perms = [MockPermission("new")] 102 | 103 | new_link = link.with_config(properties=new_props, permissions=new_perms) 104 | 105 | # Original should be unchanged 106 | assert link.properties == {"old": "value"} 107 | assert len(link.permissions) == 1 108 | assert link.permissions[0].id == "old" 109 | 110 | # New link should have new values 111 | assert new_link.name == "test-link" # Name stays the same 112 | assert new_link.properties == new_props 113 | assert new_link.permissions == new_perms 114 | 115 | 116 | def test_with_properties(): 117 | link = Link("test-link", {"old": "value"}, [MockPermission("perm")]) 118 | 119 | new_link = link.with_properties(new="value", another="prop") 120 | 121 | # Original should be unchanged 122 | assert link.properties == {"old": "value"} 123 | 124 | # New link should have new properties 125 | assert new_link.properties == {"new": "value", "another": "prop"} 126 | assert new_link.permissions == link.permissions # Permissions unchanged 127 | 128 | 129 | def test_with_permissions(): 130 | perm1 = MockPermission("perm1") 131 | link = Link("test-link", {"prop": "value"}, [perm1]) 132 | 133 | perm2 = MockPermission("perm2") 134 | perm3 = MockPermission("perm3") 135 | new_link = link.with_permissions(perm2, perm3) 136 | 137 | # Original should be unchanged 138 | assert len(link.permissions) == 1 139 | assert link.permissions[0].id == "perm1" 140 | 141 | # New link should have new permissions 142 | assert len(new_link.permissions) == 2 143 | assert new_link.permissions[0].id == "perm2" 144 | assert new_link.permissions[1].id == "perm3" 145 | assert new_link.properties == link.properties # Properties unchanged 146 | 147 | 148 | def test_add_properties(): 149 | link = Link("test-link", {"existing": "value"}, []) 150 | 151 | new_link = link.add_properties(new="value", another="prop") 152 | 153 | # Original should be unchanged 154 | assert link.properties == {"existing": "value"} 155 | 156 | # New link should have combined properties 157 | assert new_link.properties == {"existing": "value", "new": "value", "another": "prop"} 158 | 159 | # Test with None properties 160 | link_none = Link("test-link", None, []) 161 | new_link_none = link_none.add_properties(new="value") 162 | assert new_link_none.properties == {"new": "value"} 163 | 164 | 165 | def test_add_permissions(): 166 | perm1 = MockPermission("perm1") 167 | link = Link("test-link", {}, [perm1]) 168 | 169 | perm2 = MockPermission("perm2") 170 | perm3 = MockPermission("perm3") 171 | new_link = link.add_permissions(perm2, perm3) 172 | 173 | # Original should be unchanged 174 | assert len(link.permissions) == 1 175 | assert link.permissions[0].id == "perm1" 176 | 177 | # New link should have combined permissions 178 | assert len(new_link.permissions) == 3 179 | assert new_link.permissions[0].id == "perm1" 180 | assert new_link.permissions[1].id == "perm2" 181 | assert new_link.permissions[2].id == "perm3" 182 | 183 | # Test with None permissions 184 | link_none = Link("test-link", {}, None) 185 | new_link_none = link_none.add_permissions(perm1) 186 | assert len(new_link_none.permissions) == 1 187 | assert new_link_none.permissions[0].id == "perm1" 188 | 189 | 190 | def test_remove_properties(): 191 | link = Link("test-link", {"keep": "value", "remove1": "value", "remove2": "value"}, []) 192 | 193 | new_link = link.remove_properties("remove1", "remove2") 194 | 195 | # Original should be unchanged 196 | assert link.properties == {"keep": "value", "remove1": "value", "remove2": "value"} 197 | 198 | # New link should have filtered properties 199 | assert new_link.properties == {"keep": "value"} 200 | 201 | # Test with None properties 202 | link_none = Link("test-link", None, []) 203 | new_link_none = link_none.remove_properties("anything") 204 | assert new_link_none.properties is None 205 | 206 | # Test removing non-existent properties 207 | link_no_match = Link("test-link", {"keep": "value"}, []) 208 | new_link_no_match = link_no_match.remove_properties("not-there") 209 | assert new_link_no_match.properties == {"keep": "value"} 210 | 211 | 212 | # Link registry tests 213 | 214 | 215 | def test_default_link_creator(clear_registry): 216 | # Define a default link creator 217 | @link_config_creator(MockComponent) 218 | def default_link_creator(resource): 219 | return LinkConfig( 220 | properties={"name": resource.name, "arn": resource.arn}, 221 | permissions=[MockPermission("default")], 222 | ) 223 | 224 | # Get the registered creator 225 | creator = ComponentRegistry.get_link_config_creator(MockComponent) 226 | 227 | # Cannot check identity because decorator returns wrapped function 228 | # Instead test the behavior is as expected 229 | assert creator is not None 230 | 231 | # Test creating a link config 232 | mock_resource = MockResource("test-component") 233 | config = creator(mock_resource) 234 | 235 | assert config.properties == {"name": "test-component", "arn": "arn:aws:mock:::test-component"} 236 | assert len(config.permissions) == 1 237 | assert config.permissions[0].id == "default" 238 | 239 | 240 | def test_user_link_creator_override(clear_registry): 241 | # Define a default link creator 242 | @link_config_creator(MockComponent) 243 | def default_link_creator(resource): 244 | return LinkConfig(properties={"default": "value"}, permissions=[MockPermission("default")]) 245 | 246 | # Define a user link creator 247 | def user_link_creator(resource): 248 | return LinkConfig(properties={"user": "value"}, permissions=[MockPermission("user")]) 249 | 250 | # Register the user creator 251 | ComponentRegistry.register_user_link_creator(MockComponent, user_link_creator) 252 | 253 | # Get the registered creator - should be the user one 254 | creator = ComponentRegistry.get_link_config_creator(MockComponent) 255 | 256 | # Verify it's the user function 257 | assert creator is user_link_creator 258 | 259 | # Test creating a link config 260 | mock_resource = MockResource() 261 | config = creator(mock_resource) 262 | 263 | assert config.properties == {"user": "value"} 264 | assert len(config.permissions) == 1 265 | assert config.permissions[0].id == "user" 266 | --------------------------------------------------------------------------------