From 576f6ce8fb00d2f678cd9faeb77fecf6757e94a7 Mon Sep 17 00:00:00 2001 From: Maarten ter Huurne Date: Sun, 4 Sep 2022 02:45:45 +0200 Subject: [PATCH 01/12] Tell isort to use a line length of 88 This is the default line length applied by Black. Flake8 is configured in `setup.cfg` to use the same line length. --- pyproject.toml | 1 + src/towncrier/build.py | 6 +----- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b7a31f45..29297151 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ exclude = ''' [tool.isort] profile = "attrs" +line_length = 88 [build-system] diff --git a/src/towncrier/build.py b/src/towncrier/build.py index a59b13cf..986541ff 100644 --- a/src/towncrier/build.py +++ b/src/towncrier/build.py @@ -16,11 +16,7 @@ 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 From c6ae33d9b0dbd3deb5eafa727e2589919aa5f998 Mon Sep 17 00:00:00 2001 From: Maarten ter Huurne Date: Sun, 4 Sep 2022 02:47:53 +0200 Subject: [PATCH 02/12] Add type annotations --- src/towncrier/_builder.py | 65 ++++++++++++++--------- src/towncrier/_git.py | 11 ++-- src/towncrier/_project.py | 9 ++-- src/towncrier/_settings/fragment_types.py | 22 ++++---- src/towncrier/_settings/load.py | 21 ++++---- src/towncrier/_shell.py | 2 +- src/towncrier/_writer.py | 14 +++-- src/towncrier/build.py | 39 +++++++------- src/towncrier/check.py | 15 ++++-- src/towncrier/create.py | 36 +++++++++---- 10 files changed, 146 insertions(+), 88 deletions(-) diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 42c3bb00..15feba29 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -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: @@ -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(".") @@ -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. """ @@ -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) @@ -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) @@ -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(): @@ -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). @@ -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 @@ -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) @@ -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(): @@ -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) @@ -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) diff --git a/src/towncrier/_git.py b/src/towncrier/_git.py index 54afbf38..cb582c56 100644 --- a/src/towncrier/_git.py +++ b/src/towncrier/_git.py @@ -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 @@ -24,12 +25,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 +38,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..d6c49dac 100644 --- a/src/towncrier/_project.py +++ b/src/towncrier/_project.py @@ -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) @@ -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) @@ -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) @@ -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)}") diff --git a/src/towncrier/_settings/fragment_types.py b/src/towncrier/_settings/fragment_types.py index 434b6475..058a4d44 100644 --- a/src/towncrier/_settings/fragment_types.py +++ b/src/towncrier/_settings/fragment_types.py @@ -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: @@ -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.""" @@ -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 @@ -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() @@ -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)) @@ -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, {}) diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index f4335af8..50c13106 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -4,6 +4,7 @@ import os from collections import OrderedDict +from typing import Any, Mapping, Optional, Tuple import pkg_resources import tomli @@ -12,7 +13,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 +24,22 @@ def __init__(self, *args, **kwargs): _underlines = ["=", "-", "~"] -def load_config_from_options(directory, config): - if config is None: +def load_config_from_options( + directory: Optional[str], config_path: Optional[str] +) -> 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 +47,7 @@ def load_config_from_options(directory, config): return base_directory, config -def load_config(directory): +def load_config(directory: str) -> Optional[Mapping[str, Any]]: towncrier_toml = os.path.join(directory, "towncrier.toml") pyproject_toml = os.path.join(directory, "pyproject.toml") @@ -59,14 +62,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..b072a2e5 100644 --- a/src/towncrier/_shell.py +++ b/src/towncrier/_shell.py @@ -19,7 +19,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..888ac312 100644 --- a/src/towncrier/_writer.py +++ b/src/towncrier/_writer.py @@ -7,11 +7,17 @@ """ from pathlib import Path +from typing import Tuple 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 = True, +) -> None: """ Write *content* to *directory*/*filename* behind *start_string*. @@ -43,7 +49,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 986541ff..ca82bcf5 100644 --- a/src/towncrier/build.py +++ b/src/towncrier/build.py @@ -10,6 +10,7 @@ import sys from datetime import date +from typing import Optional import click @@ -20,7 +21,7 @@ from ._writer import append_to_newsfile -def _get_date(): +def _get_date() -> str: return date.today().isoformat() @@ -70,14 +71,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: Optional[str], + config_file: Optional[str], + project_name: Optional[str], + project_version: Optional[str], + project_date: Optional[str], + answer_yes: bool, +) -> None: """ Build a combined news file from news fragment. """ @@ -97,14 +98,14 @@ def _main( def __main( - draft, - directory, - config_file, - project_name, - project_version, - project_date, - answer_yes, -): + draft: bool, + directory: Optional[str], + config_file: Optional[str], + project_name: Optional[str], + project_version: Optional[str], + project_date: Optional[str], + answer_yes: bool, +) -> None: """ The main entry point. """ @@ -128,13 +129,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..59cefca7 100644 --- a/src/towncrier/check.py +++ b/src/towncrier/check.py @@ -6,6 +6,7 @@ import sys from subprocess import CalledProcessError +from typing import Container, Optional from warnings import warn import click @@ -15,7 +16,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]) -> Optional[str]: if "origin/main" in branches: return "origin/main" if "origin/master" in branches: @@ -54,16 +55,20 @@ def _get_default_compare_branch(branches): metavar="FILE_PATH", help=config_option_help, ) -def _main(compare_with, directory, config): +def _main( + compare_with: Optional[str], directory: Optional[str], config: Optional[str] +) -> 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: Optional[str], directory: Optional[str], config_path: Optional[str] +) -> 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..cdc0ac2a 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -7,6 +7,8 @@ import os +from typing import Optional + import click from ._settings import config_option_help, load_config_from_options @@ -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: Optional[str], + config: Optional[str], + 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: Optional[str], + config_path: Optional[str], + 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) -> Optional[str]: initial_content = ( "# Please write your news content. When finished, save the file.\n" "# In order to abort, exit without saving.\n" From dd8e366f73c1033aaf0c3e1e1c88d00b418b44a3 Mon Sep 17 00:00:00 2001 From: Maarten ter Huurne Date: Sun, 4 Sep 2022 02:48:49 +0200 Subject: [PATCH 03/12] Add configuration for mypy --- MANIFEST.in | 1 + mypy.ini | 9 +++++++++ 2 files changed, 10 insertions(+) create mode 100644 mypy.ini diff --git a/MANIFEST.in b/MANIFEST.in index a2261b30..0335ceaf 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -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 diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..2dba7cd3 --- /dev/null +++ b/mypy.ini @@ -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 From 46be5f0b27d3356111e001139e872744fce2c5c3 Mon Sep 17 00:00:00 2001 From: Maarten ter Huurne Date: Sun, 4 Sep 2022 03:11:48 +0200 Subject: [PATCH 04/12] Add tox env for type checking using mypy --- tox.ini | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 From 219e1d18672ed1d346fdae8563ca0aeb5e7552eb Mon Sep 17 00:00:00 2001 From: Maarten ter Huurne Date: Sun, 4 Sep 2022 03:27:51 +0200 Subject: [PATCH 05/12] Add empty newsfragment --- src/towncrier/newsfragments/421.misc | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/towncrier/newsfragments/421.misc diff --git a/src/towncrier/newsfragments/421.misc b/src/towncrier/newsfragments/421.misc new file mode 100644 index 00000000..e69de29b From 8914c85a843fdb117f3751a723c5e08c0d4f28fb Mon Sep 17 00:00:00 2001 From: Maarten ter Huurne Date: Sun, 4 Sep 2022 03:51:29 +0200 Subject: [PATCH 06/12] Add test case for handling of unknown __version__ types --- src/towncrier/test/test_project.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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. From 59a565c3bad7535a573ab818218c9acc3688de8b Mon Sep 17 00:00:00 2001 From: Adi Roiban Date: Sun, 4 Sep 2022 02:59:45 +0100 Subject: [PATCH 07/12] Enable mypy checks on CI. --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) 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 From 2bf80d0fb81c69e7d406a2958a9480e640b1ecb3 Mon Sep 17 00:00:00 2001 From: Maarten ter Huurne Date: Sun, 4 Sep 2022 04:30:54 +0200 Subject: [PATCH 08/12] Remove accidentally committed commented-out mypy message --- src/towncrier/_builder.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 15feba29..f00fc1ca 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -271,9 +271,6 @@ 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) From 2770b62d60824e272607c4748100e5097e491d48 Mon Sep 17 00:00:00 2001 From: Maarten ter Huurne Date: Sun, 4 Sep 2022 16:31:52 +0200 Subject: [PATCH 09/12] Move mypy config into pyproject.toml --- mypy.ini | 9 --------- pyproject.toml | 12 ++++++++++++ 2 files changed, 12 insertions(+), 9 deletions(-) delete mode 100644 mypy.ini diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 2dba7cd3..00000000 --- a/mypy.ini +++ /dev/null @@ -1,9 +0,0 @@ -[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 diff --git a/pyproject.toml b/pyproject.toml index 29297151..b15dbf68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,18 @@ 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] requires = [ "setuptools ~= 44.1.1", From 90695f200479063aa7865d1c2a256b787f0a8d56 Mon Sep 17 00:00:00 2001 From: Maarten ter Huurne Date: Sun, 4 Sep 2022 16:38:10 +0200 Subject: [PATCH 10/12] Enable postponed evaluation of annotations --- src/towncrier/__init__.py | 2 ++ src/towncrier/__main__.py | 2 ++ src/towncrier/_builder.py | 26 ++++++++++++----------- src/towncrier/_git.py | 9 ++++---- src/towncrier/_project.py | 2 ++ src/towncrier/_settings/__init__.py | 2 ++ src/towncrier/_settings/fragment_types.py | 8 ++++--- src/towncrier/_settings/load.py | 10 +++++---- src/towncrier/_shell.py | 2 ++ src/towncrier/_writer.py | 5 +++-- src/towncrier/build.py | 23 ++++++++++---------- src/towncrier/check.py | 12 +++++------ src/towncrier/create.py | 14 ++++++------ 13 files changed, 68 insertions(+), 49 deletions(-) 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 f00fc1ca..af4663ad 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -2,12 +2,14 @@ # See LICENSE for details. +from __future__ import annotations + import os import textwrap import traceback from collections import OrderedDict -from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple, Union +from typing import Any, Iterator, Mapping, Sequence from jinja2 import Template @@ -27,7 +29,7 @@ def strip_if_integer_string(s: str) -> str: # could not be parsed or doesn't contain a valid category. def parse_newfragment_basename( basename: str, definitions: Sequence[str] -) -> Union[Tuple[str, str, int], Tuple[None, None, None]]: +) -> tuple[str, str, int] | tuple[None, None, None]: invalid = (None, None, None) parts = basename.split(".") @@ -80,9 +82,9 @@ def parse_newfragment_basename( def find_fragments( base_directory: str, sections: Mapping[str, str], - fragment_directory: Optional[str], + fragment_directory: str | None, definitions: Sequence[str], -) -> Tuple[Mapping[str, Mapping[Tuple[str, str, int], str]], List[str]]: +) -> tuple[Mapping[str, Mapping[tuple[str, str, int], str]], list[str]]: """ Sections are a dictonary of section names to paths. """ @@ -150,7 +152,7 @@ def prefixed_lines() -> Iterator[str]: # add an example output here. Next time someone digs deep enough to figure it # out, please do so... def split_fragments( - fragments: Mapping[str, Mapping[Tuple[str, str, int], str]], + 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]]]]: @@ -158,7 +160,7 @@ def split_fragments( output = OrderedDict() for section_name, section_fragments in fragments.items(): - section: Dict[str, Dict[str, List[str]]] = {} + section: dict[str, dict[str, list[str]]] = {} for (ticket, category, counter), content in section_fragments.items(): @@ -188,7 +190,7 @@ def split_fragments( return output -def issue_key(issue: str) -> Tuple[int, str]: +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). @@ -199,12 +201,12 @@ def issue_key(issue: str) -> Tuple[int, str]: return (-1, issue) -def entry_key(entry: Tuple[str, Sequence[str]]) -> List[Tuple[int, str]]: +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: Tuple[str, Sequence[str]]) -> int: +def bullet_key(entry: tuple[str, Sequence[str]]) -> int: text, _ = entry if not text: return -1 @@ -217,7 +219,7 @@ def bullet_key(entry: Tuple[str, Sequence[str]]) -> int: return 3 -def render_issue(issue_format: Optional[str], issue: str) -> str: +def render_issue(issue_format: str | None, issue: str) -> str: if issue_format is None: try: int(issue) @@ -230,7 +232,7 @@ def render_issue(issue_format: Optional[str], issue: str) -> str: def render_fragments( template: str, - issue_format: Optional[str], + issue_format: str | None, fragments: Mapping[str, Mapping[str, Mapping[str, Sequence[str]]]], definitions: Sequence[str], underlines: Sequence[str], @@ -246,7 +248,7 @@ def render_fragments( jinja_template = Template(template, trim_blocks=True) - data: Dict[str, Dict[str, Dict[str, List[str]]]] = OrderedDict() + data: dict[str, dict[str, dict[str, list[str]]]] = OrderedDict() for section_name, section_value in fragments.items(): diff --git a/src/towncrier/_git.py b/src/towncrier/_git.py index cb582c56..c4360870 100644 --- a/src/towncrier/_git.py +++ b/src/towncrier/_git.py @@ -1,15 +1,16 @@ # Copyright (c) Amber Brown, 2015 # See LICENSE for details. +from __future__ import annotations + import os from subprocess import STDOUT, call, check_output -from typing import List import click -def remove_files(fragment_filenames: List[str], answer_yes: bool) -> None: +def remove_files(fragment_filenames: list[str], answer_yes: bool) -> None: if not fragment_filenames: return @@ -30,7 +31,7 @@ def stage_newsfile(directory: str, filename: str) -> None: call(["git", "add", os.path.join(directory, filename)]) -def get_remote_branches(base_directory: str) -> List[str]: +def get_remote_branches(base_directory: str) -> list[str]: output = check_output( ["git", "branch", "-r"], cwd=base_directory, encoding="utf-8", stderr=STDOUT ) @@ -40,7 +41,7 @@ def get_remote_branches(base_directory: str) -> List[str]: def list_changed_files_compared_to_branch( base_directory: str, compare_with: str -) -> List[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 d6c49dac..9c887ff0 100644 --- a/src/towncrier/_project.py +++ b/src/towncrier/_project.py @@ -6,6 +6,8 @@ """ +from __future__ import annotations + import sys from importlib import import_module 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 058a4d44..a9b83676 100644 --- a/src/towncrier/_settings/fragment_types.py +++ b/src/towncrier/_settings/fragment_types.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import abc import collections as clt -from typing import Any, Iterable, Mapping, Type +from typing import Any, Iterable, Mapping class BaseFragmentTypesLoader: @@ -14,8 +16,8 @@ def __init__(self, config: Mapping[str, Any]): self.config = config @classmethod - def factory(cls, config: Mapping[str, Any]) -> "BaseFragmentTypesLoader": - fragment_types_class: Type[BaseFragmentTypesLoader] = 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: diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index 50c13106..534a638f 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -1,10 +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, Optional, Tuple +from typing import Any, Mapping import pkg_resources import tomli @@ -25,8 +27,8 @@ def __init__(self, *args: str, **kwargs: str): def load_config_from_options( - directory: Optional[str], config_path: Optional[str] -) -> Tuple[str, Mapping[str, Any]]: + directory: str | None, config_path: str | None +) -> tuple[str, Mapping[str, Any]]: if config_path is None: if directory is None: directory = os.getcwd() @@ -47,7 +49,7 @@ def load_config_from_options( return base_directory, config -def load_config(directory: str) -> Optional[Mapping[str, Any]]: +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") diff --git a/src/towncrier/_shell.py b/src/towncrier/_shell.py index b072a2e5..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 diff --git a/src/towncrier/_writer.py b/src/towncrier/_writer.py index 888ac312..b7a251f0 100644 --- a/src/towncrier/_writer.py +++ b/src/towncrier/_writer.py @@ -6,8 +6,9 @@ affecting existing content. """ +from __future__ import annotations + from pathlib import Path -from typing import Tuple def append_to_newsfile( @@ -51,7 +52,7 @@ def append_to_newsfile( def _figure_out_existing_content( news_file: Path, start_string: str, single_file: bool -) -> Tuple[str, str]: +) -> 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 ca82bcf5..5c4a012c 100644 --- a/src/towncrier/build.py +++ b/src/towncrier/build.py @@ -6,11 +6,12 @@ """ +from __future__ import annotations + import os import sys from datetime import date -from typing import Optional import click @@ -72,11 +73,11 @@ def _get_date() -> str: ) def _main( draft: bool, - directory: Optional[str], - config_file: Optional[str], - project_name: Optional[str], - project_version: Optional[str], - project_date: Optional[str], + directory: str | None, + config_file: str | None, + project_name: str | None, + project_version: str | None, + project_date: str | None, answer_yes: bool, ) -> None: """ @@ -99,11 +100,11 @@ def _main( def __main( draft: bool, - directory: Optional[str], - config_file: Optional[str], - project_name: Optional[str], - project_version: Optional[str], - project_date: Optional[str], + directory: str | None, + config_file: str | None, + project_name: str | None, + project_version: str | None, + project_date: str | None, answer_yes: bool, ) -> None: """ diff --git a/src/towncrier/check.py b/src/towncrier/check.py index 59cefca7..9dae3ba7 100644 --- a/src/towncrier/check.py +++ b/src/towncrier/check.py @@ -2,11 +2,13 @@ # See LICENSE for details. +from __future__ import annotations + import os import sys from subprocess import CalledProcessError -from typing import Container, Optional +from typing import Container from warnings import warn import click @@ -16,7 +18,7 @@ from ._settings import config_option_help, load_config_from_options -def _get_default_compare_branch(branches: Container[str]) -> Optional[str]: +def _get_default_compare_branch(branches: Container[str]) -> str | None: if "origin/main" in branches: return "origin/main" if "origin/master" in branches: @@ -55,9 +57,7 @@ def _get_default_compare_branch(branches: Container[str]) -> Optional[str]: metavar="FILE_PATH", help=config_option_help, ) -def _main( - compare_with: Optional[str], directory: Optional[str], config: Optional[str] -) -> None: +def _main(compare_with: str | None, directory: str | None, config: str | None) -> None: """ Check for new fragments on a branch. """ @@ -65,7 +65,7 @@ def _main( def __main( - comparewith: Optional[str], directory: Optional[str], config_path: Optional[str] + comparewith: str | None, directory: str | None, config_path: str | None ) -> None: base_directory, config = load_config_from_options(directory, config_path) diff --git a/src/towncrier/create.py b/src/towncrier/create.py index cdc0ac2a..a075f48c 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -5,9 +5,9 @@ Create a new fragment. """ -import os +from __future__ import annotations -from typing import Optional +import os import click @@ -45,8 +45,8 @@ @click.argument("filename") def _main( ctx: click.Context, - directory: Optional[str], - config: Optional[str], + directory: str | None, + config: str | None, filename: str, edit: bool, content: str, @@ -70,8 +70,8 @@ def _main( def __main( ctx: click.Context, - directory: Optional[str], - config_path: Optional[str], + directory: str | None, + config_path: str | None, filename: str, edit: bool, content: str, @@ -126,7 +126,7 @@ def __main( click.echo(f"Created news fragment at {segment_file}") -def _get_news_content_from_user(message: str) -> Optional[str]: +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" From 114127ee8b8b32d5f2041dc9dcc02d670aa49bcd Mon Sep 17 00:00:00 2001 From: Maarten ter Huurne Date: Mon, 5 Sep 2022 21:27:07 +0200 Subject: [PATCH 11/12] Remove mypy.ini from manifest I forgot to do this when moving the mypy config into `pyproject.toml`. --- MANIFEST.in | 1 - 1 file changed, 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 0335ceaf..a2261b30 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,7 +6,6 @@ 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 From 993bd6ebc3316a1e575103c39c12fa9eafe8cab0 Mon Sep 17 00:00:00 2001 From: Maarten ter Huurne Date: Mon, 5 Sep 2022 21:37:39 +0200 Subject: [PATCH 12/12] Remove accidentally resurrected default value for `single_file` param --- src/towncrier/_writer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/towncrier/_writer.py b/src/towncrier/_writer.py index b7a251f0..92119114 100644 --- a/src/towncrier/_writer.py +++ b/src/towncrier/_writer.py @@ -17,7 +17,7 @@ def append_to_newsfile( start_string: str, top_line: str, content: str, - single_file: bool = True, + single_file: bool, ) -> None: """ Write *content* to *directory*/*filename* behind *start_string*.