Skip to content

Commit 81a078d

Browse files
Feature/136 add mypy tool to project (#139)
* #136 - Add mypy tool to project - Introduces mypy tool support.
1 parent 9dd60c6 commit 81a078d

17 files changed

+164
-70
lines changed

.github/workflows/test.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,26 @@ jobs:
108108

109109
- name: Check code coverage with Pytest
110110
run: pytest --cov=. -v tests/ --cov-fail-under=80
111+
112+
mypy-check:
113+
runs-on: ubuntu-latest
114+
name: Mypy Type Check
115+
steps:
116+
- name: Checkout repository
117+
uses: actions/checkout@v4.1.5
118+
with:
119+
persist-credentials: false
120+
121+
- name: Set up Python
122+
uses: actions/setup-python@v5.1.0
123+
with:
124+
python-version: '3.11'
125+
cache: 'pip'
126+
127+
- name: Install dependencies
128+
run: |
129+
pip install -r requirements.txt
130+
- name: Check types with Mypy
131+
id: check-types
132+
run: |
133+
mypy .

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
- [Get Started](#get-started)
2020
- [Run Static Code Analysis](#running-static-code-analysis)
2121
- [Run Black Tool Locally](#run-black-tool-locally)
22+
- [Run mypy Tool Locally](#run-mypy-tool-locally)
2223
- [Run Unit Test](#running-unit-test)
2324
- [Run Action Locally](#run-action-locally)
2425
- [GitHub Workflow Examples](#github-workflow-examples)
@@ -353,6 +354,33 @@ All done! ✨ 🍰 ✨
353354
```
354355

355356

357+
## Run mypy Tool Locally
358+
359+
This project uses the [my[py]](https://mypy.readthedocs.io/en/stable/)
360+
tool which is a static type checker for Python.
361+
362+
> Type checkers help ensure that you’re using variables and functions in your code correctly.
363+
> With mypy, add type hints (PEP 484) to your Python programs,
364+
> and mypy will warn you when you use those types incorrectly.
365+
my[py] configuration is in `pyproject.toml` file.
366+
367+
Follow these steps to format your code with my[py] locally:
368+
369+
### Run my[py]
370+
371+
Run my[py] on all files in the project.
372+
```shell
373+
mypy .
374+
```
375+
376+
To run my[py] check on a specific file, follow the pattern `mypy <path_to_file>/<name_of_file>.py --check-untyped-defs`.
377+
378+
Example:
379+
```shell
380+
mypy living_documentation_regime/living_documentation_generator.py
381+
```
382+
383+
356384
## Running Unit Test
357385

358386
Unit tests are written using pytest. To run the tests, use the following command:

main.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@ def run() -> None:
5555
logger.debug("Generated release notes: \n%s", rls_notes)
5656

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

6164

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,7 @@ force-exclude = '''test'''
55

66
[tool.coverage.run]
77
omit = ["tests/*"]
8+
9+
[tool.mypy]
10+
check_untyped_defs = true
11+
exclude = "tests"

release_notes_generator/action_inputs.py

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@
2323
import sys
2424
import re
2525

26-
from typing import Optional
27-
2826
import yaml
2927

3028
from release_notes_generator.utils.constants import (
@@ -69,28 +67,28 @@ def get_github_repository() -> str:
6967
"""
7068
Get the GitHub repository from the action inputs.
7169
"""
72-
return get_action_input(GITHUB_REPOSITORY)
70+
return get_action_input(GITHUB_REPOSITORY) or ""
7371

7472
@staticmethod
7573
def get_github_token() -> str:
7674
"""
7775
Get the GitHub token from the action inputs.
7876
"""
79-
return get_action_input(GITHUB_TOKEN)
77+
return get_action_input(GITHUB_TOKEN) or ""
8078

8179
@staticmethod
8280
def get_tag_name() -> str:
8381
"""
8482
Get the tag name from the action inputs.
8583
"""
86-
return get_action_input(TAG_NAME)
84+
return get_action_input(TAG_NAME) or ""
8785

8886
@staticmethod
8987
def get_from_tag_name() -> str:
9088
"""
9189
Get the from-tag name from the action inputs.
9290
"""
93-
return get_action_input(FROM_TAG_NAME, default="")
91+
return get_action_input(FROM_TAG_NAME, default="") # type: ignore[return-value] # string is returned as default
9492

9593
@staticmethod
9694
def is_from_tag_name_defined() -> bool:
@@ -101,22 +99,23 @@ def is_from_tag_name_defined() -> bool:
10199
return value.strip() != ""
102100

103101
@staticmethod
104-
def get_chapters() -> Optional[list[dict[str, str]]]:
102+
def get_chapters() -> list[dict[str, str]]:
105103
"""
106104
Get list of the chapters from the action inputs. Each chapter is a dict.
107105
"""
108106
# Get the 'chapters' input from environment variables
109-
chapters_input: str = get_action_input(CHAPTERS, default="")
107+
chapters_input: str = get_action_input(CHAPTERS, default="") # type: ignore[assignment]
108+
# mypy: string is returned as default
110109

111110
# Parse the received string back to YAML array input.
112111
try:
113112
chapters = yaml.safe_load(chapters_input)
114113
if not isinstance(chapters, list):
115114
logger.error("Error: 'chapters' input is not a valid YAML list.")
116-
return None
115+
return []
117116
except yaml.YAMLError as exc:
118117
logger.error("Error parsing 'chapters' input: {%s}", exc)
119-
return None
118+
return []
120119

121120
return chapters
122121

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

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

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

150151
@staticmethod
151-
def get_skip_release_notes_labels() -> str:
152+
def get_skip_release_notes_labels() -> list[str]:
152153
"""
153154
Get the skip release notes label from the action inputs.
154155
"""
@@ -163,29 +164,36 @@ def get_verbose() -> bool:
163164
"""
164165
Get the verbose parameter value from the action inputs.
165166
"""
166-
return os.getenv(RUNNER_DEBUG, "0") == "1" or get_action_input(VERBOSE).lower() == "true"
167+
return (
168+
os.getenv(RUNNER_DEBUG, "0") == "1"
169+
or get_action_input(VERBOSE).lower() == "true" # type: ignore[union-attr]
170+
)
171+
# mypy: string is returned as default
167172

168173
@staticmethod
169174
def get_release_notes_title() -> str:
170175
"""
171176
Get the release notes title from the action inputs.
172177
"""
173-
return get_action_input(RELEASE_NOTES_TITLE, RELEASE_NOTE_TITLE_DEFAULT)
178+
return get_action_input(RELEASE_NOTES_TITLE, RELEASE_NOTE_TITLE_DEFAULT) # type: ignore[return-value]
179+
# mypy: string is returned as default
174180

175181
# Features
176182
@staticmethod
177183
def get_warnings() -> bool:
178184
"""
179185
Get the warnings parameter value from the action inputs.
180186
"""
181-
return get_action_input(WARNINGS, "true").lower() == "true"
187+
return get_action_input(WARNINGS, "true").lower() == "true" # type: ignore[union-attr]
188+
# mypy: string is returned as default
182189

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

190198
@staticmethod
191199
def validate_input(input_value, expected_type: type, error_message: str, error_buffer: list) -> bool:
@@ -211,7 +219,11 @@ def get_row_format_issue() -> str:
211219
"""
212220
if ActionInputs._row_format_issue is None:
213221
ActionInputs._row_format_issue = ActionInputs._detect_row_format_invalid_keywords(
214-
get_action_input(ROW_FORMAT_ISSUE, "{number} _{title}_ in {pull-requests}").strip(), clean=True
222+
get_action_input(
223+
ROW_FORMAT_ISSUE, "{number} _{title}_ in {pull-requests}"
224+
).strip(), # type: ignore[union-attr]
225+
clean=True,
226+
# mypy: string is returned as default
215227
)
216228
return ActionInputs._row_format_issue
217229

@@ -222,7 +234,9 @@ def get_row_format_pr() -> str:
222234
"""
223235
if ActionInputs._row_format_pr is None:
224236
ActionInputs._row_format_pr = ActionInputs._detect_row_format_invalid_keywords(
225-
get_action_input(ROW_FORMAT_PR, "{number} _{title}_").strip(), clean=True
237+
get_action_input(ROW_FORMAT_PR, "{number} _{title}_").strip(), # type: ignore[union-attr]
238+
clean=True,
239+
# mypy: string is returned as default
226240
)
227241
return ActionInputs._row_format_pr
228242

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

236251
@staticmethod
237252
def validate_inputs() -> None:
@@ -243,6 +258,9 @@ def validate_inputs() -> None:
243258
errors = []
244259

245260
repository_id = ActionInputs.get_github_repository()
261+
if not isinstance(repository_id, str) or not repository_id.strip():
262+
errors.append("Repository ID must be a non-empty string.")
263+
246264
if "/" in repository_id:
247265
owner, repo_name = ActionInputs.get_github_repository().split("/")
248266
else:
@@ -260,8 +278,8 @@ def validate_inputs() -> None:
260278
errors.append("From tag name must be a string.")
261279

262280
chapters = ActionInputs.get_chapters()
263-
if chapters is None:
264-
errors.append("Chapters must be a valid yaml array.")
281+
if len(chapters) == 0:
282+
errors.append("Chapters must be a valid yaml array and not empty.")
265283

266284
duplicity_icon = ActionInputs.get_duplicity_icon()
267285
if not isinstance(duplicity_icon, str) or not duplicity_icon.strip() or len(duplicity_icon) != 1:

release_notes_generator/generator.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import sys
2424

2525
from typing import Optional
26+
2627
import semver
2728

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

8485
# get the latest release
85-
rls: GitRelease = self.get_latest_release(repo)
86+
rls: Optional[GitRelease] = self.get_latest_release(repo)
87+
88+
# get all issues
89+
if rls is None:
90+
issues = issues_all = self._safe_call(repo.get_issues)(state=ISSUE_STATE_ALL)
91+
else:
92+
# default is repository creation date if no releases OR created_at of latest release
93+
since = rls.created_at if rls else repo.created_at
94+
if rls and ActionInputs.get_published_at():
95+
since = rls.published_at
8696

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

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

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

113-
changelog_url = get_change_url(tag_name=ActionInputs.get_tag_name(), repository=repo, git_release=rls)
119+
changelog_url: str = get_change_url(tag_name=ActionInputs.get_tag_name(), repository=repo, git_release=rls)
114120

115121
rls_notes_records: dict[int, Record] = RecordFactory.generate(
116122
github=self.github_instance,
@@ -135,10 +141,12 @@ def get_latest_release(self, repo: Repository) -> Optional[GitRelease]:
135141
@param repo: The repository to get the latest release from.
136142
@return: The latest release of the repository, or None if no releases are found.
137143
"""
144+
rls: Optional[GitRelease] = None
145+
138146
# check if from-tag name is defined
139147
if ActionInputs.is_from_tag_name_defined():
140148
logger.info("Getting latest release by from-tag name %s", ActionInputs.get_tag_name())
141-
rls: GitRelease = self._safe_call(repo.get_release)(ActionInputs.get_from_tag_name())
149+
rls = self._safe_call(repo.get_release)(ActionInputs.get_from_tag_name())
142150

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

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

181-
if latest_version is None or current_version > latest_version:
189+
if latest_version is None or current_version > latest_version: # type: ignore[operator]
190+
# mypy: check for None is done first
182191
latest_version = current_version
183192
rls = release
184193

release_notes_generator/model/chapter.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,19 @@
1717
"""
1818
This module contains the Chapter class which is responsible for representing a chapter in the release notes.
1919
"""
20+
from typing import Optional
2021

2122

2223
class Chapter:
2324
"""
2425
A class representing a chapter in the release notes.
2526
"""
2627

27-
def __init__(self, title: str = "", labels: list[str] = None, empty_message: str = "No entries detected."):
28+
def __init__(
29+
self, title: str = "", labels: Optional[list[str]] = None, empty_message: str = "No entries detected."
30+
):
2831
self.title: str = title
29-
if labels is None:
30-
self.labels = []
31-
else:
32-
self.labels: list[str] = labels
32+
self.labels: list[str] = labels if labels else []
3333
self.rows: dict[int, str] = {}
3434
self.empty_message = empty_message
3535

0 commit comments

Comments
 (0)