Skip to content

Commit

Permalink
Merge pull request #422 from twisted/421-type-annotations
Browse files Browse the repository at this point in the history
Add type annotations
  • Loading branch information
hynek committed Sep 6, 2022
2 parents e14a73d + d9eabf0 commit 6c94dc2
Show file tree
Hide file tree
Showing 18 changed files with 205 additions and 95 deletions.
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
13 changes: 13 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ exclude = '''

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


[tool.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$'

[[tool.mypy.overrides]]
module = 'click_default_group'
# 2022-09-04: This library has no type annotations.
ignore_missing_imports = true


[build-system]
Expand Down
2 changes: 2 additions & 0 deletions src/towncrier/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
towncrier, a builder for your news files.
"""

from __future__ import annotations

from ._version import __version__


Expand Down
2 changes: 2 additions & 0 deletions src/towncrier/__main__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from towncrier._shell import cli


Expand Down
64 changes: 40 additions & 24 deletions src/towncrier/_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@
# See LICENSE for details.


from __future__ import annotations

import os
import textwrap
import traceback

from collections import OrderedDict
from typing import Any, Iterator, Mapping, Sequence

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 +27,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]
) -> tuple[str, str, int] | tuple[None, None, None]:
invalid = (None, None, None)
parts = basename.split(".")

Expand Down Expand Up @@ -74,7 +79,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: str | None,
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 +115,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 +136,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 +151,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 +190,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 +201,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 +219,7 @@ def bullet_key(entry):
return 3


def render_issue(issue_format, issue):
def render_issue(issue_format: str | None, issue: str) -> str:
if issue_format is None:
try:
int(issue)
Expand All @@ -215,24 +231,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: str | None,
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 Down Expand Up @@ -271,7 +287,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
12 changes: 8 additions & 4 deletions src/towncrier/_git.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
# Copyright (c) Amber Brown, 2015
# See LICENSE for details.

from __future__ import annotations

import os

from subprocess import STDOUT, call, check_output

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 +26,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
11 changes: 8 additions & 3 deletions src/towncrier/_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@
"""


from __future__ import annotations

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 +38,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 +63,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 +79,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)}")
2 changes: 2 additions & 0 deletions src/towncrier/_settings/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Subpackage to handle settings parsing."""

from __future__ import annotations

from towncrier._settings import load


Expand Down
24 changes: 14 additions & 10 deletions src/towncrier/_settings/fragment_types.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
from __future__ import annotations

import abc
import collections as clt

from typing import Any, Iterable, Mapping


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 +29,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 +46,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 +68,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 +109,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 +125,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

0 comments on commit 6c94dc2

Please sign in to comment.