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

Make configuration a proper class, push settings & check coverage to 100% #429

Merged
merged 11 commits into from
Sep 20, 2022
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ line_length = 88
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$'
exclude = '^src/towncrier/test/test_.*\.py$'

[[tool.mypy.overrides]]
module = 'click_default_group'
Expand All @@ -91,6 +91,10 @@ source = ["src", ".tox/*/site-packages"]
[tool.coverage.report]
show_missing = true
skip_covered = true
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
]
omit = [
"src/towncrier/__main__.py",
"src/towncrier/test/*",
Expand Down
14 changes: 7 additions & 7 deletions src/towncrier/_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import traceback

from collections import OrderedDict, defaultdict
from typing import Any, DefaultDict, Iterator, Mapping, Sequence
from typing import Any, DefaultDict, Iterable, Iterator, Mapping, Sequence

from jinja2 import Template

Expand All @@ -28,7 +28,7 @@ def strip_if_integer_string(s: str) -> str:
# 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: str, definitions: Sequence[str]
basename: str, frag_type_names: Iterable[str]
) -> tuple[str, str, int] | tuple[None, None, None]:
invalid = (None, None, None)
parts = basename.split(".")
Expand All @@ -38,14 +38,14 @@ def parse_newfragment_basename(
if len(parts) == 2:
ticket, category = parts
ticket = strip_if_integer_string(ticket)
return (ticket, category, 0) if category in definitions else invalid
return (ticket, category, 0) if category in frag_type_names else invalid

# There are at least 3 parts. Search for a valid category from the second
# part onwards.
# The category is used as the reference point in the parts list to later
# infer the issue number and counter value.
for i in range(1, len(parts)):
if parts[i] in definitions:
if parts[i] in frag_type_names:
# Current part is a valid category according to given definitions.
category = parts[i]
# Use the previous part as the ticket number.
Expand Down Expand Up @@ -83,7 +83,7 @@ def find_fragments(
base_directory: str,
sections: Mapping[str, str],
fragment_directory: str | None,
definitions: Sequence[str],
frag_type_names: Iterable[str],
orphan_prefix: str | None = None,
) -> tuple[Mapping[str, Mapping[tuple[str, str, int], str]], list[str]]:
"""
Expand Down Expand Up @@ -115,7 +115,7 @@ def find_fragments(
for basename in files:

ticket, category, counter = parse_newfragment_basename(
basename, definitions
basename, frag_type_names
)
if category is None:
continue
Expand Down Expand Up @@ -244,7 +244,7 @@ def render_fragments(
template: str,
issue_format: str | None,
fragments: Mapping[str, Mapping[str, Mapping[str, Sequence[str]]]],
definitions: Sequence[str],
definitions: Mapping[str, Mapping[str, Any]],
underlines: Sequence[str],
wrap: bool,
versiondata: Mapping[str, str],
Expand Down
84 changes: 57 additions & 27 deletions src/towncrier/_settings/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,49 @@
import sys

from collections import OrderedDict
from typing import Any, Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Mapping

import pkg_resources

from .._settings import fragment_types as ft


if TYPE_CHECKING:
# We only use Literal for type-checking and Mypy always brings its own
# typing_extensions so this is safe without further dependencies.
hynek marked this conversation as resolved.
Show resolved Hide resolved
if sys.version_info < (3, 8):
from typing_extensions import Literal
else:
from typing import Literal

if sys.version_info < (3, 11):
import tomli as tomllib
else:
import tomllib


@dataclass
class Config:
package: str
package_dir: str
single_file: bool
filename: str
directory: str | None
version: str | None
name: str | None
sections: Mapping[str, str]
types: Mapping[str, Mapping[str, Any]]
template: str
start_string: str
title_format: str | Literal[False]
issue_format: str | None
underlines: list[str]
wrap: bool
all_bullets: bool
orphan_prefix: str


class ConfigError(Exception):
def __init__(self, *args: str, **kwargs: str):
self.failing_option = kwargs.get("failing_option")
Expand All @@ -34,7 +64,7 @@ def __init__(self, *args: str, **kwargs: str):

def load_config_from_options(
directory: str | None, config_path: str | None
) -> tuple[str, Mapping[str, Any]]:
) -> tuple[str, Config]:
if config_path is None:
if directory is None:
directory = os.getcwd()
Expand All @@ -43,10 +73,10 @@ def load_config_from_options(
config = load_config(base_directory)
else:
config_path = os.path.abspath(config_path)
if directory:
base_directory = os.path.abspath(directory)
else:
if directory is None:
base_directory = os.path.dirname(config_path)
else:
base_directory = os.path.abspath(directory)
Comment on lines +76 to +79
Copy link
Member Author

Choose a reason for hiding this comment

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

I've flipped this logic to mirror the logic from above, because it was confusing to have the same check inverted.

config = load_config_from_file(os.path.dirname(config_path), config_path)

if config is None:
Expand All @@ -55,7 +85,7 @@ def load_config_from_options(
return base_directory, config


def load_config(directory: str) -> Mapping[str, Any] | None:
def load_config(directory: str) -> Config | None:

towncrier_toml = os.path.join(directory, "towncrier.toml")
pyproject_toml = os.path.join(directory, "pyproject.toml")
Expand All @@ -70,14 +100,14 @@ def load_config(directory: str) -> Mapping[str, Any] | None:
return load_config_from_file(directory, config_file)


def load_config_from_file(directory: str, config_file: str) -> Mapping[str, Any]:
def load_config_from_file(directory: str, config_file: str) -> Config:
with open(config_file, "rb") as conffile:
config = tomllib.load(conffile)

return parse_toml(directory, config)


def parse_toml(base_path: str, config: Mapping[str, Any]) -> Mapping[str, Any]:
def parse_toml(base_path: str, config: Mapping[str, Any]) -> Config:
if "tool" not in config:
raise ConfigError("No [tool.towncrier] section.", failing_option="all")

Expand Down Expand Up @@ -134,22 +164,22 @@ def parse_toml(base_path: str, config: Mapping[str, Any]) -> Mapping[str, Any]:
failing_option="template",
)

return {
"package": config.get("package", ""),
"package_dir": config.get("package_dir", "."),
"single_file": single_file,
"filename": config.get("filename", "NEWS.rst"),
"directory": config.get("directory"),
"version": config.get("version"),
"name": config.get("name"),
"sections": sections,
"types": types,
"template": template,
"start_string": config.get("start_string", _start_string),
"title_format": config.get("title_format", _title_format),
"issue_format": config.get("issue_format"),
"underlines": config.get("underlines", _underlines),
"wrap": wrap,
"all_bullets": all_bullets,
"orphan_prefix": config.get("orphan_prefix", "+"),
}
return Config(
package=config.get("package", ""),
package_dir=config.get("package_dir", "."),
single_file=single_file,
filename=config.get("filename", "NEWS.rst"),
directory=config.get("directory"),
version=config.get("version"),
name=config.get("name"),
sections=sections,
types=types,
template=template,
start_string=config.get("start_string", _start_string),
title_format=config.get("title_format", _title_format),
issue_format=config.get("issue_format"),
underlines=config.get("underlines", _underlines),
wrap=wrap,
all_bullets=all_bullets,
orphan_prefix=config.get("orphan_prefix", "+"),
)
hynek marked this conversation as resolved.
Show resolved Hide resolved
59 changes: 27 additions & 32 deletions src/towncrier/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,51 +114,47 @@ def __main(
to_err = draft

click.echo("Loading template...", err=to_err)
with open(config["template"], "rb") as tmpl:
with open(config.template, "rb") as tmpl:
template = tmpl.read().decode("utf8")

click.echo("Finding news fragments...", err=to_err)

definitions = config["types"]

if config.get("directory"):
fragment_base_directory = os.path.abspath(config["directory"])
if config.directory is not None:
fragment_base_directory = os.path.abspath(config.directory)
fragment_directory = None
else:
fragment_base_directory = os.path.abspath(
os.path.join(base_directory, config["package_dir"], config["package"])
os.path.join(base_directory, config.package_dir, config.package)
)
fragment_directory = "newsfragments"

fragment_contents, fragment_filenames = find_fragments(
fragment_base_directory,
config["sections"],
config.sections,
fragment_directory,
definitions,
config["orphan_prefix"],
config.types,
config.orphan_prefix,
)

click.echo("Rendering news fragments...", err=to_err)
fragments = split_fragments(
fragment_contents, definitions, all_bullets=config["all_bullets"]
fragment_contents, config.types, all_bullets=config.all_bullets
)

if project_version is None:
project_version = config.get("version")
project_version = config.version
if project_version is None:
project_version = get_version(
os.path.join(base_directory, config["package_dir"]), config["package"]
os.path.join(base_directory, config.package_dir), config.package
).strip()

if project_name is None:
project_name = config.get("name")
project_name = config.name
if not project_name:
package = config.get("package")
package = config.package
if package:
project_name = get_project_name(
os.path.abspath(
os.path.join(base_directory, config["package_dir"])
),
os.path.abspath(os.path.join(base_directory, config.package_dir)),
package,
)
else:
Expand All @@ -168,13 +164,13 @@ def __main(
if project_date is None:
project_date = _get_date().strip()

if config["title_format"]:
top_line = config["title_format"].format(
if config.title_format:
top_line = config.title_format.format(
name=project_name, version=project_version, project_date=project_date
)
render_title_with_fragments = False
render_title_separately = True
elif config["title_format"] is False:
elif config.title_format is False:
# This is an odd check but since we support both "" and False with
# different effects we have to do something a bit abnormal here.
top_line = ""
Expand All @@ -188,22 +184,22 @@ def __main(
rendered = render_fragments(
# The 0th underline is used for the top line
template,
config["issue_format"],
config.issue_format,
fragments,
definitions,
config["underlines"][1:],
config["wrap"],
config.types,
config.underlines[1:],
config.wrap,
{"name": project_name, "version": project_version, "date": project_date},
top_underline=config["underlines"][0],
all_bullets=config["all_bullets"],
top_underline=config.underlines[0],
all_bullets=config.all_bullets,
render_title=render_title_with_fragments,
)

if render_title_separately:
content = "\n".join(
[
top_line,
config["underlines"][0] * len(top_line),
config.underlines[0] * len(top_line),
rendered,
]
)
Expand All @@ -219,10 +215,9 @@ def __main(
click.echo(content)
else:
click.echo("Writing to newsfile...", err=to_err)
start_string = config["start_string"]
news_file = config["filename"]
news_file = config.filename

if config["single_file"] is False:
if config.single_file is False:
# The release notes for each version are stored in a separate file.
# The name of that file is generated based on the current version and project.
news_file = news_file.format(
Expand All @@ -232,10 +227,10 @@ def __main(
append_to_newsfile(
base_directory,
news_file,
start_string,
config.start_string,
top_line,
content,
single_file=config["single_file"],
single_file=config.single_file,
)

click.echo("Staging newsfile...", err=to_err)
Expand Down
12 changes: 6 additions & 6 deletions src/towncrier/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,27 +104,27 @@ def __main(
click.echo(f"{n}. {change}")
click.echo("----")

news_file = os.path.normpath(os.path.join(base_directory, config["filename"]))
news_file = os.path.normpath(os.path.join(base_directory, config.filename))
if news_file in files:
click.echo("Checks SKIPPED: news file changes detected.")
sys.exit(0)

if config.get("directory"):
fragment_base_directory = os.path.abspath(config["directory"])
if config.directory:
fragment_base_directory = os.path.abspath(config.directory)
fragment_directory = None
else:
fragment_base_directory = os.path.abspath(
os.path.join(base_directory, config["package_dir"], config["package"])
os.path.join(base_directory, config.package_dir, config.package)
)
fragment_directory = "newsfragments"

fragments = {
os.path.normpath(path)
for path in find_fragments(
fragment_base_directory,
config["sections"],
config.sections,
fragment_directory,
config["types"],
config.types.keys(),
)[1]
}
fragments_in_branch = fragments & files
Expand Down
Loading