From dd56775c07c4fa2689619e063ce7c1db698a1090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Kara=C5=9B?= Date: Sat, 31 Jan 2026 01:17:58 +0100 Subject: [PATCH] feat: added lambda project type --- copier.yaml | 19 +++++- justfile | 5 ++ skeleton/.github/workflows/ci.yml.jinja | 2 +- skeleton/README.md.jinja | 33 ++++++++-- skeleton/justfile.jinja | 61 +++++++++++++------ skeleton/pyproject.toml.jinja | 41 +++++++------ ...bda_project %}conftest.py{% endif %}.jinja | 37 +++++++++++ ...%}test_lambda_function.py{% endif %}.jinja | 14 +++++ ..._project %}.dockerignore{% endif %}.jinja} | 0 ...r_project %}.env.example{% endif %}.jinja} | 0 ...ker_project %}Dockerfile{% endif %}.jinja} | 0 ...ose.override.yml.example{% endif %}.jinja} | 0 ...ect %}docker-compose.yml{% endif %}.jinja} | 0 ...t %}docker-entrypoint.sh{% endif %}.jinja} | 0 ...ject %}lambda_function.py{% endif %}.jinja | 39 ++++++++++++ test_answers/docker.yml | 2 +- test_answers/lambda.yml | 5 ++ test_answers/plain.yml | 2 +- tests/conftest.py | 2 +- tests/test_syntax.py | 22 +++++-- 20 files changed, 228 insertions(+), 56 deletions(-) create mode 100644 skeleton/tests/{% if is_lambda_project %}conftest.py{% endif %}.jinja create mode 100644 skeleton/tests/{% if is_lambda_project %}test_lambda_function.py{% endif %}.jinja rename skeleton/{{% if docker %}.dockerignore{% endif %}.jinja => {% if is_docker_project %}.dockerignore{% endif %}.jinja} (100%) rename skeleton/{{% if docker %}.env.example{% endif %}.jinja => {% if is_docker_project %}.env.example{% endif %}.jinja} (100%) rename skeleton/{{% if docker %}Dockerfile{% endif %}.jinja => {% if is_docker_project %}Dockerfile{% endif %}.jinja} (100%) rename skeleton/{{% if docker %}docker-compose.override.yml.example{% endif %}.jinja => {% if is_docker_project %}docker-compose.override.yml.example{% endif %}.jinja} (100%) rename skeleton/{{% if docker %}docker-compose.yml{% endif %}.jinja => {% if is_docker_project %}docker-compose.yml{% endif %}.jinja} (100%) rename skeleton/{{% if docker %}docker-entrypoint.sh{% endif %}.jinja => {% if is_docker_project %}docker-entrypoint.sh{% endif %}.jinja} (100%) create mode 100644 skeleton/{{ project_name_snake_case }}/{% if is_lambda_project %}lambda_function.py{% endif %}.jinja create mode 100644 test_answers/lambda.yml diff --git a/copier.yaml b/copier.yaml index 519e867..e4d27c0 100644 --- a/copier.yaml +++ b/copier.yaml @@ -47,10 +47,23 @@ author_email: type: str help: Provide the author's email address -docker: +project_type: + type: str + help: Select the type of project + choices: + Empty: empty + Docker: dockerized + AWS Lambda: lambda + +is_docker_project: + type: bool + default: "{{ project_type == 'dockerized' }}" + when: false + +is_lambda_project: type: bool - default: true - help: Enable Docker support + default: "{{ project_type == 'lambda' }}" + when: false tasks: # Make entrypoint executable if it exists diff --git a/justfile b/justfile index d7bdcc2..52a5a7b 100644 --- a/justfile +++ b/justfile @@ -2,10 +2,12 @@ TEST_CATALOG := "test_catalog" DEFAULT_OUTPUT := TEST_CATALOG + "/generated_project" DOCKER_OUTPUT := TEST_CATALOG + "/dockerized_project" PLAIN_OUTPUT := TEST_CATALOG + "/plain_project" +LAMBDA_OUTPUT := TEST_CATALOG + "/lambda_project" TEST_ANSWERS := "test_answers" DOCKER_ANSWERS := TEST_ANSWERS + "/docker.yml" PLAIN_ANSWERS := TEST_ANSWERS + "/plain.yml" +LAMBDA_ANSWERS := TEST_ANSWERS + "/lambda.yml" PATHS_TO_LINT := "tests" @@ -42,10 +44,12 @@ output_all_ff output=DEFAULT_OUTPUT: [group("development")] [doc("Run all checks and tests (lints, mypy, tests...)")] all: lint_full test generation_check generation_check_docker + just generation_check {{ LAMBDA_ANSWERS }} {{ LAMBDA_OUTPUT }} [group("development")] [doc("Run all checks and tests, but fail on first that returns error (lints, mypy, tests...)")] all_ff: lint_full_ff test generation_check generation_check_docker + just generation_check {{ LAMBDA_ANSWERS }} {{ LAMBDA_OUTPUT }} [group("development")] [doc("Runs template tests.")] @@ -105,3 +109,4 @@ generation_check data_file=PLAIN_ANSWERS output=PLAIN_OUTPUT: copy_all: just copy {{ DOCKER_ANSWERS }} {{ DOCKER_OUTPUT }} just copy {{ PLAIN_ANSWERS }} {{ PLAIN_OUTPUT }} + just copy {{ LAMBDA_ANSWERS }} {{ LAMBDA_OUTPUT }} diff --git a/skeleton/.github/workflows/ci.yml.jinja b/skeleton/.github/workflows/ci.yml.jinja index b4f92e5..c916915 100644 --- a/skeleton/.github/workflows/ci.yml.jinja +++ b/skeleton/.github/workflows/ci.yml.jinja @@ -16,7 +16,7 @@ jobs: - name: "Install just" uses: extractions/setup-just@v3 - {%- if docker %} + {%- if is_docker_project %} - name: "Run linters and tests" run: just dc all diff --git a/skeleton/README.md.jinja b/skeleton/README.md.jinja index ab5edd7..40d051c 100644 --- a/skeleton/README.md.jinja +++ b/skeleton/README.md.jinja @@ -2,7 +2,9 @@ {{ description }} -{%- if docker %} +{%- if is_docker_project %} +## Development with Docker + 1. Build docker image: ``` docker compose build {{ project_name_snake_case }} @@ -16,9 +18,32 @@ ``` just dc bash ``` +{%- endif %} -3. Run tests: - ``` - just test +### Running Tests + +```bash +just test +``` + +### Code Style + +The project uses Ruff for linting and formatting, all linters can be run with `just`: + +```bash +just lint_full +just lint_full_ff # (fast-fail mode) +just all # (lint + tests) +just all_ff # (lint + tests in fast-fail mode) +``` + +{%- if is_lambda_project %} +## Deployment to AWS Lambda +1. Package the application: + ```bash + just build ``` + +2. You will see in `/build` directory a zip file ready for deployment to AWS Lambda. {%- endif %} + diff --git a/skeleton/justfile.jinja b/skeleton/justfile.jinja index 7a6b736..691ac0e 100644 --- a/skeleton/justfile.jinja +++ b/skeleton/justfile.jinja @@ -1,17 +1,19 @@ set dotenv-load -{% if docker -%} -CONTAINER_NAME := "{{ project_name_snake_case }}" -{% endif -%} PYTHONPATH := "./{{ project_name_snake_case }}" -{%- if docker %} -WORKDIR := "/code" -{%- endif %} PATHS_TO_LINT := "{{ project_name_snake_case }} tests" TEST_PATH := "tests" ANSWERS_FILE := ".copier/.copier-answers.copier-python-project.yml" - -{% raw -%} +{%- if is_docker_project %} +CONTAINER_NAME := "{{ project_name_snake_case }}" +WORKDIR := "/code" +{%- endif %} +{%- if is_lambda_project %} +BUILD_PATH := "build" +CURRENT_DATETIME := `date +'%Y%m%d_%H%M%S'` +PYTHON_VERSION := "3.13" +{%- endif %} +{%- raw %} [doc("Command run when 'just' is called without any arguments")] default: help @@ -27,9 +29,14 @@ all: lint_full test [doc("Run all checks and tests, but fail on first that returns error (lints, mypy, tests...)")] all_ff: lint_full_ff test {%- endraw -%} -{%- if docker -%} +{%- if is_docker_project -%} {%- raw %} +[group("development")] +[doc("Run any command inside docker (e.g. just dc bash)")] +dc command *args: + docker compose run --rm -w {{WORKDIR}} {{CONTAINER_NAME}} just {{command}} {{args}} + [group("development")] [doc("Open bash console (useful when prefixed with dc, as it opens bash inside docker)")] @bash: @@ -49,17 +56,6 @@ ruff: copier_update answers=ANSWERS_FILE skip-answered="true": uv run copier update --answers-file {{answers}} \ {{ if skip-answered == "true" { "--skip-answered" } else { "" } }} -{%- endraw -%} -{%- if docker -%} -{%- raw %} - -[group("development")] -[doc("Run any command inside docker (e.g. just dc bash)")] -dc command *args: - docker compose run --rm -w {{WORKDIR}} {{CONTAINER_NAME}} just {{command}} {{args}} -{%- endraw -%} -{%- endif -%} -{%- raw %} [group("lint")] [doc("Run fawltydeps lint check (deopendency issues)")] @@ -112,3 +108,28 @@ _set_pythonpath path=PYTHONPATH: test file=TEST_PATH: _set_pythonpath uv run pytest {{file}} --durations=10 {% endraw -%} +{%- if is_lambda_project -%} +{%- raw %} +[group("lambda_build")] +build: build_create_env + cd {{ BUILD_PATH }}/pkg && \ + zip -r ../{% endraw %}{{ project_name_snake_case }}{% raw %}_{{ CURRENT_DATETIME }}.zip . + +[group("lambda_build")] +build_create_env: build_generate_requirements build_install + +[group("lambda_build")] +build_generate_requirements: + uv export --frozen --no-dev --no-editable -o {{ BUILD_PATH }}/requirements.txt + +[group("lambda_build")] +build_install: + uv pip install \ + --no-installer-metadata \ + --no-compile-bytecode \ + --python-platform x86_64-manylinux2014 \ + --python {{ PYTHON_VERSION }} \ + --target {{ BUILD_PATH }}/pkg \ + -r {{ BUILD_PATH }}/requirements.txt +{% endraw -%} +{%- endif -%} diff --git a/skeleton/pyproject.toml.jinja b/skeleton/pyproject.toml.jinja index 3c0db5b..f6512af 100644 --- a/skeleton/pyproject.toml.jinja +++ b/skeleton/pyproject.toml.jinja @@ -5,30 +5,36 @@ description = "{{ description }}" authors = [{ name = "{{ author_name }}", email = "{{ author_email }}" }] requires-python = ">=3.13" readme = "README.md" -dependencies = [] +dependencies = [ + {%- if is_lambda_project %} + "aws-lambda-powertools>=3.16.0,<4", + {%- endif %} +] [dependency-groups] dev = [ "copier>=9.11.3,<10", - {%- if docker %} - "dotenv-linter>=0.7.0,<1", - {%- endif %} "fawltydeps>=0.20.0,<1.0.0", "ipython>=9.9.0,<10", "mypy>=1.19.1,<2", "pytest-asyncio>=1.3.0,<2", "pytest-cov>=7.0.0,<8", - {%- if docker %} - "pytest-env>=1.2.0,<2", - {%- endif %} "pytest-mock>=3.15.1,<4", "pytest>=9.0.2,<10", - {%- if docker %} + "ruff>=0.14.14,<0.15.0", + {%- if is_docker_project %} + "dotenv-linter>=0.7.0,<1", + "pytest-env>=1.2.0,<2", "python-dotenv>=1.2.1,<2", {%- endif %} - "ruff>=0.14.14,<0.15.0", ] +{%- if is_lambda_project %} +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +{%- endif %} + [tool.uv] package = false @@ -63,6 +69,7 @@ mark-parentheses = true [tool.ruff.lint.isort] order-by-type = false +known-first-party = ["{{ project_name_snake_case }}"] [tool.coverage.report] exclude_lines = [ @@ -76,23 +83,19 @@ deps = ["pyproject.toml"] ignore_unused = [ # Dev dependencies. This list should contain ALL of them! "copier", - {%- if docker %} - "dotenv-linter", - {%- endif %} "fawltydeps", "ipython", "mypy", "pytest-asyncio", "pytest-cov", - {%- if docker %} - "pytest-env", - {%- endif %} "pytest-mock", - {%- if docker %} - "python-dotenv", - {%- endif %} "pytest", "ruff", + {%- if is_docker_project %} + "dotenv-linter", + "pytest-env", + "python-dotenv", + {%- endif %} ] [tool.mypy] @@ -105,7 +108,7 @@ disable_error_code = "misc" python_files = ["tests.py", "test_*.py", "*_tests.py"] addopts = "--strict-markers -p no:warnings --cov=. --cov-fail-under=90 --cov-config=.coveragerc" asyncio_mode = "auto" -{%- if docker %} +{%- if is_docker_project %} [tool.pytest_env] FLAVOR = "test" diff --git a/skeleton/tests/{% if is_lambda_project %}conftest.py{% endif %}.jinja b/skeleton/tests/{% if is_lambda_project %}conftest.py{% endif %}.jinja new file mode 100644 index 0000000..cde398c --- /dev/null +++ b/skeleton/tests/{% if is_lambda_project %}conftest.py{% endif %}.jinja @@ -0,0 +1,37 @@ +from dataclasses import dataclass +from typing import Any + +import pytest + + +@dataclass +class LambdaContext: + function_name: str = "test" + memory_limit_in_mb: int = 128 + invoked_function_arn: str = "arn:aws:lambda:eu-west-1:809313241:function:test" + aws_request_id: str = "52fdfc07-2182-154f-163f-5f0f9a621d72" + + +@pytest.fixture() +def lambda_context() -> LambdaContext: + return LambdaContext() + + +@pytest.fixture() +def api_gw_event() -> dict[str, Any]: + return { + "headers": {}, + "body": "", + "requestContext": { + "http": { + "method": "GET", + "path": "/some_id/some_path", + "protocol": "HTTP/1.1", + "sourceIp": "192.168.0.1/32", + "userAgent": "agent", + }, + "stage": "$default", + }, + "resource": "/{some_id}/some_path", + "rawPath": "/some_id/some_path", + } diff --git a/skeleton/tests/{% if is_lambda_project %}test_lambda_function.py{% endif %}.jinja b/skeleton/tests/{% if is_lambda_project %}test_lambda_function.py{% endif %}.jinja new file mode 100644 index 0000000..ba8b4f9 --- /dev/null +++ b/skeleton/tests/{% if is_lambda_project %}test_lambda_function.py{% endif %}.jinja @@ -0,0 +1,14 @@ +from typing import Any + +from aws_lambda_powertools.utilities.typing import LambdaContext + +from {{ project_name_snake_case }}.lambda_function import lambda_handler + + +def test_lambda_handler( + api_gw_event: dict[str, Any], + lambda_context: LambdaContext, +) -> None: + response = lambda_handler(event=api_gw_event, context=lambda_context) + assert response["statusCode"] == 200 + assert response["body"] == "hello world" diff --git a/skeleton/{% if docker %}.dockerignore{% endif %}.jinja b/skeleton/{% if is_docker_project %}.dockerignore{% endif %}.jinja similarity index 100% rename from skeleton/{% if docker %}.dockerignore{% endif %}.jinja rename to skeleton/{% if is_docker_project %}.dockerignore{% endif %}.jinja diff --git a/skeleton/{% if docker %}.env.example{% endif %}.jinja b/skeleton/{% if is_docker_project %}.env.example{% endif %}.jinja similarity index 100% rename from skeleton/{% if docker %}.env.example{% endif %}.jinja rename to skeleton/{% if is_docker_project %}.env.example{% endif %}.jinja diff --git a/skeleton/{% if docker %}Dockerfile{% endif %}.jinja b/skeleton/{% if is_docker_project %}Dockerfile{% endif %}.jinja similarity index 100% rename from skeleton/{% if docker %}Dockerfile{% endif %}.jinja rename to skeleton/{% if is_docker_project %}Dockerfile{% endif %}.jinja diff --git a/skeleton/{% if docker %}docker-compose.override.yml.example{% endif %}.jinja b/skeleton/{% if is_docker_project %}docker-compose.override.yml.example{% endif %}.jinja similarity index 100% rename from skeleton/{% if docker %}docker-compose.override.yml.example{% endif %}.jinja rename to skeleton/{% if is_docker_project %}docker-compose.override.yml.example{% endif %}.jinja diff --git a/skeleton/{% if docker %}docker-compose.yml{% endif %}.jinja b/skeleton/{% if is_docker_project %}docker-compose.yml{% endif %}.jinja similarity index 100% rename from skeleton/{% if docker %}docker-compose.yml{% endif %}.jinja rename to skeleton/{% if is_docker_project %}docker-compose.yml{% endif %}.jinja diff --git a/skeleton/{% if docker %}docker-entrypoint.sh{% endif %}.jinja b/skeleton/{% if is_docker_project %}docker-entrypoint.sh{% endif %}.jinja similarity index 100% rename from skeleton/{% if docker %}docker-entrypoint.sh{% endif %}.jinja rename to skeleton/{% if is_docker_project %}docker-entrypoint.sh{% endif %}.jinja diff --git a/skeleton/{{ project_name_snake_case }}/{% if is_lambda_project %}lambda_function.py{% endif %}.jinja b/skeleton/{{ project_name_snake_case }}/{% if is_lambda_project %}lambda_function.py{% endif %}.jinja new file mode 100644 index 0000000..30566e4 --- /dev/null +++ b/skeleton/{{ project_name_snake_case }}/{% if is_lambda_project %}lambda_function.py{% endif %}.jinja @@ -0,0 +1,39 @@ +from typing import Any + +from aws_lambda_powertools.event_handler import APIGatewayHttpResolver, Response +from aws_lambda_powertools.utilities.typing import LambdaContext + +app = APIGatewayHttpResolver() + + +@app.get("//some_path") +def main(some_id: str) -> Response[str]: + return Response( + status_code=200, + content_type="text/plain; charset=utf-8", + body="hello world", + ) + + +def lambda_handler(event: dict[str, Any], context: LambdaContext) -> dict: + """ + Parameters + ---------- + event: dict, required + API Gateway Lambda Proxy Input Format + + Event doc: + https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format + + context: LambdaContext, required + Lambda Context runtime methods and attributes + + Context doc: https://docs.aws.amazon.com/lambda/latest/dg/python-context-object.html + + Returns + ------ + API Gateway Lambda Proxy Output Format: dict + + Return doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html + """ + return app.resolve(event, context) diff --git a/test_answers/docker.yml b/test_answers/docker.yml index 13a84bc..3d3b06e 100644 --- a/test_answers/docker.yml +++ b/test_answers/docker.yml @@ -1,5 +1,5 @@ author_email: author@email.test author_name: test-author description: Service description. -docker: true project_name: test-service +project_type: dockerized diff --git a/test_answers/lambda.yml b/test_answers/lambda.yml new file mode 100644 index 0000000..3c48d8d --- /dev/null +++ b/test_answers/lambda.yml @@ -0,0 +1,5 @@ +author_email: author@email.test +author_name: test-author +description: Lambda description. +project_name: test-lambda +project_type: lambda diff --git a/test_answers/plain.yml b/test_answers/plain.yml index 3e31a4e..29923c5 100644 --- a/test_answers/plain.yml +++ b/test_answers/plain.yml @@ -1,5 +1,5 @@ author_email: author@email.test author_name: test-author description: Service description. -docker: false project_name: test-service +project_type: empty diff --git a/tests/conftest.py b/tests/conftest.py index 5590817..f0ae80b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ def answers() -> dict[str, Any]: "author_name": "test-author", "author_email": "author@email.test", "project_name": "test-project", - "docker": True, + "project_type": "dockerized", "description": "A test project", } diff --git a/tests/test_syntax.py b/tests/test_syntax.py index b5a7bc2..53b1a94 100644 --- a/tests/test_syntax.py +++ b/tests/test_syntax.py @@ -1,4 +1,5 @@ import mimetypes +from enum import StrEnum from pathlib import Path import pytest @@ -10,13 +11,22 @@ JINJA_LEFTOVERS = ["{%", "{#", "%}", "#}"] -@pytest.mark.parametrize("docker", [True, False]) +class ProjectType(StrEnum): + EMPTY = "empty" + DOCKERIZED = "dockerized" + AWS_LAMBDA = "lambda" + + +ALL_PROJECT_TYPES = list(ProjectType) + + +@pytest.mark.parametrize("project_type", ALL_PROJECT_TYPES) def test_no_leftovers( - docker: bool, + project_type: ProjectType, tmp_path: Path, answers: dict, ) -> None: - answers["docker"] = docker + answers["project_type"] = project_type run_result = generate_project(tmp_path, answers) @@ -59,13 +69,13 @@ def test_determine_file_type(filename: str, expected: str) -> None: assert determine_file_type(path=Path(filename)) == expected -@pytest.mark.parametrize("docker", [True, False]) +@pytest.mark.parametrize("project_type", ALL_PROJECT_TYPES) def test_files_syntax( - docker: bool, + project_type: ProjectType, tmp_path: Path, answers: dict, ) -> None: - answers["docker"] = docker + answers["project_type"] = project_type run_result = generate_project(tmp_path, answers)