├── .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 | [](https://pypi.org/project/hatch-aws) [](https://pypi.org/project/hatch-aws) [](https://github.com/pypa/hatch) [](https://github.com/psf/black) [](https://github.com/python/mypy) [](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 |
--------------------------------------------------------------------------------