diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6412605..60802d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: "CI - linters and tests" +name: "๐Ÿงช CI - linters and tests" on: push: diff --git a/.github/workflows/conventional-commit-pr-name.yml b/.github/workflows/conventional-commit-pr-name.yml index 475f1e8..ab97f44 100644 --- a/.github/workflows/conventional-commit-pr-name.yml +++ b/.github/workflows/conventional-commit-pr-name.yml @@ -1,4 +1,4 @@ -name: "Check PR title matches conventional commit specification" +name: "๐Ÿงช CI - Check PR title matches conventional commit specification" on: pull_request: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8cb0855..c3bb2cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: "Release" +name: "๐Ÿš€ Release" on: workflow_dispatch: diff --git a/copier.yaml b/copier.yaml index e4d27c0..ea480cd 100644 --- a/copier.yaml +++ b/copier.yaml @@ -35,17 +35,29 @@ project_name_title_case: default: "{{ sanitized_project_name | replace('-', ' ') | title }}" when: false +source_catalog_name: + type: str + default: "{{ project_name_snake_case }}" + when: false + description: type: str help: Provide a short description of the project +specify_author: + type: bool + default: false + help: Would you like to specify author information? + author_name: type: str help: Provide an author name in format "Firstname Lastname" + when: "{{ specify_author }}" author_email: type: str help: Provide the author's email address + when: "{{ specify_author }}" project_type: type: str @@ -65,6 +77,17 @@ is_lambda_project: default: "{{ project_type == 'lambda' }}" when: false +enforce_conventional_commits: + type: bool + default: true + help: Enforce conventional commit messages in Git (needed for automatic GitHub releases) + +automatic_gh_release: + type: bool + default: true + help: Enable automatic GitHub releases based on conventional commits + when: "{{ enforce_conventional_commits }}" + tasks: # Make entrypoint executable if it exists - [invoke, "chmod +x docker-entrypoint.sh || true", after-copy] diff --git a/justfile b/justfile index 52a5a7b..92862e6 100644 --- a/justfile +++ b/justfile @@ -18,9 +18,14 @@ default: help help: just --list +[group("generation")] +[doc("Runs copier copy using current local implementation")] +copy output=DEFAULT_OUTPUT: + uv run copier copy . {{ output }} --vcs-ref=HEAD + [group("generation")] [doc("Generates repo from template to output directory")] -copy data_file output=DEFAULT_OUTPUT: +copy_from_file data_file output=DEFAULT_OUTPUT: uv run copier copy . {{ output }} \ --vcs-ref=HEAD \ --data-file {{ data_file }} \ @@ -95,18 +100,18 @@ lint_fix: [group("development")] [group("generation")] generation_check_docker data_file=DOCKER_ANSWERS output=DOCKER_OUTPUT: - just copy {{ data_file }} {{ output }} + just copy_from_file {{ data_file }} {{ output }} just output_all_ff_docker {{ output }} [group("development")] [group("generation")] generation_check data_file=PLAIN_ANSWERS output=PLAIN_OUTPUT: - just copy {{ data_file }} {{ output }} + just copy_from_file {{ data_file }} {{ output }} just output_all_ff {{ output }} [group("development")] [group("generation")] copy_all: - just copy {{ DOCKER_ANSWERS }} {{ DOCKER_OUTPUT }} - just copy {{ PLAIN_ANSWERS }} {{ PLAIN_OUTPUT }} - just copy {{ LAMBDA_ANSWERS }} {{ LAMBDA_OUTPUT }} + just copy_from_file {{ DOCKER_ANSWERS }} {{ DOCKER_OUTPUT }} + just copy_from_file {{ PLAIN_ANSWERS }} {{ PLAIN_OUTPUT }} + just copy_from_file {{ LAMBDA_ANSWERS }} {{ LAMBDA_OUTPUT }} diff --git a/skeleton/.github/workflows/ci.yml.jinja b/skeleton/.github/workflows/ci.yml.jinja index c916915..bce7b51 100644 --- a/skeleton/.github/workflows/ci.yml.jinja +++ b/skeleton/.github/workflows/ci.yml.jinja @@ -1,4 +1,4 @@ -name: "CI - linters and tests" +name: "๐Ÿงช CI - linters and tests" on: push: diff --git a/skeleton/.github/workflows/{% if automatic_gh_release %}release.yml{% endif %} b/skeleton/.github/workflows/{% if automatic_gh_release %}release.yml{% endif %} new file mode 100644 index 0000000..c3bb2cb --- /dev/null +++ b/skeleton/.github/workflows/{% if automatic_gh_release %}release.yml{% endif %} @@ -0,0 +1,41 @@ +name: "๐Ÿš€ Release" + +on: + workflow_dispatch: + workflow_run: + workflows: ["CI - linters and tests"] + types: + - completed + branches: + - main + +concurrency: # Run release builds sequentially + group: ci-${{ github.ref }} + +permissions: # Grant write access to github.token within non-pull_request builds + contents: write + +jobs: + release: + name: release + runs-on: ubuntu-latest + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + steps: + - name: "Check out code" + uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + + - id: create-release + name: "Prepare release" + uses: mgoltzsche/conventional-release@v0 + + - name: "Show results" + run: | + if [ "${{ steps.create-release.outputs.publish }}" = "true" ]; then + echo "A release was created." + echo "Version: ${{ steps.create-release.outputs.version }}" + else + echo "No release was created." + fi diff --git a/skeleton/.github/workflows/conventional-commit-pr-name.yml b/skeleton/.github/workflows/{% if enforce_conventional_commits %}conventional-commit-pr-name.yml{% endif %} similarity index 95% rename from skeleton/.github/workflows/conventional-commit-pr-name.yml rename to skeleton/.github/workflows/{% if enforce_conventional_commits %}conventional-commit-pr-name.yml{% endif %} index 53247da..55032b6 100644 --- a/skeleton/.github/workflows/conventional-commit-pr-name.yml +++ b/skeleton/.github/workflows/{% if enforce_conventional_commits %}conventional-commit-pr-name.yml{% endif %} @@ -1,4 +1,4 @@ -name: "Check PR title matches conventional commit specification" +name: "๐Ÿงช CI - Check PR title matches conventional commit specification" on: pull_request: diff --git a/skeleton/justfile.jinja b/skeleton/justfile.jinja index 691ac0e..9371e12 100644 --- a/skeleton/justfile.jinja +++ b/skeleton/justfile.jinja @@ -1,7 +1,7 @@ set dotenv-load -PYTHONPATH := "./{{ project_name_snake_case }}" -PATHS_TO_LINT := "{{ project_name_snake_case }} tests" +PYTHONPATH := "./{{ source_catalog_name }}" +PATHS_TO_LINT := "{{ source_catalog_name }} tests" TEST_PATH := "tests" ANSWERS_FILE := ".copier/.copier-answers.copier-python-project.yml" {%- if is_docker_project %} @@ -113,7 +113,7 @@ test file=TEST_PATH: _set_pythonpath [group("lambda_build")] build: build_create_env cd {{ BUILD_PATH }}/pkg && \ - zip -r ../{% endraw %}{{ project_name_snake_case }}{% raw %}_{{ CURRENT_DATETIME }}.zip . + zip -r ../{% endraw %}{{ source_catalog_name }}{% raw %}_{{ CURRENT_DATETIME }}.zip . [group("lambda_build")] build_create_env: build_generate_requirements build_install diff --git a/skeleton/pyproject.toml.jinja b/skeleton/pyproject.toml.jinja index f6512af..4e013b2 100644 --- a/skeleton/pyproject.toml.jinja +++ b/skeleton/pyproject.toml.jinja @@ -1,15 +1,13 @@ [project] name = "{{ project_name_kebab_case }}" -version = "0.0.0" # This is a placeholder, version is determined by git tag during CI/CD +version = "0.0.0"{% if automatic_gh_release %} # This is a placeholder, version is determined by git tag and commits{% endif %} description = "{{ description }}" -authors = [{ name = "{{ author_name }}", email = "{{ author_email }}" }] +authors = [{% if specify_author %}{ name = "{{ author_name }}", email = "{{ author_email }}" }{% endif %}] requires-python = ">=3.13" readme = "README.md" -dependencies = [ - {%- if is_lambda_project %} +dependencies = [{% if is_lambda_project %} "aws-lambda-powertools>=3.16.0,<4", - {%- endif %} -] +{% endif %}] [dependency-groups] dev = [ @@ -69,7 +67,7 @@ mark-parentheses = true [tool.ruff.lint.isort] order-by-type = false -known-first-party = ["{{ project_name_snake_case }}"] +known-first-party = ["{{ source_catalog_name }}"] [tool.coverage.report] exclude_lines = [ @@ -78,7 +76,7 @@ exclude_lines = [ ] [tool.fawltydeps] -code = ["{{ project_name_snake_case }}"] +code = ["{{ source_catalog_name }}"] deps = ["pyproject.toml"] ignore_unused = [ # Dev dependencies. This list should contain ALL of them! 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 index ba8b4f9..db181dc 100644 --- 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 @@ -2,7 +2,7 @@ from typing import Any from aws_lambda_powertools.utilities.typing import LambdaContext -from {{ project_name_snake_case }}.lambda_function import lambda_handler +from {{ source_catalog_name }}.lambda_function import lambda_handler def test_lambda_handler( diff --git a/skeleton/{% if is_docker_project %}Dockerfile{% endif %}.jinja b/skeleton/{% if is_docker_project %}Dockerfile{% endif %}.jinja index 09d425f..8b7dd6c 100644 --- a/skeleton/{% if is_docker_project %}Dockerfile{% endif %}.jinja +++ b/skeleton/{% if is_docker_project %}Dockerfile{% endif %}.jinja @@ -43,7 +43,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ # Adds our application code to the image COPY . /code -WORKDIR /code/{{ project_name_snake_case }} +WORKDIR /code/{{ source_catalog_name }} # Sync the project RUN --mount=type=cache,target=/root/.cache/uv \ diff --git a/skeleton/{{ project_name_snake_case }}/__init__.py b/skeleton/{{ source_catalog_name }}/__init__.py similarity index 100% rename from skeleton/{{ project_name_snake_case }}/__init__.py rename to skeleton/{{ source_catalog_name }}/__init__.py diff --git a/skeleton/{{ project_name_snake_case }}/{% if is_lambda_project %}lambda_function.py{% endif %}.jinja b/skeleton/{{ source_catalog_name }}/{% if is_lambda_project %}lambda_function.py{% endif %} similarity index 100% rename from skeleton/{{ project_name_snake_case }}/{% if is_lambda_project %}lambda_function.py{% endif %}.jinja rename to skeleton/{{ source_catalog_name }}/{% if is_lambda_project %}lambda_function.py{% endif %} diff --git a/test_answers/docker.yml b/test_answers/docker.yml index 3d3b06e..c209c82 100644 --- a/test_answers/docker.yml +++ b/test_answers/docker.yml @@ -1,5 +1,7 @@ author_email: author@email.test author_name: test-author description: Service description. +enforce_conventional_commits: false project_name: test-service project_type: dockerized +specify_author: true diff --git a/test_answers/lambda.yml b/test_answers/lambda.yml index 3c48d8d..fc438f1 100644 --- a/test_answers/lambda.yml +++ b/test_answers/lambda.yml @@ -1,5 +1,8 @@ author_email: author@email.test author_name: test-author +automatic_gh_release: false description: Lambda description. +enforce_conventional_commits: true project_name: test-lambda project_type: lambda +specify_author: true diff --git a/test_answers/plain.yml b/test_answers/plain.yml index 29923c5..bcf387d 100644 --- a/test_answers/plain.yml +++ b/test_answers/plain.yml @@ -1,5 +1,6 @@ -author_email: author@email.test -author_name: test-author +automatic_gh_release: true description: Service description. +enforce_conventional_commits: true project_name: test-service project_type: empty +specify_author: false diff --git a/tests/conftest.py b/tests/conftest.py index f0ae80b..8906f43 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,21 +1,92 @@ +from enum import Enum, StrEnum from pathlib import Path -from typing import Any +from typing import Any, Final import pytest +from _pytest.fixtures import FixtureRequest -@pytest.fixture() -def answers() -> dict[str, Any]: - """Fixture to provide default answers for project generation.""" +class ProjectType(StrEnum): + EMPTY = "empty" + DOCKERIZED = "dockerized" + AWS_LAMBDA = "lambda" + + +DEFAULT_ANSWERS = { + "author_email": "author@email.test", + "author_name": "test-author", + "automatic_gh_release": True, + "enforce_conventional_commits": True, + "description": "A test project", + "project_name": "test-project", + "project_type": "dockerized", + "specify_author": True, +} + + +class ArgSetType(Enum): + NOTSET = 0 + DEFAULT = 1 + + +NOTSET: Final = ArgSetType.NOTSET +DEFAULT: Final = ArgSetType.DEFAULT + + +def create_answers( + author_email: str | ArgSetType = DEFAULT, + author_name: str | ArgSetType = DEFAULT, + automatic_gh_release: bool | ArgSetType = DEFAULT, + description: str | ArgSetType = DEFAULT, + enforce_conventional_commits: bool | ArgSetType = DEFAULT, + project_name: str | ArgSetType = DEFAULT, + project_type: ProjectType | ArgSetType = DEFAULT, + specify_author: bool | ArgSetType = DEFAULT, +) -> dict[str, Any]: + """ + Fixture to easily create different answer sets for tests. + Each argument can be set to: + - A specific value + - DEFAULT to use the default answer + - NOTSET to omit the answer (useful for testing optional questions) + """ return { - "author_name": "test-author", - "author_email": "author@email.test", - "project_name": "test-project", - "project_type": "dockerized", - "description": "A test project", + key: DEFAULT_ANSWERS[key] if value is DEFAULT else value + for key, value in { + "author_email": author_email, + "author_name": author_name, + "automatic_gh_release": automatic_gh_release, + "description": description, + "enforce_conventional_commits": enforce_conventional_commits, + "project_name": project_name, + "project_type": project_type, + "specify_author": specify_author, + }.items() + if value is not NOTSET } +@pytest.fixture( + params=[ + pytest.param(create_answers(project_type=ProjectType.AWS_LAMBDA), id="lambda"), + pytest.param(create_answers(project_type=ProjectType.DOCKERIZED), id="dockerized"), + pytest.param(create_answers(project_type=ProjectType.EMPTY), id="plain"), + pytest.param( + create_answers(specify_author=False, author_name=NOTSET, author_email=NOTSET), + id="no-author", + ), + pytest.param( + create_answers(enforce_conventional_commits=False, automatic_gh_release=NOTSET), + id="no-cc-no-release", + ), + ], +) +def answers( + request: FixtureRequest, +) -> dict[str, Any]: + return request.param + + def determine_file_type(path: Path) -> str: if not path.suffix: return path.name diff --git a/tests/test_syntax.py b/tests/test_syntax.py index 53b1a94..d32f000 100644 --- a/tests/test_syntax.py +++ b/tests/test_syntax.py @@ -1,5 +1,4 @@ import mimetypes -from enum import StrEnum from pathlib import Path import pytest @@ -11,23 +10,10 @@ JINJA_LEFTOVERS = ["{%", "{#", "%}", "#}"] -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( - project_type: ProjectType, tmp_path: Path, answers: dict, ) -> None: - answers["project_type"] = project_type - run_result = generate_project(tmp_path, answers) assert run_result[1] == 0 @@ -69,14 +55,10 @@ def test_determine_file_type(filename: str, expected: str) -> None: assert determine_file_type(path=Path(filename)) == expected -@pytest.mark.parametrize("project_type", ALL_PROJECT_TYPES) def test_files_syntax( - project_type: ProjectType, tmp_path: Path, answers: dict, ) -> None: - answers["project_type"] = project_type - run_result = generate_project(tmp_path, answers) assert run_result[1] == 0