Skip to content

Feature/136 add mypy tool to project #139

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Apr 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,26 @@ jobs:

- name: Check code coverage with Pytest
run: pytest --cov=. -v tests/ --cov-fail-under=80

mypy-check:
runs-on: ubuntu-latest
name: Mypy Type Check
steps:
- name: Checkout repository
uses: actions/checkout@v4.1.5
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@v5.1.0
with:
python-version: '3.11'
cache: 'pip'

- name: Install dependencies
run: |
pip install -r requirements.txt
- name: Check types with Mypy
id: check-types
run: |
mypy .
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- [Get Started](#get-started)
- [Run Static Code Analysis](#running-static-code-analysis)
- [Run Black Tool Locally](#run-black-tool-locally)
- [Run mypy Tool Locally](#run-mypy-tool-locally)
- [Run Unit Test](#running-unit-test)
- [Run Action Locally](#run-action-locally)
- [GitHub Workflow Examples](#github-workflow-examples)
Expand Down Expand Up @@ -353,6 +354,33 @@ All done! ✨ 🍰 ✨
```


## Run mypy Tool Locally

This project uses the [my[py]](https://mypy.readthedocs.io/en/stable/)
tool which is a static type checker for Python.

> Type checkers help ensure that you’re using variables and functions in your code correctly.
> With mypy, add type hints (PEP 484) to your Python programs,
> and mypy will warn you when you use those types incorrectly.
my[py] configuration is in `pyproject.toml` file.

Follow these steps to format your code with my[py] locally:

### Run my[py]

Run my[py] on all files in the project.
```shell
mypy .
```

To run my[py] check on a specific file, follow the pattern `mypy <path_to_file>/<name_of_file>.py --check-untyped-defs`.

Example:
```shell
mypy living_documentation_regime/living_documentation_generator.py
```


## Running Unit Test

Unit tests are written using pytest. To run the tests, use the following command:
Expand Down
5 changes: 4 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ def run() -> None:
logger.debug("Generated release notes: \n%s", rls_notes)

# Set the output for the GitHub Action
set_action_output("release-notes", rls_notes)
set_action_output(
"release-notes",
rls_notes if rls_notes is not None else "Failed to generate release notes. See logs for details.",
)
logger.info("GitHub Action 'Release Notes Generator' completed successfully")


Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ force-exclude = '''test'''

[tool.coverage.run]
omit = ["tests/*"]

[tool.mypy]
check_untyped_defs = true
exclude = "tests"
64 changes: 41 additions & 23 deletions release_notes_generator/action_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@
import sys
import re

from typing import Optional

import yaml

from release_notes_generator.utils.constants import (
Expand Down Expand Up @@ -69,28 +67,28 @@ def get_github_repository() -> str:
"""
Get the GitHub repository from the action inputs.
"""
return get_action_input(GITHUB_REPOSITORY)
return get_action_input(GITHUB_REPOSITORY) or ""

@staticmethod
def get_github_token() -> str:
"""
Get the GitHub token from the action inputs.
"""
return get_action_input(GITHUB_TOKEN)
return get_action_input(GITHUB_TOKEN) or ""

@staticmethod
def get_tag_name() -> str:
"""
Get the tag name from the action inputs.
"""
return get_action_input(TAG_NAME)
return get_action_input(TAG_NAME) or ""

@staticmethod
def get_from_tag_name() -> str:
"""
Get the from-tag name from the action inputs.
"""
return get_action_input(FROM_TAG_NAME, default="")
return get_action_input(FROM_TAG_NAME, default="") # type: ignore[return-value] # string is returned as default

@staticmethod
def is_from_tag_name_defined() -> bool:
Expand All @@ -101,22 +99,23 @@ def is_from_tag_name_defined() -> bool:
return value.strip() != ""

@staticmethod
def get_chapters() -> Optional[list[dict[str, str]]]:
def get_chapters() -> list[dict[str, str]]:
"""
Get list of the chapters from the action inputs. Each chapter is a dict.
"""
# Get the 'chapters' input from environment variables
chapters_input: str = get_action_input(CHAPTERS, default="")
chapters_input: str = get_action_input(CHAPTERS, default="") # type: ignore[assignment]
# mypy: string is returned as default

# Parse the received string back to YAML array input.
try:
chapters = yaml.safe_load(chapters_input)
if not isinstance(chapters, list):
logger.error("Error: 'chapters' input is not a valid YAML list.")
return None
return []
except yaml.YAMLError as exc:
logger.error("Error parsing 'chapters' input: {%s}", exc)
return None
return []

return chapters

Expand All @@ -125,7 +124,8 @@ def get_duplicity_scope() -> DuplicityScopeEnum:
"""
Get the duplicity scope parameter value from the action inputs.
"""
duplicity_scope = get_action_input(DUPLICITY_SCOPE, "both").upper()
duplicity_scope = get_action_input(DUPLICITY_SCOPE, "both").upper() # type: ignore[union-attr]
# mypy: string is returned as default

try:
return DuplicityScopeEnum(duplicity_scope)
Expand All @@ -138,17 +138,18 @@ def get_duplicity_icon() -> str:
"""
Get the duplicity icon from the action inputs.
"""
return get_action_input(DUPLICITY_ICON, "🔔")
return get_action_input(DUPLICITY_ICON, "🔔") # type: ignore[return-value] # string is returned as default

@staticmethod
def get_published_at() -> bool:
"""
Get the published at parameter value from the action inputs.
"""
return get_action_input(PUBLISHED_AT, "false").lower() == "true"
return get_action_input(PUBLISHED_AT, "false").lower() == "true" # type: ignore[union-attr]
# mypy: string is returned as default

@staticmethod
def get_skip_release_notes_labels() -> str:
def get_skip_release_notes_labels() -> list[str]:
"""
Get the skip release notes label from the action inputs.
"""
Expand All @@ -163,29 +164,36 @@ def get_verbose() -> bool:
"""
Get the verbose parameter value from the action inputs.
"""
return os.getenv(RUNNER_DEBUG, "0") == "1" or get_action_input(VERBOSE).lower() == "true"
return (
os.getenv(RUNNER_DEBUG, "0") == "1"
or get_action_input(VERBOSE).lower() == "true" # type: ignore[union-attr]
)
# mypy: string is returned as default

@staticmethod
def get_release_notes_title() -> str:
"""
Get the release notes title from the action inputs.
"""
return get_action_input(RELEASE_NOTES_TITLE, RELEASE_NOTE_TITLE_DEFAULT)
return get_action_input(RELEASE_NOTES_TITLE, RELEASE_NOTE_TITLE_DEFAULT) # type: ignore[return-value]
# mypy: string is returned as default

# Features
@staticmethod
def get_warnings() -> bool:
"""
Get the warnings parameter value from the action inputs.
"""
return get_action_input(WARNINGS, "true").lower() == "true"
return get_action_input(WARNINGS, "true").lower() == "true" # type: ignore[union-attr]
# mypy: string is returned as default

@staticmethod
def get_print_empty_chapters() -> bool:
"""
Get the print empty chapters parameter value from the action inputs.
"""
return get_action_input(PRINT_EMPTY_CHAPTERS, "true").lower() == "true"
return get_action_input(PRINT_EMPTY_CHAPTERS, "true").lower() == "true" # type: ignore[union-attr]
# mypy: string is returned as default

@staticmethod
def validate_input(input_value, expected_type: type, error_message: str, error_buffer: list) -> bool:
Expand All @@ -211,7 +219,11 @@ def get_row_format_issue() -> str:
"""
if ActionInputs._row_format_issue is None:
ActionInputs._row_format_issue = ActionInputs._detect_row_format_invalid_keywords(
get_action_input(ROW_FORMAT_ISSUE, "{number} _{title}_ in {pull-requests}").strip(), clean=True
get_action_input(
ROW_FORMAT_ISSUE, "{number} _{title}_ in {pull-requests}"
).strip(), # type: ignore[union-attr]
clean=True,
# mypy: string is returned as default
)
return ActionInputs._row_format_issue

Expand All @@ -222,7 +234,9 @@ def get_row_format_pr() -> str:
"""
if ActionInputs._row_format_pr is None:
ActionInputs._row_format_pr = ActionInputs._detect_row_format_invalid_keywords(
get_action_input(ROW_FORMAT_PR, "{number} _{title}_").strip(), clean=True
get_action_input(ROW_FORMAT_PR, "{number} _{title}_").strip(), # type: ignore[union-attr]
clean=True,
# mypy: string is returned as default
)
return ActionInputs._row_format_pr

Expand All @@ -231,7 +245,8 @@ def get_row_format_link_pr() -> bool:
"""
Get the value controlling whether the row format should include a 'PR:' prefix when linking to PRs.
"""
return get_action_input(ROW_FORMAT_LINK_PR, "true").lower() == "true"
return get_action_input(ROW_FORMAT_LINK_PR, "true").lower() == "true" # type: ignore[union-attr]
# mypy: string is returned as default

@staticmethod
def validate_inputs() -> None:
Expand All @@ -243,6 +258,9 @@ def validate_inputs() -> None:
errors = []

repository_id = ActionInputs.get_github_repository()
if not isinstance(repository_id, str) or not repository_id.strip():
errors.append("Repository ID must be a non-empty string.")

if "/" in repository_id:
owner, repo_name = ActionInputs.get_github_repository().split("/")
else:
Expand All @@ -260,8 +278,8 @@ def validate_inputs() -> None:
errors.append("From tag name must be a string.")

chapters = ActionInputs.get_chapters()
if chapters is None:
errors.append("Chapters must be a valid yaml array.")
if len(chapters) == 0:
errors.append("Chapters must be a valid yaml array and not empty.")

duplicity_icon = ActionInputs.get_duplicity_icon()
if not isinstance(duplicity_icon, str) or not duplicity_icon.strip() or len(duplicity_icon) != 1:
Expand Down
31 changes: 20 additions & 11 deletions release_notes_generator/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import sys

from typing import Optional

import semver

from github import Github
Expand Down Expand Up @@ -82,15 +83,20 @@ def generate(self) -> Optional[str]:
return None

# get the latest release
rls: GitRelease = self.get_latest_release(repo)
rls: Optional[GitRelease] = self.get_latest_release(repo)

# get all issues
if rls is None:
issues = issues_all = self._safe_call(repo.get_issues)(state=ISSUE_STATE_ALL)
else:
# default is repository creation date if no releases OR created_at of latest release
since = rls.created_at if rls else repo.created_at
if rls and ActionInputs.get_published_at():
since = rls.published_at

# default is repository creation date if no releases OR created_at of latest release
since = rls.created_at if rls else repo.created_at
if rls and ActionInputs.get_published_at():
since = rls.published_at
issues = issues_all = self._safe_call(repo.get_issues)(state=ISSUE_STATE_ALL, since=since)

# get all issues, pulls and commits, and then reduce them by the latest release since time
issues = issues_all = self._safe_call(repo.get_issues)(state=ISSUE_STATE_ALL, since=since)
# pulls and commits, and then reduce them by the latest release since time
pulls = pulls_all = self._safe_call(repo.get_pulls)(state="closed")
commits = commits_all = list(self._safe_call(repo.get_commits)())

Expand All @@ -110,7 +116,7 @@ def generate(self) -> Optional[str]:
commits = list(filter(lambda commit: commit.commit.author.date > since, list(commits_all)))
logger.debug("Count of commits reduced from %d to %d", len(list(commits_all)), len(commits))

changelog_url = get_change_url(tag_name=ActionInputs.get_tag_name(), repository=repo, git_release=rls)
changelog_url: str = get_change_url(tag_name=ActionInputs.get_tag_name(), repository=repo, git_release=rls)

rls_notes_records: dict[int, Record] = RecordFactory.generate(
github=self.github_instance,
Expand All @@ -135,10 +141,12 @@ def get_latest_release(self, repo: Repository) -> Optional[GitRelease]:
@param repo: The repository to get the latest release from.
@return: The latest release of the repository, or None if no releases are found.
"""
rls: Optional[GitRelease] = None

# check if from-tag name is defined
if ActionInputs.is_from_tag_name_defined():
logger.info("Getting latest release by from-tag name %s", ActionInputs.get_tag_name())
rls: GitRelease = self._safe_call(repo.get_release)(ActionInputs.get_from_tag_name())
rls = self._safe_call(repo.get_release)(ActionInputs.get_from_tag_name())

if rls is None:
logger.info("Latest release not found for received tag %s. Ending!", ActionInputs.get_from_tag_name())
Expand All @@ -147,7 +155,7 @@ def get_latest_release(self, repo: Repository) -> Optional[GitRelease]:
else:
logger.info("Getting latest release by semantic ordering (could not be the last one by time).")
gh_releases: list = list(self._safe_call(repo.get_releases)())
rls: GitRelease = self.__get_latest_semantic_release(gh_releases)
rls = self.__get_latest_semantic_release(gh_releases)

if rls is None:
logger.info("Latest release not found for %s. 1st release for repository!", repo.full_name)
Expand Down Expand Up @@ -178,7 +186,8 @@ def __get_latest_semantic_release(self, releases) -> Optional[GitRelease]:
logger.debug("Skipping invalid type of version tag: %s", release.tag_name)
continue

if latest_version is None or current_version > latest_version:
if latest_version is None or current_version > latest_version: # type: ignore[operator]
# mypy: check for None is done first
latest_version = current_version
rls = release

Expand Down
10 changes: 5 additions & 5 deletions release_notes_generator/model/chapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,19 @@
"""
This module contains the Chapter class which is responsible for representing a chapter in the release notes.
"""
from typing import Optional


class Chapter:
"""
A class representing a chapter in the release notes.
"""

def __init__(self, title: str = "", labels: list[str] = None, empty_message: str = "No entries detected."):
def __init__(
self, title: str = "", labels: Optional[list[str]] = None, empty_message: str = "No entries detected."
):
self.title: str = title
if labels is None:
self.labels = []
else:
self.labels: list[str] = labels
self.labels: list[str] = labels if labels else []
self.rows: dict[int, str] = {}
self.empty_message = empty_message

Expand Down
Loading
Loading