├── .editorconfig ├── .github └── workflows │ ├── bumper.yaml │ ├── guardian.yaml │ ├── publisher.yaml │ └── releaser.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── pyproject.toml ├── src └── hatch_aws │ ├── __init__.py │ ├── aws.py │ ├── builder.py │ ├── config.py │ └── hooks.py └── tests ├── __init__.py ├── assets ├── pyproject.toml ├── sam-template-3.8.yml ├── sam-template-unsupported-handler.yml └── sam-template.yml ├── conftest.py ├── test_aws.py └── test_builder.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [*.py] 15 | indent_size = 4 16 | 17 | [*.md] 18 | indent_size = 4 19 | -------------------------------------------------------------------------------- /.github/workflows/bumper.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bumper 3 | 4 | on: 5 | push: 6 | branches: 7 | - 'main' 8 | 9 | env: 10 | PYTHONUNBUFFERED: '1' 11 | FORCE_COLOR: '1' 12 | 13 | jobs: 14 | bump: 15 | if: "!startsWith(github.event.head_commit.message, 'bump:')" 16 | runs-on: ubuntu-latest 17 | permissions: 18 | pull-requests: write 19 | contents: write 20 | name: Create PR 21 | steps: 22 | - name: Checkout 🌩️ 23 | uses: actions/checkout@v3 24 | with: 25 | fetch-depth: 0 26 | - name: Set up Python 🐍 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: '3.10' 30 | - name: Install pumper ⛽︎ 31 | run: pip install pumper==0.2.0 32 | - name: Create PR 🥘 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | run: pumper create --gh-env --assign --label bump 36 | - name: Merge PR ✅ 37 | if: env.PR_NUM 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.PUMPER }} 40 | run: | 41 | pumper approve 42 | pumper merge --method rebase 43 | -------------------------------------------------------------------------------- /.github/workflows/guardian.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Guardian 3 | 4 | on: 5 | push: 6 | branches-ignore: 7 | - 'main' 8 | env: 9 | PYTHONUNBUFFERED: '1' 10 | FORCE_COLOR: '1' 11 | 12 | jobs: 13 | quality-gate: 14 | name: > 15 | Python ${{ matrix.python }} on ${{ startsWith(matrix.os, 'macos-') && 'macOS' || 'Linux' }} 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | os: 21 | - ubuntu-latest 22 | - macos-latest 23 | python: ['3.8', '3.9'] 24 | steps: 25 | - name: Checkout 🌩️ 26 | uses: actions/checkout@v3 27 | - name: Set up Python 🐍 28 | uses: actions/setup-python@v4 29 | with: 30 | python-version: ${{ matrix.python }} 31 | - name: Install Hatch 🥚 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install hatch 35 | - name: Static analysis 👀 36 | run: hatch run check 37 | - name: Setup AWS SAM (for testing) 👾 38 | uses: aws-actions/setup-sam@v2 39 | - name: Unit tests 🔍 40 | if: success() || failure() 41 | run: hatch run +py='${{ matrix.python }}' test:unit 42 | -------------------------------------------------------------------------------- /.github/workflows/publisher.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Publisher 3 | 4 | on: 5 | release: 6 | types: 7 | - released 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | name: Publish to PYPI 13 | environment: pypi 14 | steps: 15 | - name: Checkout 🌩️ 16 | uses: actions/checkout@v3 17 | - name: Set up Python 🐍 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: '3.10' 21 | - name: Install Hatch 🥚 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install hatch 25 | - name: Build 🔧 26 | run: hatch build 27 | - name: Publish 🚀 28 | env: 29 | HATCH_INDEX_AUTH: ${{ secrets.INDEX_AUTH }} 30 | HATCH_INDEX_USER: ${{ secrets.INDEX_USER }} 31 | run: hatch publish 32 | -------------------------------------------------------------------------------- /.github/workflows/releaser.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Releaser 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - main 8 | types: 9 | - closed 10 | 11 | jobs: 12 | create: 13 | if: | 14 | github.event.pull_request.merged && 15 | startsWith(github.head_ref, 'release/') 16 | runs-on: ubuntu-latest 17 | name: Create release 18 | permissions: 19 | contents: write 20 | steps: 21 | - name: Checkout 🌩️ 22 | uses: actions/checkout@v3 23 | - name: Get version ✌️ 24 | run: | 25 | VERSION=$(cut -d "/" -f2 <<< ${{ github.event.pull_request.title }}) 26 | echo "VERSION=$VERSION" >> $GITHUB_ENV 27 | - name: Release 🚀 28 | env: 29 | GH_TOKEN: ${{ secrets.RELEASER }} 30 | run: | 31 | gh release create ${{ env.VERSION }} \ 32 | --notes "${{ github.event.pull_request.body }}" \ 33 | --title ${{ env.VERSION }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Don't track content of these folders 2 | *.egg-info/ 3 | __pycache__/ 4 | .pytest_cache/ 5 | .mypy_cache/ 6 | .venv/ 7 | .aws-sam/ 8 | .vscode/ 9 | dist/ 10 | 11 | # Don't track this files 12 | *.pyc 13 | lib64 14 | pip-selfcheck.json 15 | .coverag* 16 | *.log 17 | .DS_Store 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.1.0 (2023-02-25) 2 | 3 | ### Feat 4 | 5 | - add option to set custom path to sam cli 6 | 7 | ## 1.0.1 (2023-02-25) 8 | 9 | ### Fix 10 | 11 | - create requirements.txt parent dir before writing into 12 | 13 | ## 1.0.0 (2023-02-23) 14 | 15 | ### Refactor 16 | 17 | - drop support for python 3.7 18 | - change build process to support hatch config api 19 | - use PEP complient lambda func name in pyproject 20 | - optimize AwsLambda model 21 | 22 | ## 0.2.1 (2023-02-16) 23 | 24 | ### Refactor 25 | 26 | - drop dependency on sam cli 27 | 28 | ## 0.2.0 (2023-01-02) 29 | 30 | ### Feat 31 | 32 | - support lambdas with subset of src 33 | 34 | ## 0.1.1 (2022-08-26) 35 | 36 | ### Refactor 37 | 38 | - make custom exceptions less unique 39 | - build lambda deps independently of sam 40 | 41 | ## 0.1.0 (2022-08-22) 42 | 43 | ### Feat 44 | 45 | - build aws lambdas using sam 46 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 [pj] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | # hatch-aws 5 | 6 | [![PyPI - Version](https://img.shields.io/pypi/v/hatch-aws.svg)](https://pypi.org/project/hatch-aws) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/hatch-aws.svg)](https://pypi.org/project/hatch-aws) [![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch) [![code style - black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![types - mypy](https://img.shields.io/badge/types-Mypy-blue.svg)](https://github.com/python/mypy) [![imports - isort](https://img.shields.io/badge/imports-isort-ef8336.svg)](https://github.com/pycqa/isort) 7 | 8 | AWS builder plugin for **[Hatch 🥚🐍]()**. 9 | *Hatch is modern, extensible Python project manager.* 10 | 11 |
12 | 13 | --- 14 | 15 | Checkout my other plugin [hatch-aws-publisher](https://github.com/aka-raccoon/hatch-aws-publisher). 16 | 17 | ## Table of Contents 18 | 19 | - [hatch-aws](#hatch-aws) 20 | - [Table of Contents](#table-of-contents) 21 | - [Global dependency](#global-dependency) 22 | - [Builder](#builder) 23 | - [How to use it](#how-to-use-it) 24 | - [Options](#options) 25 | - [License](#license) 26 | 27 | ## Global dependency 28 | 29 | Add `hatch-aws` within the `build-system.requires` field in your `pyproject.toml` file. 30 | 31 | ```toml 32 | [build-system] 33 | requires = ["hatchling", "hatch-aws"] 34 | build-backend = "hatchling.build" 35 | ``` 36 | 37 | ## Builder 38 | 39 | The [builder plugin](https://hatch.pypa.io/latest/plugins/builder/reference/) name is called `aws`. 40 | 41 | To start build process, run `hatch build -t aws`: 42 | 43 | ```bash 44 | ❯ hatch build -t aws 45 | [aws] 46 | Building lambda functions ... 47 | MyAwsLambdaFunc ... success 48 | Build successfull 🚀 49 | /path/to/build/.aws-sam/build 50 | ``` 51 | 52 | ### How to use it 53 | 54 | 1. Put your module and lambdas inside of `src` folder. 55 | 56 | ```shell 57 | . 58 | ├── pyproject.toml 59 | ├── src 60 | │ └── my_app 61 | │ ├── __init__.py 62 | │ ├── common 63 | │ │ ├── __init__.py 64 | │ │ ├── config.py 65 | │ │ └── models.py 66 | │ └── lambdas 67 | │ ├── lambda1 68 | │ │ ├── __init__.py 69 | │ │ └── main.py 70 | │ └── lambda2 71 | │ ├── __init__.py 72 | │ └── main.py 73 | └── template.yml 74 | ``` 75 | 76 | 2. Specify common requirements for your project in `pyproject.toml` as dependencies. 77 | 78 | ```toml 79 | [project] 80 | dependencies = ["boto3"] 81 | ``` 82 | 83 | 3. Specify requirements for your lambda functions in `pyproject.toml` as optional dependencies. Use resource name from SAM template, but you have to adapt it to be compliant with [PEP standard](https://peps.python.org/pep-0503/#normalized-names>) (transform to lower case and replace `_` with `-`). For example, if you function name in SAM template is `GetAll_Accounts`, use `getall-accounts`. 84 | 85 | ```toml 86 | [project.optional-dependencies] 87 | lambda1 = ["pyaml"] 88 | lambda2 = ["request", "pydantic"] 89 | ``` 90 | 91 | 4. Specify additional paths(source/destination) you want to copy to the build folder. Destination is relative to a build directory (`.aws-sam/build` by default). You can use glob `*` to copy common to all lambda functions. 92 | 93 | ```toml 94 | [tool.hatch.build.force-include] 95 | "src/batman/common" = "*/batman/common" # copy to all lambda functions 96 | ".editorconfig" = ".editorconfig.txt" 97 | "CHANGELOG.md" = "../CH.txt" 98 | "images/" = "*/images" 99 | ``` 100 | 101 | 5. Set the `CodeUri` and `Handler` parameter pointing to your lambdas in SAM template. Only resources with `Runtime: python{version}` are supported. The rest is ignored. 102 | 103 | ```yaml 104 | Resources: 105 | Lambda1: 106 | Type: AWS::Serverless::Function 107 | Properties: 108 | Runtime: python3.9 109 | FunctionName: lambda1-function 110 | CodeUri: src 111 | Handler: my_app.lambdas.lambda1.main.app 112 | ... 113 | 114 | Lambda2: 115 | Type: AWS::Serverless::Function 116 | Properties: 117 | Runtime: python3.9 118 | FunctionName: lambda2-function 119 | CodeUri: src 120 | Handler: my_app.lambdas.lambda2.main.app 121 | ... 122 | ``` 123 | 124 | ### Options 125 | 126 | Following table contains available customization of builder behavior. You can find example of `pyproject.toml` in [tests/assets/pyproject.toml](https://github.com/aka-raccoon/hatch-aws/blob/main/tests/assets/pyproject.toml). 127 | 128 | | Option | Type | Default | Description | 129 | | ------------ | ------- | -------------- | -------------------------------------------------------- | 130 | | `template` | `str` | `template.yml` | SAM template filename. | 131 | | `use-sam` | `bool` | `false` | Use only `sam build` command without any custom actions. | 132 | | `sam-exec` | `str` | `sam` | Path to `sam` executable. Env var: `HATCH_SAM_EXEC`. | 133 | | `sam-params` | `array` | | Additional `sam build` args. | 134 | 135 | ## License 136 | 137 | Plugin `hatch-aws` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. 138 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "hatch-aws" 7 | version = "1.1.0" 8 | description = 'Hatch plugin for building AWS Lambda functions with SAM' 9 | readme = "README.md" 10 | requires-python = ">=3.8" 11 | license = { file = "LICENSE.txt" } 12 | keywords = ["hatch", "aws", "plugin", "sam", "lambda"] 13 | authors = [{ name = "aka-raccoon", email = "aka-raccoon@pm.me" }] 14 | classifiers = [ 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 3.8", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: Implementation :: CPython", 21 | "Programming Language :: Python :: Implementation :: PyPy", 22 | "Framework :: Hatch", 23 | ] 24 | dependencies = ["hatchling", "PyYAML"] 25 | 26 | [project.optional-dependencies] 27 | dev = ["bandit[toml]", "black", "isort", "mypy", "pylint", "types-PyYAML"] 28 | test = ["coverage", "pytest", "pytest-cov", "pytest-mock", "tomli_w"] 29 | 30 | [project.entry-points.hatch] 31 | aws = "hatch_aws.hooks" 32 | 33 | [tool.hatch.build] 34 | sources = ["src"] 35 | only-packages = true 36 | 37 | [project.urls] 38 | Documentation = "https://github.com/aka-raccoon/hatch-aws#readme" 39 | Issues = "https://github.com/aka-raccoon/hatch-aws/issues" 40 | Source = "https://github.com/aka-raccoon/hatch-aws" 41 | 42 | [tool.coverage.run] 43 | branch = true 44 | parallel = true 45 | source = ["hatch_aws"] 46 | omit = ["src/hatch_aws/hooks.py"] 47 | 48 | [tool.coverage.report] 49 | show_missing = true 50 | skip_covered = false 51 | fail_under = 70 52 | 53 | [tool.hatch.envs.default] 54 | features = ["dev", "test"] 55 | 56 | [tool.hatch.envs.default.scripts] 57 | check = [ 58 | "pylint $SRC", 59 | "black --check --diff $SRC", 60 | "isort --check-only --diff $SRC", 61 | "mypy $SRC", 62 | "bandit -r --configfile pyproject.toml $SRC", 63 | ] 64 | fix = ["black $SRC", "isort $SRC"] 65 | 66 | [tool.hatch.envs.default.env-vars] 67 | SRC = "src/" 68 | 69 | [tool.hatch.envs.test] 70 | features = ["test"] 71 | 72 | [tool.hatch.envs.test.scripts] 73 | unit = "pytest --cov {args}" 74 | 75 | [[tool.hatch.envs.test.matrix]] 76 | python = ["3.8", "3.9"] 77 | 78 | [tool.black] 79 | line-length = 100 80 | 81 | [tool.isort] 82 | profile = "black" 83 | 84 | [tool.pylint.messages_control] 85 | disable = [ 86 | "missing-module-docstring", 87 | "missing-class-docstring", 88 | "missing-function-docstring", 89 | ] 90 | 91 | [tool.pylint.format] 92 | max-line-length = 100 93 | 94 | [tool.bandit] 95 | exclude_dirs = ["tests/"] 96 | 97 | [tool.mypy] 98 | namespace_packages = true 99 | ignore_missing_imports = true 100 | explicit_package_bases = true 101 | warn_return_any = false 102 | warn_unused_configs = true 103 | no_implicit_optional = true 104 | warn_redundant_casts = true 105 | warn_unused_ignores = true 106 | show_column_numbers = true 107 | show_error_codes = true 108 | show_error_context = true 109 | 110 | [tool.pytest.ini_options] 111 | testpaths = ["tests"] 112 | markers = [ 113 | "slow: marks tests as slow (deselect with '-m \"not slow\"')", 114 | "integration: marks integration tests (deselect with '-m \"not integration\"')", 115 | "serial", 116 | ] 117 | 118 | [tool.commitizen] 119 | name = "cz_conventional_commits" 120 | version = "1.1.0" 121 | version_files = ["pyproject.toml:^version"] 122 | tag_format = "$version" 123 | bump_message = "bump: $current_version → $new_version" 124 | -------------------------------------------------------------------------------- /src/hatch_aws/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trash-panda-v91-beta/hatch-aws/de575a14432fee96d06ee014c763dff166967dfa/src/hatch_aws/__init__.py -------------------------------------------------------------------------------- /src/hatch_aws/aws.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from os import sep 3 | from pathlib import Path 4 | from subprocess import run # nosec 5 | from typing import Dict, List, Optional 6 | 7 | import yaml 8 | 9 | 10 | @dataclass 11 | class AwsLambda: 12 | name: str 13 | path: Path 14 | 15 | 16 | class Sam: # pylint: disable=too-few-public-methods 17 | def __init__(self, sam_exec: str, template: Path): 18 | self.exec = sam_exec 19 | self.template_path = template 20 | self.template = self._parse_sam_template() 21 | self.lambdas = self._get_aws_lambdas() 22 | 23 | def _get_aws_lambdas(self) -> List[AwsLambda]: 24 | resources = self.template["Resources"] 25 | lambdas = { 26 | resource: { 27 | **self.template.get("Globals", {}).get("Function", {}), 28 | **resources[resource]["Properties"], 29 | } 30 | for resource, param in resources.items() 31 | if param["Type"] == "AWS::Serverless::Function" 32 | } 33 | return [ 34 | AwsLambda( 35 | name=resource, 36 | path=Path(param["Handler"].replace(".", sep)).parent.parent, 37 | ) 38 | for resource, param in lambdas.items() 39 | if param.get("Runtime", "").lower().startswith("python") 40 | ] 41 | 42 | def _parse_sam_template(self) -> Dict: 43 | yaml.SafeLoader.add_multi_constructor("!", lambda *args: None) 44 | return yaml.safe_load(self.template_path.read_text(encoding="utf-8")) 45 | 46 | def invoke_sam_build(self, build_dir: str, params: Optional[List[str]] = None): 47 | def_params = ["--template", str(self.template_path), "--build-dir", build_dir] 48 | if not params: 49 | params = [] 50 | params.extend(def_params) 51 | 52 | return run( 53 | [self.exec, "build"] + params, 54 | text=True, 55 | encoding="utf-8", 56 | capture_output=True, 57 | check=False, 58 | ) 59 | -------------------------------------------------------------------------------- /src/hatch_aws/builder.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from contextlib import suppress 3 | from os import sep 4 | from pathlib import Path 5 | from shlex import quote 6 | from shutil import copy, rmtree 7 | from subprocess import PIPE, check_call # nosec 8 | from typing import Dict, List 9 | 10 | from hatchling.builders.plugin.interface import BuilderInterface, IncludedFile 11 | from hatchling.metadata.utils import normalize_project_name 12 | 13 | from hatch_aws.aws import AwsLambda, Sam 14 | from hatch_aws.config import AwsBuilderConfig 15 | 16 | 17 | def matches_shared_file(path: Path, build_dir: Path, shared_files: List[IncludedFile]): 18 | dist_path = (build_dir / file.distribution_path for file in shared_files) 19 | return any(file in path.parents or file == path for file in dist_path) 20 | 21 | 22 | class AwsBuilder(BuilderInterface): 23 | """ 24 | Build AWS Lambda Functions source code 25 | """ 26 | 27 | PLUGIN_NAME = "aws" 28 | 29 | def get_version_api(self) -> Dict: 30 | return {"standard": self.build_standard} 31 | 32 | def clean(self, directory: str, _versions): 33 | rmtree(directory) 34 | 35 | def build_lambda(self, aws_lambda: AwsLambda, shared_files: List[IncludedFile]) -> None: 36 | build_dir = Path(self.config.directory) / aws_lambda.name 37 | target = build_dir / aws_lambda.path 38 | dirs: List[Path] = [] 39 | for path in build_dir.rglob("*"): 40 | if path.is_dir(): 41 | dirs.append(path) 42 | continue 43 | if not any( 44 | ( 45 | target in path.parents, 46 | matches_shared_file(build_dir=build_dir, path=path, shared_files=shared_files), 47 | ) 48 | ): 49 | path.unlink(missing_ok=True) 50 | for folder in sorted(dirs, reverse=True): 51 | with suppress(OSError): 52 | folder.rmdir() 53 | deps = self.get_dependencies(module_name=normalize_project_name(aws_lambda.name)) 54 | 55 | if not deps: 56 | return 57 | requirements_file = target / "requirements.txt" 58 | requirements_file.parent.mkdir(exist_ok=True, parents=True) 59 | requirements_file.write_text(data="\n".join(deps), encoding="utf-8") 60 | check_call( 61 | [ 62 | sys.executable, 63 | "-m", 64 | "pip", 65 | "install", 66 | "--upgrade", 67 | "--disable-pip-version-check", 68 | "--no-python-version-warning", 69 | "-r", 70 | quote(str(requirements_file)), 71 | "-t", 72 | quote(str(build_dir)), 73 | ], 74 | stdout=PIPE, 75 | stderr=PIPE, 76 | shell=False, 77 | ) 78 | 79 | def build_standard(self, directory: str, **_build_data) -> str: 80 | self.config: AwsBuilderConfig 81 | try: 82 | sam = Sam(sam_exec=self.config.sam_exec, template=self.config.template) 83 | except AttributeError: 84 | self.app.abort( 85 | "Unsupported type for a 'CodeUri' or 'Handler'. Only string is supported." 86 | "Functions !Sub, !Ref and others are not supported yet. " 87 | ) 88 | raise 89 | self.app.display_waiting("Building lambda functions ...") 90 | result = sam.invoke_sam_build( 91 | build_dir=self.config.directory, params=self.config.sam_params 92 | ) 93 | if result.returncode != 0: 94 | self.app.display_error(result.stderr) 95 | self.app.abort("SAM build failed!") 96 | 97 | if self.config.use_sam: 98 | return directory 99 | 100 | shared_files = list(self.recurse_included_files()) 101 | for aws_lambda in sam.lambdas: 102 | self.app.display_info(f"{aws_lambda.name} ...", end=" ") 103 | self.build_lambda(aws_lambda=aws_lambda, shared_files=shared_files) 104 | self.app.display_success("success") 105 | 106 | if self.config.force_include: 107 | build_dir = Path(self.config.directory) 108 | for file in shared_files: 109 | if not "*" in file.distribution_path: 110 | copy(src=file.path, dst=build_dir / file.distribution_path) 111 | continue 112 | *glob, filename = file.distribution_path.rpartition("*") 113 | for path in build_dir.glob(pattern="".join(glob)): 114 | if path.is_file(): 115 | continue 116 | if filename.startswith(sep): 117 | filename = filename[1:] 118 | target = path / filename 119 | target.parent.mkdir(exist_ok=True, parents=True) 120 | if not target.exists(): 121 | copy(src=file.path, dst=target) 122 | self.app.display_success("Build successfull 🚀") 123 | return directory 124 | 125 | def get_dependencies(self, module_name: str) -> List[str]: 126 | return sorted( 127 | [ 128 | *self.metadata.core.dependencies, 129 | *self.metadata.core.optional_dependencies.get(module_name, []), 130 | ] 131 | ) 132 | 133 | @classmethod 134 | def get_config_class(cls): 135 | return AwsBuilderConfig 136 | -------------------------------------------------------------------------------- /src/hatch_aws/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | from typing import List, Optional 6 | 7 | from hatchling.builders.config import BuilderConfig 8 | 9 | DEFAULT_TEMPLATE = "template.yml" 10 | 11 | 12 | class AwsBuilderConfig(BuilderConfig): 13 | def __init__(self, *args, **kwargs): 14 | super().__init__(*args, **kwargs) 15 | self.__directory = None 16 | self.__template = None 17 | self.__sam_exec = "sam" 18 | self.__use_sam = False 19 | self.__sam_params = None 20 | 21 | def default_exclude(self) -> list: 22 | return ["tests/"] 23 | 24 | @property 25 | def directory(self) -> str: 26 | if self.__directory is None: 27 | if "directory" in self.target_config: 28 | directory = self.target_config["directory"] 29 | if not isinstance(directory, str): 30 | raise TypeError( 31 | f"Field `tool.hatch.build.targets.{self.plugin_name}.directory` " 32 | "must be a string" 33 | ) 34 | else: 35 | directory = self.build_config.get( 36 | "directory", str(Path(self.root) / ".aws-sam/build") 37 | ) 38 | if not isinstance(directory, str): 39 | raise TypeError("Field `tool.hatch.build.directory` must be a string") 40 | self.__directory = self.normalize_build_directory(str(directory)) 41 | 42 | return self.__directory 43 | 44 | @property 45 | def template(self) -> Path: 46 | if self.__template is None: 47 | template = Path(self.target_config.get("template", DEFAULT_TEMPLATE)) 48 | if not template.is_absolute(): 49 | template = self.root / template 50 | 51 | self.__template = template 52 | 53 | return self.__template 54 | 55 | @property 56 | def sam_exec(self) -> str: 57 | self.__sam_exec = self.target_config.get("sam_exec", self.__sam_exec) 58 | self.__sam_exec = os.getenv("HATCH_SAM_EXEC", self.__sam_exec) 59 | if not isinstance(self.__sam_exec, str): 60 | raise TypeError( 61 | f"Field `tool.hatch.build.targets.{self.plugin_name}.sam_exec` " "must be a string." 62 | ) 63 | return self.__sam_exec 64 | 65 | @property 66 | def use_sam(self) -> bool: 67 | self.__use_sam = self.target_config.get("use-sam", self.__use_sam) 68 | if not isinstance(self.__use_sam, bool): 69 | raise TypeError( 70 | f"Field `tool.hatch.build.targets.{self.plugin_name}.use-sam` " "must be a boolean." 71 | ) 72 | return self.__use_sam 73 | 74 | @property 75 | def sam_params(self) -> Optional[List[str]]: 76 | self.__sam_params = self.target_config.get("sam-params") 77 | if self.__sam_params is not None and not isinstance(self.__sam_params, list): 78 | raise TypeError( 79 | f"Field `tool.hatch.build.targets.{self.plugin_name}.sam-params` " 80 | "must be an array." 81 | ) 82 | return self.__sam_params 83 | 84 | @property 85 | def only_packages(self) -> bool: 86 | return True 87 | 88 | def default_include(self): 89 | return list(self.force_include.keys()) 90 | -------------------------------------------------------------------------------- /src/hatch_aws/hooks.py: -------------------------------------------------------------------------------- 1 | from hatchling.plugin import hookimpl 2 | 3 | from hatch_aws.builder import AwsBuilder 4 | 5 | 6 | @hookimpl 7 | def hatch_register_builder(): 8 | return AwsBuilder 9 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022-present U.N. Owen 2 | # 3 | # SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /tests/assets/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling<1.4.0", "hatch-aws"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "my-app" 7 | version = "1.0.0" 8 | description = 'The short brief description' 9 | readme = "README.md" 10 | requires-python = ">=3.7" 11 | license = { file = "LICENSE.txt" } 12 | keywords = ["whatever"] 13 | authors = [{ name = "me", email = "me@dot.me" }] 14 | dependencies = ["boto3"] 15 | 16 | [project.optional-dependencies] 17 | lambda1 = ["pyaml"] 18 | lambda2 = ["requrest", "pydantic"] 19 | 20 | [tool.hatch.build.force-include] 21 | "src/batman/common" = "*/batman/common" # copy to all lambda funcs 22 | ".editorconfig" = ".editorconfig.txt" 23 | "CHANGELOG.md" = "../CH.txt" 24 | "images/" = "*/images" 25 | 26 | 27 | [tool.hatch.build.targets.aws] 28 | template = "template.yaml" 29 | use-sam = false 30 | sam-params = ["--region", "us-east-2", "--parallel"] 31 | -------------------------------------------------------------------------------- /tests/assets/sam-template-3.8.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | 4 | Globals: 5 | Function: 6 | Architectures: 7 | - x86_64 8 | MemorySize: 1024 9 | CodeUri: src 10 | Timeout: 120 11 | 12 | Resources: 13 | MyLambda1Func: 14 | Type: AWS::Serverless::Function 15 | Properties: 16 | Handler: my_app.lambdas.lambda1.main.app 17 | Policies: AWSLambdaExecute 18 | Runtime: python3.8 19 | Timeout: 900 20 | Events: 21 | CreateThumbnailEvent: 22 | Type: S3 23 | Properties: 24 | Bucket: !Ref SrcBucket 25 | Events: s3:ObjectCreated:* 26 | 27 | MyLambda2Func: 28 | Type: AWS::Serverless::Function 29 | Properties: 30 | Runtime: python3.8 31 | CodeUri: test 32 | Handler: my_app.lambdas.lambda2.main.app 33 | Policies: AWSLambdaExecute 34 | Events: 35 | CreateThumbnailEvent: 36 | Type: S3 37 | Properties: 38 | Bucket: !Ref SrcBucket 39 | Events: s3:ObjectCreated:* 40 | 41 | MyLambda3Func: 42 | Type: AWS::Serverless::Function 43 | Properties: 44 | CodeUri: src/my_app/lambdas/lambda3 45 | Runtime: python3.8 46 | Handler: main.app 47 | Policies: AWSLambdaExecute 48 | Events: 49 | CreateThumbnailEvent: 50 | Type: S3 51 | Properties: 52 | Bucket: !Ref SrcBucket 53 | Events: s3:ObjectCreated:* 54 | 55 | MyLambda4Func: 56 | Type: AWS::Serverless::Function 57 | Properties: 58 | CodeUri: src/my_app/lambdas/lambda3 59 | PackageType: Image 60 | Policies: AWSLambdaExecute 61 | Events: 62 | CreateThumbnailEvent: 63 | Type: S3 64 | Properties: 65 | Bucket: !Ref SrcBucket 66 | Events: s3:ObjectCreated:* 67 | 68 | SrcBucket: 69 | Type: AWS::S3::Bucket 70 | -------------------------------------------------------------------------------- /tests/assets/sam-template-unsupported-handler.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | 4 | Parameters: 5 | PythonVersion: 6 | Type: String 7 | AllowedValues: 8 | - '3.7' 9 | - '3.8' 10 | - '3.9' 11 | 12 | Globals: 13 | Function: 14 | Architectures: 15 | - x86_64 16 | Runtime: python3.9 17 | MemorySize: 1024 18 | CodeUri: src 19 | Timeout: 120 20 | 21 | Resources: 22 | MyLambda1Func: 23 | Type: AWS::Serverless::Function 24 | Properties: 25 | Handler: my_app.lambdas.lambda1.main.app 26 | Policies: AWSLambdaExecute 27 | Timeout: 900 28 | Events: 29 | CreateThumbnailEvent: 30 | Type: S3 31 | Properties: 32 | Bucket: !Ref SrcBucket 33 | Events: s3:ObjectCreated:* 34 | 35 | MyLambda2Func: 36 | Type: AWS::Serverless::Function 37 | Properties: 38 | CodeUri: src 39 | Handler: !Ref PythonVersion 40 | Policies: AWSLambdaExecute 41 | Events: 42 | CreateThumbnailEvent: 43 | Type: S3 44 | Properties: 45 | Bucket: !Ref SrcBucket 46 | Events: s3:ObjectCreated:* 47 | 48 | SrcBucket: 49 | Type: AWS::S3::Bucket 50 | -------------------------------------------------------------------------------- /tests/assets/sam-template.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | 4 | Globals: 5 | Function: 6 | Architectures: 7 | - x86_64 8 | MemorySize: 1024 9 | CodeUri: src 10 | Timeout: 120 11 | 12 | Resources: 13 | MyLambda1Func: 14 | Type: AWS::Serverless::Function 15 | Properties: 16 | Handler: my_app.lambdas.lambda1.main.app 17 | Policies: AWSLambdaExecute 18 | Runtime: python3.9 19 | Timeout: 900 20 | Events: 21 | CreateThumbnailEvent: 22 | Type: S3 23 | Properties: 24 | Bucket: !Ref SrcBucket 25 | Events: s3:ObjectCreated:* 26 | 27 | MyLambda2Func: 28 | Type: AWS::Serverless::Function 29 | Properties: 30 | Runtime: python3.9 31 | CodeUri: test 32 | Handler: my_app.lambdas.lambda2.main.app 33 | Policies: AWSLambdaExecute 34 | Events: 35 | CreateThumbnailEvent: 36 | Type: S3 37 | Properties: 38 | Bucket: !Ref SrcBucket 39 | Events: s3:ObjectCreated:* 40 | 41 | MyLambda3Func: 42 | Type: AWS::Serverless::Function 43 | Properties: 44 | CodeUri: src/my_app/lambdas/lambda3 45 | Runtime: python3.9 46 | Handler: main.app 47 | Policies: AWSLambdaExecute 48 | Events: 49 | CreateThumbnailEvent: 50 | Type: S3 51 | Properties: 52 | Bucket: !Ref SrcBucket 53 | Events: s3:ObjectCreated:* 54 | 55 | MyLambda4Func: 56 | Type: AWS::Serverless::Function 57 | Properties: 58 | CodeUri: src/my_app/lambdas/lambda3 59 | PackageType: Image 60 | Policies: AWSLambdaExecute 61 | Events: 62 | CreateThumbnailEvent: 63 | Type: S3 64 | Properties: 65 | Bucket: !Ref SrcBucket 66 | Events: s3:ObjectCreated:* 67 | 68 | SrcBucket: 69 | Type: AWS::S3::Bucket 70 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from shutil import copy 3 | from subprocess import check_call 4 | from sys import version_info 5 | from tempfile import TemporaryDirectory 6 | from typing import Any, Callable, Dict, Generator, List, Optional 7 | 8 | # pylint: disable=redefined-outer-name 9 | import pytest 10 | import tomli_w 11 | 12 | from hatch_aws.aws import AwsLambda 13 | from hatch_aws.builder import AwsBuilder 14 | 15 | 16 | @pytest.fixture 17 | def temp_dir() -> Generator[Path, None, None]: 18 | with TemporaryDirectory() as directory: 19 | yield Path(directory).resolve() 20 | 21 | 22 | @pytest.fixture 23 | def asset() -> Callable: 24 | def _locate_asset(file: str) -> Path: 25 | return Path(__file__).parent.resolve() / "assets" / file 26 | 27 | return _locate_asset 28 | 29 | 30 | def make_files(root: Path, files: List[str]) -> None: 31 | for file in files: 32 | path = root / file 33 | path.parent.mkdir(parents=True, exist_ok=True) 34 | path.touch() 35 | 36 | 37 | @pytest.fixture 38 | def hatch(temp_dir, asset) -> Callable: 39 | default_config: Dict[str, Any] = { 40 | "project": { 41 | "name": "my-app", 42 | "version": "0.0.1", 43 | }, 44 | "tool": {"hatch": {"build": {"targets": {"aws": {}}}}}, 45 | } 46 | 47 | def _make_project( 48 | config: Optional[Dict] = None, 49 | dependencies: Optional[List] = None, 50 | optional_dependencies: Optional[Dict] = None, 51 | template: str = "sam-template.yml", 52 | build_conf: Optional[Dict] = None, 53 | force_include: Optional[Dict] = None, 54 | files: Optional[List[str]] = None, 55 | build: bool = False, 56 | ): 57 | if not config: 58 | config = default_config 59 | if dependencies: 60 | config["project"]["dependencies"] = dependencies 61 | if optional_dependencies: 62 | config["project"]["optional-dependencies"] = optional_dependencies 63 | 64 | if build_conf: 65 | config["tool"]["hatch"]["build"]["targets"]["aws"] = build_conf 66 | if force_include: 67 | config["tool"]["hatch"]["build"]["force-include"] = force_include 68 | 69 | builder = AwsBuilder(str(temp_dir), config=config) 70 | 71 | major, minor, *_ = version_info 72 | if (major, minor) < (3, 9): 73 | template = f"sam-template-{major}.{minor}.yml" 74 | 75 | copy(src=asset(template), dst=temp_dir / "template.yml") 76 | 77 | pyproject = temp_dir / "pyproject.toml" 78 | with open(pyproject, mode="wb") as file: 79 | tomli_w.dump(config, file) 80 | 81 | if not files: 82 | files = [ 83 | "tests/test_app.py", 84 | "scripts/something.sh", 85 | "src/my_app/lambdas/lambda1/main.py", 86 | "src/my_app/lambdas/lambda1/db.py", 87 | "src/my_app/lambdas/lambda2/main.py", 88 | "src/my_app/lambdas/lambda3/main.py", 89 | "src/my_app/lambdas/lambda3/db.py", 90 | "src/my_app/common/config.py", 91 | "src/my_app/common/models.py", 92 | "src/my_app/utils/storage.py", 93 | "src/my_app/utils/logger.py", 94 | ] 95 | 96 | make_files(root=temp_dir, files=files) 97 | if build: 98 | check_call( 99 | [ 100 | "sam", 101 | "build", 102 | "--template", 103 | temp_dir / "template.yml", 104 | "--build-dir", 105 | temp_dir / ".aws-sam/build", 106 | ] 107 | ) 108 | 109 | return builder 110 | 111 | return _make_project 112 | 113 | 114 | @pytest.fixture 115 | def aws_lambda() -> AwsLambda: 116 | return AwsLambda(name="MyLambda1Func", path=Path("my_app/lambdas/lambda1")) 117 | 118 | 119 | @pytest.fixture 120 | def limited_src_lambda() -> AwsLambda: 121 | return AwsLambda(path=Path("."), name="MyLambda3Func") 122 | -------------------------------------------------------------------------------- /tests/test_aws.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from hatch_aws.aws import Sam 4 | 5 | 6 | def test_sam_init(asset): 7 | sam = Sam(sam_exec="sam", template=asset("sam-template.yml")) 8 | 9 | lambda1, lambda2, lambda3, *other = sam.lambdas 10 | 11 | assert lambda1.path.as_posix() == "my_app/lambdas/lambda1" 12 | assert lambda1.name == "MyLambda1Func" 13 | 14 | assert lambda2.path.as_posix() == "my_app/lambdas/lambda2" 15 | assert lambda2.name == "MyLambda2Func" 16 | 17 | assert lambda3.path.as_posix() == "." 18 | assert lambda3.name == "MyLambda3Func" 19 | 20 | assert not other 21 | 22 | 23 | def test_unsupported_template(asset): 24 | with pytest.raises(AttributeError): 25 | Sam(sam_exec="sam", template=asset("sam-template-unsupported-handler.yml")) 26 | -------------------------------------------------------------------------------- /tests/test_builder.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from platform import python_version_tuple 4 | 5 | import pytest 6 | from hatchling.builders.plugin.interface import IncludedFile 7 | 8 | 9 | @pytest.mark.slow 10 | def test_build_with_real_sam(hatch): 11 | major, minor, _patch = python_version_tuple() 12 | assert major == "3" 13 | minor = min(int(minor), 9) 14 | build_conf = {"sam-params": ["--parameter-overrides", f"PythonVersion={major}.{minor}"]} 15 | builder = hatch(build_conf=build_conf) 16 | builder.build_standard(directory=builder.config.directory) 17 | 18 | dist = Path(f"{builder.root}/.aws-sam") 19 | 20 | assert (dist / "build.toml").is_file() 21 | assert (dist / "build" / "template.yaml").is_file() 22 | assert Path( 23 | f"{builder.root}/.aws-sam/build/MyLambda1Func/my_app/lambdas/lambda1/main.py" 24 | ).is_file() 25 | 26 | 27 | def test_clean_method(hatch): 28 | builder = hatch() 29 | 30 | build_dir = Path(builder.config.directory) 31 | build_dir.mkdir(parents=True, exist_ok=True) 32 | app_file = build_dir / "data" / "app.py" 33 | app_file.parent.mkdir(parents=True, exist_ok=True) 34 | app_file.touch() 35 | 36 | builder.clean(directory=builder.config.directory, _versions=None) 37 | 38 | assert not build_dir.exists() 39 | 40 | 41 | @pytest.mark.parametrize("template", ["bla", "template.yaml", "sam-template.yaml"]) 42 | def test_sam_template_config_option(hatch, template): 43 | config = { 44 | "project": { 45 | "name": "my-app", 46 | "version": "0.0.1", 47 | }, 48 | "tool": {"hatch": {"build": {"targets": {"aws": {"template": template}}}}}, 49 | } 50 | builder = hatch(config=config) 51 | 52 | assert builder.config.template == Path(builder.root) / template 53 | 54 | 55 | @pytest.mark.slow 56 | @pytest.mark.parametrize( 57 | "force_include, expected_files", 58 | ( 59 | ( 60 | { 61 | "src/my_app/common": "my_app/common", 62 | "src/my_app/utils/logger.py": "my_app/utils/logger.py", 63 | }, 64 | [ 65 | "my_app/lambdas/lambda1/main.py", 66 | "my_app/lambdas/lambda1/db.py", 67 | "my_app/common/config.py", 68 | "my_app/common/models.py", 69 | "my_app/utils/logger.py", 70 | ], 71 | ), 72 | ( 73 | None, 74 | [ 75 | "my_app/lambdas/lambda1/main.py", 76 | "my_app/lambdas/lambda1/db.py", 77 | ], 78 | ), 79 | ( 80 | {"void/null/nothing": "void/null/nothing"}, 81 | [ 82 | "my_app/lambdas/lambda1/main.py", 83 | "my_app/lambdas/lambda1/db.py", 84 | ], 85 | ), 86 | ), 87 | ) 88 | def test_build_lambda(hatch, force_include, expected_files, aws_lambda): 89 | builder = hatch(force_include=force_include, build=True) 90 | 91 | shared_files = [] 92 | 93 | if force_include: 94 | for rel, dist in force_include.items(): 95 | absolute_path = Path(builder.root) / rel 96 | if absolute_path.is_file(): 97 | shared_files.append( 98 | IncludedFile(distribution_path=dist, relative_path=rel, path=str(absolute_path)) 99 | ) 100 | for path in absolute_path.rglob("*"): 101 | if path.is_file(): 102 | shared_files.append( 103 | IncludedFile( 104 | distribution_path=dist, relative_path=rel, path=str(absolute_path) 105 | ) 106 | ) 107 | 108 | builder.build_lambda(aws_lambda=aws_lambda, shared_files=shared_files) 109 | dist_folder = Path(f"{builder.root}/.aws-sam/build/MyLambda1Func") 110 | 111 | expected = sorted([str(dist_folder / file) for file in expected_files]) 112 | files_in_dist = [str(path) for path in dist_folder.rglob("*") if path.is_file()] 113 | 114 | assert sorted(files_in_dist) == expected 115 | 116 | 117 | @pytest.mark.slow 118 | @pytest.mark.parametrize( 119 | "deps", [{"dependencies": ["pytest"]}, {"optional_dependencies": {"mylambda1func": ["pytest"]}}] 120 | ) 121 | def test_build_lambda_with_pip_requirements(hatch, aws_lambda, deps): 122 | builder = hatch(build=True, **deps) 123 | 124 | builder.build_lambda(aws_lambda=aws_lambda, shared_files=[]) 125 | dist_folder = Path(f"{builder.root}/.aws-sam/build/MyLambda1Func") 126 | assert (dist_folder / "pytest").is_dir() 127 | 128 | 129 | @pytest.mark.slow 130 | def test_build_lambda_limited_src(hatch, limited_src_lambda): 131 | builder = hatch(build=True) 132 | 133 | builder.build_lambda(aws_lambda=limited_src_lambda, shared_files=[]) 134 | 135 | dist_folder = Path(f"{builder.root}/.aws-sam/build/MyLambda3Func") 136 | files = os.listdir(dist_folder) 137 | assert sorted(files) == ["db.py", "main.py"] 138 | --------------------------------------------------------------------------------