diff --git a/README.rst b/README.rst index 76f896d0..cf47bee0 100644 --- a/README.rst +++ b/README.rst @@ -10,6 +10,7 @@ Hear ye, hear ye, says the ``towncrier`` ``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 one single file which developers all write to, ``towncrier`` reads "news fragments" which contain information `useful to end users`. + Philosophy ---------- @@ -34,34 +35,51 @@ Install from PyPI:: It is usable by projects written in other languages, provided you give it the version of the project when invoking it. For Python 2/3 compatible projects, the version can be discovered automatically. -In your project root, add a ``pyproject.toml`` file, with the contents:: +In your project root, add a ``pyproject.toml`` file. +You can configure your project in two ways. +To configure it via an explicit directory, add:: + + + [tool.towncrier] + directory = "changes" + +Alternatively, to configure it relative to a (Python) package directory, add:: [tool.towncrier] package = "mypackage" package_dir = "src" filename = "NEWS.rst" -Then put news fragments (see "News Fragments" below) into a "newsfragments" directory under your package (so, if your project is named "myproject", and it's kept under ``src``, your newsfragments dir would be ``src/myproject/newsfragments/``). +For the latter, news fragments (see "News Fragments" below) should be in a ``newsfragments`` directory under your package. +Using the above example, your news fragments would be ``src/myproject/newsfragments/``). -To prevent git from removing the newsfragments directory, make a ``.gitignore`` file in it with:: +.. tip:: - !.gitignore + To prevent git from removing the ``newsfragments`` directory, make a ``.gitignore`` file in it with:: -This will keep the folder around, but otherwise "empty". + !.gitignore + + This will keep the folder around, but otherwise "empty". ``towncrier`` needs to know what version your project is, and there are two ways you can give it: - For Python 2/3 compatible projects, a ``__version__`` in the top level package. This can be either a string literal, a tuple, or an `Incremental `_ version. + - Manually passing ``--version=`` when interacting with ``towncrier``. +To create a new news fragment, use the ``towncrier create`` command. +For example:: + + towncrier create 123.feature + To produce a draft of the news file, run:: - towncrier --draft + towncrier build --draft To produce the news file for real, run:: - towncrier + towncrier build This command will remove the news files (with ``git rm``) and append the built news to the filename specified in ``towncrier.ini``, and then stage the news file changes (with ``git add``). It leaves committing the changes up to the user. diff --git a/setup.py b/setup.py index 68f4376b..abe32fe8 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ maintainer='Amber Brown', maintainer_email='hawkowl@twistedmatrix.com', url="https://github.com/hawkowl/towncrier", - classifiers = [ + classifiers=[ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 2", @@ -21,7 +21,8 @@ use_incremental=True, setup_requires=['incremental'], install_requires=[ - 'Click', + 'click', + 'click-default-group', 'incremental', 'jinja2', 'toml', @@ -35,7 +36,7 @@ long_description=open('README.rst').read(), entry_points={ 'console_scripts': [ - 'towncrier = towncrier:_main', + 'towncrier = towncrier._shell:cli', ], } ) diff --git a/src/towncrier/__init__.py b/src/towncrier/__init__.py index 68a7b698..78db5995 100644 --- a/src/towncrier/__init__.py +++ b/src/towncrier/__init__.py @@ -5,160 +5,6 @@ towncrier, a builder for your news files. """ -from __future__ import absolute_import, division - -import os -import click -import pkg_resources - -from datetime import date - -from ._settings import load_config_from_file -from ._builder import find_fragments, split_fragments, render_fragments -from ._project import get_version, get_project_name -from ._writer import append_to_newsfile -from ._git import remove_files, stage_newsfile from ._version import __version__ - -def _get_date(): - return date.today().isoformat() - - -@click.command() -@click.option( - "--draft", - "draft", - default=False, - flag_value=True, - help=("Render the news fragments, don't write to files, " "don't check versions."), -) -@click.option( - "--config", - "config_file", - default='pyproject.toml', - help='Configuration file name.') -@click.option("--dir", "directory", default=".") -@click.option("--name", "project_name", default=None) -@click.option( - "--version", - "project_version", - default=None, - help="Render the news fragments using given version.", -) -@click.option("--date", "project_date", default=None) -@click.option( - "--yes", - "answer_yes", - default=False, - flag_value=True, - help="Do not ask for confirmation to remove news fragments.", -) -def _main(draft, directory, config_file, project_name, project_version, project_date, answer_yes): - return __main( - draft, directory, config_file, project_name, project_version, project_date, answer_yes - ) - - -def __main(draft, directory, config_file, project_name, project_version, project_date, answer_yes): - """ - The main entry point. - """ - directory = os.path.abspath(directory) - config = load_config_from_file(os.path.join(directory, config_file)) - to_err = draft - - click.echo("Loading template...", err=to_err) - if config.get("template") is None: - template = pkg_resources.resource_string( - __name__, "templates/template.rst" - ).decode("utf8") - else: - 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"): - base_directory = os.path.abspath(config["directory"]) - fragment_directory = None - else: - base_directory = os.path.abspath( - os.path.join(directory, config["package_dir"], config["package"]) - ) - fragment_directory = "newsfragments" - - fragments, fragment_filenames = find_fragments( - base_directory, config["sections"], fragment_directory, definitions - ) - - click.echo("Rendering news fragments...", err=to_err) - fragments = split_fragments(fragments, definitions) - - if project_version is None: - project_version = get_version( - os.path.join(directory, config["package_dir"]), config["package"] - ).strip() - - if project_name is None: - package = config.get("package") - if package: - project_name = get_project_name( - os.path.abspath(os.path.join(directory, config["package_dir"])), package - ) - else: - # Can't determine a project_name, but maybe it is not needed. - project_name = "" - - if project_date is None: - project_date = _get_date().strip() - - if config["title_format"]: - top_line = config["title_format"].format( - name=project_name, version=project_version, project_date=project_date - ) - top_line += u"\n" + (config["underlines"][0] * len(top_line)) + u"\n" - else: - top_line = "" - - rendered = render_fragments( - # The 0th underline is used for the top line - template, - config["issue_format"], - fragments, - definitions, - config["underlines"][1:], - config["wrap"], - {"name": project_name, "version": project_version, "date": project_date}, - top_underline=config["underlines"][0], - ) - - if draft: - click.echo( - "Draft only -- nothing has been written.\n" - "What is seen below is what would be written.\n", - err=to_err, - ) - if top_line: - click.echo("\n%s\n%s" % (top_line, rendered)) - else: - click.echo(rendered) - else: - click.echo("Writing to newsfile...", err=to_err) - start_line = config["start_line"] - append_to_newsfile( - directory, config["filename"], start_line, top_line, rendered - ) - - click.echo("Staging newsfile...", err=to_err) - stage_newsfile(directory, config["filename"]) - - click.echo("Removing news fragments...", err=to_err) - remove_files(fragment_filenames, answer_yes) - - click.echo("Done!", err=to_err) - - __all__ = ["__version__"] diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 89950d0e..d862cec5 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -78,8 +78,9 @@ def find_fragments(base_directory, sections, fragment_directory, definitions): for basename in files: - ticket, category, counter = parse_newfragment_basename(basename, - definitions) + ticket, category, counter = parse_newfragment_basename( + basename, definitions + ) if category is None or category not in definitions: continue diff --git a/src/towncrier/_settings.py b/src/towncrier/_settings.py index 83d88b73..d5093deb 100644 --- a/src/towncrier/_settings.py +++ b/src/towncrier/_settings.py @@ -37,7 +37,7 @@ def load_config_from_file(from_file): def parse_toml(config): - if 'tool' not in config: + if "tool" not in config: raise ValueError("No [tool.towncrier] section.") config = config["tool"]["towncrier"] diff --git a/src/towncrier/_shell.py b/src/towncrier/_shell.py new file mode 100644 index 00000000..55be0607 --- /dev/null +++ b/src/towncrier/_shell.py @@ -0,0 +1,23 @@ +# Copyright (c) Stephen Finucane, 2019 +# See LICENSE for details. + +""" +towncrier, a builder for your news files. +""" + +import click +from click_default_group import DefaultGroup + +from .build import _main as _build_cmd +from .check import _main as _check_cmd +from .create import _main as _create_cmd + + +@click.group(cls=DefaultGroup, default="build", default_if_no_args=True) +def cli(): + pass + + +cli.add_command(_build_cmd) +cli.add_command(_check_cmd) +cli.add_command(_create_cmd) diff --git a/src/towncrier/_version.py b/src/towncrier/_version.py index bd394907..df10872d 100644 --- a/src/towncrier/_version.py +++ b/src/towncrier/_version.py @@ -7,5 +7,5 @@ from incremental import Version -__version__ = Version('towncrier', 19, 2, 0) +__version__ = Version("towncrier", 19, 2, 0) __all__ = ["__version__"] diff --git a/src/towncrier/build.py b/src/towncrier/build.py new file mode 100644 index 00000000..3869afcf --- /dev/null +++ b/src/towncrier/build.py @@ -0,0 +1,184 @@ +# Copyright (c) Amber Brown, 2015 +# See LICENSE for details. + +""" +Build a combined news file from news fragments. +""" + +from __future__ import absolute_import, division + +import os +import click +import pkg_resources + +from datetime import date + +from ._settings import load_config_from_file +from ._builder import find_fragments, split_fragments, render_fragments +from ._project import get_version, get_project_name +from ._writer import append_to_newsfile +from ._git import remove_files, stage_newsfile + + +def _get_date(): + return date.today().isoformat() + + +@click.command(name="build") +@click.option( + "--draft", + "draft", + default=False, + flag_value=True, + help=("Render the news fragments, don't write to files, don't check versions."), +) +@click.option( + "--config", "config_file", default="pyproject.toml", help="Configuration file name." +) +@click.option("--dir", "directory", default=".") +@click.option("--name", "project_name", default=None) +@click.option( + "--version", + "project_version", + default=None, + help="Render the news fragments using given version.", +) +@click.option("--date", "project_date", default=None) +@click.option( + "--yes", + "answer_yes", + default=False, + flag_value=True, + help="Do not ask for confirmation to remove news fragments.", +) +def _main( + draft, + directory, + config_file, + project_name, + project_version, + project_date, + answer_yes, +): + return __main( + draft, + directory, + config_file, + project_name, + project_version, + project_date, + answer_yes, + ) + + +def __main( + draft, + directory, + config_file, + project_name, + project_version, + project_date, + answer_yes, +): + """ + The main entry point. + """ + directory = os.path.abspath(directory) + config = load_config_from_file(os.path.join(directory, config_file)) + to_err = draft + + click.echo("Loading template...", err=to_err) + if config.get("template") is None: + template = pkg_resources.resource_string( + __name__, "templates/template.rst" + ).decode("utf8") + else: + 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"): + base_directory = os.path.abspath(config["directory"]) + fragment_directory = None + else: + base_directory = os.path.abspath( + os.path.join(directory, config["package_dir"], config["package"]) + ) + fragment_directory = "newsfragments" + + fragments, fragment_filenames = find_fragments( + base_directory, config["sections"], fragment_directory, definitions + ) + + click.echo("Rendering news fragments...", err=to_err) + fragments = split_fragments(fragments, definitions) + + if project_version is None: + project_version = get_version( + os.path.join(directory, config["package_dir"]), config["package"] + ).strip() + + if project_name is None: + package = config.get("package") + if package: + project_name = get_project_name( + os.path.abspath(os.path.join(directory, config["package_dir"])), package + ) + else: + # Can't determine a project_name, but maybe it is not needed. + project_name = "" + + if project_date is None: + project_date = _get_date().strip() + + if config["title_format"]: + top_line = config["title_format"].format( + name=project_name, version=project_version, project_date=project_date + ) + top_line += u"\n" + (config["underlines"][0] * len(top_line)) + u"\n" + else: + top_line = "" + + rendered = render_fragments( + # The 0th underline is used for the top line + template, + config["issue_format"], + fragments, + definitions, + config["underlines"][1:], + config["wrap"], + {"name": project_name, "version": project_version, "date": project_date}, + top_underline=config["underlines"][0], + ) + + if draft: + click.echo( + "Draft only -- nothing has been written.\n" + "What is seen below is what would be written.\n", + err=to_err, + ) + if top_line: + click.echo("\n%s\n%s" % (top_line, rendered)) + else: + click.echo(rendered) + else: + click.echo("Writing to newsfile...", err=to_err) + start_line = config["start_line"] + append_to_newsfile( + directory, config["filename"], start_line, top_line, rendered + ) + + click.echo("Staging newsfile...", err=to_err) + stage_newsfile(directory, config["filename"]) + + click.echo("Removing news fragments...", err=to_err) + remove_files(fragment_filenames, answer_yes) + + click.echo("Done!", err=to_err) + + +if __name__ == "__main__": # pragma: no cover + _main() diff --git a/src/towncrier/check.py b/src/towncrier/check.py index bdb140fe..919e6d73 100644 --- a/src/towncrier/check.py +++ b/src/towncrier/check.py @@ -19,7 +19,7 @@ def _run(args, **kwargs): return check_output(args, **kwargs) -@click.command() +@click.command(name="check") @click.option("--compare-with", default="origin/master") @click.option("--dir", "directory", default=".") @click.option("--pyproject", "pyproject", default=None) diff --git a/src/towncrier/create.py b/src/towncrier/create.py new file mode 100644 index 00000000..cbab4db8 --- /dev/null +++ b/src/towncrier/create.py @@ -0,0 +1,64 @@ +# Copyright (c) Stephen Finucane, 2019 +# See LICENSE for details. + +""" +Create a new fragment. +""" + +from __future__ import absolute_import + +import os +import click + +from ._settings import load_config + + +@click.command(name="create") +@click.option("--dir", "directory", default=".") +@click.argument("filename") +def _main(directory, filename): + return __main(directory, filename) + + +def __main(directory, filename): + """ + The main entry point. + """ + directory = os.path.abspath(directory) + config = load_config(directory) + + definitions = config["types"] or [] + if len(filename.split(".")) < 2 or ( + filename.split(".")[-1] not in definitions + and filename.split(".")[-2] not in definitions + ): + raise click.BadParameter( + "Expected filename '{}' to be of format '{{name}}.{{type}}', " + "where '{{name}}' is an arbitrary slug and '{{type}}' is " + "one of: {}".format(filename, ", ".join(definitions)) + ) + + if config.get("directory"): + fragments_directory = os.path.abspath(config["directory"]) + else: + fragments_directory = os.path.abspath( + os.path.join( + directory, config["package_dir"], config["package"], "newsfragments" + ) + ) + + if not os.path.exists(fragments_directory): + os.makedirs(fragments_directory) + + segment_file = os.path.join(fragments_directory, filename) + if os.path.exists(segment_file): + raise click.ClickException("{} already exists".format(segment_file)) + + with open(segment_file, "w") as f: + f.writelines(["Add your info here"]) + + click.echo("Created news fragment at {}".format(segment_file)) + + +if __name__ == "__main__": # pragma: no cover + _main() diff --git a/src/towncrier/newsfragments/144.feature.rst b/src/towncrier/newsfragments/144.feature.rst new file mode 100644 index 00000000..5120f777 --- /dev/null +++ b/src/towncrier/newsfragments/144.feature.rst @@ -0,0 +1,13 @@ +Add support for subcommands, meaning the functionality of the ``towncrier`` +executable is now replaced by the ``build`` subcommand:: + + $ towncrier build --draft + +A new ``check`` subcommand is exposed. This is an alternative to calling the +``towncrier.check`` module manually:: + + $ towncrier check + +Calling ``towncrier`` without a subcommand will result in a call to the +``build`` subcommand to ensure backwards compatibility. This may be removed in a +future release. diff --git a/src/towncrier/newsfragments/4.feature.rst b/src/towncrier/newsfragments/4.feature.rst new file mode 100644 index 00000000..7638ab09 --- /dev/null +++ b/src/towncrier/newsfragments/4.feature.rst @@ -0,0 +1,2 @@ +Add ``create`` subcommand, which can be used to quickly create a news +fragment command in the location defined by config. diff --git a/src/towncrier/test/test_cli.py b/src/towncrier/test/test_build.py similarity index 96% rename from src/towncrier/test/test_cli.py rename to src/towncrier/test/test_build.py index cfbee479..7d8a8609 100644 --- a/src/towncrier/test/test_cli.py +++ b/src/towncrier/test/test_build.py @@ -7,7 +7,9 @@ from twisted.trial.unittest import TestCase from click.testing import CliRunner -from .. import _main + +from ..build import _main +from .._shell import cli def setup_simple_project(): @@ -22,7 +24,7 @@ def setup_simple_project(): class TestCli(TestCase): maxDiff = None - def test_happy_path(self): + def _test_command(self, command): runner = CliRunner() with runner.isolated_filesystem(): @@ -46,12 +48,13 @@ def test_happy_path(self): with open("foo/newsfragments/README.rst", "w") as f: f.write("**Blah blah**") - result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) + result = runner.invoke(command, ["--draft", "--date", "01-01-2001"]) self.assertEqual(0, result.exit_code) self.assertEqual( result.output, - dedent("""\ + dedent( + """\ Loading template... Finding news fragments... Rendering news fragments... @@ -69,9 +72,16 @@ def test_happy_path(self): - Adds levitation (#123) - Extends levitation (#124) - """) + """ + ), ) + def test_command(self): + self._test_command(cli) + + def test_subcommand(self): + self._test_command(_main) + def test_collision(self): runner = CliRunner() diff --git a/src/towncrier/test/test_builder.py b/src/towncrier/test/test_builder.py index 18d75354..d5db5ab7 100644 --- a/src/towncrier/test/test_builder.py +++ b/src/towncrier/test/test_builder.py @@ -7,37 +7,43 @@ class TestParseNewsfragmentBasename(TestCase): - def test_simple(self): - self.assertEqual(parse_newfragment_basename('123.feature', ['feature']), - ('123', 'feature', 0)) + self.assertEqual( + parse_newfragment_basename("123.feature", ["feature"]), + ("123", "feature", 0), + ) def test_counter(self): - self.assertEqual(parse_newfragment_basename('123.feature.1', - ['feature']), - ('123', 'feature', 1)) + self.assertEqual( + parse_newfragment_basename("123.feature.1", ["feature"]), + ("123", "feature", 1), + ) def test_ignores_extension(self): - self.assertEqual(parse_newfragment_basename('123.feature.ext', - ['feature']), - ('123', 'feature', 0)) + self.assertEqual( + parse_newfragment_basename("123.feature.ext", ["feature"]), + ("123", "feature", 0), + ) def test_non_numeric_ticket(self): - self.assertEqual(parse_newfragment_basename('baz.feature', - ['feature']), - ('baz', 'feature', 0)) + self.assertEqual( + parse_newfragment_basename("baz.feature", ["feature"]), + ("baz", "feature", 0), + ) def test_dots_in_ticket_name(self): - self.assertEqual(parse_newfragment_basename('baz.1.2.feature', - ['feature']), - ('2', 'feature', 0)) + self.assertEqual( + parse_newfragment_basename("baz.1.2.feature", ["feature"]), + ("2", "feature", 0), + ) def test_dots_in_ticket_name_unknown_category(self): - self.assertEqual(parse_newfragment_basename('baz.1.2.notfeature', - ['feature']), - ('1', '2', 0)) + self.assertEqual( + parse_newfragment_basename("baz.1.2.notfeature", ["feature"]), ("1", "2", 0) + ) def test_dots_in_ticket_name_and_counter(self): - self.assertEqual(parse_newfragment_basename('baz.1.2.feature.3', - ['feature']), - ('2', 'feature', 3)) + self.assertEqual( + parse_newfragment_basename("baz.1.2.feature.3", ["feature"]), + ("2", "feature", 3), + ) diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py new file mode 100644 index 00000000..6e1ee957 --- /dev/null +++ b/src/towncrier/test/test_create.py @@ -0,0 +1,110 @@ +# Copyright (c) Amber Brown, 2015 +# See LICENSE for details. + +import os +from textwrap import dedent +from twisted.trial.unittest import TestCase + +from click.testing import CliRunner + +from ..create import _main + + +def setup_simple_project(config=None, mkdir=True): + if not config: + config = dedent( + """\ + [tool.towncrier] + package = "foo" + """ + ) + + with open("pyproject.toml", "w") as f: + f.write(config) + + os.mkdir("foo") + with open("foo/__init__.py", "w") as f: + f.write('__version__ = "1.2.3"\n') + + if mkdir: + os.mkdir("foo/newsfragments") + + +class TestCli(TestCase): + maxDiff = None + + def _test_success(self, config=None, mkdir=True): + runner = CliRunner() + + with runner.isolated_filesystem(): + setup_simple_project(config, mkdir) + + result = runner.invoke(_main, ["123.feature.rst"]) + + self.assertEqual(["123.feature.rst"], os.listdir("foo/newsfragments")) + + with open("foo/newsfragments/123.feature.rst") as fh: + self.assertEqual("Add your info here", fh.readlines()[0]) + + self.assertEqual(0, result.exit_code) + + def test_basics(self): + """Ensure file created where output directory already exists.""" + self._test_success(mkdir=True) + + def test_directory_created(self): + """Ensure both file and output directory created if necessary.""" + self._test_success(mkdir=False) + + def test_different_directory(self): + """Ensure non-standard directories are used.""" + runner = CliRunner() + config = dedent( + """\ + [tool.towncrier] + directory = "releasenotes" + """ + ) + + with runner.isolated_filesystem(): + setup_simple_project(config, mkdir=False) + os.mkdir("releasenotes") + + result = runner.invoke(_main, ["123.feature.rst"]) + + self.assertEqual(["123.feature.rst"], os.listdir("releasenotes")) + + self.assertEqual(0, result.exit_code) + + def test_invalid_section(self): + """Ensure creating a path without a valid section is rejected.""" + runner = CliRunner() + + with runner.isolated_filesystem(): + setup_simple_project() + + self.assertEqual([], os.listdir("foo/newsfragments")) + + result = runner.invoke(_main, ["123.foobar.rst"]) + + self.assertEqual([], os.listdir("foo/newsfragments")) + + self.assertEqual(type(result.exception), SystemExit) + self.assertIn( + "Expected filename '123.foobar.rst' to be of format", result.output + ) + + def test_file_exists(self): + """Ensure we don't overwrite existing files.""" + runner = CliRunner() + + with runner.isolated_filesystem(): + setup_simple_project() + + self.assertEqual([], os.listdir("foo/newsfragments")) + + runner.invoke(_main, ["123.feature.rst"]) + result = runner.invoke(_main, ["123.feature.rst"]) + + self.assertEqual(type(result.exception), SystemExit) + self.assertIn("123.feature.rst already exists", result.output)