diff --git a/docs/configuration.rst b/docs/configuration.rst index 28607c22..7d73f508 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -2,57 +2,206 @@ Configuration Reference ======================= ``towncrier`` has many knobs and switches you can use, to customize it to your project's needs. -The setup in the `Tutorial `_ doesn't touch on many, but this document will detail each of these options for you! +The setup in the :doc:`tutorial` doesn't touch on many, but this document will detail each of these options for you! -For how to perform common customization tasks, see `Customization `_. +For how to perform common customization tasks, see :doc:`customization/index`. ``[tool.towncrier]`` -------------------- -All configuration for ``towncrier`` sits inside ``pyproject.toml``, under the ``tool.towncrier`` namespace. +All configuration for ``towncrier`` sits inside ``towncrier.toml`` or ``pyproject.toml``, under the ``tool.towncrier`` namespace. Please see https://toml.io/ for how to write TOML. +A minimal configuration for a Python project looks like this: + +.. code-block:: toml + + # pyproject.toml + + [tool.towncrier] + package = "myproject" + +A minimal configuration for a non-Python project looks like this: + +.. code-block:: toml + + # towncrier.toml + + [tool.towncrier] + name = "My Project" Top level keys ~~~~~~~~~~~~~~ -- ``directory`` -- If you are not storing your news fragments in your Python package, or aren't using Python, this is the path to where your newsfragments will be put. -- ``filename`` -- The filename of your news file. - ``NEWS.rst`` by default. -- ``package`` -- The package name of your project. - (Python projects only) -- ``package_dir`` -- The folder your package lives. ``./`` by default, some projects might need to use ``src``. - (Python projects only) -- ``template`` -- Path to an alternate template for generating the news file, if you have one. -- ``start_string`` -- The magic string that ``towncrier`` looks for when considering where the release notes should start. - ``.. towncrier release notes start`` by default. -- ``title_format`` -- A format string for the title of your project. - ``{name} {version} ({project_date})`` by default. -- ``issue_format`` -- A format string for rendering the issue/ticket number in newsfiles. - ``#{issue}`` by default. -- ``underlines`` -- The characters used for underlining headers. - ``["=", "-", "~"]`` by default. +``name`` + The name of your project. + + For Python projects that provide a ``package`` key, if left empty then the name will be automatically determined. + + ``""`` by default. + +``version`` + The version of your project. + + Python projects that provide the ``package`` key can have the version to be automatically determined from a ``__version__`` variable in the package's module. + + If not provided or able to be determined, the version must be passed explicitly by the command line argument ``--version``. + +``directory`` + The directory storing your news fragments. + + For Python projects that provide a ``package`` key, the default is a ``newsfragments`` directory within the package. + Otherwise the default is a ``newsfragments`` directory relative to the configuration file. + +``filename`` + The filename of your news file. + + ``"NEWS.rst"`` by default. + +``template`` + Path to the template for generating the news file. + + If the path looks like ``:``, it is interpreted as a template bundled with an installed Python package. + + ``"towncrier:default.rst"`` by default unless ``filename`` ends with ``.md``, in which case the default is ``"towncrier:default.md"``. + +``start_string`` + The magic string that ``towncrier`` looks for when considering where the release notes should start. + + ``".. towncrier release notes start\n"`` by default unless ``filename`` ends with ``.md``, in which case the default is ``"\n"``. + +``title_format`` + A format string for the title of your project. + + The explicit value of ``False`` will disable the title entirely. + Any other empty value means the template should render the title (the bundled templates use `` ()``). + Strings should use the following keys to render the title dynamically: ``{name}``, ``{version}``, and ``{project_date}``. + + ``""`` by default. + +``issue_format`` + A format string for rendering the issue/ticket number in newsfiles. + + If none, the issues are rendered as ``#`` if for issues that are integers, or just ```` otherwise. + Use the ``{issue}`` key in your string render the issue number, for example Markdown projects may want to use ``"[{issue}]: https:///{issue}"``. + + ``None`` by default. + +``underlines`` + The characters used for underlining headers. + + Not used in the bundled Markdown template. + + ``["=", "-", "~"]`` by default. + +``wrap`` + Boolean value indicating whether to wrap news fragments to a line length of 79. + + ``false`` by default. + +``all_bullets`` + Boolean value indicating whether the template uses bullets for each news fragment. + + ``true`` by default. + +``single_file`` + Boolean value indicating whether to write all news fragments to a single file. + + If ``false``, the ``filename`` should use the following keys to render the filenames dynamically: + ``{name}``, ``{version}``, and ``{project_date}``. + + ``true`` by default. + +``orphan_prefix`` + The prefix used for orphaned news fragments. + + ``"+"`` by default. + +Extra top level keys for Python projects +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``package`` + The Python package name of your project. + + Allows ``name`` and ``version`` to be automatically determined from the Python package. + Changes the default ``directory`` to be a ``newsfragments`` directory within this package. + +``package_dir`` + The folder your package lives. + + ``"."`` by default, some projects might need to use ``"src"``. + + +Sections +-------- + +``towncrier`` supports splitting fragments into multiple sections, each with its own news of fragment types. + +Add an array of tables your ``.toml`` configuration file named ``[[tool.towncrier.section]]``. + +Each table within this array has the following mandatory keys: + + +``name`` + The name of the section. + +``path`` + The path to the directory containing the news fragments for this section, relative to the configured ``directory``. + Use ``""`` for the root directory. + +For example: + +.. code-block:: toml + + [[tool.towncrier.section]] + name = "Main Platform" + path = "" + + [[tool.towncrier.section]] + name = "Secondary" + path = "secondary" + +Section Path Behaviour +~~~~~~~~~~~~~~~~~~~~~~ + +The path behaviour is slightly different depending on whether ``directory`` is explicitly set. + +If ``directory`` is not set, "newsfragments" is added to the end of each path. For example, with the above sections, the paths would be: + +:Main Platform: ./newsfragments +:Secondary: ./secondary/newsfragments + +If ``directory`` *is* set, the section paths are appended to this path. For example, with ``directory = "changes"`` and the above sections, the paths would be: + +:Main Platform: ./changes +:Secondary: ./changes/secondary Custom fragment types --------------------- -``towncrier`` allows defining custom fragment types. -Custom fragment types will be used instead ``towncrier`` default ones, they are not combined. -There are two ways to add custom fragment types. +``towncrier`` has the following default fragment types: ``feature``, ``bugfix``, ``doc``, ``removal``, and ``misc``. + +You can use either of the two following method to define custom types instead (you will need to redefine any of the default types you want to use). -Defining Custom Fragment Types With a TOML Mapping -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Use TOML tables (alphabetical order) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Users can configure each of their own custom fragment types by adding tables to -the pyproject.toml named ``[tool.towncrier.fragment.]``. +Adding tables to your ``.toml`` configuration file named ``[tool.towncrier.fragment.]``. -These tables may include the following optional keys: +These may include the following optional keys: - * ``name``: The description of the fragment type, as it must be included in the news file. - If omitted, it defaults to its fragment type, but capitalized. - * ``showcontent``: Whether if the fragment contents should be included in the news file. If omitted, it defaults to ``true`` + +``name`` + The description of the fragment type, as it must be included in the news file. + + Defaults to its fragment type, but capitalized. + +``showcontent`` + A boolean value indicating whether the fragment contents should be included in the news file. + + ``true`` by default. For example, if you want your custom fragment types to be ``["feat", "fix", "chore",]`` and you want all of them to use the default configuration except ``"chore"`` you can do it as follows: @@ -70,25 +219,30 @@ For example, if you want your custom fragment types to be ``["feat", "fix", "cho .. warning:: - Since TOML mappings aren't ordered, the sections are always rendered alphabetically. + Since TOML mappings aren't ordered, types defined using this method are always rendered alphabetically. + +Use a TOML Array (defined order) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Defining Custom Fragment Types With an Array of TOML Tables -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Add an array of tables to your ``.toml`` configuration file named ``[[tool.towncrier.type]]``. -Users can create their own custom fragment types by adding an array of -tables to the pyproject.toml named ``[[tool.towncrier.type]]``. +If you use this way to configure custom fragment types, ensure there is no ``tool.towncrier.fragment`` table. -If you use this way to configure custom fragment types, please note that ``fragment_types`` must be empty or not provided. +Each table within this array has the following mandatory keys: -Each custom type (``[[tool.towncrier.type]]``) has the following -mandatory keys: -* ``directory``: The type / category of the fragment. -* ``name``: The description of the fragment type, as it must be included - in the news file. -* ``showcontent``: Whether if the fragment contents should be included in the - news file. +``directory`` + The type / category of the fragment. + +``name`` + The description of the fragment type, as it must be included + in the news file. + +``showcontent`` + A boolean value indicating whether the fragment contents should be included in the news file. + + ``true`` by default. For example: @@ -104,40 +258,3 @@ For example: directory = "chore" name = "Other Tasks" showcontent = false - - -All Options ------------ - -``towncrier`` has the following global options, which can be specified in the toml file: - -.. code-block:: toml - - [tool.towncrier] - package = "" - package_dir = "." - single_file = true # if false, filename is formatted like `title_format`. - filename = "NEWS.rst" - directory = "directory/of/news/fragments" - version = "1.2.3" # project version if maintained separately - name = "arbitrary project name" - template = "path/to/template.rst" - start_string = "Text used to detect where to add the generated content in the middle of a file. Generated content added after this text. Newline auto added." - title_format = "{name} {version} ({project_date})" # or false if template includes title - issue_format = "format string for {issue} (issue is the first part of fragment name)" - underlines = "=-~" - wrap = false # Wrap text to 79 characters - all_bullets = true # make all fragments bullet points - orphan_prefix = "+" # Prefix for orphan news fragment files, set to "" to disable. - -If ``single_file`` is set to ``true`` or unspecified, all changes will be written to a single fixed newsfile, whose name is literally fixed as the ``filename`` option. -In each run of ``towncrier build``, content of new changes will append at the top of old content, and after ``start_string`` if the ``start_string`` already appears in the newsfile. -If the corresponding ``top_line``, which is formatted as the option 'title_format', already exists in newsfile, ``ValueError`` will be raised to remind you "already produced newsfiles for this version". - -If ``single_file`` is set to ``false`` instead, each versioned ``towncrier build`` will generate a separate newsfile, whose name is formatted as the pattern given by option ``filename``. -For example, if ``filename="{version}-notes.rst"``, then the release note with version "7.8.9" will be written to the file "7.8.9-notes.rst". -If the newsfile already exists, its content will be overwritten with new release note, without throwing a ``ValueError`` warning. - -If ``title_format`` is unspecified or an empty string, the default format will be used. -If set to ``false``, no title will be created. -This can be useful if the specified template creates the title itself. diff --git a/docs/customization/newsfile.rst b/docs/customization/newsfile.rst index dc5a7935..67f3df4f 100644 --- a/docs/customization/newsfile.rst +++ b/docs/customization/newsfile.rst @@ -5,23 +5,36 @@ Adding Content Above ``towncrier`` ---------------------------------- If you wish to have content at the top of the news file (for example, to say where you can find the tickets), you can use a special rST comment to tell ``towncrier`` to only update after it. -In your existing news file (e.g. ``NEWS.rst``), add the following line above where you want ``towncrier`` to put content:: +In your existing news file (e.g. ``NEWS.rst``), add the following line above where you want ``towncrier`` to put content: - .. towncrier release notes start +.. code-block:: restructuredtext -In an existing news file, it'll look something like this:: + .. towncrier release notes start - This is the changelog of my project. You can find the - issue tracker at http://blah. +In an existing news file, it'll look something like this: - .. towncrier release notes start +.. code-block:: restructuredtext - myproject 1.0.2 (2018-01-01) - ============================ + This is the changelog of my project. You can find the + issue tracker at http://blah. - Bugfixes - -------- + .. towncrier release notes start - - Fixed, etc... + myproject 1.0.2 (2018-01-01) + ============================ + + Bugfixes + -------- + + - Fixed, etc... ``towncrier`` will not alter content above the comment. + +Markdown +~~~~~~~~ + +If your news file is in Markdown (e.g. ``NEWS.md``), use the following comment instead: + +.. code-block:: html + + diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 7b893b1d..0a42ff35 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -1,7 +1,7 @@ Tutorial ======== -This tutorial assumes you have a Python project with a *reStructuredText* (rst) news file (also known as changelog) file that you wish to use ``towncrier`` on, to generate its news file. +This tutorial assumes you have a Python project with a *reStructuredText* (rst) or *Markdown* (md) news file (also known as changelog) that you wish to use ``towncrier`` on, to generate its news file. It will cover setting up your project with a basic configuration, which you can then feel free to `customize `_. Install from PyPI:: @@ -144,6 +144,8 @@ You should get an output similar to this:: - #1, #2 +Note: if you configure a Markdown file (for example, ``filename = "CHANGES.md"``) in your configuration file, the titles will be output in Markdown format instead. + Producing News Files In Production ---------------------------------- @@ -161,6 +163,8 @@ If you wish to have content at the top of the news file (for example, to say whe ``towncrier`` will then put the version notes after this comment, and leave your existing content that was above it where it is. +Note: if you configure a Markdown file (for example, ``filename = "CHANGES.md"``) in your configuration file, the comment should be ```` instead. + Finale ------ diff --git a/pyproject.toml b/pyproject.toml index d54647d1..8738e59b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ requires-python = ">=3.7" dependencies = [ "click", "click-default-group", - "importlib-resources>1.3; python_version<'3.9'", + "importlib-resources>=5; python_version<'3.10'", "incremental", "jinja2", "tomli; python_version<'3.11'", @@ -136,6 +136,12 @@ profile = "attrs" line_length = 88 +[tool.ruff.isort] +# Match isort's "attrs" profile +lines-after-imports = 2 +lines-between-types = 1 + + [tool.mypy] strict = true # 2022-09-04: Trial's API isn't annotated yet, which limits the usefulness of type-checking diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index f6cd87bd..d1b14dba 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -8,7 +8,7 @@ import textwrap import traceback -from collections import OrderedDict, defaultdict +from collections import defaultdict from typing import Any, DefaultDict, Iterable, Iterator, Mapping, Sequence from jinja2 import Template @@ -66,15 +66,14 @@ def parse_newfragment_basename( # Returns a structure like: # -# OrderedDict([ -# ("", -# { -# ("142", "misc"): u"", -# ("1", "feature"): u"some cool description", -# }), -# ("Names", {}), -# ("Web", {("3", "bugfix"): u"Fixed a thing"}), -# ]) +# { +# "": { +# ("142", "misc"): "", +# ("1", "feature"): "some cool description", +# }, +# "Names": {}, +# "Web": {("3", "bugfix"): "Fixed a thing"}, +# } # # We should really use attrs. # @@ -89,7 +88,7 @@ def find_fragments( """ Sections are a dictonary of section names to paths. """ - content = OrderedDict() + content = {} fragment_filenames = [] # Multiple orphan news fragments are allowed per section, so initialize a counter # that can be incremented automatically. @@ -164,7 +163,7 @@ def split_fragments( definitions: Mapping[str, Mapping[str, Any]], all_bullets: bool = True, ) -> Mapping[str, Mapping[str, Mapping[str, Sequence[str]]]]: - output = OrderedDict() + output = {} for section_name, section_fragments in fragments.items(): section: dict[str, dict[str, list[str]]] = {} @@ -182,7 +181,7 @@ def split_fragments( if definitions[category]["showcontent"] is False: content = "" - texts = section.setdefault(category, OrderedDict()) + texts = section.setdefault(category, {}) tickets = texts.setdefault(content, []) if ticket: @@ -255,12 +254,15 @@ 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]]]] = {} + issues_by_category: dict[str, dict[str, list[str]]] = {} for section_name, section_value in fragments.items(): - data[section_name] = OrderedDict() + data[section_name] = {} + issues_by_category[section_name] = {} for category_name, category_value in section_value.items(): + category_issues: set[str] = set() # Suppose we start with an ordering like this: # # - Fix the thing (#7, #123, #2) @@ -273,6 +275,7 @@ def render_fragments( entries = [] for text, issues in category_value.items(): entries.append((text, sorted(issues, key=issue_key))) + category_issues.update(issues) # Then we sort the lines: # @@ -284,12 +287,16 @@ def render_fragments( # Then we put these nicely sorted entries back in an ordered dict # for the template, after formatting each issue number - categories = OrderedDict() + categories = {} for text, issues in entries: rendered = [render_issue(issue_format, i) for i in issues] categories[text] = rendered data[section_name][category_name] = categories + issues_by_category[section_name][category_name] = [ + render_issue(issue_format, i) + for i in sorted(category_issues, key=issue_key) + ] done = [] @@ -311,6 +318,7 @@ def get_indent(text: str) -> str: versiondata=versiondata, top_underline=top_underline, get_indent=get_indent, # simplify indentation in the jinja template. + issues_by_category=issues_by_category, ) for line in res.split("\n"): diff --git a/src/towncrier/_settings/fragment_types.py b/src/towncrier/_settings/fragment_types.py index d370986d..a9882c9a 100644 --- a/src/towncrier/_settings/fragment_types.py +++ b/src/towncrier/_settings/fragment_types.py @@ -1,7 +1,6 @@ from __future__ import annotations import abc -import collections as clt from typing import Any, Iterable, Mapping @@ -36,16 +35,14 @@ def load(self) -> Mapping[str, Mapping[str, Any]]: class DefaultFragmentTypesLoader(BaseFragmentTypesLoader): """Default towncrier's fragment types.""" - _default_types = clt.OrderedDict( - [ - # Keep in-sync with docs/tutorial.rst. - ("feature", {"name": "Features", "showcontent": True}), - ("bugfix", {"name": "Bugfixes", "showcontent": True}), - ("doc", {"name": "Improved Documentation", "showcontent": True}), - ("removal", {"name": "Deprecations and Removals", "showcontent": True}), - ("misc", {"name": "Misc", "showcontent": False}), - ] - ) + _default_types = { + # Keep in-sync with docs/tutorial.rst. + "feature": {"name": "Features", "showcontent": True}, + "bugfix": {"name": "Bugfixes", "showcontent": True}, + "doc": {"name": "Improved Documentation", "showcontent": True}, + "removal": {"name": "Deprecations and Removals", "showcontent": True}, + "misc": {"name": "Misc", "showcontent": False}, + } def load(self) -> Mapping[str, Mapping[str, Any]]: """Load default types.""" @@ -72,7 +69,7 @@ class ArrayFragmentTypesLoader(BaseFragmentTypesLoader): def load(self) -> Mapping[str, Mapping[str, Any]]: """Load types from toml array of mappings.""" - types = clt.OrderedDict() + types = {} types_config = self.config["type"] for type_config in types_config: directory = type_config["directory"] @@ -123,7 +120,7 @@ def load(self) -> Mapping[str, Mapping[str, Any]]: (fragment_type, self._load_options(fragment_type)) for fragment_type in fragment_types ] - types = clt.OrderedDict(custom_types_sequence) + types = dict(custom_types_sequence) return types def _load_options(self, fragment_type: str) -> Mapping[str, Any]: diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index 10bde248..92098f9d 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -4,13 +4,14 @@ from __future__ import annotations import atexit +import dataclasses import os +import re import sys -from collections import OrderedDict from contextlib import ExitStack -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Mapping +from pathlib import Path +from typing import TYPE_CHECKING, Any, Mapping, Sequence from .._settings import fragment_types as ft @@ -23,7 +24,7 @@ else: from typing import Literal -if sys.version_info < (3, 9): +if sys.version_info < (3, 10): import importlib_resources as resources else: from importlib import resources @@ -35,25 +36,28 @@ import tomllib -@dataclass +re_resource_template = re.compile(r"[-\w.]+:[-\w.]+$") + + +@dataclasses.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 + template: str | tuple[str, str] start_string: str - title_format: str | Literal[False] - issue_format: str | None - underlines: list[str] - wrap: bool - all_bullets: bool - orphan_prefix: str + package: str = "" + package_dir: str = "." + single_file: bool = True + filename: str = "NEWS.rst" + directory: str | None = None + version: str | None = None + name: str = "" + title_format: str | Literal[False] = "" + issue_format: str | None = None + underlines: Sequence[str] = ("=", "-", "~") + wrap: bool = False + all_bullets: bool = True + orphan_prefix: str = "+" class ConfigError(Exception): @@ -62,12 +66,6 @@ def __init__(self, *args: str, **kwargs: str): super().__init__(*args) -_start_string = ".. towncrier release notes start\n" -_title_format = None -_template_fname = "towncrier:default" -_underlines = ["=", "-", "~"] - - def load_config_from_options( directory: str | None, config_path: str | None ) -> tuple[str, Config]: @@ -118,82 +116,83 @@ def load_config_from_file(directory: str, config_file: str) -> Config: def parse_toml(base_path: str, config: Mapping[str, Any]) -> Config: - if "tool" not in config: + if "towncrier" not in (config.get("tool") or {}): raise ConfigError("No [tool.towncrier] section.", failing_option="all") config = config["tool"]["towncrier"] + parsed_data = {} + + # Check for misspelt options. + for typo, correct in [ + ("singlefile", "single_file"), + ]: + if config.get(typo): + raise ConfigError( + f"`{typo}` is not a valid option. Did you mean `{correct}`?", + failing_option=typo, + ) - sections = OrderedDict() + # Process options. + for field in dataclasses.fields(Config): + if field.name in ("sections", "types", "template"): + # Skip these options, they are processed later. + continue + if field.name in config: + # Interestingly, the __future__ annotation turns the type into a string. + if field.type in ("bool", bool): + if not isinstance(config[field.name], bool): + raise ConfigError( + f"`{field.name}` option must be boolean: false or true.", + failing_option=field.name, + ) + parsed_data[field.name] = config[field.name] + + # Process 'section'. + sections = {} if "section" in config: for x in config["section"]: sections[x.get("name", "")] = x["path"] else: sections[""] = "" + parsed_data["sections"] = sections + + # Process 'types'. fragment_types_loader = ft.BaseFragmentTypesLoader.factory(config) - types = fragment_types_loader.load() - - wrap = config.get("wrap", False) - - single_file_wrong = config.get("singlefile") - if single_file_wrong: - raise ConfigError( - "`singlefile` is not a valid option. Did you mean `single_file`?", - failing_option="singlefile", - ) - - single_file = config.get("single_file", True) - if not isinstance(single_file, bool): - raise ConfigError( - "`single_file` option must be a boolean: false or true.", - failing_option="single_file", - ) - - all_bullets = config.get("all_bullets", True) - if not isinstance(all_bullets, bool): - raise ConfigError( - "`all_bullets` option must be boolean: false or true.", - failing_option="all_bullets", - ) - - template = config.get("template", _template_fname) - if template.startswith("towncrier:"): - resource_name = f"templates/{template.split('towncrier:', 1)[1]}.rst" - resource_path = _file_manager.enter_context( - resources.as_file(resources.files("towncrier") / resource_name) - ) - - if not resource_path.is_file(): + parsed_data["types"] = fragment_types_loader.load() + + # Process 'template'. + markdown_file = Path(config.get("filename", "")).suffix == ".md" + template = config.get("template", "towncrier:default") + if re_resource_template.match(template): + package, resource = template.split(":", 1) + if not Path(resource).suffix: + resource += ".md" if markdown_file else ".rst" + if not resources.is_resource(package, resource): + if resources.is_resource(package + ".templates", resource): + package += ".templates" + else: + raise ConfigError( + f"'{package}' does not have a template named '{resource}'.", + failing_option="template", + ) + template = (package, resource) + else: + template = os.path.join(base_path, template) + if not os.path.isfile(template): raise ConfigError( - "Towncrier does not have a template named '%s'." - % (template.split("towncrier:", 1)[1],) + f"The template file '{template}' does not exist.", + failing_option="template", ) - template = str(resource_path) - else: - template = os.path.join(base_path, template) + parsed_data["template"] = template + + # Process 'start_string'. + + start_string = config.get("start_string", "") + if not start_string: + start_string_template = "\n" if markdown_file else ".. {}\n" + start_string = start_string_template.format("towncrier release notes start") + parsed_data["start_string"] = start_string - if not os.path.exists(template): - raise ConfigError( - f"The template file '{template}' does not exist.", - failing_option="template", - ) - - 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", "+"), - ) + # Return the parsed config. + return Config(**parsed_data) diff --git a/src/towncrier/build.py b/src/towncrier/build.py index b5477f72..bb016443 100644 --- a/src/towncrier/build.py +++ b/src/towncrier/build.py @@ -25,6 +25,12 @@ from ._writer import append_to_newsfile +if sys.version_info < (3, 10): + import importlib_resources as resources +else: + from importlib import resources + + def _get_date() -> str: return date.today().isoformat() @@ -146,8 +152,11 @@ def __main( to_err = draft click.echo("Loading template...", err=to_err) - with open(config.template, "rb") as tmpl: - template = tmpl.read().decode("utf8") + if isinstance(config.template, tuple): + template = resources.read_text(*config.template) + else: + with open(config.template, encoding="utf-8") as tmpl: + template = tmpl.read() click.echo("Finding news fragments...", err=to_err) diff --git a/src/towncrier/newsfragments/483.feature b/src/towncrier/newsfragments/483.feature new file mode 100644 index 00000000..3397d34b --- /dev/null +++ b/src/towncrier/newsfragments/483.feature @@ -0,0 +1,3 @@ +Provide a default Markdown template if the configured filename ends with ``.md``. + +The Markdown template uses the same rendered format as the default *reStructuredText* template, but with a Markdown syntax. diff --git a/src/towncrier/templates/default.md b/src/towncrier/templates/default.md new file mode 100644 index 00000000..45f7a395 --- /dev/null +++ b/src/towncrier/templates/default.md @@ -0,0 +1,61 @@ +{% if render_title %} +{% if versiondata.name %} +# {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) +{% else %} +{{ versiondata.version }} ({{ versiondata.date }}) +{% endif %} +{% endif %} +{% for section, _ in sections.items() %} +{% if section %} + +## {{section}} +{% endif %} + +{% if sections[section] %} +{% for category, val in definitions.items() if category in sections[section] %} +### {{ definitions[category]['name'] }} + +{% if definitions[category]['showcontent'] %} +{% for text, values in sections[section][category].items() %} +- {{ text }} +{%- if values %} +{% if "\n - " in text or '\n * ' in text %} + + + ( +{%- else %} + ( +{%- endif -%} +{%- for issue in values %} +{{ issue.split(": ", 1)[0] }}{% if not loop.last %}, {% endif %} +{%- endfor %} +) +{% else %} + +{% endif %} +{% endfor %} + +{% else %} +- {% for issue in sections[section][category][''] %} +{{ issue.split(": ", 1)[0] }}{% if not loop.last %}, {% endif %} +{% endfor %} + + +{% endif %} +{% if issues_by_category[section][category] and "]: " in issues_by_category[section][category][0] %} +{% for issue in issues_by_category[section][category] %} +{{ issue }} +{% endfor %} + +{% endif %} +{% if sections[section][category]|length == 0 %} +No significant changes. + +{% else %} +{% endif %} +{% endfor %} +{% else %} +No significant changes. + +{% endif %} +{% endfor %} diff --git a/src/towncrier/test/helpers.py b/src/towncrier/test/helpers.py index e86a9fcd..ddc9ffc6 100644 --- a/src/towncrier/test/helpers.py +++ b/src/towncrier/test/helpers.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys +import textwrap from functools import wraps from pathlib import Path @@ -19,12 +20,14 @@ def read(filename: str | Path) -> str: return Path(filename).read_text() -def write(path: str | Path, contents: str) -> None: +def write(path: str | Path, contents: str, dedent: bool = False) -> None: """ Create a file with given contents including any missing parent directories """ p = Path(path) p.parent.mkdir(parents=True, exist_ok=True) + if dedent: + contents = textwrap.dedent(contents) p.write_text(contents) diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 0a76d916..4dbc07ce 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -15,7 +15,7 @@ from .._shell import cli from ..build import _main -from .helpers import read, setup_simple_project, with_isolated_runner +from .helpers import read, setup_simple_project, with_isolated_runner, write class TestCli(TestCase): @@ -1080,6 +1080,7 @@ def test_title_format_false(self): "20-01-2001", "--draft", ], + catch_exceptions=False, ) expected_output = dedent( @@ -1162,6 +1163,103 @@ def test_start_string(self): self.assertEqual(expected_output, output) + @with_isolated_runner + def test_default_start_string(self, runner): + """ + The default start string is ``.. towncrier release notes start``. + """ + setup_simple_project() + + write("foo/newsfragments/123.feature", "Adds levitation") + write( + "NEWS.rst", + contents=""" + a line + + another + + .. towncrier release notes start + + a footer! + """, + dedent=True, + ) + + result = runner.invoke(_main, ["--date", "01-01-2001"], catch_exceptions=False) + self.assertEqual(0, result.exit_code, result.output) + output = read("NEWS.rst") + + expected_output = dedent( + """ + a line + + another + + .. towncrier release notes start + + Foo 1.2.3 (01-01-2001) + ====================== + + Features + -------- + + - Adds levitation (#123) + + + a footer! + """ + ) + + self.assertEqual(expected_output, output) + + @with_isolated_runner + def test_default_start_string_markdown(self, runner): + """ + The default start string is ```` for + Markdown. + """ + setup_simple_project(extra_config='filename = "NEWS.md"') + + write("foo/newsfragments/123.feature", "Adds levitation") + write( + "NEWS.md", + contents=""" + a line + + another + + + + a footer! + """, + dedent=True, + ) + + result = runner.invoke(_main, ["--date", "01-01-2001"], catch_exceptions=False) + self.assertEqual(0, result.exit_code, result.output) + output = read("NEWS.md") + + expected_output = dedent( + """ + a line + + another + + + + # Foo 1.2.3 (01-01-2001) + + ### Features + + - Adds levitation (#123) + + + a footer! + """ + ) + + self.assertEqual(expected_output, output) + def test_with_topline_and_template_and_draft(self): """ Spacing is proper when drafting with a topline and a template. diff --git a/src/towncrier/test/test_format.py b/src/towncrier/test/test_format.py index 241cbb8b..61e6d88c 100644 --- a/src/towncrier/test/test_format.py +++ b/src/towncrier/test/test_format.py @@ -2,8 +2,6 @@ # See LICENSE for details. -from collections import OrderedDict - from twisted.trial.unittest import TestCase from .._builder import render_fragments, split_fragments @@ -11,6 +9,8 @@ class FormatterTests(TestCase): + maxDiff = None + def test_split(self): fragments = { "": { @@ -38,13 +38,11 @@ def test_split(self): }, } - definitions = OrderedDict( - [ - ("feature", {"name": "Features", "showcontent": True}), - ("bugfix", {"name": "Bugfixes", "showcontent": True}), - ("misc", {"name": "Misc", "showcontent": False}), - ] - ) + definitions = { + "feature": {"name": "Features", "showcontent": True}, + "bugfix": {"name": "Bugfixes", "showcontent": True}, + "misc": {"name": "Misc", "showcontent": False}, + } output = split_fragments(fragments, definitions) @@ -55,36 +53,29 @@ def test_basic(self): Basic functionality -- getting a bunch of news fragments and formatting them into a rST file -- works. """ - fragments = OrderedDict( - [ - ( - "", - { - # asciibetical sorting will do 1, 142, 9 - # we want 1, 9, 142 instead - ("142", "misc", 0): "", - ("1", "misc", 0): "", - ("9", "misc", 0): "", - ("bar", "misc", 0): "", - ("4", "feature", 0): "Stuff!", - ("2", "feature", 0): "Foo added.", - ("72", "feature", 0): "Foo added.", - ("9", "feature", 0): "Foo added.", - ("baz", "feature", 0): "Fun!", - }, - ), - ("Names", {}), - ("Web", {("3", "bugfix", 0): "Web fixed."}), - ] - ) + fragments = { + "": { + # asciibetical sorting will do 1, 142, 9 + # we want 1, 9, 142 instead + ("142", "misc", 0): "", + ("1", "misc", 0): "", + ("9", "misc", 0): "", + ("bar", "misc", 0): "", + ("4", "feature", 0): "Stuff!", + ("2", "feature", 0): "Foo added.", + ("72", "feature", 0): "Foo added.", + ("9", "feature", 0): "Foo added.", + ("baz", "feature", 0): "Fun!", + }, + "Names": {}, + "Web": {("3", "bugfix", 0): "Web fixed."}, + } - definitions = OrderedDict( - [ - ("feature", {"name": "Features", "showcontent": True}), - ("bugfix", {"name": "Bugfixes", "showcontent": True}), - ("misc", {"name": "Misc", "showcontent": False}), - ] - ) + definitions = { + "feature": {"name": "Features", "showcontent": True}, + "bugfix": {"name": "Bugfixes", "showcontent": True}, + "misc": {"name": "Misc", "showcontent": False}, + } expected_output = """MyProject 1.0 (never) ===================== @@ -176,6 +167,144 @@ def test_basic(self): ) self.assertEqual(output, expected_output_weird_underlines) + def test_markdown(self): + """ + Check formating of default markdown template. + """ + fragments = { + "": { + # asciibetical sorting will do 1, 142, 9 + # we want 1, 9, 142 instead + ("142", "misc", 0): "", + ("1", "misc", 0): "", + ("9", "misc", 0): "", + ("bar", "misc", 0): "", + ("4", "feature", 0): "Stuff!", + ("2", "feature", 0): "Foo added.", + ("72", "feature", 0): "Foo added.", + ("9", "feature", 0): "Foo added.", + ("3", "feature", 0): "Multi-line\nhere", + ("baz", "feature", 0): "Fun!", + }, + "Names": {}, + "Web": { + ("3", "bugfix", 0): "Web fixed.", + ("2", "bugfix", 0): "Multi-line bulleted\n- fix\n- here", + }, + } + + definitions = { + "feature": {"name": "Features", "showcontent": True}, + "bugfix": {"name": "Bugfixes", "showcontent": True}, + "misc": {"name": "Misc", "showcontent": False}, + } + + expected_output = """# MyProject 1.0 (never) + +### Features + +- Fun! (baz) +- Foo added. (#2, #9, #72) +- Multi-line + here (#3) +- Stuff! (#4) + +### Misc + +- bar, #1, #9, #142 + + +## Names + +No significant changes. + + +## Web + +### Bugfixes + +- Multi-line bulleted + - fix + - here + + (#2) +- Web fixed. (#3) +""" + + template = read_pkg_resource("templates/default.md") + + fragments = split_fragments(fragments, definitions) + output = render_fragments( + template, + None, + fragments, + definitions, + ["-", "~"], + wrap=True, + versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, + ) + self.assertEqual(output, expected_output) + + # Also test with custom issue format + expected_output = """# MyProject 1.0 (never) + +### Features + +- Fun! ([baz]) +- Foo added. ([2], [9], [72]) +- Multi-line + here ([3]) +- Stuff! ([4]) + +[baz]: https://github.com/twisted/towncrier/issues/baz +[2]: https://github.com/twisted/towncrier/issues/2 +[3]: https://github.com/twisted/towncrier/issues/3 +[4]: https://github.com/twisted/towncrier/issues/4 +[9]: https://github.com/twisted/towncrier/issues/9 +[72]: https://github.com/twisted/towncrier/issues/72 + +### Misc + +- [bar], [1], [9], [142] + +[bar]: https://github.com/twisted/towncrier/issues/bar +[1]: https://github.com/twisted/towncrier/issues/1 +[9]: https://github.com/twisted/towncrier/issues/9 +[142]: https://github.com/twisted/towncrier/issues/142 + + +## Names + +No significant changes. + + +## Web + +### Bugfixes + +- Multi-line bulleted + - fix + - here + + ([2]) +- Web fixed. ([3]) + +[2]: https://github.com/twisted/towncrier/issues/2 +[3]: https://github.com/twisted/towncrier/issues/3 +""" + + output = render_fragments( + template, + "[{issue}]: https://github.com/twisted/towncrier/issues/{issue}", + fragments, + definitions, + ["-", "~"], + wrap=True, + versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, + ) + + self.assertEqual(output, expected_output) + def test_issue_format(self): """ issue_format option can be used to format issue text. @@ -194,7 +323,7 @@ def test_issue_format(self): } } - definitions = OrderedDict([("misc", {"name": "Misc", "showcontent": False})]) + definitions = {"misc": {"name": "Misc", "showcontent": False}} expected_output = """MyProject 1.0 (never) ===================== @@ -240,9 +369,7 @@ def test_line_wrapping(self): } } - definitions = OrderedDict( - [("feature", {"name": "Features", "showcontent": True})] - ) + definitions = {"feature": {"name": "Features", "showcontent": True}} expected_output = """MyProject 1.0 (never) ===================== @@ -295,9 +422,7 @@ def test_line_wrapping_disabled(self): } } - definitions = OrderedDict( - [("feature", {"name": "Features", "showcontent": True})] - ) + definitions = {"feature": {"name": "Features", "showcontent": True}} expected_output = """MyProject 1.0 (never) ===================== diff --git a/src/towncrier/test/test_project.py b/src/towncrier/test/test_project.py index 4b420648..c41c3a55 100644 --- a/src/towncrier/test/test_project.py +++ b/src/towncrier/test/test_project.py @@ -25,7 +25,6 @@ def test_str(self): A str __version__ will be picked up. """ temp = self.mktemp() - os.makedirs(temp) os.makedirs(os.path.join(temp, "mytestproj")) with open(os.path.join(temp, "mytestproj", "__init__.py"), "w") as f: @@ -39,7 +38,6 @@ def test_tuple(self): A tuple __version__ will be picked up. """ temp = self.mktemp() - os.makedirs(temp) os.makedirs(os.path.join(temp, "mytestproja")) with open(os.path.join(temp, "mytestproja", "__init__.py"), "w") as f: @@ -109,7 +107,6 @@ 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: @@ -133,14 +130,12 @@ def test_already_installed_import(self): project_name = "mytestproj_already_installed_import" temp = self.mktemp() - os.makedirs(temp) os.makedirs(os.path.join(temp, project_name)) with open(os.path.join(temp, project_name, "__init__.py"), "w") as f: f.write("__version__ = (1, 3, 12)") sys_path_temp = self.mktemp() - os.makedirs(sys_path_temp) os.makedirs(os.path.join(sys_path_temp, project_name)) with open(os.path.join(sys_path_temp, project_name, "__init__.py"), "w") as f: @@ -161,7 +156,6 @@ def test_installed_package_found_when_no_source_present(self): project_name = "mytestproj_only_installed" sys_path_temp = self.mktemp() - os.makedirs(sys_path_temp) os.makedirs(os.path.join(sys_path_temp, project_name)) with open(os.path.join(sys_path_temp, project_name, "__init__.py"), "w") as f: diff --git a/src/towncrier/test/test_settings.py b/src/towncrier/test/test_settings.py index c954e738..3c2dca94 100644 --- a/src/towncrier/test/test_settings.py +++ b/src/towncrier/test/test_settings.py @@ -1,59 +1,128 @@ # Copyright (c) Amber Brown, 2015 # See LICENSE for details. -import collections as clt import os -import textwrap - -from textwrap import dedent from twisted.trial.unittest import TestCase from .._settings import ConfigError, load_config +from .helpers import write class TomlSettingsTests(TestCase): + def mktemp_project( + self, *, pyproject_toml: str = "", towncrier_toml: str = "" + ) -> str: + """ + Create a temporary directory with a pyproject.toml file in it. + """ + project_dir = self.mktemp() + os.makedirs(project_dir) + + if pyproject_toml: + write( + os.path.join(project_dir, "pyproject.toml"), + pyproject_toml, + dedent=True, + ) + + if towncrier_toml: + write( + os.path.join(project_dir, "towncrier.toml"), + towncrier_toml, + dedent=True, + ) + + return project_dir + def test_base(self): """ Test a "base config". """ - temp = self.mktemp() - os.makedirs(temp) - - with open(os.path.join(temp, "pyproject.toml"), "w") as f: - f.write( - """[tool.towncrier] -package = "foobar" -orphan_prefix = "~" -""" - ) + project_dir = self.mktemp_project( + pyproject_toml=""" + [tool.towncrier] + package = "foobar" + orphan_prefix = "~" + """ + ) - config = load_config(temp) + config = load_config(project_dir) self.assertEqual(config.package, "foobar") self.assertEqual(config.package_dir, ".") self.assertEqual(config.filename, "NEWS.rst") - self.assertEqual(config.underlines, ["=", "-", "~"]) + self.assertEqual(config.underlines, ("=", "-", "~")) self.assertEqual(config.orphan_prefix, "~") + def test_markdown(self): + """ + If the filename references an .md file and the builtin template doesn't have an + extension, add .md rather than .rst. + """ + project_dir = self.mktemp_project( + pyproject_toml=""" + [tool.towncrier] + package = "foobar" + filename = "NEWS.md" + """ + ) + + config = load_config(project_dir) + + self.assertEqual(config.filename, "NEWS.md") + + self.assertEqual(config.template, ("towncrier.templates", "default.md")) + + def test_explicit_template_extension(self): + """ + If the filename references an .md file and the builtin template has an + extension, don't change it. + """ + project_dir = self.mktemp_project( + pyproject_toml=""" + [tool.towncrier] + package = "foobar" + filename = "NEWS.md" + template = "towncrier:default.rst" + """ + ) + + config = load_config(project_dir) + + self.assertEqual(config.filename, "NEWS.md") + self.assertEqual(config.template, ("towncrier.templates", "default.rst")) + + def test_template_extended(self): + """ + The template can be any package and resource, and although we look for a + resource's 'templates' package, it could also be in the specified resource + directly. + """ + project_dir = self.mktemp_project( + pyproject_toml=""" + [tool.towncrier] + package = "foobar" + template = "towncrier.templates:default.rst" + """ + ) + + config = load_config(project_dir) + + self.assertEqual(config.template, ("towncrier.templates", "default.rst")) + def test_missing(self): """ If the config file doesn't have the correct toml key, we error. """ - temp = self.mktemp() - os.makedirs(temp) - - with open(os.path.join(temp, "pyproject.toml"), "w") as f: - f.write( - dedent( - """ + project_dir = self.mktemp_project( + pyproject_toml=""" [something.else] blah='baz' - """ - ) - ) + """ + ) with self.assertRaises(ConfigError) as e: - load_config(temp) + load_config(project_dir) self.assertEqual(e.exception.failing_option, "all") @@ -61,21 +130,15 @@ def test_incorrect_single_file(self): """ single_file must be a bool. """ - temp = self.mktemp() - os.makedirs(temp) - - with open(os.path.join(temp, "pyproject.toml"), "w") as f: - f.write( - dedent( - """ + project_dir = self.mktemp_project( + pyproject_toml=""" [tool.towncrier] single_file = "a" - """ - ) - ) + """ + ) with self.assertRaises(ConfigError) as e: - load_config(temp) + load_config(project_dir) self.assertEqual(e.exception.failing_option, "single_file") @@ -83,21 +146,15 @@ def test_incorrect_all_bullets(self): """ all_bullets must be a bool. """ - temp = self.mktemp() - os.makedirs(temp) - - with open(os.path.join(temp, "pyproject.toml"), "w") as f: - f.write( - dedent( - """ + project_dir = self.mktemp_project( + pyproject_toml=""" [tool.towncrier] all_bullets = "a" - """ - ) - ) + """ + ) with self.assertRaises(ConfigError) as e: - load_config(temp) + load_config(project_dir) self.assertEqual(e.exception.failing_option, "all_bullets") @@ -105,21 +162,15 @@ def test_mistype_singlefile(self): """ singlefile is not accepted, single_file is. """ - temp = self.mktemp() - os.makedirs(temp) - - with open(os.path.join(temp, "pyproject.toml"), "w") as f: - f.write( - dedent( - """ + project_dir = self.mktemp_project( + pyproject_toml=""" [tool.towncrier] singlefile = "a" - """ - ) - ) + """ + ) with self.assertRaises(ConfigError) as e: - load_config(temp) + load_config(project_dir) self.assertEqual(e.exception.failing_option, "singlefile") @@ -127,56 +178,38 @@ def test_towncrier_toml_preferred(self): """ Towncrier prefers the towncrier.toml for autodetect over pyproject.toml. """ - temp = self.mktemp() - os.makedirs(temp) - - with open(os.path.join(temp, "towncrier.toml"), "w") as f: - f.write( - dedent( - """ + project_dir = self.mktemp_project( + towncrier_toml=""" [tool.towncrier] package = "a" - """ - ) - ) - - with open(os.path.join(temp, "pyproject.toml"), "w") as f: - f.write( - dedent( - """ + """, + pyproject_toml=""" [tool.towncrier] package = "b" - """ - ) - ) + """, + ) - config = load_config(temp) + config = load_config(project_dir) self.assertEqual(config.package, "a") def test_missing_template(self): """ Towncrier will raise an exception saying when it can't find a template. """ - temp = self.mktemp() - os.makedirs(temp) - - with open(os.path.join(temp, "towncrier.toml"), "w") as f: - f.write( - dedent( - """ + project_dir = self.mktemp_project( + towncrier_toml=""" [tool.towncrier] template = "foo.rst" - """ - ) - ) + """ + ) with self.assertRaises(ConfigError) as e: - load_config(temp) + load_config(project_dir) self.assertEqual( str(e.exception), "The template file '{}' does not exist.".format( - os.path.normpath(os.path.join(temp, "foo.rst")), + os.path.normpath(os.path.join(project_dir, "foo.rst")), ), ) @@ -185,24 +218,18 @@ def test_missing_template_in_towncrier(self): Towncrier will raise an exception saying when it can't find a template from the Towncrier templates. """ - temp = self.mktemp() - os.makedirs(temp) - - with open(os.path.join(temp, "towncrier.toml"), "w") as f: - f.write( - dedent( - """ + project_dir = self.mktemp_project( + towncrier_toml=""" [tool.towncrier] template = "towncrier:foo" - """ - ) - ) + """ + ) with self.assertRaises(ConfigError) as e: - load_config(temp) + load_config(project_dir) self.assertEqual( - str(e.exception), "Towncrier does not have a template named 'foo'." + str(e.exception), "'towncrier' does not have a template named 'foo.rst'." ) def test_custom_types_as_tables_array_deprecated(self): @@ -214,20 +241,22 @@ def test_custom_types_as_tables_array_deprecated(self): This functionality is considered deprecated, but we continue to support it to keep backward compatibility. """ - toml_content = """ - [tool.towncrier] - package = "foobar" - [[tool.towncrier.type]] - directory="foo" - name="Foo" - showcontent=false - - [[tool.towncrier.type]] - directory="spam" - name="Spam" - showcontent=true - """ - toml_content = textwrap.dedent(toml_content) + project_dir = self.mktemp_project( + pyproject_toml=""" + [tool.towncrier] + package = "foobar" + [[tool.towncrier.type]] + directory="foo" + name="Foo" + showcontent=false + + [[tool.towncrier.type]] + directory="spam" + name="Spam" + showcontent=true + """ + ) + config = load_config(project_dir) expected = [ ( "foo", @@ -244,10 +273,7 @@ def test_custom_types_as_tables_array_deprecated(self): }, ), ] - expected = clt.OrderedDict(expected) - config = self.load_config_from_string( - toml_content, - ) + expected = dict(expected) actual = config.types self.assertDictEqual(expected, actual) @@ -256,60 +282,32 @@ def test_custom_types_as_tables(self): Custom fragment categories can be defined inside the toml config file using tables. """ - test_project_path = self.mktemp() - os.makedirs(test_project_path) - toml_content = """ - [tool.towncrier] - package = "foobar" - [tool.towncrier.fragment.feat] - ignored_field="Bazz" - [tool.towncrier.fragment.fix] - [tool.towncrier.fragment.chore] - name = "Other Tasks" - showcontent = false - """ - toml_content = textwrap.dedent(toml_content) - expected = [ - ( - "chore", - { - "name": "Other Tasks", - "showcontent": False, - }, - ), - ( - "feat", - { - "name": "Feat", - "showcontent": True, - }, - ), - ( - "fix", - { - "name": "Fix", - "showcontent": True, - }, - ), - ] - - expected = clt.OrderedDict(expected) - config = self.load_config_from_string( - toml_content, + project_dir = self.mktemp_project( + pyproject_toml=""" + [tool.towncrier] + package = "foobar" + [tool.towncrier.fragment.feat] + ignored_field="Bazz" + [tool.towncrier.fragment.fix] + [tool.towncrier.fragment.chore] + name = "Other Tasks" + showcontent = false + """ ) + config = load_config(project_dir) + expected = { + "chore": { + "name": "Other Tasks", + "showcontent": False, + }, + "feat": { + "name": "Feat", + "showcontent": True, + }, + "fix": { + "name": "Fix", + "showcontent": True, + }, + } actual = config.types self.assertDictEqual(expected, actual) - - def load_config_from_string(self, toml_content): - """Load configuration from a string. - - Given a string following toml syntax, - obtain the towncrier configuration. - """ - test_project_path = self.mktemp() - os.makedirs(test_project_path) - toml_path = os.path.join(test_project_path, "pyproject.toml") - with open(toml_path, "w") as f: - f.write(toml_content) - config = load_config(test_project_path) - return config diff --git a/src/towncrier/test/test_write.py b/src/towncrier/test/test_write.py index 83443f20..563fde6f 100644 --- a/src/towncrier/test/test_write.py +++ b/src/towncrier/test/test_write.py @@ -3,7 +3,6 @@ import os -from collections import OrderedDict from pathlib import Path from textwrap import dedent @@ -13,40 +12,31 @@ from .._builder import render_fragments, split_fragments from .._writer import append_to_newsfile from ..build import _main -from .helpers import read_pkg_resource +from .helpers import read_pkg_resource, write class WritingTests(TestCase): maxDiff = None def test_append_at_top(self): - fragments = OrderedDict( - [ - ( - "", - OrderedDict( - [ - (("142", "misc", 0), ""), - (("1", "misc", 0), ""), - (("4", "feature", 0), "Stuff!"), - (("4", "feature", 1), "Second Stuff!"), - (("2", "feature", 0), "Foo added."), - (("72", "feature", 0), "Foo added."), - ] - ), - ), - ("Names", {}), - ("Web", {("3", "bugfix", 0): "Web fixed."}), - ] - ) - - definitions = OrderedDict( - [ - ("feature", {"name": "Features", "showcontent": True}), - ("bugfix", {"name": "Bugfixes", "showcontent": True}), - ("misc", {"name": "Misc", "showcontent": False}), - ] - ) + fragments = { + "": { + ("142", "misc", 0): "", + ("1", "misc", 0): "", + ("4", "feature", 0): "Stuff!", + ("4", "feature", 1): "Second Stuff!", + ("2", "feature", 0): "Foo added.", + ("72", "feature", 0): "Foo added.", + }, + "Names": {}, + "Web": {("3", "bugfix", 0): "Web fixed."}, + } + + definitions = { + "feature": {"name": "Features", "showcontent": True}, + "bugfix": {"name": "Bugfixes", "showcontent": True}, + "misc": {"name": "Misc", "showcontent": False}, + } expected_output = """MyProject 1.0 (never) ===================== @@ -84,7 +74,7 @@ def test_append_at_top(self): """ tempdir = self.mktemp() - os.mkdir(tempdir) + os.makedirs(tempdir) with open(os.path.join(tempdir, "NEWS.rst"), "w") as f: f.write("Old text.\n") @@ -120,31 +110,24 @@ def test_append_at_top_with_hint(self): If there is a comment with C{.. towncrier release notes start}, towncrier will add the version notes after it. """ - fragments = OrderedDict( - [ - ( - "", - { - ("142", "misc", 0): "", - ("1", "misc", 0): "", - ("4", "feature", 0): "Stuff!", - ("2", "feature", 0): "Foo added.", - ("72", "feature", 0): "Foo added.", - ("99", "feature", 0): "Foo! " * 100, - }, - ), - ("Names", {}), - ("Web", {("3", "bugfix", 0): "Web fixed."}), - ] - ) - - definitions = OrderedDict( - [ - ("feature", {"name": "Features", "showcontent": True}), - ("bugfix", {"name": "Bugfixes", "showcontent": True}), - ("misc", {"name": "Misc", "showcontent": False}), - ] - ) + fragments = { + "": { + ("142", "misc", 0): "", + ("1", "misc", 0): "", + ("4", "feature", 0): "Stuff!", + ("2", "feature", 0): "Foo added.", + ("72", "feature", 0): "Foo added.", + ("99", "feature", 0): "Foo! " * 100, + }, + "Names": {}, + "Web": {("3", "bugfix", 0): "Web fixed."}, + } + + definitions = { + "feature": {"name": "Features", "showcontent": True}, + "bugfix": {"name": "Bugfixes", "showcontent": True}, + "misc": {"name": "Misc", "showcontent": False}, + } expected_output = """Hello there! Here is some info. @@ -192,13 +175,16 @@ def test_append_at_top_with_hint(self): """ tempdir = self.mktemp() - os.mkdir(tempdir) - - with open(os.path.join(tempdir, "NEWS.rst"), "w") as f: - f.write( - "Hello there! Here is some info.\n\n" - ".. towncrier release notes start\nOld text.\n" - ) + write( + os.path.join(tempdir, "NEWS.rst"), + contents="""\ + Hello there! Here is some info. + + .. towncrier release notes start + Old text. + """, + dedent=True, + ) fragments = split_fragments(fragments, definitions) @@ -232,7 +218,7 @@ def test_multiple_file_no_start_string(self): the start of the file. """ tempdir = self.mktemp() - os.mkdir(tempdir) + os.makedirs(tempdir) definitions = {} fragments = split_fragments(fragments={}, definitions=definitions)