Skip to content
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

Add type annotations #422

Merged
merged 13 commits into from
Sep 6, 2022
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ jobs:
- name: Check package manifest
tox: check-manifest
run-if: true
- name: Check mypy
tox: typecheck
run-if: true

steps:
- uses: actions/checkout@v3
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ include pyproject.toml
include tox.ini
include tox_build.sh
include tox_check-release.sh
include mypy.ini
include *.yaml
include .git-blame-ignore-revs
recursive-include src *.rst
Expand Down
9 changes: 9 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[mypy]
strict=True
# 2022-09-04: Trial's API isn't annotated yet, which limits the usefulness of type-checking
# the unit tests. Therefore they have not been annotated yet.
exclude=^src/towncrier/test/.*\.py$

[mypy-click_default_group.*]
# 2022-09-04: This library has no type annotations.
ignore_missing_imports=True
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ exclude = '''

[tool.isort]
profile = "attrs"
line_length = 88


[build-system]
Expand Down
65 changes: 41 additions & 24 deletions src/towncrier/_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@
import traceback

from collections import OrderedDict
from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple, Union

from jinja2 import Template

from ._settings import ConfigError


def strip_if_integer_string(s):
def strip_if_integer_string(s: str) -> str:
try:
i = int(s)
except ValueError:
Expand All @@ -24,7 +25,9 @@ def strip_if_integer_string(s):

# Returns ticket, category and counter or (None, None, None) if the basename
# could not be parsed or doesn't contain a valid category.
def parse_newfragment_basename(basename, definitions):
def parse_newfragment_basename(
basename: str, definitions: Sequence[str]
) -> Union[Tuple[str, str, int], Tuple[None, None, None]]:
invalid = (None, None, None)
parts = basename.split(".")

Expand Down Expand Up @@ -74,7 +77,12 @@ def parse_newfragment_basename(basename, definitions):
# We should really use attrs.
#
# Also returns a list of the paths that the fragments were taken from.
def find_fragments(base_directory, sections, fragment_directory, definitions):
def find_fragments(
base_directory: str,
sections: Mapping[str, str],
fragment_directory: Optional[str],
definitions: Sequence[str],
) -> Tuple[Mapping[str, Mapping[Tuple[str, str, int], str]], List[str]]:
"""
Sections are a dictonary of section names to paths.
"""
Expand Down Expand Up @@ -105,6 +113,8 @@ def find_fragments(base_directory, sections, fragment_directory, definitions):
)
if category is None:
continue
assert ticket is not None
assert counter is not None

full_filename = os.path.join(section_dir, basename)
fragment_filenames.append(full_filename)
Expand All @@ -124,12 +134,12 @@ def find_fragments(base_directory, sections, fragment_directory, definitions):
return content, fragment_filenames


def indent(text, prefix):
def indent(text: str, prefix: str) -> str:
"""
Adds `prefix` to the beginning of non-empty lines in `text`.
"""
# Based on Python 3's textwrap.indent
def prefixed_lines():
def prefixed_lines() -> Iterator[str]:
for line in text.splitlines(True):
yield (prefix + line if line.strip() else line)

Expand All @@ -139,12 +149,16 @@ def prefixed_lines():
# Takes the output from find_fragments above. Probably it would be useful to
# add an example output here. Next time someone digs deep enough to figure it
# out, please do so...
def split_fragments(fragments, definitions, all_bullets=True):
def split_fragments(
fragments: Mapping[str, Mapping[Tuple[str, str, int], str]],
definitions: Mapping[str, Mapping[str, Any]],
all_bullets: bool = True,
) -> Mapping[str, Mapping[str, Mapping[str, Sequence[str]]]]:

output = OrderedDict()

for section_name, section_fragments in fragments.items():
section = {}
section: Dict[str, Dict[str, List[str]]] = {}

for (ticket, category, counter), content in section_fragments.items():

Expand Down Expand Up @@ -174,7 +188,7 @@ def split_fragments(fragments, definitions, all_bullets=True):
return output


def issue_key(issue):
def issue_key(issue: str) -> Tuple[int, str]:
# We want integer issues to sort as integers, and we also want string
# issues to sort as strings. We arbitrarily put string issues before
# integer issues (hopefully no-one uses both at once).
Expand All @@ -185,12 +199,12 @@ def issue_key(issue):
return (-1, issue)


def entry_key(entry):
def entry_key(entry: Tuple[str, Sequence[str]]) -> List[Tuple[int, str]]:
_, issues = entry
return [issue_key(issue) for issue in issues]


def bullet_key(entry):
def bullet_key(entry: Tuple[str, Sequence[str]]) -> int:
text, _ = entry
if not text:
return -1
Expand All @@ -203,7 +217,7 @@ def bullet_key(entry):
return 3


def render_issue(issue_format, issue):
def render_issue(issue_format: Optional[str], issue: str) -> str:
if issue_format is None:
try:
int(issue)
Expand All @@ -215,24 +229,24 @@ def render_issue(issue_format, issue):


def render_fragments(
template,
issue_format,
fragments,
definitions,
underlines,
wrap,
versiondata,
top_underline="=",
all_bullets=False,
render_title=True,
):
template: str,
issue_format: Optional[str],
fragments: Mapping[str, Mapping[str, Mapping[str, Sequence[str]]]],
definitions: Sequence[str],
underlines: Sequence[str],
wrap: bool,
versiondata: Mapping[str, str],
top_underline: str = "=",
all_bullets: bool = False,
render_title: bool = True,
) -> str:
"""
Render the fragments into a news file.
"""

jinja_template = Template(template, trim_blocks=True)

data = OrderedDict()
data: Dict[str, Dict[str, Dict[str, List[str]]]] = OrderedDict()

for section_name, section_value in fragments.items():

Expand All @@ -257,6 +271,9 @@ def render_fragments(
# - Fix the other thing (#1)
# - Fix the thing (#2, #7, #123)
entries.sort(key=entry_key)
# Argument "key" to "sort" of "list" has incompatible type
# has "Callable[[Tuple[str, Sequence[str]]], Sequence[Tuple[int, str]]]";
# exp "Callable[[Tuple[str, List[str]]], Union[SupportsDunderLT, SupportsDunderGT]]"
if not all_bullets:
entries.sort(key=bullet_key)

Expand All @@ -271,7 +288,7 @@ def render_fragments(

done = []

def get_indent(text):
def get_indent(text: str) -> str:
# If bullets are not assumed and we wrap, the subsequent
# indentation depends on whether or not this is a bullet point.
# (it is probably usually best to disable wrapping in that case)
Expand Down
11 changes: 7 additions & 4 deletions src/towncrier/_git.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
import os

from subprocess import STDOUT, call, check_output
from typing import List

import click


def remove_files(fragment_filenames, answer_yes):
def remove_files(fragment_filenames: List[str], answer_yes: bool) -> None:
if not fragment_filenames:
return

Expand All @@ -24,20 +25,22 @@ def remove_files(fragment_filenames, answer_yes):
call(["git", "rm", "--quiet"] + fragment_filenames)


def stage_newsfile(directory, filename):
def stage_newsfile(directory: str, filename: str) -> None:

call(["git", "add", os.path.join(directory, filename)])


def get_remote_branches(base_directory):
def get_remote_branches(base_directory: str) -> List[str]:
output = check_output(
["git", "branch", "-r"], cwd=base_directory, encoding="utf-8", stderr=STDOUT
)

return [branch.strip() for branch in output.strip().splitlines()]


def list_changed_files_compared_to_branch(base_directory, compare_with):
def list_changed_files_compared_to_branch(
base_directory: str, compare_with: str
) -> List[str]:
output = check_output(
["git", "diff", "--name-only", compare_with + "..."],
cwd=base_directory,
Expand Down
9 changes: 6 additions & 3 deletions src/towncrier/_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
import sys

from importlib import import_module
from types import ModuleType

from incremental import Version


def _get_package(package_dir, package):
def _get_package(package_dir: str, package: str) -> ModuleType:

try:
module = import_module(package)
Expand All @@ -35,7 +36,7 @@ def _get_package(package_dir, package):
return module


def get_version(package_dir, package):
def get_version(package_dir: str, package: str) -> str:

module = _get_package(package_dir, package)

Expand All @@ -60,7 +61,7 @@ def get_version(package_dir, package):
)


def get_project_name(package_dir, package):
def get_project_name(package_dir: str, package: str) -> str:

module = _get_package(package_dir, package)

Expand All @@ -76,3 +77,5 @@ def get_project_name(package_dir, package):
if isinstance(version, Version):
# Incremental has support for package names
return version.package

raise TypeError(f"Unsupported type for __version__: {type(version)}")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous code would implicitly return None here, but the caller couldn't handler None, so I believe that was a bug.

22 changes: 12 additions & 10 deletions src/towncrier/_settings/fragment_types.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import abc
import collections as clt

from typing import Any, Iterable, Mapping, Type


class BaseFragmentTypesLoader:
"""Base class to load fragment types."""

__metaclass__ = abc.ABCMeta

def __init__(self, config):
def __init__(self, config: Mapping[str, Any]):
"""Initialize."""
self.config = config

@classmethod
def factory(cls, config):
fragment_types_class = DefaultFragmentTypesLoader
def factory(cls, config: Mapping[str, Any]) -> "BaseFragmentTypesLoader":
fragment_types_class: Type[BaseFragmentTypesLoader] = DefaultFragmentTypesLoader
fragment_types = config.get("fragment", {})
types_config = config.get("type", {})
if fragment_types:
Expand All @@ -25,7 +27,7 @@ def factory(cls, config):
return new

@abc.abstractmethod
def load(self):
def load(self) -> Mapping[str, Mapping[str, Any]]:
"""Load fragment types."""


Expand All @@ -42,7 +44,7 @@ class DefaultFragmentTypesLoader(BaseFragmentTypesLoader):
]
)

def load(self):
def load(self) -> Mapping[str, Mapping[str, Any]]:
"""Load default types."""
return self._default_types

Expand All @@ -64,7 +66,7 @@ class ArrayFragmentTypesLoader(BaseFragmentTypesLoader):

"""

def load(self):
def load(self) -> Mapping[str, Mapping[str, Any]]:
"""Load types from toml array of mappings."""

types = clt.OrderedDict()
Expand Down Expand Up @@ -105,14 +107,14 @@ class TableFragmentTypesLoader(BaseFragmentTypesLoader):

"""

def __init__(self, config):
def __init__(self, config: Mapping[str, Mapping[str, Any]]):
"""Initialize."""
self.config = config
self.fragment_options = config.get("fragment", {})

def load(self):
def load(self) -> Mapping[str, Mapping[str, Any]]:
"""Load types from nested mapping."""
fragment_types = self.fragment_options.keys()
fragment_types: Iterable[str] = self.fragment_options.keys()
fragment_types = sorted(fragment_types)
custom_types_sequence = [
(fragment_type, self._load_options(fragment_type))
Expand All @@ -121,7 +123,7 @@ def load(self):
types = clt.OrderedDict(custom_types_sequence)
return types

def _load_options(self, fragment_type):
def _load_options(self, fragment_type: str) -> Mapping[str, Any]:
"""Load fragment options."""
capitalized_fragment_type = fragment_type.capitalize()
options = self.fragment_options.get(fragment_type, {})
Expand Down
Loading