diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7c0eb71..72de85fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index b7a31f45..b15dbf68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/towncrier/__init__.py b/src/towncrier/__init__.py index ba09c39a..ab8ced7c 100644 --- a/src/towncrier/__init__.py +++ b/src/towncrier/__init__.py @@ -5,6 +5,8 @@ towncrier, a builder for your news files. """ +from __future__ import annotations + from ._version import __version__ diff --git a/src/towncrier/__main__.py b/src/towncrier/__main__.py index e7257122..cf8d9378 100644 --- a/src/towncrier/__main__.py +++ b/src/towncrier/__main__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from towncrier._shell import cli diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 42c3bb00..af4663ad 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -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: @@ -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(".") @@ -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. """ @@ -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) @@ -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) @@ -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(): @@ -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). @@ -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 @@ -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) @@ -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(): @@ -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) diff --git a/src/towncrier/_git.py b/src/towncrier/_git.py index 54afbf38..c4360870 100644 --- a/src/towncrier/_git.py +++ b/src/towncrier/_git.py @@ -1,6 +1,8 @@ # Copyright (c) Amber Brown, 2015 # See LICENSE for details. +from __future__ import annotations + import os from subprocess import STDOUT, call, check_output @@ -8,7 +10,7 @@ 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 @@ -24,12 +26,12 @@ 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 ) @@ -37,7 +39,9 @@ def get_remote_branches(base_directory): 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, diff --git a/src/towncrier/_project.py b/src/towncrier/_project.py index c5e4676f..9c887ff0 100644 --- a/src/towncrier/_project.py +++ b/src/towncrier/_project.py @@ -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) @@ -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) @@ -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) @@ -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)}") diff --git a/src/towncrier/_settings/__init__.py b/src/towncrier/_settings/__init__.py index 2eb48803..0f25f2f4 100644 --- a/src/towncrier/_settings/__init__.py +++ b/src/towncrier/_settings/__init__.py @@ -1,5 +1,7 @@ """Subpackage to handle settings parsing.""" +from __future__ import annotations + from towncrier._settings import load diff --git a/src/towncrier/_settings/fragment_types.py b/src/towncrier/_settings/fragment_types.py index 434b6475..a9b83676 100644 --- a/src/towncrier/_settings/fragment_types.py +++ b/src/towncrier/_settings/fragment_types.py @@ -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: @@ -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.""" @@ -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 @@ -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() @@ -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)) @@ -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, {}) diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index f4335af8..534a638f 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -1,9 +1,12 @@ # Copyright (c) Amber Brown, 2015 # See LICENSE for details. +from __future__ import annotations + import os from collections import OrderedDict +from typing import Any, Mapping import pkg_resources import tomli @@ -12,7 +15,7 @@ class ConfigError(Exception): - def __init__(self, *args, **kwargs): + def __init__(self, *args: str, **kwargs: str): self.failing_option = kwargs.get("failing_option") super().__init__(*args) @@ -23,20 +26,22 @@ def __init__(self, *args, **kwargs): _underlines = ["=", "-", "~"] -def load_config_from_options(directory, config): - if config is None: +def load_config_from_options( + directory: str | None, config_path: str | None +) -> tuple[str, Mapping[str, Any]]: + if config_path is None: if directory is None: directory = os.getcwd() base_directory = os.path.abspath(directory) config = load_config(base_directory) else: - config = os.path.abspath(config) + config_path = os.path.abspath(config_path) if directory: base_directory = os.path.abspath(directory) else: - base_directory = os.path.dirname(config) - config = load_config_from_file(os.path.dirname(config), config) + base_directory = os.path.dirname(config_path) + config = load_config_from_file(os.path.dirname(config_path), config_path) if config is None: raise ConfigError(f"No configuration file found.\nLooked in: {base_directory}") @@ -44,7 +49,7 @@ def load_config_from_options(directory, config): return base_directory, config -def load_config(directory): +def load_config(directory: str) -> Mapping[str, Any] | None: towncrier_toml = os.path.join(directory, "towncrier.toml") pyproject_toml = os.path.join(directory, "pyproject.toml") @@ -59,14 +64,14 @@ def load_config(directory): return load_config_from_file(directory, config_file) -def load_config_from_file(directory, config_file): +def load_config_from_file(directory: str, config_file: str) -> Mapping[str, Any]: with open(config_file, "rb") as conffile: config = tomli.load(conffile) return parse_toml(directory, config) -def parse_toml(base_path, config): +def parse_toml(base_path: str, config: Mapping[str, Any]) -> Mapping[str, Any]: if "tool" not in config: raise ConfigError("No [tool.towncrier] section.", failing_option="all") diff --git a/src/towncrier/_shell.py b/src/towncrier/_shell.py index fd916ab4..0efe1b69 100644 --- a/src/towncrier/_shell.py +++ b/src/towncrier/_shell.py @@ -7,6 +7,8 @@ Each sub-command has its separate CLI definition andd help messages. """ +from __future__ import annotations + import click from click_default_group import DefaultGroup @@ -19,7 +21,7 @@ @click.group(cls=DefaultGroup, default="build", default_if_no_args=True) @click.version_option(__version__.public()) -def cli(): +def cli() -> None: """ Towncrier is a utility to produce useful, summarised news files for your project. Rather than reading the Git history as some newer tools to produce it, or having diff --git a/src/towncrier/_writer.py b/src/towncrier/_writer.py index e58535b6..92119114 100644 --- a/src/towncrier/_writer.py +++ b/src/towncrier/_writer.py @@ -6,12 +6,19 @@ affecting existing content. """ +from __future__ import annotations + from pathlib import Path def append_to_newsfile( - directory, filename, start_string, top_line, content, single_file -): + directory: str, + filename: str, + start_string: str, + top_line: str, + content: str, + single_file: bool, +) -> None: """ Write *content* to *directory*/*filename* behind *start_string*. @@ -43,7 +50,9 @@ def append_to_newsfile( f.write(f"\n\n{prev_body}") -def _figure_out_existing_content(news_file, start_string, single_file): +def _figure_out_existing_content( + news_file: Path, start_string: str, single_file: bool +) -> tuple[str, str]: """ Try to read *news_file* and split it into header (everything before *start_string*) and the old body (everything after *start_string*). diff --git a/src/towncrier/build.py b/src/towncrier/build.py index a59b13cf..5c4a012c 100644 --- a/src/towncrier/build.py +++ b/src/towncrier/build.py @@ -6,6 +6,8 @@ """ +from __future__ import annotations + import os import sys @@ -16,15 +18,11 @@ from ._builder import find_fragments, render_fragments, split_fragments from ._git import remove_files, stage_newsfile from ._project import get_project_name, get_version -from ._settings import ( - ConfigError, - config_option_help, - load_config_from_options, -) +from ._settings import ConfigError, config_option_help, load_config_from_options from ._writer import append_to_newsfile -def _get_date(): +def _get_date() -> str: return date.today().isoformat() @@ -74,14 +72,14 @@ def _get_date(): help="Do not ask for confirmation to remove news fragments.", ) def _main( - draft, - directory, - config_file, - project_name, - project_version, - project_date, - answer_yes, -): + draft: bool, + directory: str | None, + config_file: str | None, + project_name: str | None, + project_version: str | None, + project_date: str | None, + answer_yes: bool, +) -> None: """ Build a combined news file from news fragment. """ @@ -101,14 +99,14 @@ def _main( def __main( - draft, - directory, - config_file, - project_name, - project_version, - project_date, - answer_yes, -): + draft: bool, + directory: str | None, + config_file: str | None, + project_name: str | None, + project_version: str | None, + project_date: str | None, + answer_yes: bool, +) -> None: """ The main entry point. """ @@ -132,13 +130,13 @@ def __main( ) fragment_directory = "newsfragments" - fragments, fragment_filenames = find_fragments( + fragment_contents, fragment_filenames = find_fragments( fragment_base_directory, config["sections"], fragment_directory, definitions ) click.echo("Rendering news fragments...", err=to_err) fragments = split_fragments( - fragments, definitions, all_bullets=config["all_bullets"] + fragment_contents, definitions, all_bullets=config["all_bullets"] ) if project_version is None: diff --git a/src/towncrier/check.py b/src/towncrier/check.py index deb86fc9..9dae3ba7 100644 --- a/src/towncrier/check.py +++ b/src/towncrier/check.py @@ -2,10 +2,13 @@ # See LICENSE for details. +from __future__ import annotations + import os import sys from subprocess import CalledProcessError +from typing import Container from warnings import warn import click @@ -15,7 +18,7 @@ from ._settings import config_option_help, load_config_from_options -def _get_default_compare_branch(branches): +def _get_default_compare_branch(branches: Container[str]) -> str | None: if "origin/main" in branches: return "origin/main" if "origin/master" in branches: @@ -54,16 +57,18 @@ def _get_default_compare_branch(branches): metavar="FILE_PATH", help=config_option_help, ) -def _main(compare_with, directory, config): +def _main(compare_with: str | None, directory: str | None, config: str | None) -> None: """ Check for new fragments on a branch. """ - return __main(compare_with, directory, config) + __main(compare_with, directory, config) -def __main(comparewith, directory, config): +def __main( + comparewith: str | None, directory: str | None, config_path: str | None +) -> None: - base_directory, config = load_config_from_options(directory, config) + base_directory, config = load_config_from_options(directory, config_path) if comparewith is None: comparewith = _get_default_compare_branch( diff --git a/src/towncrier/create.py b/src/towncrier/create.py index ce37fc49..a075f48c 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -5,6 +5,8 @@ Create a new fragment. """ +from __future__ import annotations + import os import click @@ -41,7 +43,14 @@ help="Sets the content of the new fragment.", ) @click.argument("filename") -def _main(ctx, directory, config, filename, edit, content): +def _main( + ctx: click.Context, + directory: str | None, + config: str | None, + filename: str, + edit: bool, + content: str, +) -> None: """ Create a new news fragment. @@ -56,14 +65,21 @@ def _main(ctx, directory, config, filename, edit, content): * .removal - a deprecation or removal of public API, * .misc - a ticket has been closed, but it is not of interest to users. """ - return __main(ctx, directory, config, filename, edit, content) + __main(ctx, directory, config, filename, edit, content) -def __main(ctx, directory, config, filename, edit, content): +def __main( + ctx: click.Context, + directory: str | None, + config_path: str | None, + filename: str, + edit: bool, + content: str, +) -> None: """ The main entry point. """ - base_directory, config = load_config_from_options(directory, config) + base_directory, config = load_config_from_options(directory, config_path) definitions = config["types"] or [] if len(filename.split(".")) < 2 or ( @@ -98,11 +114,11 @@ def __main(ctx, directory, config, filename, edit, content): raise click.ClickException(f"{segment_file} already exists") if edit: - content = _get_news_content_from_user(content) - - if content is None: - click.echo("Abort creating news fragment.") - ctx.exit(1) + edited_content = _get_news_content_from_user(content) + if edited_content is None: + click.echo("Abort creating news fragment.") + ctx.exit(1) + content = edited_content with open(segment_file, "w") as f: f.write(content) @@ -110,7 +126,7 @@ def __main(ctx, directory, config, filename, edit, content): click.echo(f"Created news fragment at {segment_file}") -def _get_news_content_from_user(message): +def _get_news_content_from_user(message: str) -> str | None: initial_content = ( "# Please write your news content. When finished, save the file.\n" "# In order to abort, exit without saving.\n" diff --git a/src/towncrier/newsfragments/421.misc b/src/towncrier/newsfragments/421.misc new file mode 100644 index 00000000..e69de29b diff --git a/src/towncrier/test/test_project.py b/src/towncrier/test/test_project.py index 6c9b4657..4d3531ce 100644 --- a/src/towncrier/test/test_project.py +++ b/src/towncrier/test/test_project.py @@ -8,7 +8,7 @@ from twisted.trial.unittest import TestCase -from .._project import get_version +from .._project import get_project_name, get_version class VersionFetchingTests(TestCase): @@ -40,6 +40,21 @@ def test_tuple(self): version = get_version(temp, "mytestproja") self.assertEqual(version, "1.3.12") + def test_unknown_type(self): + """ + A __version__ of unknown type will lead to an exception. + """ + temp = self.mktemp() + os.makedirs(temp) + os.makedirs(os.path.join(temp, "mytestprojb")) + + with open(os.path.join(temp, "mytestprojb", "__init__.py"), "w") as f: + f.write("__version__ = object()") + + self.assertRaises(Exception, get_version, temp, "mytestprojb") + + self.assertRaises(TypeError, get_project_name, temp, "mytestprojb") + def test_import_fails(self): """ An exception is raised when getting the version failed due to missing Python package files. diff --git a/tox.ini b/tox.ini index b569d967..bcc85c1c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = pre-commit, {pypy37,pypy38,py37,py38,py39,py310}-tests, check-manifest, check-newsfragment +envlist = pre-commit, {pypy37,pypy38,py37,py38,py39,py310}-tests, check-manifest, check-newsfragment, typecheck isolated_build=true skip_missing_envs = true @@ -35,6 +35,15 @@ commands = coverage combine -a coverage report +[testenv:typecheck] +deps = + mypy + # 2022-09-04: There is no release yet which includes type annotations. + incremental @ git+https://github.com/twisted/incremental.git@5845557ab9 + types-setuptools +commands = + mypy src/ + [testenv:build] allowlist_externals = bash