├── .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 |
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 |
--------------------------------------------------------------------------------