diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc1c259c..050f37b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: hynek/build-and-inspect-python-package@f336040a31ea03a2551adcc994d3ddb2f3ea3f7f + - uses: hynek/build-and-inspect-python-package@f51d0e79a34e62af977fcfe458b41fa8490e6e65 - name: Set up ${{ matrix.python.name }} uses: actions/setup-python@v4 @@ -59,6 +59,7 @@ jobs: strategy: fail-fast: false matrix: + # Keep list in-sync with noxfile/tests & pyproject.toml. python: - name: CPython 3.8 action: 3.8 @@ -68,6 +69,8 @@ jobs: action: '3.10' - name: CPython 3.11 action: '3.11' + - name: CPython 3.12 + action: '3.12' - name: PyPy 3.8 action: pypy3.8 task: @@ -87,6 +90,8 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python.action }} + allow-prereleases: true + cache: pip - name: Install dependencies run: python -m pip install --upgrade pip nox @@ -155,10 +160,12 @@ jobs: python-version: '3.11' task: - name: Check Newsfragment - nox: check_newsfragment + run: | + nox -e check_newsfragment + nox -e draft_newsfragment >> $GITHUB_STEP_SUMMARY run-if: ${{ github.head_ref != 'pre-commit-ci-update-config' }} - name: Check mypy - nox: typecheck + run: nox -e typecheck run-if: true steps: @@ -182,10 +189,42 @@ jobs: - uses: twisted/python-info-action@v1 - - run: nox -e ${{ matrix.task.nox }} + - name: Check + run: | + ${{ matrix.task.run }} if: ${{ matrix.task.run-if }} + pre-commit: + name: Check pre-commit integration + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up python + uses: actions/setup-python@v5 + with: + python-version: 3.12 + + - name: Install dependencies + run: python -m pip install pre-commit + + - name: Install pre-commit + run: | + pre-commit install + + - name: Update pre-commit + run: | + pre-commit autoupdate + + - name: Run pre-commit + run: | + pre-commit run -a + + pypi-publish: name: Check tag and publish # Only trigger this for tag changes. @@ -252,7 +291,7 @@ jobs: python -Im coverage html --skip-covered --skip-empty # Report and write to summary. - python -Im coverage report | sed 's/^/ /' >> $GITHUB_STEP_SUMMARY + python -Im coverage report --format=markdown >> $GITHUB_STEP_SUMMARY # Report again and fail if under 100%. python -Im coverage report --fail-under=100 @@ -283,6 +322,7 @@ jobs: - test-windows - coverage - check + - pre-commit steps: - name: Require all successes uses: re-actors/alls-green@3a2de129f0713010a71314c74e33c0e3ef90e696 diff --git a/.gitignore b/.gitignore index fa4a8cd0..e101cbb7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ .vs/ .vscode Justfile +*egg-info/ _trial_temp*/ apidocs/ dist/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 93938b59..fe7c2da2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,32 +4,37 @@ ci: repos: - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 24.4.2 hooks: - id: black - repo: https://github.com/asottile/pyupgrade - rev: v3.8.0 + rev: v3.16.0 hooks: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort additional_dependencies: [toml] - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 7.1.0 hooks: - id: flake8 - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: debug-statements - id: check-toml - id: check-yaml + + - repo: https://github.com/twisted/towncrier + rev: 23.11.0 + hooks: + - id: towncrier-check diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 71513bbb..146090f5 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -13,7 +13,6 @@ build: python: "3.11" python: - system_packages: False install: - method: pip path: . diff --git a/NEWS.rst b/NEWS.rst index 26a6ad7c..23215d55 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -5,6 +5,70 @@ Release notes .. towncrier release notes start +towncrier 23.11.0 (2023-11-08) +============================== + +No significant changes since the previous release candidate. + + +Bugfixes +-------- + +- ``build`` now treats a missing fragments directory the same as an empty one, consistent with other operations. (`#538 `_) +- Fragments with filenames like `fix-1.2.3.feature` are now associated with the issue `fix-1.2.3`. + In previous versions they were incorrectly associated to issue `3`. (`#562 `_) +- Orphan newsfragments containing numeric values are no longer accidentally associated to issues. In previous versions the orphan marker was ignored and the newsfragment was associated to an issue having the last numerical value from the filename. (`#562 `_) + + +Misc +---- + +- `#558 `_, `#559 `_ + + +towncrier 23.10.0 (2023-10-24) +============================== + +No significant changes since the previous release candidate. + + +Features +-------- + +- Python 3.12 is now officially supported. (`#541 `_) +- Initial support was added for monorepo-style setup. + One project with multiple independent news files stored in separate sub-directories, that share the same towncrier config. (`#548 `_) +- Two newlines are no longer always added between the current release notes and the previous content. + The newlines are now defined only inside the template. + + **Important! If you're using a custom template and want to keep the same whitespace between releases, you may have to modify your template.** (`#552 `_) + + +Bugfixes +-------- + +- Towncrier now vendors the click-default-group package that prevented installations on modern Pips. (`#540 `_) + + +Improved Documentation +---------------------- + +- The markdown docs now use the default markdown template rather than a simpler custom one. (`#545 `_) +- Cleanup a duplicate backtick in the tutorial. (`#551 `_) + + +Deprecations and Removals +------------------------- + +- The support for Python 3.7 has been dropped. (`#521 `_) + + +Misc +---- + +- `#481 `_, `#520 `_, `#522 `_, `#523 `_, `#529 `_, `#536 `_ + + towncrier 23.6.0 (2023-06-06) ============================= @@ -184,7 +248,7 @@ towncrier 21.3.0.rc1 (2021-03-21) Features -------- -- Ticket number from file names will be stripped down to avoid ticket links such as ``#007``. (`#126 `_) +- Issue number from file names will be stripped down to avoid issue links such as ``#007``. (`#126 `_) - Allow definition of the project ``version`` and ``name`` in the configuration file. This allows use of towncrier seamlessly with non-Python projects. (`#165 `_) - Improve news fragment file name parsing to allow using file names like diff --git a/README.rst b/README.rst index da2250a8..f6cda3e3 100644 --- a/README.rst +++ b/README.rst @@ -30,7 +30,7 @@ Philosophy That is, by duplicating what has changed from the "developer log" (which may contain complex information about the original issue, how it was fixed, who authored the fix, and who reviewed the fix) into a "news fragment" (a small file containing just enough information to be useful to end users), ``towncrier`` can produce a digest of the changes which is valuable to those who may wish to use the software. These fragments are also commonly called "topfiles" or "newsfiles". -``towncrier`` works best in a development system where all merges involve closing a ticket. +``towncrier`` works best in a development system where all merges involve closing an issue. To get started, check out our `tutorial `_! diff --git a/RELEASE.rst b/RELEASE.rst index ebdea07f..9f19d851 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -38,8 +38,8 @@ Create a new release candidate using `GitHub New release UI `_ for more details. You can announce the release over IRC or Gitter. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..a17a60d6 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security Policy + +The twisted/towncrier project uses the same security policy as [twisted/twisted](https://github.com/twisted/twisted). + +For more details, please check the [Twisted security process](https://github.com/twisted/twisted?tab=security-ov-file#readme). diff --git a/docs/cli.rst b/docs/cli.rst index af03c784..c8040461 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -12,7 +12,8 @@ The following options can be passed to all of the commands that explained below: .. option:: --dir PATH - Build fragment in ``PATH``. + The command is executed relative to ``PATH``. + For instance with the default config news fragments are checked and added in ``PATH/newsfragments`` and the news file is built in ``PATH/NEWS.rst``. Default: current directory. @@ -23,6 +24,14 @@ The following options can be passed to all of the commands that explained below: Build the combined news file from news fragments. ``build`` is also assumed if no command is passed. +If there are no news fragments (including an empty fragments directory or a +non-existent directory), a notice of "no significant changes" will be added to +the news file. + +By default, the processed news fragments are removed. For any fragments +committed in your git repository, git rm will be used (which will also remove +the fragments directory if now empty). + .. option:: --draft Only render news fragments to standard output. @@ -64,19 +73,36 @@ Create a news fragment in the directory that ``towncrier`` is configured to look $ towncrier create 123.bugfix.rst +If you don't provide a file name, ``towncrier`` will prompt you for one. + ``towncrier create`` will enforce that the passed type (e.g. ``bugfix``) is valid. +If the fragments directory does not exist, it will be created. + If the filename exists already, ``towncrier create`` will add (and then increment) a number after the fragment type until it finds a filename that does not exist yet. In the above example, it will generate ``123.bugfix.1.rst`` if ``123.bugfix.rst`` already exists. +To create a news fragment not tied to a specific issue (which towncrier calls an "orphan fragment"), start the fragment name with a ``+``. +If that is the entire fragment name, a random hash will be added for you:: + + $ towncrier create +.feature.rst + $ ls newsfragments/ + +fcc4dc7b.feature.rst + .. option:: --content, -c CONTENT A string to use for content. Default: an instructive placeholder. -.. option:: --edit +.. option:: --edit / --no-edit + + Whether to start ``$EDITOR`` to edit the news fragment right away. + Default: ``$EDITOR`` will be started unless you also provided content. + +.. option:: --section SECTION - Create file and start `$EDITOR` to edit it right away.` + The section to use for the news fragment. + Default: the section with no path, or if all sections have a path then the first defined section. ``towncrier check`` diff --git a/docs/conf.py b/docs/conf.py index c625d898..6d0a2962 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -71,9 +71,6 @@ # This patterns also effect to html_static_path and html_extra_path exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" - # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False @@ -89,7 +86,9 @@ # further. For a list of options available for each theme, see the # documentation. # -# html_theme_options = {} +if os.environ.get("READTHEDOCS_VERSION_NAME", "trunk") not in ("trunk", "latest"): + # Remove the "Edit on GitHub" link for non-trunk versions of the docs + html_theme_options = {"top_of_page_buttons": []} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/docs/configuration.rst b/docs/configuration.rst index 7d73f508..aa4edfca 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -43,7 +43,7 @@ Top level keys ``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. + Python projects that provide the ``package`` key, if left empty then the version will be automatically determined from the installed package's version metadata or 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``. @@ -51,12 +51,13 @@ Top level keys 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. + Otherwise the default is a ``newsfragments`` directory relative to either the directory passed as ``--dir`` or (by default) the configuration file. ``filename`` The filename of your news file. ``"NEWS.rst"`` by default. + Its location is determined the same way as the location of the directory storing the news fragments. ``template`` Path to the template for generating the news file. @@ -79,6 +80,8 @@ Top level keys ``""`` by default. + Formatted titles are appended a line of ``=`` on the following line (reStructuredText title format) unless the template has an ``.md`` suffix, in which case the title will instead be prefixed with ``#`` (markdown title format). + ``issue_format`` A format string for rendering the issue/ticket number in newsfiles. @@ -117,6 +120,16 @@ Top level keys ``"+"`` by default. +``create_eof_newline`` + Ensure the content of a news fragment file created with ``towncrier create`` ends with an empty line. + + ``true`` by default. + +``create_add_extension`` + Add the ``filename`` option extension to news fragment files created with ``towncrier create`` if an extension is not explicitly provided. + + ``true`` by default. + Extra top level keys for Python projects ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -203,6 +216,16 @@ These may include the following optional keys: ``true`` by default. + .. note:: + + Orphan fragments (those without an issue number) always have their content included. + If a fragment was created, it means that information is important for end users. + +``check`` + A boolean value indicating whether the fragment should be considered by the ``towncrier check`` command. + + ``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: .. code-block:: toml @@ -216,6 +239,10 @@ For example, if you want your custom fragment types to be ``["feat", "fix", "cho name = "Other Tasks" showcontent = false + [tool.towncrier.fragment.deps] + name = "Dependency Changes" + check = false + .. warning:: @@ -244,6 +271,16 @@ Each table within this array has the following mandatory keys: ``true`` by default. + .. note:: + + Orphan fragments (those without an issue number) always have their content included. + If a fragment was created, it means that information is important for end users. + +``check`` + A boolean value indicating whether the fragment should be considered by the ``towncrier check`` command. + + ``true`` by default. + For example: .. code-block:: toml @@ -258,3 +295,9 @@ For example: directory = "chore" name = "Other Tasks" showcontent = false + + [[tool.towncrier.type]] + directory = "deps" + name = "Dependency Changes" + showcontent = true + check = false diff --git a/docs/customization/newsfile.rst b/docs/customization/newsfile.rst index 67f3df4f..03b97a3a 100644 --- a/docs/customization/newsfile.rst +++ b/docs/customization/newsfile.rst @@ -4,7 +4,7 @@ Customizing the News File Output 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. +If you wish to have content at the top of the news file (for example, to say where you can find the issues), 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: .. code-block:: restructuredtext diff --git a/docs/index.rst b/docs/index.rst index 975373dd..f83e8ce4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,6 +13,7 @@ Narrative tutorial markdown + monorepo Reference diff --git a/docs/markdown.rst b/docs/markdown.rst index a81fbbcc..7c07aa5e 100644 --- a/docs/markdown.rst +++ b/docs/markdown.rst @@ -21,7 +21,6 @@ Put the following into your ``pyproject.toml`` or ``towncrier.toml``: filename = "CHANGELOG.md" start_string = "\n" underlines = ["", "", ""] - template = "changelog.d/changelog_template.jinja" title_format = "## [{version}](https://github.com/twisted/my-project/tree/{version}) - {project_date}" issue_format = "[#{issue}](https://github.com/twisted/my-project/issues/{issue})" @@ -57,33 +56,12 @@ Put the following into your ``pyproject.toml`` or ``towncrier.toml``: -Next create the news fragment directory and the news file template: +Next create the news fragment directory: .. code-block:: console $ mkdir changelog.d -And put the following into ``changelog.d/changelog_template.jinja``: - -.. code-block:: jinja - - {% if sections[""] %} - {% for category, val in definitions.items() if category in sections[""] %} - - ### {{ definitions[category]['name'] }} - - {% for text, values in sections[""][category].items() %} - - {{ text }} {{ values|join(', ') }} - {% endfor %} - - {% endfor %} - {% else %} - No significant changes. - - - {% endif %} - - Next, create the news file with an explanatory header:: $ cat >CHANGELOG.md <`_ ``pyproject.toml`` or a ``towncrier.toml`` file. If the latter exists, it takes precedence. -The most basic configuration is just telling ``towncrier`` where to look for news fragments:: +The most basic configuration is just telling ``towncrier`` where to look for news fragments and what file to generate:: [tool.towncrier] directory = "changes" + # Where you want your news files to come out, `NEWS.rst` is the default. + # This can be .rst or .md, towncrier's default template works with both. + # filename = "NEWS.rst" Which will look into "./changes" for news fragments and write them into "./NEWS.rst". @@ -32,9 +35,6 @@ If you're working on a Python project, you can also specify a package:: # but if you don't keep your code in a 'src' dir, remove the # config option package_dir = "src" - # Where you want your news files to come out. This can be .rst - # or .md, towncrier's default template works with both. - filename = "NEWS.rst" By default, ``towncrier`` will look for news fragments inside your Python package, in a directory named ``newsfragments``. With this example project, it will look in ``src/myproject/newsfragments/`` for them. @@ -48,17 +48,17 @@ Create this folder:: The ``.gitignore`` will remain and keep Git from not tracking the directory. -Detecting Dates & Versions --------------------------- +Detecting Version +----------------- -``towncrier`` needs to know what version your project is. +``towncrier`` needs to know what version your project is when generating news files. For Python projects, the version can be automatically determined in one of the following ways: - if the project is installed, the version can be read from the package's metadata - the version can be provided in a ``__version__`` attribute of the top level package (as a string literal, a tuple, or an `Incremental `_ version) -For other projects, you can store the version in the ``towncrier.toml`` file:: +For non-Python projects, you can store the version in the ``towncrier.toml`` file:: [tool.towncrier] version = "1.0.0" @@ -67,6 +67,10 @@ If you don't want to store the version, you can manually pass ``--version= src/myproject/newsfragments/1234.bugfix $ towncrier create --content 'Can also be ``rst`` as well!' 3456.doc.rst - # You can associate multiple ticket numbers with a news fragment by giving them the same contents. + # You can associate multiple issue numbers with a news fragment by giving them the same contents. $ towncrier create --content 'Can also be ``rst`` as well!' 7890.doc.rst $ echo 'The final part is ignored, so set it to whatever you want.' > src/myproject/newsfragments/8765.removal.txt $ echo 'misc is special, and does not put the contents of the file in the newsfile.' > src/myproject/newsfragments/1.misc $ towncrier create --edit 2.misc.rst # starts an editor - $ towncrier create -c "Orphan fragments have no ticket ID." +random.bugfix.rst + $ towncrier create -c "Orphan fragments have no issue ID." +random.bugfix.rst -For orphan news fragments (those that don't need to be linked to any ticket ID or other identifier), start the file name with ``+``. +For orphan news fragments (those that don't need to be linked to any issue ID or other identifier), start the file name with ``+``. The content will still be included in the release notes, at the end of the category corresponding to the file extension:: $ echo 'Fixed an unreported thing!' > src/myproject/newsfragments/+anything.bugfix @@ -130,13 +134,13 @@ You should get an output similar to this:: -------- - Fixed a thing! (#1234) - - Orphan fragments have no ticket ID. + - Orphan fragments have no issue ID. Improved Documentation ---------------------- - - Can also be ``rst``` as well! (#3456, #7890) + - Can also be ``rst`` as well! (#3456, #7890) Deprecations and Removals @@ -152,6 +156,8 @@ You should get an output similar to this:: 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. +Note: all files (news fragments, the news file, the configuration file, and templates) are encoded and are expected to be encoded as UTF-8. + Producing News Files In Production ---------------------------------- @@ -163,7 +169,7 @@ To produce the news file for real, run:: This command will remove the news files (with ``git rm``) and append the built news to the filename specified in ``pyproject.toml``, and then stage the news file changes (with ``git add``). It leaves committing the changes up to the user. -If you wish to have content at the top of the news file (for example, to say where you can find the tickets), put your text above a rST comment that says:: +If you wish to have content at the top of the news file (for example, to say where you can find the issues), put your text above a rST comment that says:: .. towncrier release notes start diff --git a/noxfile.py b/noxfile.py index 3f792708..e7a4bafe 100644 --- a/noxfile.py +++ b/noxfile.py @@ -17,8 +17,10 @@ def pre_commit(session: nox.Session) -> None: session.run("pre-commit", "run", "--all-files", "--show-diff-on-failure") -@nox.session(python=["pypy3.8", "3.8", "3.9", "3.10", "3.11"]) +# Keep list in-sync with ci.yml/test-linux & pyproject.toml +@nox.session(python=["pypy3.8", "3.8", "3.9", "3.10", "3.11", "3.12"]) def tests(session: nox.Session) -> None: + session.env["PYTHONWARNDEFAULTENCODING"] = "1" session.install("Twisted", "coverage[toml]") posargs = list(session.posargs) @@ -53,6 +55,12 @@ def check_newsfragment(session: nox.Session) -> None: session.run("python", "-m", "towncrier.check", "--compare-with", "origin/trunk") +@nox.session +def draft_newsfragment(session: nox.Session) -> None: + session.install(".") + session.run("python", "-m", "towncrier.build", "--draft") + + @nox.session def typecheck(session: nox.Session) -> None: # Click 8.1.4 is bad type hints -- lets not complicate packaging and only diff --git a/pyproject.toml b/pyproject.toml index ac70153f..01f99241 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,18 @@ [build-system] requires = [ - "hatchling ~= 1.17.1", - "wheel ~= 0.38.4", + "hatchling", + "wheel", ] build-backend = "hatchling.build" [project] +dynamic = ["version"] name = "towncrier" -version = "23.6.1.dev0" description = "Building newsfiles for your project." readme = "README.rst" license = "MIT" +# Keep version list in-sync with noxfile/tests & ci.yml/test-linux. classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", @@ -22,14 +23,15 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] requires-python = ">=3.8" dependencies = [ "click", - "click-default-group", "importlib-resources>=5; python_version<'3.10'", + "importlib-metadata>=4.6; python_version<'3.10'", "jinja2", "tomli; python_version<'3.11'", ] @@ -38,8 +40,9 @@ dependencies = [ dev = [ "packaging", "sphinx >= 5", - "furo", + "furo >= 2024.05.06", "twisted", + "nox", ] [project.scripts] @@ -56,6 +59,11 @@ Coverage = "https://codecov.io/gh/twisted/towncrier" Distribution = "https://pypi.org/project/towncrier" +[tool.hatch.version] +source = "code" +path = "src/towncrier/_version.py" +expression = "_hatchling_version" + [tool.hatch.build] exclude = [ "admin", @@ -140,9 +148,9 @@ strict = true exclude = '^src/towncrier/test/test_.*\.py$' [[tool.mypy.overrides]] -module = 'click_default_group' -# 2022-09-04: This library has no type annotations. -ignore_missing_imports = true +module = 'towncrier.click_default_group' +# Vendored module without type annotations. +ignore_errors = true [tool.coverage.run] parallel = true @@ -162,4 +170,5 @@ exclude_lines = [ omit = [ "src/towncrier/__main__.py", "src/towncrier/test/*", + "src/towncrier/click_default_group.py", ] diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index d1b14dba..e7fbf12b 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -5,27 +5,19 @@ from __future__ import annotations import os +import re import textwrap -import traceback from collections import defaultdict -from typing import Any, DefaultDict, Iterable, Iterator, Mapping, Sequence +from pathlib import Path +from typing import Any, DefaultDict, Iterable, Iterator, Mapping, NamedTuple, Sequence from jinja2 import Template -from ._settings import ConfigError +from towncrier._settings.load import Config -def strip_if_integer_string(s: str) -> str: - try: - i = int(s) - except ValueError: - return s - - return str(i) - - -# Returns ticket, category and counter or (None, None, None) if the basename +# Returns issue, 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, frag_type_names: Iterable[str] @@ -35,111 +27,136 @@ def parse_newfragment_basename( if len(parts) == 1: return invalid - if len(parts) == 2: - ticket, category = parts - ticket = strip_if_integer_string(ticket) - 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. + # There are at least 2 parts. Search for a valid category from the second + # part onwards starting at the back. # 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)): + for i in reversed(range(1, len(parts))): 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. + # Use all previous parts as the issue number. # NOTE: This allows news fragment names like fix-1.2.3.feature or - # something-cool.feature.ext for projects that don't use ticket + # something-cool.feature.ext for projects that don't use issue # numbers in news fragment names. - ticket = strip_if_integer_string(parts[i - 1]) + issue = ".".join(parts[0:i]).strip() + # If the issue is an integer, remove any leading zeros (to resolve + # issue #126). + if issue.isdigit(): + issue = str(int(issue)) counter = 0 # Use the following part as the counter if it exists and is a valid # digit. if len(parts) > (i + 1) and parts[i + 1].isdigit(): counter = int(parts[i + 1]) - return ticket, category, counter + return issue, category, counter else: # No valid category found. return invalid +class FragmentsPath: + """ + A helper to get the full path to a fragments directory. + + This is a callable that optionally takes a section directory and returns the full + path to the fragments directory for that section (or the default if no section is + provided). + """ + + def __init__(self, base_directory: str, config: Config): + self.base_directory = base_directory + self.config = config + if config.directory is not None: + self.base_directory = os.path.abspath( + os.path.join(base_directory, config.directory) + ) + self.append_directory = "" + else: + self.base_directory = os.path.abspath( + os.path.join(base_directory, config.package_dir, config.package) + ) + self.append_directory = "newsfragments" + + def __call__(self, section_directory: str = "") -> str: + return os.path.join( + self.base_directory, section_directory, self.append_directory + ) + + # Returns a structure like: # # { # "": { -# ("142", "misc"): "", -# ("1", "feature"): "some cool description", +# ("142", "misc", 1): "", +# ("1", "feature", 1): "some cool description", # }, # "Names": {}, -# "Web": {("3", "bugfix"): "Fixed a thing"}, +# "Web": {("3", "bugfix", 1): "Fixed a thing"}, # } # -# We should really use attrs. +# and a list like: +# [ +# ("/path/to/fragments/142.misc.1", "misc"), +# ("/path/to/fragments/1.feature.1", "feature"), +# ] # -# Also returns a list of the paths that the fragments were taken from. +# We should really use attrs. def find_fragments( base_directory: str, - sections: Mapping[str, str], - fragment_directory: str | None, - frag_type_names: Iterable[str], - orphan_prefix: str | None = None, -) -> tuple[Mapping[str, Mapping[tuple[str, str, int], str]], list[str]]: + config: Config, +) -> tuple[Mapping[str, Mapping[tuple[str, str, int], str]], list[tuple[str, str]]]: """ Sections are a dictonary of section names to paths. """ + get_section_path = FragmentsPath(base_directory, config) + content = {} - fragment_filenames = [] + fragment_files = [] # Multiple orphan news fragments are allowed per section, so initialize a counter # that can be incremented automatically. orphan_fragment_counter: DefaultDict[str | None, int] = defaultdict(int) - for key, val in sections.items(): - if fragment_directory is not None: - section_dir = os.path.join(base_directory, val, fragment_directory) - else: - section_dir = os.path.join(base_directory, val) + for key, section_dir in config.sections.items(): + section_dir = get_section_path(section_dir) try: files = os.listdir(section_dir) - except FileNotFoundError as e: - message = "Failed to list the news fragment files.\n{}".format( - "".join(traceback.format_exception_only(type(e), e)), - ) - raise ConfigError(message) + except FileNotFoundError: + files = [] file_content = {} for basename in files: - ticket, category, counter = parse_newfragment_basename( - basename, frag_type_names + issue, category, counter = parse_newfragment_basename( + basename, config.types ) if category is None: continue - assert ticket is not None + assert issue is not None assert counter is not None - if orphan_prefix and ticket.startswith(orphan_prefix): - ticket = "" + if config.orphan_prefix and issue.startswith(config.orphan_prefix): + issue = "" # Use and increment the orphan news fragment counter. counter = orphan_fragment_counter[category] orphan_fragment_counter[category] += 1 full_filename = os.path.join(section_dir, basename) - fragment_filenames.append(full_filename) - with open(full_filename, "rb") as f: - data = f.read().decode("utf8", "replace") + fragment_files.append((full_filename, category)) + data = Path(full_filename).read_text(encoding="utf-8", errors="replace") - if (ticket, category, counter) in file_content: + if (issue, category, counter) in file_content: raise ValueError( "multiple files for {}.{} in {}".format( - ticket, category, section_dir + issue, category, section_dir ) ) - file_content[ticket, category, counter] = data + file_content[issue, category, counter] = data content[key] = file_content - return content, fragment_filenames + return content, fragment_files def indent(text: str, prefix: str) -> str: @@ -168,7 +185,7 @@ def split_fragments( for section_name, section_fragments in fragments.items(): section: dict[str, dict[str, list[str]]] = {} - for (ticket, category, counter), content in section_fragments.items(): + for (issue, category, counter), content in section_fragments.items(): if all_bullets: # By default all fragmetns are append by "-" automatically, # and need to be indented because of that. @@ -178,35 +195,66 @@ def split_fragments( # Assume the text is formatted correctly content = content.rstrip() - if definitions[category]["showcontent"] is False: + if definitions[category]["showcontent"] is False and issue: + # If this category is not supposed to show content (and we have an + # issue) then we should just add the issue to the section rather than + # the content. If there isn't an issue, still add the content so that + # it's recorded. content = "" texts = section.setdefault(category, {}) - tickets = texts.setdefault(content, []) - if ticket: - # Only add the ticket if we have one (it can be blank for orphan news + issues = texts.setdefault(content, []) + if issue: + # Only add the issue if we have one (it can be blank for orphan news # fragments). - tickets.append(ticket) - tickets.sort() + issues.append(issue) + issues.sort() output[section_name] = section return output -def issue_key(issue: str) -> tuple[int, str]: - # We want integer issues to sort as integers, and we also want string - # issues to sort as strings. We arbitrarily put string issues before - # integer issues (hopefully no-one uses both at once). - try: - return (int(issue), "") - except Exception: - # Maybe we should sniff strings like "gh-10" -> (10, "gh-10")? - return (-1, issue) +class IssueParts(NamedTuple): + is_digit: bool + has_digit: bool + non_digit_part: str + number: int + + +def issue_key(issue: str) -> IssueParts: + """ + Used to sort the issue ID inside a news fragment in a human-friendly way. + + Issue IDs are grouped by their non-integer part, then sorted by their integer part. + + For backwards compatible consistency, issues without no number are sorted first and + digit only issues are sorted last. + + For example:: + + >>> sorted(["2", "#11", "#3", "gh-10", "gh-4", "omega", "alpha"], key=issue_key) + ['alpha', 'omega', '#3', '#11', 'gh-4', 'gh-10', '2'] + """ + if issue.isdigit(): + return IssueParts( + is_digit=True, has_digit=True, non_digit_part="", number=int(issue) + ) + match = re.search(r"\d+", issue) + if not match: + return IssueParts( + is_digit=False, has_digit=False, non_digit_part=issue, number=-1 + ) + return IssueParts( + is_digit=False, + has_digit=True, + non_digit_part=issue[: match.start()] + issue[match.end() :], + number=int(match.group()), + ) -def entry_key(entry: tuple[str, Sequence[str]]) -> tuple[str, list[tuple[int, str]]]: +def entry_key(entry: tuple[str, Sequence[str]]) -> tuple[str, list[IssueParts]]: content, issues = entry # Orphan news fragments (those without any issues) should sort last by content. return "" if issues else content, [issue_key(issue) for issue in issues] @@ -335,4 +383,4 @@ def get_indent(text: str) -> str: else: done.append(line) - return "\n".join(done).rstrip() + "\n" + return "\n".join(done) diff --git a/src/towncrier/_git.py b/src/towncrier/_git.py index 94f82d62..ff3d5448 100644 --- a/src/towncrier/_git.py +++ b/src/towncrier/_git.py @@ -5,12 +5,27 @@ import os -from subprocess import STDOUT, call, check_output +from subprocess import STDOUT, CalledProcessError, call, check_output def remove_files(fragment_filenames: list[str]) -> None: - if fragment_filenames: - call(["git", "rm", "--quiet"] + fragment_filenames) + if not fragment_filenames: + return + + # Filter out files that are unknown to git + try: + git_fragments = check_output( + ["git", "ls-files"] + fragment_filenames, encoding="utf-8" + ).split("\n") + except CalledProcessError: + # we may not be in a git repository + git_fragments = [] + + git_fragments = [os.path.abspath(f) for f in git_fragments if os.path.isfile(f)] + call(["git", "rm", "--quiet", "--force"] + git_fragments) + unknown_fragments = set(fragment_filenames) - set(git_fragments) + for unknown_fragment in unknown_fragments: + os.remove(unknown_fragment) def stage_newsfile(directory: str, filename: str) -> None: diff --git a/src/towncrier/_project.py b/src/towncrier/_project.py index 2c03b6f2..3489e123 100644 --- a/src/towncrier/_project.py +++ b/src/towncrier/_project.py @@ -5,17 +5,23 @@ Responsible for getting the version and name from a project. """ - from __future__ import annotations import importlib.metadata as importlib_metadata import sys from importlib import import_module +from importlib.metadata import version as metadata_version from types import ModuleType from typing import Any +if sys.version_info >= (3, 10): + from importlib.metadata import packages_distributions +else: + from importlib_metadata import packages_distributions # type: ignore + + def _get_package(package_dir: str, package: str) -> ModuleType: try: module = import_module(package) @@ -37,16 +43,41 @@ def _get_package(package_dir: str, package: str) -> ModuleType: return module +def _get_metadata_version(package: str) -> str | None: + """ + Try to get the version from the package metadata. + """ + distributions = packages_distributions() + distribution_names = distributions.get(package) + if not distribution_names or len(distribution_names) != 1: + # We can only determine the version if there is exactly one matching distribution. + return None + return metadata_version(distribution_names[0]) + + def get_version(package_dir: str, package: str) -> str: + """ + Get the version of a package. + + Try to extract the version from the distribution version metadata that matches + `package`, then fall back to looking for the package in `package_dir`. + """ version: Any + # First try to get the version from the package metadata. + if version := _get_metadata_version(package): + return version + + # When no version if found, fall back to looking for the package in `package_dir`. module = _get_package(package_dir, package) version = getattr(module, "__version__", None) if not version: try: version = importlib_metadata.version(package) except importlib_metadata.PackageNotFoundError: - raise Exception(f"Package not installed and no {package}.__version__ found") + raise Exception( + f"No __version__ or metadata version info for the '{package}' package." + ) if isinstance(version, str): return version.strip() diff --git a/src/towncrier/_settings/fragment_types.py b/src/towncrier/_settings/fragment_types.py index a9882c9a..bb1313be 100644 --- a/src/towncrier/_settings/fragment_types.py +++ b/src/towncrier/_settings/fragment_types.py @@ -37,11 +37,15 @@ class DefaultFragmentTypesLoader(BaseFragmentTypesLoader): _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}, + "feature": {"name": "Features", "showcontent": True, "check": True}, + "bugfix": {"name": "Bugfixes", "showcontent": True, "check": True}, + "doc": {"name": "Improved Documentation", "showcontent": True, "check": True}, + "removal": { + "name": "Deprecations and Removals", + "showcontent": True, + "check": True, + }, + "misc": {"name": "Misc", "showcontent": False, "check": True}, } def load(self) -> Mapping[str, Mapping[str, Any]]: @@ -75,9 +79,11 @@ def load(self) -> Mapping[str, Mapping[str, Any]]: directory = type_config["directory"] fragment_type_name = type_config["name"] is_content_required = type_config["showcontent"] + check = type_config.get("check", True) types[directory] = { "name": fragment_type_name, "showcontent": is_content_required, + "check": check, } return types @@ -129,8 +135,10 @@ def _load_options(self, fragment_type: str) -> Mapping[str, Any]: options = self.fragment_options.get(fragment_type, {}) fragment_description = options.get("name", capitalized_fragment_type) show_content = options.get("showcontent", True) + check = options.get("check", True) clean_fragment_options = { "name": fragment_description, "showcontent": show_content, + "check": check, } return clean_fragment_options diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index 0b845662..724a7768 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -52,6 +52,8 @@ class Config: wrap: bool = False all_bullets: bool = True orphan_prefix: str = "+" + create_eof_newline: bool = True + create_add_extension: bool = True class ConfigError(ClickException): @@ -63,26 +65,53 @@ def __init__(self, *args: str, **kwargs: str): def load_config_from_options( directory: str | None, config_path: str | None ) -> tuple[str, Config]: + """ + Load the configuration from a given directory or specific configuration file. + + Unless an explicit configuration file is given, traverse back from the given + directory looking for a configuration file. + + Returns a tuple of the base directory and the parsed Config instance. + """ if config_path is None: - if directory is None: - directory = os.getcwd() + return traverse_for_config(directory) + + config_path = os.path.abspath(config_path) + # When a directory is provided (in addition to the config file), use it as the base + # directory. Otherwise use the directory containing the config file. + if directory is not None: base_directory = os.path.abspath(directory) - config = load_config(base_directory) else: - config_path = os.path.abspath(config_path) - if directory is None: - base_directory = os.path.dirname(config_path) - else: - base_directory = os.path.abspath(directory) - config = load_config_from_file(os.path.dirname(config_path), config_path) + base_directory = os.path.dirname(config_path) - if config is None: - raise ConfigError(f"No configuration file found.\nLooked in: {base_directory}") + if not os.path.isfile(config_path): + raise ConfigError(f"Configuration file '{config_path}' not found.") + config = load_config_from_file(base_directory, config_path) return base_directory, config +def traverse_for_config(path: str | None) -> tuple[str, Config]: + """ + Search for a configuration file in the current directory and all parent directories. + + Returns the directory containing the configuration file and the parsed configuration. + """ + start_directory = directory = os.path.abspath(path or os.getcwd()) + while True: + config = load_config(directory) + if config is not None: + return directory, config + + parent = os.path.dirname(directory) + if parent == directory: + raise ConfigError( + f"No configuration file found.\nLooked back from: {start_directory}" + ) + directory = parent + + def load_config(directory: str) -> Config | None: towncrier_toml = os.path.join(directory, "towncrier.toml") pyproject_toml = os.path.join(directory, "pyproject.toml") diff --git a/src/towncrier/_shell.py b/src/towncrier/_shell.py index 24642e3e..20e557bb 100644 --- a/src/towncrier/_shell.py +++ b/src/towncrier/_shell.py @@ -17,6 +17,7 @@ from .build import _main as _build_cmd from .check import _main as _check_cmd +from .click_default_group import DefaultGroup from .create import _main as _create_cmd diff --git a/src/towncrier/_writer.py b/src/towncrier/_writer.py index 92119114..634b5f19 100644 --- a/src/towncrier/_writer.py +++ b/src/towncrier/_writer.py @@ -8,7 +8,22 @@ from __future__ import annotations +import sys + from pathlib import Path +from typing import Any + + +if sys.version_info < (3, 10): + # Compatibility shim for newline parameter to write_text, added in 3.10 + def _newline_write_text(path: Path, content: str, **kwargs: Any) -> None: + with path.open("w", **kwargs) as strm: # pragma: no branch + strm.write(content) + +else: + + def _newline_write_text(path: Path, content: str, **kwargs: Any) -> None: + path.write_text(content, **kwargs) def append_to_newsfile( @@ -37,17 +52,17 @@ def append_to_newsfile( if top_line and top_line in prev_body: raise ValueError("It seems you've already produced newsfiles for this version?") - # Leave newlines alone. This probably leads to inconsistent newlines, - # because we've loaded existing content with universal newlines, but that's - # the original behavior. - with news_file.open("w", encoding="utf8", newline="") as f: - if header: - f.write(header) - - f.write(content) - - if prev_body: - f.write(f"\n\n{prev_body}") + _newline_write_text( + news_file, + # If there is no previous body that means we're writing a brand new news file. + # We don't want extra whitespace at the end of this new file. + header + (content + prev_body if prev_body else content.rstrip() + "\n"), + encoding="utf-8", + # Leave newlines alone. This probably leads to inconsistent newlines, + # because we've loaded existing content with universal newlines, but that's + # the original behavior. + newline="", + ) def _figure_out_existing_content( @@ -66,10 +81,7 @@ def _figure_out_existing_content( # Non-existent files have no existing content. return "", "" - # If we didn't use universal newlines here, we wouldn't find *start_string* - # which usually contains a `\n`. - with news_file.open(encoding="utf8") as f: - content = f.read() + content = Path(news_file).read_text(encoding="utf-8") t = content.split(start_string, 1) if len(t) == 2: diff --git a/src/towncrier/build.py b/src/towncrier/build.py index 60e0e861..bf7cd350 100644 --- a/src/towncrier/build.py +++ b/src/towncrier/build.py @@ -5,13 +5,13 @@ Build a combined news file from news fragments. """ - from __future__ import annotations import os import sys from datetime import date +from pathlib import Path import click @@ -166,30 +166,20 @@ def __main( click.echo("Loading template...", err=to_err) if isinstance(config.template, tuple): template = ( - resources.files(config.template[0]).joinpath(config.template[1]).read_text() + resources.files(config.template[0]) + .joinpath(config.template[1]) + .read_text(encoding="utf-8") ) + template_extension = os.path.splitext(config.template[1])[1] else: - with open(config.template, encoding="utf-8") as tmpl: - template = tmpl.read() + template = Path(config.template).read_text(encoding="utf-8") + template_extension = os.path.splitext(config.template)[1] + is_markdown = template_extension.lower() == ".md" click.echo("Finding news fragments...", err=to_err) - 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) - ) - fragment_directory = "newsfragments" - - fragment_contents, fragment_filenames = find_fragments( - fragment_base_directory, - config.sections, - fragment_directory, - config.types, - config.orphan_prefix, - ) + fragment_contents, fragment_files = find_fragments(base_directory, config) + fragment_filenames = [filename for (filename, _category) in fragment_files] click.echo("Rendering news fragments...", err=to_err) fragments = split_fragments( @@ -212,22 +202,10 @@ def __main( 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 - ) - render_title_with_fragments = False - render_title_separately = True - 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 = "" - render_title_separately = False - render_title_with_fragments = False - else: - top_line = "" - render_title_separately = False - render_title_with_fragments = True + # Render the title in the template if the title format is set to "". It can + # alternatively be set to False or a string, in either case it shouldn't be rendered + # in the template. + render_title = config.title_format == "" rendered = render_fragments( # The 0th underline is used for the top line @@ -240,18 +218,21 @@ def __main( {"name": project_name, "version": project_version, "date": project_date}, top_underline=config.underlines[0], all_bullets=config.all_bullets, - render_title=render_title_with_fragments, + render_title=render_title, ) - if render_title_separately: - content = "\n".join( - [ - top_line, - config.underlines[0] * len(top_line), - rendered, - ] + if config.title_format: + top_line = config.title_format.format( + name=project_name, version=project_version, project_date=project_date ) + if is_markdown: + parts = [f"# {top_line}"] + else: + parts = [top_line, config.underlines[0] * len(top_line)] + parts.append(rendered) + content = "\n".join(parts) else: + top_line = "" content = rendered if draft: diff --git a/src/towncrier/check.py b/src/towncrier/check.py index 2124590c..a519f9ae 100644 --- a/src/towncrier/check.py +++ b/src/towncrier/check.py @@ -93,9 +93,7 @@ def __main( ) sys.exit(0) - files = { - os.path.normpath(os.path.join(base_directory, path)) for path in files_changed - } + files = {os.path.abspath(path) for path in files_changed} click.echo("Looking at these files:") click.echo("----") @@ -108,28 +106,24 @@ def __main( click.echo("Checks SKIPPED: news file changes detected.") sys.exit(0) - 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) - ) - fragment_directory = "newsfragments" - - fragments = { - os.path.normpath(path) - for path in find_fragments( - fragment_base_directory, - config.sections, - fragment_directory, - config.types.keys(), - )[1] - } + all_fragment_files = find_fragments(base_directory, config)[1] + fragments = set() # will only include fragments of types that are checked + unchecked_fragments = set() # will include fragments of types that are not checked + for fragment_filename, category in all_fragment_files: + if config.types[category]["check"]: + fragments.add(fragment_filename) + else: + unchecked_fragments.add(fragment_filename) fragments_in_branch = fragments & files if not fragments_in_branch: - click.echo("No new newsfragments found on this branch.") + unchecked_fragments_in_branch = unchecked_fragments & files + if unchecked_fragments: + click.echo("Found newsfragments of unchecked types in the branch:") + for n, fragment in enumerate(unchecked_fragments_in_branch, start=1): + click.echo(f"{n}. {fragment}") + else: + click.echo("No new newsfragments found on this branch.") sys.exit(1) else: click.echo("Found:") diff --git a/src/towncrier/click_default_group.py b/src/towncrier/click_default_group.py new file mode 100644 index 00000000..6d5e69bd --- /dev/null +++ b/src/towncrier/click_default_group.py @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: 2015 Heungsub Lee <19982+sublee@users.noreply.github.com> +# +# SPDX-License-Identifier: BSD-3-Clause + +# Vendored from +# https://github.com/click-contrib/click-default-group/tree/b671ae5325d186fe5ea7abb584f15852a1e931aa +# Because the PyPI package could not be installed on modern Pips anymore and +# the project looks unmaintaintained. + +""" + click_default_group + ~~~~~~~~~~~~~~~~~~~ + + Define a default subcommand by `default=True`: + + .. sourcecode:: python + + import click + from click_default_group import DefaultGroup + + @click.group(cls=DefaultGroup, default_if_no_args=True) + def cli(): + pass + + @cli.command(default=True) + def foo(): + click.echo('foo') + + @cli.command() + def bar(): + click.echo('bar') + + Then you can invoke that without explicit subcommand name: + + .. sourcecode:: console + + $ cli.py --help + Usage: cli.py [OPTIONS] COMMAND [ARGS]... + + Options: + --help Show this message and exit. + + Command: + foo* + bar + + $ cli.py + foo + $ cli.py foo + foo + $ cli.py bar + bar + +""" +import warnings + +import click + + +__all__ = ["DefaultGroup"] +__version__ = "1.2.2" + + +class DefaultGroup(click.Group): + """Invokes a subcommand marked with `default=True` if any subcommand not + chosen. + + :param default_if_no_args: resolves to the default command if no arguments + passed. + + """ + + def __init__(self, *args, **kwargs): + # To resolve as the default command. + if not kwargs.get("ignore_unknown_options", True): + raise ValueError("Default group accepts unknown options") + self.ignore_unknown_options = True + self.default_cmd_name = kwargs.pop("default", None) + self.default_if_no_args = kwargs.pop("default_if_no_args", False) + super().__init__(*args, **kwargs) + + def set_default_command(self, command): + """Sets a command function as the default command.""" + cmd_name = command.name + self.add_command(command) + self.default_cmd_name = cmd_name + + def parse_args(self, ctx, args): + if not args and self.default_if_no_args: + args.insert(0, self.default_cmd_name) + return super().parse_args(ctx, args) + + def get_command(self, ctx, cmd_name): + if cmd_name not in self.commands: + # No command name matched. + ctx.arg0 = cmd_name + cmd_name = self.default_cmd_name + return super().get_command(ctx, cmd_name) + + def resolve_command(self, ctx, args): + base = super() + cmd_name, cmd, args = base.resolve_command(ctx, args) + if hasattr(ctx, "arg0"): + args.insert(0, ctx.arg0) + cmd_name = cmd.name + return cmd_name, cmd, args + + def format_commands(self, ctx, formatter): + formatter = DefaultCommandFormatter(self, formatter, mark="*") + return super().format_commands(ctx, formatter) + + def command(self, *args, **kwargs): + default = kwargs.pop("default", False) + decorator = super().command(*args, **kwargs) + if not default: + return decorator + warnings.warn( + "Use default param of DefaultGroup or " "set_default_command() instead", + DeprecationWarning, + ) + + def _decorator(f): + cmd = decorator(f) + self.set_default_command(cmd) + return cmd + + return _decorator + + +class DefaultCommandFormatter: + """Wraps a formatter to mark a default command.""" + + def __init__(self, group, formatter, mark="*"): + self.group = group + self.formatter = formatter + self.mark = mark + + def __getattr__(self, attr): + return getattr(self.formatter, attr) + + def write_dl(self, rows, *args, **kwargs): + rows_ = [] + for cmd_name, help in rows: + if cmd_name == self.group.default_cmd_name: + rows_.insert(0, (cmd_name + self.mark, help)) + else: + rows_.append((cmd_name, help)) + return self.formatter.write_dl(rows_, *args, **kwargs) diff --git a/src/towncrier/create.py b/src/towncrier/create.py index 5242e124..e78fb658 100644 --- a/src/towncrier/create.py +++ b/src/towncrier/create.py @@ -9,11 +9,18 @@ import os +from pathlib import Path +from typing import cast + import click +from ._builder import FragmentsPath from ._settings import config_option_help, load_config_from_options +DEFAULT_CONTENT = "Add your info here" + + @click.command(name="create") @click.pass_context @click.option( @@ -32,30 +39,38 @@ ) @click.option( "--edit/--no-edit", - default=False, + default=None, help="Open an editor for writing the newsfragment content.", -) # TODO: default should be true +) @click.option( "-c", "--content", type=str, - default="Add your info here", + default=DEFAULT_CONTENT, help="Sets the content of the new fragment.", ) -@click.argument("filename") +@click.option( + "--section", + type=str, + help="The section to create the fragment for.", +) +@click.argument("filename", default="") def _main( ctx: click.Context, directory: str | None, config: str | None, filename: str, - edit: bool, + edit: bool | None, content: str, + section: str | None, ) -> None: """ Create a new news fragment. - Create a new news fragment called FILENAME or pass the full path for a file. - Towncrier has a few standard types of news fragments, signified by the file extension. + If FILENAME is not provided, you'll be prompted to create it. + + Towncrier has a few standard types of news fragments, signified by the file + extension. \b These are: @@ -63,9 +78,12 @@ def _main( * .bugfix - a bug fix * .doc - a documentation improvement, * .removal - a deprecation or removal of public API, - * .misc - a ticket has been closed, but it is not of interest to users. + * .misc - an issue has been closed, but it is not of interest to users. + + If the FILENAME base is just '+' (to create a fragment not tied to an + issue), it will be appended with a random hex string. """ - __main(ctx, directory, config, filename, edit, content) + __main(ctx, directory, config, filename, edit, content, section) def __main( @@ -73,14 +91,82 @@ def __main( directory: str | None, config_path: str | None, filename: str, - edit: bool, + edit: bool | None, content: str, + section: str | None, ) -> None: """ The main entry point. """ base_directory, config = load_config_from_options(directory, config_path) + filename_ext = "" + if config.create_add_extension: + ext = os.path.splitext(config.filename)[1] + if ext.lower() in (".rst", ".md"): + filename_ext = ext + + section_provided = section is not None + if not section_provided: + # Get the default section. + if len(config.sections) == 1: + section = next(iter(config.sections)) + else: + # If there are multiple sections then the first without a path is the default + # section, otherwise it's the first defined section. + for ( + section_name, + section_dir, + ) in config.sections.items(): # pragma: no branch + if not section_dir: + section = section_name + break + if section is None: + section = list(config.sections.keys())[0] + + if section not in config.sections: + # Raise a click exception with the correct parameter. + section_param = None + for p in ctx.command.params: # pragma: no branch + if p.name == "section": + section_param = p + break + expected_sections = ", ".join(f"'{s}'" for s in config.sections) + raise click.BadParameter( + f"expected one of {expected_sections}", + param=section_param, + ) + section = cast(str, section) + + if not filename: + if not section_provided: + sections = list(config.sections) + if len(sections) > 1: + click.echo("Pick a section:") + default_section_index = None + for i, s in enumerate(sections): + click.echo(f" {i+1}: {s or '(primary)'}") + if not default_section_index and s == section: + default_section_index = str(i + 1) + section_index = click.prompt( + "Section", + type=click.Choice([str(i + 1) for i in range(len(sections))]), + default=default_section_index, + ) + section = sections[int(section_index) - 1] + prompt = "Issue number" + # Add info about adding orphan if config is set. + if config.orphan_prefix: + prompt += f" (`{config.orphan_prefix}` if none)" + issue = click.prompt(prompt) + fragment_type = click.prompt( + "Fragment type", + type=click.Choice(list(config.types)), + ) + filename = f"{issue}.{fragment_type}" + if edit is None and content == DEFAULT_CONTENT: + edit = True + file_dir, file_basename = os.path.split(filename) if config.orphan_prefix and file_basename.startswith(f"{config.orphan_prefix}."): # Append a random hex string to the orphan news fragment base name. @@ -91,29 +177,21 @@ def __main( f"{file_basename[len(config.orphan_prefix):]}" ), ) - if len(filename.split(".")) < 2 or ( - filename.split(".")[-1] not in config.types - and filename.split(".")[-2] not in config.types + filename_parts = filename.split(".") + if len(filename_parts) < 2 or ( + filename_parts[-1] not in config.types + and filename_parts[-2] not in config.types ): 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(config.types)) ) + if filename_parts[-1] in config.types and filename_ext: + filename += filename_ext - if config.directory: - fragments_directory = os.path.abspath( - os.path.join(base_directory, config.directory) - ) - else: - fragments_directory = os.path.abspath( - os.path.join( - base_directory, - config.package_dir, - config.package, - "newsfragments", - ) - ) + get_fragments_path = FragmentsPath(base_directory, config) + fragments_directory = get_fragments_path(section_directory=config.sections[section]) if not os.path.exists(fragments_directory): os.makedirs(fragments_directory) @@ -132,31 +210,34 @@ def __main( ) if edit: - edited_content = _get_news_content_from_user(content) - if edited_content is None: - click.echo("Abort creating news fragment.") + if content == DEFAULT_CONTENT: + content = "" + content = _get_news_content_from_user(content, extension=filename_ext) + if not content: + click.echo("Aborted creating news fragment due to empty message.") ctx.exit(1) - content = edited_content - with open(segment_file, "w") as f: - f.write(content) + add_newline = bool( + config.create_eof_newline and content and not content.endswith("\n") + ) + Path(segment_file).write_text(content + "\n" * add_newline, encoding="utf-8") click.echo(f"Created news fragment at {segment_file}") -def _get_news_content_from_user(message: str) -> str | None: - initial_content = ( - "# Please write your news content. When finished, save the file.\n" - "# In order to abort, exit without saving.\n" - '# Lines starting with "#" are ignored.\n' - ) - initial_content += f"\n{message}\n" - content = click.edit(initial_content) +def _get_news_content_from_user(message: str, extension: str = "") -> str: + initial_content = """ +# Please write your news content. Lines starting with '#' will be ignored, and +# an empty message aborts. +""" + if message: + initial_content = f"{message}\n{initial_content}" + content = click.edit(initial_content, extension=extension or ".txt") if content is None: - return None + return message all_lines = content.split("\n") lines = [line.rstrip() for line in all_lines if not line.lstrip().startswith("#")] - return "\n".join(lines) + return "\n".join(lines).strip() if __name__ == "__main__": # pragma: no cover diff --git a/src/towncrier/newsfragments/357.feature.rst b/src/towncrier/newsfragments/357.feature.rst new file mode 100644 index 00000000..d459d5c3 --- /dev/null +++ b/src/towncrier/newsfragments/357.feature.rst @@ -0,0 +1 @@ +``towncrier build`` now handles removing news fragments which are not part of the git repository. For example, uncommitted or unstaged files. diff --git a/src/towncrier/newsfragments/432.doc.rst b/src/towncrier/newsfragments/432.doc.rst new file mode 100644 index 00000000..92e5247f --- /dev/null +++ b/src/towncrier/newsfragments/432.doc.rst @@ -0,0 +1 @@ +Clarify version discovery behavior. diff --git a/src/towncrier/newsfragments/432.feature.rst b/src/towncrier/newsfragments/432.feature.rst new file mode 100644 index 00000000..94f19ea4 --- /dev/null +++ b/src/towncrier/newsfragments/432.feature.rst @@ -0,0 +1 @@ +Inferring the version of a Python package now tries to use the metadata of the installed package before importing the package explicitly (which only looks for ``[package].__version__``). diff --git a/src/towncrier/newsfragments/482.feature.rst b/src/towncrier/newsfragments/482.feature.rst new file mode 100644 index 00000000..81b66604 --- /dev/null +++ b/src/towncrier/newsfragments/482.feature.rst @@ -0,0 +1,5 @@ +If no filename is given when doing ``towncrier`` create, interactively ask for the issue number and fragment type (and then launch an interactive editor for the fragment content). + +Now by default, when creating a fragment it will be appended with the ``filename`` option's extension (unless an extension is explicitly provided). For example, ``towncrier create 123.feature`` will create ``news/123.feature.rst``. This can be changed in configuration file by setting `add_extension = false`. + +A new line is now added by default to the end of the fragment contents. This can be reverted in the configuration file by setting `add_newline = false`. diff --git a/src/towncrier/newsfragments/521.removal b/src/towncrier/newsfragments/521.removal deleted file mode 100644 index c93bd0c6..00000000 --- a/src/towncrier/newsfragments/521.removal +++ /dev/null @@ -1 +0,0 @@ -The support for Python 3.7 has been dropped. diff --git a/src/towncrier/newsfragments/561.bugfix.rst b/src/towncrier/newsfragments/561.bugfix.rst new file mode 100644 index 00000000..9e0d382e --- /dev/null +++ b/src/towncrier/newsfragments/561.bugfix.rst @@ -0,0 +1 @@ +Add explicit encoding to read_text. diff --git a/src/towncrier/newsfragments/561.misc.rst b/src/towncrier/newsfragments/561.misc.rst new file mode 100644 index 00000000..bdb991a5 --- /dev/null +++ b/src/towncrier/newsfragments/561.misc.rst @@ -0,0 +1 @@ +Enable reporting of EncodingWarnings when running tests. diff --git a/src/towncrier/newsfragments/562.misc b/src/towncrier/newsfragments/562.misc new file mode 100644 index 00000000..88d21020 --- /dev/null +++ b/src/towncrier/newsfragments/562.misc @@ -0,0 +1 @@ +Improved structure and readability of some tests for building the changelog. diff --git a/src/towncrier/newsfragments/520.misc b/src/towncrier/newsfragments/568.misc similarity index 100% rename from src/towncrier/newsfragments/520.misc rename to src/towncrier/newsfragments/568.misc diff --git a/src/towncrier/newsfragments/569.misc.rst b/src/towncrier/newsfragments/569.misc.rst new file mode 100644 index 00000000..3dfd8638 --- /dev/null +++ b/src/towncrier/newsfragments/569.misc.rst @@ -0,0 +1 @@ +Constrain jinja2 to v3+ -- our templates require this and it was released over 3 years ago (May 2021) diff --git a/src/towncrier/newsfragments/522.misc b/src/towncrier/newsfragments/571.misc similarity index 100% rename from src/towncrier/newsfragments/522.misc rename to src/towncrier/newsfragments/571.misc diff --git a/src/towncrier/newsfragments/523.misc b/src/towncrier/newsfragments/574.misc similarity index 100% rename from src/towncrier/newsfragments/523.misc rename to src/towncrier/newsfragments/574.misc diff --git a/src/towncrier/newsfragments/529.misc b/src/towncrier/newsfragments/575.misc similarity index 100% rename from src/towncrier/newsfragments/529.misc rename to src/towncrier/newsfragments/575.misc diff --git a/src/towncrier/newsfragments/582.misc b/src/towncrier/newsfragments/582.misc new file mode 100644 index 00000000..e69de29b diff --git a/src/towncrier/newsfragments/586.doc b/src/towncrier/newsfragments/586.doc new file mode 100644 index 00000000..cbaf3461 --- /dev/null +++ b/src/towncrier/newsfragments/586.doc @@ -0,0 +1 @@ +The tutorial now introduces the `filename` option in the appropriate paragraph and mentions its default value. diff --git a/src/towncrier/newsfragments/587.bugfix b/src/towncrier/newsfragments/587.bugfix new file mode 100644 index 00000000..eeec305f --- /dev/null +++ b/src/towncrier/newsfragments/587.bugfix @@ -0,0 +1 @@ +The default Markdown template now renders a title containing the release version and date, even when the `name` configuration is left empty. diff --git a/src/towncrier/newsfragments/588.bugfix b/src/towncrier/newsfragments/588.bugfix new file mode 100644 index 00000000..f1c3e8ef --- /dev/null +++ b/src/towncrier/newsfragments/588.bugfix @@ -0,0 +1 @@ +Orphan news fragments, fragments not associated with an issue, consisting of only digits (e.g. '+12345678.feature') now retain their leading marker character. diff --git a/src/towncrier/newsfragments/589.doc b/src/towncrier/newsfragments/589.doc new file mode 100644 index 00000000..3664626f --- /dev/null +++ b/src/towncrier/newsfragments/589.doc @@ -0,0 +1 @@ +Add docs to explain how ``towncrier create +.feature.rst`` (orphan fragments) works. diff --git a/src/towncrier/newsfragments/591.misc.rst b/src/towncrier/newsfragments/591.misc.rst new file mode 100644 index 00000000..10f5e3a7 --- /dev/null +++ b/src/towncrier/newsfragments/591.misc.rst @@ -0,0 +1 @@ +Leveraged pathlib in most file operations. diff --git a/src/towncrier/newsfragments/594.feature.rst b/src/towncrier/newsfragments/594.feature.rst new file mode 100644 index 00000000..0f8d1296 --- /dev/null +++ b/src/towncrier/newsfragments/594.feature.rst @@ -0,0 +1 @@ +The temporary file ``towncrier create`` creates now uses the correct ``.rst`` or ``.md`` extension, which may help your editor with with syntax highlighting. diff --git a/src/towncrier/newsfragments/596.misc.rst b/src/towncrier/newsfragments/596.misc.rst new file mode 100644 index 00000000..d1b07d7b --- /dev/null +++ b/src/towncrier/newsfragments/596.misc.rst @@ -0,0 +1 @@ +Remove github edit link in non-trunk versions of docs diff --git a/src/towncrier/newsfragments/597.misc.rst b/src/towncrier/newsfragments/597.misc.rst new file mode 100644 index 00000000..e7c7562c --- /dev/null +++ b/src/towncrier/newsfragments/597.misc.rst @@ -0,0 +1 @@ +Fix readthedocs to keep the edit link for ``latest`` in addition to ``trunk``. diff --git a/src/towncrier/newsfragments/601.feature.rst b/src/towncrier/newsfragments/601.feature.rst new file mode 100644 index 00000000..ca209ca5 --- /dev/null +++ b/src/towncrier/newsfragments/601.feature.rst @@ -0,0 +1 @@ +Running ``towncrier`` will now traverse back up directories looking for the configuration file. diff --git a/src/towncrier/newsfragments/602.doc.rst b/src/towncrier/newsfragments/602.doc.rst new file mode 100644 index 00000000..92e5247f --- /dev/null +++ b/src/towncrier/newsfragments/602.doc.rst @@ -0,0 +1 @@ +Clarify version discovery behavior. diff --git a/src/towncrier/newsfragments/603.feature.rst b/src/towncrier/newsfragments/603.feature.rst new file mode 100644 index 00000000..afe48164 --- /dev/null +++ b/src/towncrier/newsfragments/603.feature.rst @@ -0,0 +1 @@ +The ``towncrier create`` action now uses sections defined in your config (either interactively, or via the new ``--section`` option). diff --git a/src/towncrier/newsfragments/608.feature.rst b/src/towncrier/newsfragments/608.feature.rst new file mode 100644 index 00000000..4cccc128 --- /dev/null +++ b/src/towncrier/newsfragments/608.feature.rst @@ -0,0 +1,8 @@ +News fragments are now sorted by issue number even if they have non-digit characters. +For example:: + + - some issue (gh-3, gh-10) + - another issue (gh-4) + - yet another issue (gh-11) + +The sorting algorithm groups the issues first by non-text characters and then by number. diff --git a/src/towncrier/newsfragments/610.feature.rst b/src/towncrier/newsfragments/610.feature.rst new file mode 100644 index 00000000..5b76ab2c --- /dev/null +++ b/src/towncrier/newsfragments/610.feature.rst @@ -0,0 +1 @@ +The ``title_format`` configuration option now uses a markdown format for markdown templates. diff --git a/src/towncrier/newsfragments/612.bugfix.rst b/src/towncrier/newsfragments/612.bugfix.rst new file mode 100644 index 00000000..577a9c48 --- /dev/null +++ b/src/towncrier/newsfragments/612.bugfix.rst @@ -0,0 +1 @@ +Orphan news fragments, fragments not associated with an issue, will now still show in categories that are marked to not show content, since they do not have an issue number to show. diff --git a/src/towncrier/newsfragments/617.feature.rst b/src/towncrier/newsfragments/617.feature.rst new file mode 100644 index 00000000..95799672 --- /dev/null +++ b/src/towncrier/newsfragments/617.feature.rst @@ -0,0 +1 @@ +newsfragment categories can now be marked with ``check = false``, causing them to be ignored in ``towncrier check`` diff --git a/src/towncrier/newsfragments/625.misc b/src/towncrier/newsfragments/625.misc new file mode 100644 index 00000000..e69de29b diff --git a/src/towncrier/templates/default.md b/src/towncrier/templates/default.md index 45f7a395..0aa7262c 100644 --- a/src/towncrier/templates/default.md +++ b/src/towncrier/templates/default.md @@ -2,7 +2,7 @@ {% if versiondata.name %} # {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) {% else %} -{{ versiondata.version }} ({{ versiondata.date }}) +# {{ versiondata.version }} ({{ versiondata.date }}) {% endif %} {% endif %} {% for section, _ in sections.items() %} @@ -15,7 +15,6 @@ {% 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 %} @@ -24,24 +23,18 @@ ( {%- else %} - ( +{% if text %} ({% endif %} {%- endif -%} {%- for issue in values %} {{ issue.split(": ", 1)[0] }}{% if not loop.last %}, {% endif %} {%- endfor %} -) +{% if text %}){% endif %} + {% 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 }} @@ -58,4 +51,8 @@ No significant changes. No significant changes. {% endif %} -{% endfor %} +{% endfor +%} +{# +This comment adds one more newline at the end of the rendered newsfile content. +In this way the there are 2 newlines between the latest release and the previous release content. +#} diff --git a/src/towncrier/templates/default.rst b/src/towncrier/templates/default.rst index f494036d..bee15720 100644 --- a/src/towncrier/templates/default.rst +++ b/src/towncrier/templates/default.rst @@ -18,16 +18,11 @@ {{ definitions[category]['name'] }} {{ underline * definitions[category]['name']|length }} -{% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} -- {{ text }}{% if values %} ({{ values|join(', ') }}){% endif %} +- {% if text %}{{ text }}{% if values %} ({{ values|join(', ') }}){% endif %}{% else %}{{ values|join(', ') }}{% endif %} {% endfor %} -{% else %} -- {{ sections[section][category]['']|join(', ') }} - -{% endif %} {% if sections[section][category]|length == 0 %} No significant changes. diff --git a/src/towncrier/test/helpers.py b/src/towncrier/test/helpers.py index ddc9ffc6..77cea36d 100644 --- a/src/towncrier/test/helpers.py +++ b/src/towncrier/test/helpers.py @@ -5,6 +5,7 @@ from functools import wraps from pathlib import Path +from subprocess import call from typing import Any, Callable from click.testing import CliRunner @@ -35,7 +36,7 @@ def read_pkg_resource(path: str) -> str: """ Read *path* from the towncrier package. """ - return (resources.files("towncrier") / path).read_text("utf8") + return (resources.files("towncrier") / path).read_text("utf-8") def with_isolated_runner(fn: Callable[..., Any]) -> Callable[..., Any]: @@ -62,10 +63,83 @@ def setup_simple_project( ) -> None: if config is None: config = "[tool.towncrier]\n" 'package = "foo"\n' + extra_config - + else: + config = textwrap.dedent(config) Path(pyproject_path).write_text(config) Path("foo").mkdir() Path("foo/__init__.py").write_text('__version__ = "1.2.3"\n') if mkdir_newsfragments: Path("foo/newsfragments").mkdir() + + +def with_project( + *, + config: str | None = None, + pyproject_path: str = "pyproject.toml", +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Decorator to run a test with an isolated directory containing a simple + project. + + The files are not managed by git. + + `config` is the content of the config file. + It will be automatically dedented. + + `pyproject_path` is the path where to store the config file. + """ + + def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: + @wraps(fn) + def test(*args: Any, **kw: Any) -> Any: + runner = CliRunner() + with runner.isolated_filesystem(): + setup_simple_project( + config=config, + pyproject_path=pyproject_path, + ) + + return fn(*args, runner=runner, **kw) + + return test + + return decorator + + +def with_git_project( + *, + config: str | None = None, + pyproject_path: str = "pyproject.toml", +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Decorator to run a test with an isolated directory containing a simple + project checked into git. + Use `config` to tweak the content of the config file. + Use `pyproject_path` to tweak the location of the config file. + """ + + def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: + def _commit() -> None: + call(["git", "add", "."]) + call(["git", "commit", "-m", "Second Commit"]) + + @wraps(fn) + def test(*args: Any, **kw: Any) -> Any: + runner = CliRunner() + with runner.isolated_filesystem(): + setup_simple_project( + config=config, + pyproject_path=pyproject_path, + ) + + call(["git", "init"]) + call(["git", "config", "user.name", "user"]) + call(["git", "config", "user.email", "user@example.com"]) + call(["git", "config", "commit.gpgSign", "false"]) + call(["git", "add", "."]) + call(["git", "commit", "-m", "Initial Commit"]) + + return fn(*args, runner=runner, commit=_commit, **kw) + + return test + + return decorator diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index d4561263..5141fe3d 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -15,44 +15,50 @@ from .._shell import cli from ..build import _main -from .helpers import read, setup_simple_project, with_isolated_runner, write +from .helpers import read, with_git_project, with_project, write class TestCli(TestCase): maxDiff = None - def _test_command(self, command): - runner = CliRunner() - - with runner.isolated_filesystem(): - setup_simple_project() - with open("foo/newsfragments/123.feature", "w") as f: - f.write("Adds levitation") - # Towncrier treats this as 124.feature, ignoring .rst extension - with open("foo/newsfragments/124.feature.rst", "w") as f: - f.write("Extends levitation") - # Towncrier supports non-numeric newsfragment names. - with open("foo/newsfragments/baz.feature.rst", "w") as f: - f.write("Baz levitation") - # Towncrier supports files that have a dot in the name of the - # newsfragment - with open("foo/newsfragments/fix-1.2.feature", "w") as f: - f.write("Baz fix levitation") - # Towncrier supports fragments not linked to a feature - with open("foo/newsfragments/+anything.feature", "w") as f: - f.write("Orphaned feature") - with open("foo/newsfragments/+xxx.feature", "w") as f: - f.write("Another orphaned feature") - # Towncrier ignores files that don't have a dot - with open("foo/newsfragments/README", "w") as f: - f.write("Blah blah") - # And files that don't have a valid category - with open("foo/newsfragments/README.rst", "w") as f: - f.write("**Blah blah**") - - result = runner.invoke(command, ["--draft", "--date", "01-01-2001"]) + @with_project() + def _test_command(self, command, runner): + # Off the shelf newsfragment + with open("foo/newsfragments/123.feature", "w") as f: + f.write("Adds levitation") + # Towncrier treats this as 124.feature, ignoring .rst extension + with open("foo/newsfragments/124.feature.rst", "w") as f: + f.write("Extends levitation") + # Towncrier supports non-numeric newsfragment names. + with open("foo/newsfragments/baz.feature.rst", "w") as f: + f.write("Baz levitation") + # Towncrier supports files that have a dot in the name of the + # newsfragment + with open("foo/newsfragments/fix-1.2.feature", "w") as f: + f.write("Baz fix levitation") + # Towncrier supports fragments not linked to a feature + with open("foo/newsfragments/+anything.feature", "w") as f: + f.write("Orphaned feature") + with open("foo/newsfragments/+xxx.feature", "w") as f: + f.write("Another orphaned feature") + with open("foo/newsfragments/+123_orphaned.feature", "w") as f: + f.write("An orphaned feature starting with a number") + with open("foo/newsfragments/+12.3_orphaned.feature", "w") as f: + f.write("An orphaned feature starting with a dotted number") + with open("foo/newsfragments/+orphaned_123.feature", "w") as f: + f.write("An orphaned feature ending with a number") + with open("foo/newsfragments/+orphaned_12.3.feature", "w") as f: + f.write("An orphaned feature ending with a dotted number") + # Towncrier ignores files that don't have a dot + with open("foo/newsfragments/README", "w") as f: + f.write("Blah blah") + # And files that don't have a valid category + with open("foo/newsfragments/README.rst", "w") as f: + f.write("**Blah blah**") + + result = runner.invoke(command, ["--draft", "--date", "01-01-2001"]) - self.assertEqual(0, result.exit_code) + self.assertEqual(0, result.exit_code, result.output) self.assertEqual( result.output, dedent( @@ -70,12 +76,18 @@ def _test_command(self, command): -------- - Baz levitation (baz) - - Baz fix levitation (#2) + - Baz fix levitation (fix-1.2) - Adds levitation (#123) - Extends levitation (#124) + - An orphaned feature ending with a dotted number + - An orphaned feature ending with a number + - An orphaned feature starting with a dotted number + - An orphaned feature starting with a number - Another orphaned feature - Orphaned feature + + """ ), ) @@ -86,7 +98,7 @@ def test_command(self): def test_subcommand(self): self._test_command(_main) - @with_isolated_runner + @with_project() def test_in_different_dir_dir_option(self, runner): """ The current working directory doesn't matter as long as we pass @@ -94,7 +106,6 @@ def test_in_different_dir_dir_option(self, runner): """ project_dir = Path(".").resolve() - setup_simple_project() Path("foo/newsfragments/123.feature").write_text("Adds levitation") # Ensure our assetion below is meaningful. self.assertFalse((project_dir / "NEWS.rst").exists()) @@ -111,16 +122,25 @@ def test_in_different_dir_dir_option(self, runner): self.assertEqual(0, result.exit_code) self.assertTrue((project_dir / "NEWS.rst").exists()) - @with_isolated_runner + @with_project() + def test_traverse_up_to_find_config(self, runner): + """ + When the current directory doesn't contain the configuration file, Towncrier + will traverse up the directory tree until it finds it. + """ + os.chdir("foo") + result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) + self.assertEqual(0, result.exit_code, result.output) + + @with_project() def test_in_different_dir_config_option(self, runner): """ The current working directory and the location of the configuration - don't matter as long as we pass corrct paths to the directory and the + don't matter as long as we pass correct paths to the directory and the config file. """ project_dir = Path(".").resolve() - setup_simple_project() Path("foo/newsfragments/123.feature").write_text("Adds levitation") # Ensure our assetion below is meaningful. self.assertFalse((project_dir / "NEWS.rst").exists()) @@ -143,62 +163,79 @@ def test_in_different_dir_config_option(self, runner): self.assertEqual(0, result.exit_code) self.assertTrue((project_dir / "NEWS.rst").exists()) - @with_isolated_runner + @with_project( + config=""" + [tool.towncrier] + directory = "changelog.d" + """ + ) + def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner): + """ + Using the `--dir` CLI argument, the NEWS file can + be generated in a sub-directory from fragments + that are relatives to that sub-directory. + + The path passed to `--dir` becomes the + working directory. + """ + Path("foo/foo").mkdir(parents=True) + Path("foo/foo/__init__.py").write_text("") + Path("foo/changelog.d").mkdir() + Path("foo/changelog.d/123.feature").write_text("Adds levitation") + self.assertFalse(Path("foo/NEWS.rst").exists()) + + result = runner.invoke( + cli, + ("--yes", "--config", "pyproject.toml", "--dir", "foo", "--version", "1.0"), + ) + + self.assertEqual(0, result.exit_code) + self.assertTrue(Path("foo/NEWS.rst").exists()) + + @with_project() def test_no_newsfragment_directory(self, runner): """ A missing newsfragment directory acts as if there are no changes. """ - setup_simple_project() os.rmdir("foo/newsfragments") result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) - self.assertEqual(1, result.exit_code, result.output) - self.assertIn("Failed to list the news fragment files.\n", result.output) + self.assertEqual(0, result.exit_code) + self.assertIn("No significant changes.\n", result.output) - def test_no_newsfragments_draft(self): + @with_project() + def test_no_newsfragments_draft(self, runner): """ An empty newsfragment directory acts as if there are no changes. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - setup_simple_project() - - result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) + result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) self.assertEqual(0, result.exit_code) self.assertIn("No significant changes.\n", result.output) - def test_no_newsfragments(self): + @with_project() + def test_no_newsfragments(self, runner): """ An empty newsfragment directory acts as if there are no changes and removing files handles it gracefully. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - setup_simple_project() - - result = runner.invoke(_main, ["--date", "01-01-2001"]) + result = runner.invoke(_main, ["--date", "01-01-2001"]) - news = read("NEWS.rst") + news = read("NEWS.rst") self.assertEqual(0, result.exit_code) self.assertIn("No significant changes.\n", news) - def test_collision(self): - runner = CliRunner() - - with runner.isolated_filesystem(): - setup_simple_project() - # Note that both are 123.feature - with open("foo/newsfragments/123.feature", "w") as f: - f.write("Adds levitation") - with open("foo/newsfragments/123.feature.rst", "w") as f: - f.write("Extends levitation") + @with_project() + def test_collision(self, runner): + # Note that both are 123.feature + with open("foo/newsfragments/123.feature", "w") as f: + f.write("Adds levitation") + with open("foo/newsfragments/123.feature.rst", "w") as f: + f.write("Extends levitation") - result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) + result = runner.invoke(_main, ["--draft", "--date", "01-01-2001"]) # This should fail self.assertEqual(type(result.exception), ValueError) @@ -307,6 +344,8 @@ def run_order_scenario(sections, types): - section-b type-2 (#1) + + """ ), ) @@ -350,71 +389,58 @@ def run_order_scenario(sections, types): - section-a type-1 (#1) + + """ ), ) - def test_draft_no_date(self): + @with_git_project() + def test_draft_no_date(self, runner, commit): """ If no date is passed, today's date is used. """ - runner = CliRunner() + fragment_path1 = "foo/newsfragments/123.feature" + fragment_path2 = "foo/newsfragments/124.feature.rst" + with open(fragment_path1, "w") as f: + f.write("Adds levitation") + with open(fragment_path2, "w") as f: + f.write("Extends levitation") - with runner.isolated_filesystem(): - setup_simple_project() - fragment_path1 = "foo/newsfragments/123.feature" - fragment_path2 = "foo/newsfragments/124.feature.rst" - with open(fragment_path1, "w") as f: - f.write("Adds levitation") - with open(fragment_path2, "w") as f: - f.write("Extends levitation") - - call(["git", "init"]) - call(["git", "config", "user.name", "user"]) - call(["git", "config", "user.email", "user@example.com"]) - call(["git", "add", "."]) - call(["git", "commit", "-m", "Initial Commit"]) - - today = date.today() - result = runner.invoke(_main, ["--draft"]) + commit() - self.assertEqual(0, result.exit_code) - self.assertIn(f"Foo 1.2.3 ({today.isoformat()})", result.output) + today = date.today() + result = runner.invoke(_main, ["--draft"]) - def test_no_confirmation(self): - runner = CliRunner() + self.assertEqual(0, result.exit_code) + self.assertIn(f"Foo 1.2.3 ({today.isoformat()})", result.output) - with runner.isolated_filesystem(): - setup_simple_project() - fragment_path1 = "foo/newsfragments/123.feature" - fragment_path2 = "foo/newsfragments/124.feature.rst" - with open(fragment_path1, "w") as f: - f.write("Adds levitation") - with open(fragment_path2, "w") as f: - f.write("Extends levitation") - - call(["git", "init"]) - call(["git", "config", "user.name", "user"]) - call(["git", "config", "user.email", "user@example.com"]) - call(["git", "add", "."]) - call(["git", "commit", "-m", "Initial Commit"]) - - result = runner.invoke(_main, ["--date", "01-01-2001", "--yes"]) - - self.assertEqual(0, result.exit_code) - path = "NEWS.rst" - self.assertTrue(os.path.isfile(path)) - self.assertFalse(os.path.isfile(fragment_path1)) - self.assertFalse(os.path.isfile(fragment_path2)) - - @with_isolated_runner - def test_keep_fragments(self, runner): + @with_git_project() + def test_no_confirmation(self, runner, commit): + fragment_path1 = "foo/newsfragments/123.feature" + fragment_path2 = "foo/newsfragments/124.feature.rst" + with open(fragment_path1, "w") as f: + f.write("Adds levitation") + with open(fragment_path2, "w") as f: + f.write("Extends levitation") + + commit() + + result = runner.invoke(_main, ["--date", "01-01-2001", "--yes"]) + + self.assertEqual(0, result.exit_code) + path = "NEWS.rst" + self.assertTrue(os.path.isfile(path)) + self.assertFalse(os.path.isfile(fragment_path1)) + self.assertFalse(os.path.isfile(fragment_path2)) + + @with_git_project() + def test_keep_fragments(self, runner, commit): """ The `--keep` option will build the full final news file without deleting the fragment files and without any extra CLI interaction or confirmation. """ - setup_simple_project() fragment_path1 = "foo/newsfragments/123.feature" fragment_path2 = "foo/newsfragments/124.feature.rst" with open(fragment_path1, "w") as f: @@ -422,11 +448,7 @@ def test_keep_fragments(self, runner): with open(fragment_path2, "w") as f: f.write("Extends levitation") - call(["git", "init"]) - call(["git", "config", "user.name", "user"]) - call(["git", "config", "user.email", "user@example.com"]) - call(["git", "add", "."]) - call(["git", "commit", "-m", "Initial Commit"]) + commit() result = runner.invoke(_main, ["--date", "01-01-2001", "--keep"]) @@ -437,8 +459,8 @@ def test_keep_fragments(self, runner): self.assertTrue(os.path.isfile(fragment_path1)) self.assertTrue(os.path.isfile(fragment_path2)) - @with_isolated_runner - def test_yes_keep_error(self, runner): + @with_git_project() + def test_yes_keep_error(self, runner, commit): """ It will fail to perform any action when the conflicting --keep and --yes options are provided. @@ -447,7 +469,6 @@ def test_yes_keep_error(self, runner): to make sure both orders are validated since click triggers the validator in the order it parses the command line. """ - setup_simple_project() fragment_path1 = "foo/newsfragments/123.feature" fragment_path2 = "foo/newsfragments/124.feature.rst" with open(fragment_path1, "w") as f: @@ -455,11 +476,7 @@ def test_yes_keep_error(self, runner): with open(fragment_path2, "w") as f: f.write("Extends levitation") - call(["git", "init"]) - call(["git", "config", "user.name", "user"]) - call(["git", "config", "user.email", "user@example.com"]) - call(["git", "add", "."]) - call(["git", "commit", "-m", "Initial Commit"]) + commit() result = runner.invoke(_main, ["--date", "01-01-2001", "--yes", "--keep"]) self.assertEqual(1, result.exit_code) @@ -467,37 +484,30 @@ def test_yes_keep_error(self, runner): result = runner.invoke(_main, ["--date", "01-01-2001", "--keep", "--yes"]) self.assertEqual(1, result.exit_code) - def test_confirmation_says_no(self): + @with_git_project() + def test_confirmation_says_no(self, runner, commit): """ If the user says "no" to removing the newsfragements, we end up with a NEWS.rst AND the newsfragments. """ - runner = CliRunner() + fragment_path1 = "foo/newsfragments/123.feature" + fragment_path2 = "foo/newsfragments/124.feature.rst" + with open(fragment_path1, "w") as f: + f.write("Adds levitation") + with open(fragment_path2, "w") as f: + f.write("Extends levitation") - with runner.isolated_filesystem(): - setup_simple_project() - fragment_path1 = "foo/newsfragments/123.feature" - fragment_path2 = "foo/newsfragments/124.feature.rst" - with open(fragment_path1, "w") as f: - f.write("Adds levitation") - with open(fragment_path2, "w") as f: - f.write("Extends levitation") - - call(["git", "init"]) - call(["git", "config", "user.name", "user"]) - call(["git", "config", "user.email", "user@example.com"]) - call(["git", "add", "."]) - call(["git", "commit", "-m", "Initial Commit"]) - - with patch("towncrier.build.click.confirm") as m: - m.return_value = False - result = runner.invoke(_main, []) - - self.assertEqual(0, result.exit_code) - path = "NEWS.rst" - self.assertTrue(os.path.isfile(path)) - self.assertTrue(os.path.isfile(fragment_path1)) - self.assertTrue(os.path.isfile(fragment_path2)) + commit() + + with patch("towncrier.build.click.confirm") as m: + m.return_value = False + result = runner.invoke(_main, []) + + self.assertEqual(0, result.exit_code) + path = "NEWS.rst" + self.assertTrue(os.path.isfile(path)) + self.assertTrue(os.path.isfile(fragment_path1)) + self.assertTrue(os.path.isfile(fragment_path2)) def test_needs_config(self): """ @@ -511,50 +521,45 @@ def test_needs_config(self): self.assertEqual(1, result.exit_code, result.output) self.assertTrue(result.output.startswith("No configuration file found.")) - @with_isolated_runner + @with_project(config="[tool.towncrier]") def test_needs_version(self, runner: CliRunner): """ If the configuration file doesn't specify a version or a package, the version option is required. """ - write("towncrier.toml", "[tool.towncrier]") - result = runner.invoke(_main, ["--draft"], catch_exceptions=False) self.assertEqual(2, result.exit_code) self.assertIn("Error: '--version' is required", result.output) - def test_projectless_changelog(self): + @with_project() + def test_projectless_changelog(self, runner): """In which a directory containing news files is built into a changelog - without a Python project or version number. We override the project title from the commandline. """ - runner = CliRunner() + # Remove the version from the project + Path("foo/__init__.py").unlink() - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write("[tool.towncrier]\n" 'package = "foo"\n') - os.mkdir("foo") - os.mkdir("foo/newsfragments") - with open("foo/newsfragments/123.feature", "w") as f: - f.write("Adds levitation") - # Towncrier ignores .rst extension - with open("foo/newsfragments/124.feature.rst", "w") as f: - f.write("Extends levitation") + with open("foo/newsfragments/123.feature", "w") as f: + f.write("Adds levitation") + # Towncrier ignores .rst extension + with open("foo/newsfragments/124.feature.rst", "w") as f: + f.write("Extends levitation") - result = runner.invoke( - _main, - [ - "--name", - "FooBarBaz", - "--version", - "7.8.9", - "--date", - "01-01-2001", - "--draft", - ], - ) + result = runner.invoke( + _main, + [ + "--name", + "FooBarBaz", + "--version", + "7.8.9", + "--date", + "01-01-2001", + "--draft", + ], + ) self.assertEqual(0, result.exit_code) self.assertEqual( @@ -576,26 +581,29 @@ def test_projectless_changelog(self): - Adds levitation (#123) - Extends levitation (#124) + + """ ).lstrip(), ) - def test_version_in_config(self): - """The calling towncrier with version defined in configfile. + @with_project( + config=""" + [tool.towncrier] + version = "7.8.9" + """ + ) + def test_version_in_config(self, runner): + """Calling towncrier with version defined in configfile. Specifying a version in toml file will be helpful if version is maintained by i.e. bumpversion and it's not a python project. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write("[tool.towncrier]\n" 'version = "7.8.9"\n') - os.mkdir("newsfragments") - with open("newsfragments/123.feature", "w") as f: - f.write("Adds levitation") + os.mkdir("newsfragments") + with open("newsfragments/123.feature", "w") as f: + f.write("Adds levitation") - result = runner.invoke(_main, ["--date", "01-01-2001", "--draft"]) + result = runner.invoke(_main, ["--date", "01-01-2001", "--draft"]) self.assertEqual(0, result.exit_code, result.output) self.assertEqual( @@ -616,28 +624,31 @@ def test_version_in_config(self): - Adds levitation (#123) + + """ ).lstrip(), ) - def test_project_name_in_config(self): + @with_project( + config=""" + [tool.towncrier] + name = "ImGoProject" + """ + ) + def test_project_name_in_config(self, runner): """The calling towncrier with project name defined in configfile. Specifying a project name in toml file will be helpful to keep the project name consistent as part of the towncrier configuration, not call. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write("[tool.towncrier]\n" 'name = "ImGoProject"\n') - os.mkdir("newsfragments") - with open("newsfragments/123.feature", "w") as f: - f.write("Adds levitation") + os.mkdir("newsfragments") + with open("newsfragments/123.feature", "w") as f: + f.write("Adds levitation") - result = runner.invoke( - _main, ["--version", "7.8.9", "--date", "01-01-2001", "--draft"] - ) + result = runner.invoke( + _main, ["--version", "7.8.9", "--date", "01-01-2001", "--draft"] + ) self.assertEqual(0, result.exit_code, result.output) self.assertEqual( @@ -658,11 +669,14 @@ def test_project_name_in_config(self): - Adds levitation (#123) + + """ ).lstrip(), ) - def test_no_package_changelog(self): + @with_project(config="[tool.towncrier]") + def test_no_package_changelog(self, runner): """The calling towncrier with any package argument. Specifying a package in the toml file or the command line @@ -671,18 +685,13 @@ def test_no_package_changelog(self): so we do not need the package for that. - we don't need to include the package in the changelog header. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write("[tool.towncrier]") - os.mkdir("newsfragments") - with open("newsfragments/123.feature", "w") as f: - f.write("Adds levitation") + os.mkdir("newsfragments") + with open("newsfragments/123.feature", "w") as f: + f.write("Adds levitation") - result = runner.invoke( - _main, ["--version", "7.8.9", "--date", "01-01-2001", "--draft"] - ) + result = runner.invoke( + _main, ["--version", "7.8.9", "--date", "01-01-2001", "--draft"] + ) self.assertEqual(0, result.exit_code, result.output) self.assertEqual( @@ -703,17 +712,25 @@ def test_no_package_changelog(self): - Adds levitation (#123) + + """ ).lstrip(), ) - def test_release_notes_in_separate_files(self): + @with_project( + config=""" + [tool.towncrier] + single_file=false + filename="{version}-notes.rst" + """ + ) + def test_release_notes_in_separate_files(self, runner): """ When `single_file = false` the release notes for each version are stored in a separate file. The name of the file is defined by the `filename` configuration value. """ - runner = CliRunner() def do_build_once_with(version, fragment_file, fragment): with open(f"newsfragments/{fragment_file}", "w") as f: @@ -731,83 +748,70 @@ def do_build_once_with(version, fragment_file, fragment): "--yes", ], ) - # not git repository, manually remove fragment file - Path(f"newsfragments/{fragment_file}").unlink() + # Fragment files unknown to git are removed even without a git repo + assert not Path(f"newsfragments/{fragment_file}").exists() return result results = [] - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write( - "\n".join( - [ - "[tool.towncrier]", - " single_file=false", - ' filename="{version}-notes.rst"', - ] - ) - ) - os.mkdir("newsfragments") - results.append( - do_build_once_with("7.8.9", "123.feature", "Adds levitation") - ) - results.append(do_build_once_with("7.9.0", "456.bugfix", "Adds catapult")) + os.mkdir("newsfragments") + results.append(do_build_once_with("7.8.9", "123.feature", "Adds levitation")) + results.append(do_build_once_with("7.9.0", "456.bugfix", "Adds catapult")) - self.assertEqual(0, results[0].exit_code, results[0].output) - self.assertEqual(0, results[1].exit_code, results[1].output) - self.assertEqual( - 2, - len(list(Path.cwd().glob("*-notes.rst"))), - "one newfile for each build", - ) - self.assertTrue(os.path.exists("7.8.9-notes.rst"), os.listdir(".")) - self.assertTrue(os.path.exists("7.9.0-notes.rst"), os.listdir(".")) + self.assertEqual(0, results[0].exit_code, results[0].output) + self.assertEqual(0, results[1].exit_code, results[1].output) + self.assertEqual( + 2, + len(list(Path.cwd().glob("*-notes.rst"))), + "one newfile for each build", + ) + self.assertTrue(os.path.exists("7.8.9-notes.rst"), os.listdir(".")) + self.assertTrue(os.path.exists("7.9.0-notes.rst"), os.listdir(".")) - outputs = [] - outputs.append(read("7.8.9-notes.rst")) - outputs.append(read("7.9.0-notes.rst")) + outputs = [] + outputs.append(read("7.8.9-notes.rst")) + outputs.append(read("7.9.0-notes.rst")) - self.assertEqual( - outputs[0], - dedent( - """ - foo 7.8.9 (01-01-2001) - ====================== + self.assertEqual( + outputs[0], + dedent( + """ + foo 7.8.9 (01-01-2001) + ====================== - Features - -------- + Features + -------- - - Adds levitation (#123) + - Adds levitation (#123) + """ + ).lstrip(), + ) + self.assertEqual( + outputs[1], + dedent( """ - ).lstrip(), - ) - self.assertEqual( - outputs[1], - dedent( - """ - foo 7.9.0 (01-01-2001) - ====================== + foo 7.9.0 (01-01-2001) + ====================== - Bugfixes - -------- + Bugfixes + -------- - - Adds catapult (#456) - """ - ).lstrip(), - ) + - Adds catapult (#456) + """ + ).lstrip(), + ) - def test_singlefile_errors_and_explains_cleanly(self): + @with_project( + config=""" + [tool.towncrier] + singlefile="fail!" + """ + ) + def test_singlefile_errors_and_explains_cleanly(self, runner): """ Failure to find the configuration file results in a clean explanation without a traceback. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write('[tool.towncrier]\n singlefile="fail!"\n') - - result = runner.invoke(_main) + result = runner.invoke(_main) self.assertEqual(1, result.exit_code) self.assertEqual( @@ -842,8 +846,8 @@ def do_build_once_with(version, fragment_file, fragment): ], catch_exceptions=False, ) - # not git repository, manually remove fragment file - Path(f"newsfragments/{fragment_file}").unlink() + # Fragment files unknown to git are removed even without a git repo + assert not Path(f"newsfragments/{fragment_file}").exists() return result results = [] @@ -900,123 +904,115 @@ def do_build_once_with(version, fragment_file, fragment): ).lstrip(), ) - def test_bullet_points_false(self): + @with_project( + config=""" + [tool.towncrier] + template="towncrier:single-file-no-bullets" + all_bullets=false + """ + ) + def test_bullet_points_false(self, runner): """ When all_bullets is false, subsequent lines are not indented. - The automatic ticket number inserted by towncrier will align with the + The automatic issue number inserted by towncrier will align with the manual bullet. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write( - "[tool.towncrier]\n" - 'template="towncrier:single-file-no-bullets"\n' - "all_bullets=false" - ) - os.mkdir("newsfragments") - with open("newsfragments/123.feature", "w") as f: - f.write("wow!\n~~~~\n\nNo indentation at all.") - with open("newsfragments/124.bugfix", "w") as f: - f.write("#. Numbered bullet list.") - with open("newsfragments/125.removal", "w") as f: - f.write("- Hyphen based bullet list.") - with open("newsfragments/126.doc", "w") as f: - f.write("* Asterisk based bullet list.") + os.mkdir("newsfragments") + with open("newsfragments/123.feature", "w") as f: + f.write("wow!\n~~~~\n\nNo indentation at all.") + with open("newsfragments/124.bugfix", "w") as f: + f.write("#. Numbered bullet list.") + with open("newsfragments/125.removal", "w") as f: + f.write("- Hyphen based bullet list.") + with open("newsfragments/126.doc", "w") as f: + f.write("* Asterisk based bullet list.") - result = runner.invoke( - _main, - [ - "--version", - "7.8.9", - "--name", - "foo", - "--date", - "01-01-2001", - "--yes", - ], - ) + result = runner.invoke( + _main, + [ + "--version", + "7.8.9", + "--name", + "foo", + "--date", + "01-01-2001", + "--yes", + ], + ) - self.assertEqual(0, result.exit_code, result.output) - output = read("NEWS.rst") + self.assertEqual(0, result.exit_code, result.output) + output = read("NEWS.rst") self.assertEqual( output, - """ -foo 7.8.9 (01-01-2001) -====================== + dedent( + """ + foo 7.8.9 (01-01-2001) + ====================== -Features --------- + Features + -------- -wow! -~~~~ + wow! + ~~~~ -No indentation at all. -(#123) + No indentation at all. + (#123) -Bugfixes --------- + Bugfixes + -------- -#. Numbered bullet list. - (#124) + #. Numbered bullet list. + (#124) -Improved Documentation ----------------------- + Improved Documentation + ---------------------- -* Asterisk based bullet list. - (#126) + * Asterisk based bullet list. + (#126) -Deprecations and Removals -------------------------- + Deprecations and Removals + ------------------------- -- Hyphen based bullet list. - (#125) -""".lstrip(), + - Hyphen based bullet list. + (#125) + """ + ).lstrip(), ) - def test_title_format_custom(self): + @with_project( + config=""" + [tool.towncrier] + package = "foo" + title_format = "[{project_date}] CUSTOM RELEASE for {name} version {version}" + """ + ) + def test_title_format_custom(self, runner): """ A non-empty title format adds the specified title. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write( - dedent( - """\ - [tool.towncrier] - package = "foo" - title_format = "[{project_date}] CUSTOM RELEASE for {name} version {version}" - """ - ) - ) - os.mkdir("foo") - os.mkdir("foo/newsfragments") - with open("foo/newsfragments/123.feature", "w") as f: - f.write("Adds levitation") - # Towncrier ignores .rst extension - with open("foo/newsfragments/124.feature.rst", "w") as f: - f.write("Extends levitation") + with open("foo/newsfragments/123.feature", "w") as f: + f.write("Adds levitation") + # Towncrier ignores .rst extension + with open("foo/newsfragments/124.feature.rst", "w") as f: + f.write("Extends levitation") - result = runner.invoke( - _main, - [ - "--name", - "FooBarBaz", - "--version", - "7.8.9", - "--date", - "20-01-2001", - "--draft", - ], - ) + result = runner.invoke( + _main, + [ + "--name", + "FooBarBaz", + "--version", + "7.8.9", + "--date", + "20-01-2001", + "--draft", + ], + ) expected_output = dedent( """\ @@ -1035,67 +1031,111 @@ def test_title_format_custom(self): - Adds levitation (#123) - Extends levitation (#124) + + + """ + ) + + self.assertEqual(0, result.exit_code) + self.assertEqual(expected_output, result.output) + + @with_project( + config=""" + [tool.towncrier] + package = "foo" + filename = "NEWS.md" + title_format = "[{project_date}] CUSTOM RELEASE for {name} version {version}" + """ + ) + def test_title_format_custom_markdown(self, runner): + """ + A non-empty title format adds the specified title, and if the target filename is + markdown then the title is added as a markdown header. + """ + with open("foo/newsfragments/123.feature", "w") as f: + f.write("Adds levitation") + result = runner.invoke( + _main, + [ + "--name", + "FooBarBaz", + "--version", + "7.8.9", + "--date", + "20-01-2001", + "--draft", + ], + ) + + expected_output = dedent( + """\ + Loading template... + Finding news fragments... + Rendering news fragments... + Draft only -- nothing has been written. + What is seen below is what would be written. + + # [20-01-2001] CUSTOM RELEASE for FooBarBaz version 7.8.9 + + ### Features + + - Adds levitation (#123) + + + """ ) self.assertEqual(0, result.exit_code) self.assertEqual(expected_output, result.output) - def test_title_format_false(self): + @with_project( + config=""" + [tool.towncrier] + package = "foo" + title_format = false + template = "template.rst" + """ + ) + def test_title_format_false(self, runner): """ Setting the title format to false disables the explicit title. This would be used, for example, when the template creates the title itself. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write( - dedent( - """\ - [tool.towncrier] - package = "foo" - title_format = false - template = "template.rst" - """ - ) - ) - os.mkdir("foo") - os.mkdir("foo/newsfragments") - with open("template.rst", "w") as f: - f.write( - dedent( - """\ - Here's a hardcoded title added by the template - ============================================== - {% for section in sections %} - {% set underline = "-" %} - {% for category, val in definitions.items() if category in sections[section] %} - - {% for text, values in sections[section][category]|dictsort(by='value') %} - - {{ text }} - - {% endfor %} - {% endfor %} - {% endfor %} - """ - ) + with open("template.rst", "w") as f: + f.write( + dedent( + """\ + Here's a hardcoded title added by the template + ============================================== + {% for section in sections %} + {% set underline = "-" %} + {% for category, val in definitions.items() if category in sections[section] %} + + {% for text, values in sections[section][category]|dictsort(by='value') %} + - {{ text }} + + {% endfor %} + {% endfor %} + {% endfor %} + """ ) - - result = runner.invoke( - _main, - [ - "--name", - "FooBarBaz", - "--version", - "7.8.9", - "--date", - "20-01-2001", - "--draft", - ], - catch_exceptions=False, ) + result = runner.invoke( + _main, + [ + "--name", + "FooBarBaz", + "--version", + "7.8.9", + "--date", + "20-01-2001", + "--draft", + ], + catch_exceptions=False, + ) + expected_output = dedent( """\ Loading template... @@ -1113,46 +1153,40 @@ def test_title_format_false(self): self.assertEqual(0, result.exit_code) self.assertEqual(expected_output, result.output) - def test_start_string(self): + @with_project( + config=""" + [tool.towncrier] + start_string="Release notes start marker" + """ + ) + def test_start_string(self, runner): """ The `start_string` configuration is used to detect the starting point for inserting the generated release notes. A newline is automatically added to the configured value. """ - runner = CliRunner() - - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write( - dedent( - """\ - [tool.towncrier] - start_string="Release notes start marker" - """ - ) - ) - os.mkdir("newsfragments") - with open("newsfragments/123.feature", "w") as f: - f.write("Adds levitation") - with open("NEWS.rst", "w") as f: - f.write("a line\n\nanother\n\nRelease notes start marker\na footer!\n") + os.mkdir("newsfragments") + with open("newsfragments/123.feature", "w") as f: + f.write("Adds levitation") + with open("NEWS.rst", "w") as f: + f.write("a line\n\nanother\n\nRelease notes start marker\na footer!\n") - result = runner.invoke( - _main, - [ - "--version", - "7.8.9", - "--name", - "foo", - "--date", - "01-01-2001", - "--yes", - ], - ) + result = runner.invoke( + _main, + [ + "--version", + "7.8.9", + "--name", + "foo", + "--date", + "01-01-2001", + "--yes", + ], + ) - self.assertEqual(0, result.exit_code, result.output) - self.assertTrue(os.path.exists("NEWS.rst"), os.listdir(".")) - output = read("NEWS.rst") + self.assertEqual(0, result.exit_code, result.output) + self.assertTrue(os.path.exists("NEWS.rst"), os.listdir(".")) + output = read("NEWS.rst") expected_output = dedent( """\ @@ -1176,13 +1210,11 @@ def test_start_string(self): self.assertEqual(expected_output, output) - @with_isolated_runner + @with_project() 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", @@ -1225,14 +1257,18 @@ def test_default_start_string(self, runner): self.assertEqual(expected_output, output) - @with_isolated_runner + @with_project( + config=""" + [tool.towncrier] + package = "foo" + filename = "NEWS.md" + """ + ) 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", @@ -1273,59 +1309,100 @@ def test_default_start_string_markdown(self, runner): self.assertEqual(expected_output, output) - def test_with_topline_and_template_and_draft(self): + @with_project( + config=""" + [tool.towncrier] + name = "" + directory = "changes" + filename = "NEWS.md" + version = "1.2.3" + """ + ) + def test_markdown_no_name_title(self, runner): + """ + When configured with an empty `name` option, + the default template used for Markdown + renders the title of the release note with just + the version number and release date. + """ + write("changes/123.feature", "Adds levitation") + write( + "NEWS.md", + contents=""" + A line + + + """, + 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 + + + + # 1.2.3 (01-01-2001) + + ### Features + + - Adds levitation (#123) + """ + ) + + self.assertEqual(expected_output, output) + + @with_project( + config=""" + [tool.towncrier] + title_format = "{version} - {project_date}" + template = "template.rst" + + [[tool.towncrier.type]] + directory = "feature" + name = "" + showcontent = true + """ + ) + def test_with_topline_and_template_and_draft(self, runner): """ Spacing is proper when drafting with a topline and a template. """ - runner = CliRunner() + os.mkdir("newsfragments") + with open("newsfragments/123.feature", "w") as f: + f.write("Adds levitation") + with open("template.rst", "w") as f: + f.write( + dedent( + """\ + {% for section in sections %} + {% set underline = "-" %} + {% for category, val in definitions.items() if category in sections[section] %} - with runner.isolated_filesystem(): - with open("pyproject.toml", "w") as f: - f.write( - dedent( - """\ - [tool.towncrier] - title_format = "{version} - {project_date}" - template = "template.rst" + {% for text, values in sections[section][category]|dictsort(by='value') %} + - {{ text }} - [[tool.towncrier.type]] - directory = "feature" - name = "" - showcontent = true - """ - ) - ) - os.mkdir("newsfragments") - with open("newsfragments/123.feature", "w") as f: - f.write("Adds levitation") - with open("template.rst", "w") as f: - f.write( - dedent( - """\ - {% for section in sections %} - {% set underline = "-" %} - {% for category, val in definitions.items() if category in sections[section] %} - - {% for text, values in sections[section][category]|dictsort(by='value') %} - - {{ text }} - - {% endfor %} - {% endfor %} - {% endfor %} - """ - ) + {% endfor %} + {% endfor %} + {% endfor %} + """ ) - - result = runner.invoke( - _main, - [ - "--version=7.8.9", - "--name=foo", - "--date=20-01-2001", - "--draft", - ], ) + result = runner.invoke( + _main, + [ + "--version=7.8.9", + "--name=foo", + "--date=20-01-2001", + "--draft", + ], + ) + expected_output = dedent( """\ Loading template... @@ -1339,8 +1416,170 @@ def test_with_topline_and_template_and_draft(self): - Adds levitation + """ ) self.assertEqual(0, result.exit_code, result.output) self.assertEqual(expected_output, result.output) + + @with_project( + config=""" + [tool.towncrier] + """ + ) + def test_orphans_in_non_showcontent(self, runner): + """ + When ``showcontent`` is false (like in the ``misc`` category by default), + orphans are still rendered because they don't have an issue number to display. + """ + os.mkdir("newsfragments") + with open("newsfragments/123.misc", "w") as f: + f.write("Misc") + with open("newsfragments/345.misc", "w") as f: + f.write("Another misc") + with open("newsfragments/+.misc", "w") as f: + f.write("Orphan misc still displayed!") + with open("newsfragments/+2.misc", "w") as f: + f.write("Another orphan misc still displayed!") + + result = runner.invoke( + _main, + [ + "--version=7.8.9", + "--date=20-01-2001", + "--draft", + ], + ) + + expected_output = dedent( + """\ + Loading template... + Finding news fragments... + Rendering news fragments... + Draft only -- nothing has been written. + What is seen below is what would be written. + + 7.8.9 (20-01-2001) + ================== + + Misc + ---- + + - #123, #345 + - Another orphan misc still displayed! + - Orphan misc still displayed! + + + + """ + ) + + self.assertEqual(0, result.exit_code, result.output) + self.assertEqual(expected_output, result.output) + + @with_project( + config=""" + [tool.towncrier] + filename = "CHANGES.md" + """ + ) + def test_orphans_in_non_showcontent_markdown(self, runner): + """ + When ``showcontent`` is false (like in the ``misc`` category by default), + orphans are still rendered because they don't have an issue number to display. + """ + os.mkdir("newsfragments") + with open("newsfragments/123.misc", "w") as f: + f.write("Misc") + with open("newsfragments/345.misc", "w") as f: + f.write("Another misc") + with open("newsfragments/+.misc", "w") as f: + f.write("Orphan misc still displayed!") + with open("newsfragments/+2.misc", "w") as f: + f.write("Another orphan misc still displayed!") + + result = runner.invoke( + _main, + [ + "--version=7.8.9", + "--date=20-01-2001", + "--draft", + ], + ) + + expected_output = dedent( + """\ + Loading template... + Finding news fragments... + Rendering news fragments... + Draft only -- nothing has been written. + What is seen below is what would be written. + + # 7.8.9 (20-01-2001) + + ### Misc + + - #123, #345 + - Another orphan misc still displayed! + - Orphan misc still displayed! + + + + """ + ) + + self.assertEqual(0, result.exit_code, result.output) + self.assertEqual(expected_output, result.output) + + @with_git_project() + def test_uncommitted_files(self, runner, commit): + """ + At build time, it will delete any fragment file regardless of its stage, + included files that are not part of the git reporsitory, + or are just staged or modified. + """ + # 123 is committed, 124 is modified, 125 is just added, 126 is unknown + + with open("foo/newsfragments/123.feature", "w") as f: + f.write("Adds levitation. File committed.") + with open("foo/newsfragments/124.feature", "w") as f: + f.write("Extends levitation. File modified in Git.") + + commit() + + with open("foo/newsfragments/125.feature", "w") as f: + f.write("Baz levitation. Staged file.") + with open("foo/newsfragments/126.feature", "w") as f: + f.write("Fix (literal) crash. File unknown to Git.") + + with open("foo/newsfragments/124.feature", "a") as f: + f.write(" Extended for an hour.") + call(["git", "add", "foo/newsfragments/125.feature"]) + + result = runner.invoke(_main, ["--date", "01-01-2001", "--yes"]) + + self.assertEqual(0, result.exit_code) + for fragment in ("123", "124", "125", "126"): + self.assertFalse(os.path.isfile(f"foo/newsfragments/{fragment}.feature")) + + path = "NEWS.rst" + self.assertTrue(os.path.isfile(path)) + news_contents = open(path).read() + self.assertEqual( + news_contents, + dedent( + """\ + Foo 1.2.3 (01-01-2001) + ====================== + + Features + -------- + + - Adds levitation. File committed. (#123) + - Extends levitation. File modified in Git. Extended for an hour. (#124) + - Baz levitation. Staged file. (#125) + - Fix (literal) crash. File unknown to Git. (#126) + """ + ), + ) diff --git a/src/towncrier/test/test_builder.py b/src/towncrier/test/test_builder.py index 62630af0..1213e1a3 100644 --- a/src/towncrier/test/test_builder.py +++ b/src/towncrier/test/test_builder.py @@ -1,75 +1,87 @@ # Copyright (c) Povilas Kanapickas, 2019 # See LICENSE for details. +from textwrap import dedent + from twisted.trial.unittest import TestCase -from .._builder import parse_newfragment_basename +from .._builder import parse_newfragment_basename, render_fragments class TestParseNewsfragmentBasename(TestCase): def test_simple(self): + """. generates a counter value of 0.""" self.assertEqual( parse_newfragment_basename("123.feature", ["feature"]), ("123", "feature", 0), ) def test_invalid_category(self): + """Files without a valid category are rejected.""" self.assertEqual( parse_newfragment_basename("README.ext", ["feature"]), (None, None, None), ) def test_counter(self): + """.. generates a custom counter value.""" self.assertEqual( parse_newfragment_basename("123.feature.1", ["feature"]), ("123", "feature", 1), ) def test_counter_with_extension(self): + """File extensions are ignored.""" self.assertEqual( parse_newfragment_basename("123.feature.1.ext", ["feature"]), ("123", "feature", 1), ) def test_ignores_extension(self): + """File extensions are ignored.""" self.assertEqual( parse_newfragment_basename("123.feature.ext", ["feature"]), ("123", "feature", 0), ) - def test_non_numeric_ticket(self): + def test_non_numeric_issue(self): + """Non-numeric issue identifiers are preserved verbatim.""" self.assertEqual( parse_newfragment_basename("baz.feature", ["feature"]), ("baz", "feature", 0), ) - def test_non_numeric_ticket_with_extension(self): + def test_non_numeric_issue_with_extension(self): + """File extensions are ignored.""" self.assertEqual( parse_newfragment_basename("baz.feature.ext", ["feature"]), ("baz", "feature", 0), ) - def test_dots_in_ticket_name(self): + def test_dots_in_issue_name(self): + """Non-numeric issue identifiers are preserved verbatim.""" self.assertEqual( parse_newfragment_basename("baz.1.2.feature", ["feature"]), - ("2", "feature", 0), + ("baz.1.2", "feature", 0), ) - def test_dots_in_ticket_name_invalid_category(self): + def test_dots_in_issue_name_invalid_category(self): + """Files without a valid category are rejected.""" self.assertEqual( parse_newfragment_basename("baz.1.2.notfeature", ["feature"]), (None, None, None), ) - def test_dots_in_ticket_name_and_counter(self): + def test_dots_in_issue_name_and_counter(self): + """Non-numeric issue identifiers are preserved verbatim.""" self.assertEqual( parse_newfragment_basename("baz.1.2.feature.3", ["feature"]), - ("2", "feature", 3), + ("baz.1.2", "feature", 3), ) def test_strip(self): """Leading spaces and subsequent leading zeros are stripped - when parsing newsfragment names into ticket numbers etc. + when parsing newsfragment names into issue numbers etc. """ self.assertEqual( parse_newfragment_basename(" 007.feature", ["feature"]), @@ -77,7 +89,118 @@ def test_strip(self): ) def test_strip_with_counter(self): + """Leading spaces and subsequent leading zeros are stripped + when parsing newsfragment names into issue numbers etc. + """ self.assertEqual( parse_newfragment_basename(" 007.feature.3", ["feature"]), ("7", "feature", 3), ) + + def test_orphan(self): + """Orphaned snippets must remain the orphan marker in the issue + identifier.""" + self.assertEqual( + parse_newfragment_basename("+orphan.feature", ["feature"]), + ("+orphan", "feature", 0), + ) + + def test_orphan_with_number(self): + """Orphaned snippets can contain numbers in the identifier.""" + self.assertEqual( + parse_newfragment_basename("+123_orphan.feature", ["feature"]), + ("+123_orphan", "feature", 0), + ) + self.assertEqual( + parse_newfragment_basename("+orphan_123.feature", ["feature"]), + ("+orphan_123", "feature", 0), + ) + + def test_orphan_with_dotted_number(self): + """Orphaned snippets can contain numbers with dots in the + identifier.""" + self.assertEqual( + parse_newfragment_basename("+12.3_orphan.feature", ["feature"]), + ("+12.3_orphan", "feature", 0), + ) + self.assertEqual( + parse_newfragment_basename("+orphan_12.3.feature", ["feature"]), + ("+orphan_12.3", "feature", 0), + ) + + def test_orphan_all_digits(self): + """Orphaned snippets can consist of only digits.""" + self.assertEqual( + parse_newfragment_basename("+123.feature", ["feature"]), + ("+123", "feature", 0), + ) + + +class TestNewsFragmentsOrdering(TestCase): + """ + Tests to ensure that issues are ordered correctly in the output. + + This tests both ordering of issues within a fragment and ordering of + fragments within a section. + """ + + template = dedent( + """ + {% for section_name, category in sections.items() %} + {% if section_name %}# {{ section_name }}{% endif %} + {%- for category_name, issues in category.items() %} + ## {{ category_name }} + {% for issue, numbers in issues.items() %} + - {{ issue }}{% if numbers %} ({{ numbers|join(', ') }}){% endif %} + + {% endfor %} + {% endfor -%} + {% endfor -%} + """ + ) + + def render(self, fragments): + return render_fragments( + template=self.template, + issue_format=None, + fragments=fragments, + definitions={}, + underlines=[], + wrap=False, + versiondata={}, + ) + + def test_ordering(self): + """ + Issues are ordered first by the non-text component, then by their number. + + For backwards compatibility, issues with no number are grouped first and issues + which are only a number are grouped last. + + Orphan news fragments are always last, sorted by their text. + """ + output = self.render( + { + "": { + "feature": { + "Added Cheese": ["10", "gh-25", "gh-3", "4"], + "Added Fish": [], + "Added Bread": [], + "Added Milk": ["gh-1"], + "Added Eggs": ["gh-2", "random"], + } + } + }, + ) + # "Eggs" are first because they have an issue with no number, and the first + # issue for each fragment is what is used for sorting the overall list. + assert output == dedent( + """ + ## feature + - Added Eggs (random, gh-2) + - Added Milk (gh-1) + - Added Cheese (gh-3, gh-25, #4, #10) + - Added Bread + - Added Fish +""" + ) diff --git a/src/towncrier/test/test_check.py b/src/towncrier/test/test_check.py index 9c7c7c81..23db3264 100644 --- a/src/towncrier/test/test_check.py +++ b/src/towncrier/test/test_check.py @@ -3,16 +3,16 @@ import os import os.path -import sys import warnings from pathlib import Path -from subprocess import PIPE, Popen, call +from subprocess import call from click.testing import CliRunner from twisted.trial.unittest import TestCase from towncrier import check +from towncrier.build import _main as towncrier_build from towncrier.check import _main as towncrier_check from .helpers import setup_simple_project, with_isolated_runner, write @@ -166,6 +166,81 @@ def test_fragment_missing(self): result.output.endswith("No new newsfragments found on this branch.\n") ) + def test_fragment_exists_but_not_in_check(self): + """A fragment that exists but is marked as check=False is ignored by the check.""" + runner = CliRunner() + + with runner.isolated_filesystem(): + create_project( + "pyproject.toml", + main_branch="master", + extra_config="[[tool.towncrier.type]]\n" + 'directory = "feature"\n' + 'name = "Features"\n' + "showcontent = true\n" + "[[tool.towncrier.type]]\n" + 'directory = "sut"\n' + 'name = "System Under Test"\n' + "showcontent = true\n" + "check=false\n", + ) + + file_path = "foo/somefile.py" + write(file_path, "import os") + + fragment_path = Path("foo/newsfragments/1234.sut").absolute() + write(fragment_path, "Adds gravity back") + commit("add a file and a newsfragment") + + result = runner.invoke(towncrier_check, ["--compare-with", "master"]) + + self.assertEqual(1, result.exit_code) + self.assertTrue( + result.output.endswith( + "Found newsfragments of unchecked types in the branch:\n1. " + + str(fragment_path) + + "\n" + ), + (result.output, str(fragment_path)), + ) + + def test_fragment_exists_and_in_check(self): + """ + A fragment that exists but is not marked as check=False is + not ignored by the check, even if other categories are marked as check=False. + """ + runner = CliRunner() + + with runner.isolated_filesystem(): + create_project( + "pyproject.toml", + main_branch="master", + extra_config="[[tool.towncrier.type]]\n" + 'directory = "feature"\n' + 'name = "Features"\n' + "showcontent = true\n" + "[[tool.towncrier.type]]\n" + 'directory = "sut"\n' + 'name = "System Under Test"\n' + "showcontent = true\n" + "check=false\n", + ) + + file_path = "foo/somefile.py" + write(file_path, "import os") + + fragment_path = Path("foo/newsfragments/1234.feature").absolute() + write(fragment_path, "Adds gravity back") + commit("add a file and a newsfragment") + + result = runner.invoke(towncrier_check, ["--compare-with", "master"]) + + self.assertEqual(0, result.exit_code) + self.assertTrue( + result.output.endswith("Found:\n1. " + str(fragment_path) + "\n"), + (result.output, str(fragment_path)), + ) + def test_none_stdout_encoding_works(self): """ No failure when output is piped causing None encoding for stdout. @@ -182,15 +257,11 @@ def test_none_stdout_encoding_works(self): call(["git", "add", fragment_path]) call(["git", "commit", "-m", "add a newsfragment"]) - proc = Popen( - [sys.executable, "-m", "towncrier.check", "--compare-with", "master"], - stdout=PIPE, - stderr=PIPE, - ) - stdout, stderr = proc.communicate() + runner = CliRunner(mix_stderr=False) + result = runner.invoke(towncrier_check, ["--compare-with", "master"]) - self.assertEqual(0, proc.returncode) - self.assertEqual(b"", stderr) + self.assertEqual(0, result.exit_code) + self.assertEqual(0, len(result.stderr)) def test_first_release(self): """ @@ -207,7 +278,7 @@ def test_first_release(self): # Before any release, the NEWS file might no exist. self.assertNotIn("NEWS.rst", os.listdir(".")) - call(["towncrier", "build", "--yes", "--version", "1.0"]) + runner.invoke(towncrier_build, ["--yes", "--version", "1.0"]) commit("Prepare a release") # When missing, # the news file is automatically created with a new release. @@ -235,7 +306,7 @@ def test_release_branch(self): # Do a first release without any checks. # And merge the release branch back into the main branch. - call(["towncrier", "build", "--yes", "--version", "1.0"]) + runner.invoke(towncrier_build, ["--yes", "--version", "1.0"]) commit("First release") # The news file is now created. self.assertIn("NEWS.rst", os.listdir(".")) @@ -260,7 +331,7 @@ def test_release_branch(self): # We now have the new release branch. call(["git", "checkout", "-b", "next-release"]) - call(["towncrier", "build", "--yes", "--version", "2.0"]) + runner.invoke(towncrier_build, ["--yes", "--version", "2.0"]) commit("Second release") # Act @@ -301,3 +372,101 @@ def test_get_default_compare_branch_fallback(self): self.assertEqual("origin/master", branch) self.assertTrue(w[0].message.args[0].startswith('Using "origin/master')) + + @with_isolated_runner + def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner): + """ + It can check the fragments located in a sub-directory + that is specified using the `--dir` CLI argument. + """ + main_branch = "main" + Path("pyproject.toml").write_text( + # Important to customize `config.directory` because the default + # already supports this scenario. + "[tool.towncrier]\n" + + 'directory = "changelog.d"\n' + ) + subproject1 = Path("foo") + (subproject1 / "foo").mkdir(parents=True) + (subproject1 / "foo/__init__.py").write_text("") + (subproject1 / "changelog.d").mkdir(parents=True) + (subproject1 / "changelog.d/123.feature").write_text("Adds levitation") + initial_commit(branch=main_branch) + call(["git", "checkout", "-b", "otherbranch"]) + + # We add a code change but forget to add a news fragment. + write(subproject1 / "foo/somefile.py", "import os") + commit("add a file") + result = runner.invoke( + towncrier_check, + ( + "--config", + "pyproject.toml", + "--dir", + str(subproject1), + "--compare-with", + "main", + ), + ) + + self.assertEqual(1, result.exit_code) + self.assertTrue( + result.output.endswith("No new newsfragments found on this branch.\n") + ) + + # We add the news fragment. + fragment_path = (subproject1 / "changelog.d/124.feature").absolute() + write(fragment_path, "Adds gravity back") + commit("add a newsfragment") + result = runner.invoke( + towncrier_check, + ("--config", "pyproject.toml", "--dir", "foo", "--compare-with", "main"), + ) + + self.assertEqual(0, result.exit_code, result.output) + self.assertTrue( + result.output.endswith("Found:\n1. " + str(fragment_path) + "\n"), + (result.output, str(fragment_path)), + ) + + # We add a change in a different subproject without a news fragment. + # Checking subproject1 should pass. + subproject2 = Path("bar") + (subproject2 / "bar").mkdir(parents=True) + (subproject2 / "changelog.d").mkdir(parents=True) + write(subproject2 / "bar/somefile.py", "import os") + commit("add a file") + result = runner.invoke( + towncrier_check, + ( + "--config", + "pyproject.toml", + "--dir", + subproject1, + "--compare-with", + "main", + ), + ) + + self.assertEqual(0, result.exit_code, result.output) + self.assertTrue( + result.output.endswith("Found:\n1. " + str(fragment_path) + "\n"), + (result.output, str(fragment_path)), + ) + + # Checking subproject2 should result in an error. + result = runner.invoke( + towncrier_check, + ( + "--config", + "pyproject.toml", + "--dir", + subproject2, + "--compare-with", + "main", + ), + ) + self.assertEqual(1, result.exit_code) + self.assertTrue( + result.output.endswith("No new newsfragments found on this branch.\n") + ) diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py index bb33da4c..dc6f6b9d 100644 --- a/src/towncrier/test/test_create.py +++ b/src/towncrier/test/test_create.py @@ -11,7 +11,7 @@ from click.testing import CliRunner from twisted.trial.unittest import TestCase -from ..create import _main +from ..create import DEFAULT_CONTENT, _main from .helpers import setup_simple_project, with_isolated_runner @@ -19,7 +19,12 @@ class TestCli(TestCase): maxDiff = None def _test_success( - self, content=None, config=None, mkdir=True, additional_args=None + self, + content=None, + config=None, + mkdir=True, + additional_args=None, + eof_newline=True, ): runner = CliRunner() @@ -28,15 +33,17 @@ def _test_success( args = ["123.feature.rst"] if content is None: - content = ["Add your info here"] + content = [DEFAULT_CONTENT] if additional_args is not None: args.extend(additional_args) result = runner.invoke(_main, args) self.assertEqual(["123.feature.rst"], os.listdir("foo/newsfragments")) + if eof_newline: + content.append("") with open("foo/newsfragments/123.feature.rst") as fh: - self.assertEqual(content, fh.readlines()) + self.assertEqual("\n".join(content), fh.read()) self.assertEqual(0, result.exit_code) @@ -50,24 +57,22 @@ def test_directory_created(self): def test_edit_without_comments(self): """Create file with dynamic content.""" - content = ["This is line 1\n", "This is line 2"] + content = ["This is line 1", "This is line 2"] with mock.patch("click.edit") as mock_edit: - mock_edit.return_value = "".join(content) + mock_edit.return_value = "\n".join(content) self._test_success(content=content, additional_args=["--edit"]) mock_edit.assert_called_once_with( - "# Please write your news content. When finished, save the file.\n" - "# In order to abort, exit without saving.\n" - '# Lines starting with "#" are ignored.\n' - "\n" - "Add your info here\n" + "\n# Please write your news content. Lines starting " + "with '#' will be ignored, and\n# an empty message aborts.\n", + extension=".rst", ) def test_edit_with_comment(self): """Create file editly with ignored line.""" - content = ["This is line 1\n", "This is line 2"] - comment = "# I am ignored\n" + content = ["This is line 1", "This is line 2"] + comment = "# I am ignored" with mock.patch("click.edit") as mock_edit: - mock_edit.return_value = "".join(content[:1] + [comment] + content[1:]) + mock_edit.return_value = "\n".join(content[:1] + [comment] + content[1:]) self._test_success(content=content, additional_args=["--edit"]) def test_edit_abort(self): @@ -83,6 +88,56 @@ def test_edit_abort(self): self.assertEqual([], os.listdir("foo/newsfragments")) self.assertEqual(1, result.exit_code) + def test_edit_markdown_extension(self): + """ + The temporary file extension used when editing is ``.md`` if the main filename + also uses that extension. + """ + + with mock.patch("click.edit") as mock_edit: + mock_edit.return_value = "This is line 1" + self._test_success( + content=["This is line 1"], + config=dedent( + """\ + [tool.towncrier] + package = "foo" + filename = "README.md" + """ + ), + additional_args=["--edit"], + ) + mock_edit.assert_called_once_with( + "\n# Please write your news content. Lines starting " + "with '#' will be ignored, and\n# an empty message aborts.\n", + extension=".md", + ) + + def test_edit_unknown_extension(self): + """ + The temporary file extension used when editing is ``.txt`` if it the main + filename isn't ``.rst`` or ``.md``. + """ + + with mock.patch("click.edit") as mock_edit: + mock_edit.return_value = "This is line 1" + self._test_success( + content=["This is line 1"], + config=dedent( + """\ + [tool.towncrier] + package = "foo" + filename = "README.FIRST" + """ + ), + additional_args=["--edit"], + ) + mock_edit.assert_called_once_with( + "\n# Please write your news content. Lines starting " + "with '#' will be ignored, and\n# an empty message aborts.\n", + extension=".txt", + ) + def test_content(self): """ When creating a new fragment the content can be passed as a @@ -92,6 +147,27 @@ def test_content(self): content_line = "This is a content" self._test_success(content=[content_line], additional_args=["-c", content_line]) + def test_content_without_eof_newline(self): + """ + When creating a new fragment the content can be passed as a command line + argument. The text editor is not invoked, and no eof newline is added if the + config option is set. + """ + config = dedent( + """\ + [tool.towncrier] + package = "foo" + create_eof_newline = false + """ + ) + content_line = "This is a content" + self._test_success( + content=[content_line], + additional_args=["-c", content_line], + config=config, + eof_newline=False, + ) + def test_message_and_edit(self): """ When creating a new message, a initial content can be passed via @@ -99,18 +175,16 @@ def test_message_and_edit(self): text editor. """ content_line = "This is a content line" - edit_content = ["This is line 1\n", "This is line 2"] + edit_content = ["This is line 1", "This is line 2"] with mock.patch("click.edit") as mock_edit: - mock_edit.return_value = "".join(edit_content) + mock_edit.return_value = "\n".join(edit_content) self._test_success( content=edit_content, additional_args=["-c", content_line, "--edit"] ) mock_edit.assert_called_once_with( - "# Please write your news content. When finished, save the file.\n" - "# In order to abort, exit without saving.\n" - '# Lines starting with "#" are ignored.\n' - "\n" - "{content_line}\n".format(content_line=content_line) + f"{content_line}\n\n# Please write your news content. Lines starting " + "with '#' will be ignored, and\n# an empty message aborts.\n", + extension=".rst", ) def test_different_directory(self): @@ -151,12 +225,81 @@ def test_invalid_section(self): "Expected filename '123.foobar.rst' to be of format", result.output ) + @with_isolated_runner + def test_custom_extension(self, runner: CliRunner): + """Ensure we can still create fragments with custom extensions.""" + setup_simple_project() + frag_path = Path("foo", "newsfragments") + + result = runner.invoke(_main, ["123.feature.txt"]) + self.assertEqual(result.exit_code, 0, result.output) + + fragments = [f.name for f in frag_path.iterdir()] + # No '.rst' extension added. + self.assertEqual(fragments, ["123.feature.txt"]) + + @with_isolated_runner + def test_md_filename_extension(self, runner: CliRunner): + """Ensure changelog filename extension is used if .md""" + setup_simple_project(extra_config='filename = "changes.md"') + frag_path = Path("foo", "newsfragments") + + result = runner.invoke(_main, ["123.feature"]) + self.assertEqual(result.exit_code, 0, result.output) + + fragments = [f.name for f in frag_path.iterdir()] + # No '.rst' extension added. + self.assertEqual(fragments, ["123.feature.md"]) + + @with_isolated_runner + def test_no_filename_extension(self, runner: CliRunner): + """ + When the NEWS filename has no extension, new fragments are will not have an + extension added. + """ + # The name of the file where towncrier will generate + # the final release notes is named `RELEASE_NOTES` + # for this test (with no file extension). + setup_simple_project(extra_config='filename = "RELEASE_NOTES"') + frag_path = Path("foo", "newsfragments") + + result = runner.invoke(_main, ["123.feature"]) + self.assertEqual(result.exit_code, 0, result.output) + + fragments = [f.name for f in frag_path.iterdir()] + # No '.rst' extension added. + self.assertEqual(fragments, ["123.feature"]) + @with_isolated_runner def test_file_exists(self, runner: CliRunner): """Ensure we don't overwrite existing files.""" setup_simple_project() frag_path = Path("foo", "newsfragments") + for _ in range(3): + result = runner.invoke(_main, ["123.feature"]) + self.assertEqual(result.exit_code, 0, result.output) + + fragments = [f.name for f in frag_path.iterdir()] + self.assertEqual( + sorted(fragments), + [ + "123.feature.1.rst", + "123.feature.2.rst", + "123.feature.rst", + ], + ) + + @with_isolated_runner + def test_file_exists_no_ext(self, runner: CliRunner): + """ + Ensure we don't overwrite existing files with when not adding filename + extensions. + """ + + setup_simple_project(extra_config="create_add_extension = false") + frag_path = Path("foo", "newsfragments") + for _ in range(3): result = runner.invoke(_main, ["123.feature"]) self.assertEqual(result.exit_code, 0, result.output) @@ -194,6 +337,243 @@ def test_file_exists_with_ext(self, runner: CliRunner): ], ) + @with_isolated_runner + def test_without_filename(self, runner: CliRunner): + """ + When no filename is provided, the user is prompted for one. + """ + setup_simple_project() + + with mock.patch("click.edit") as mock_edit: + mock_edit.return_value = "Edited content" + result = runner.invoke(_main, input="123\nfeature\n") + self.assertFalse(result.exception, result.output) + mock_edit.assert_called_once() + expected = os.path.join(os.getcwd(), "foo", "newsfragments", "123.feature.rst") + self.assertEqual( + result.output, + f"""Issue number (`+` if none): 123 +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected} +""", + ) + with open(expected) as f: + self.assertEqual(f.read(), "Edited content\n") + + @with_isolated_runner + def test_without_filename_orphan(self, runner: CliRunner): + """ + The user can create an orphan fragment from the interactive prompt. + """ + setup_simple_project() + + with mock.patch("click.edit") as mock_edit: + mock_edit.return_value = "Orphan content" + result = runner.invoke(_main, input="+\nfeature\n") + self.assertFalse(result.exception, result.output) + mock_edit.assert_called_once() + expected = os.path.join(os.getcwd(), "foo", "newsfragments", "+") + self.assertTrue( + result.output.startswith( + f"""Issue number (`+` if none): + +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected}""" + ), + result.output, + ) + # Check that the file was created with a random name + created_line = result.output.strip().rsplit("\n", 1)[-1] + # Get file names in the newsfragments directory. + files = os.listdir(os.path.join(os.getcwd(), "foo", "newsfragments")) + # Check that the file name is in the created line. + created_fragment = created_line.split(" ")[-1] + self.assertIn(Path(created_fragment).name, files) + with open(created_fragment) as f: + self.assertEqual(f.read(), "Orphan content\n") + + @with_isolated_runner + def test_without_filename_no_orphan_config(self, runner: CliRunner): + """ + If an empty orphan prefix is set, orphan creation is turned off from interactive + prompt. + """ + setup_simple_project(extra_config='orphan_prefix = ""') + + with mock.patch("click.edit") as mock_edit: + mock_edit.return_value = "Edited content" + result = runner.invoke(_main, input="+\nfeature\n") + self.assertFalse(result.exception, result.output) + mock_edit.assert_called_once() + expected = os.path.join(os.getcwd(), "foo", "newsfragments", "+.feature.rst") + self.assertEqual( + result.output, + f"""Issue number: + +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected} +""", + ) + with open(expected) as f: + self.assertEqual(f.read(), "Edited content\n") + + @with_isolated_runner + def test_sections(self, runner: CliRunner): + """ + When creating a new fragment, the user can specify the section from the command + line (and if none is provided, the default section will be used). + + The default section is either the section with a blank path, or else the first + section defined in the configuration file. + """ + setup_simple_project( + extra_config=""" +[[tool.towncrier.section]] +name = "Backend" +path = "backend" +[[tool.towncrier.section]] +name = "Frontend" +path = "" +""" + ) + result = runner.invoke(_main, ["123.feature.rst"]) + self.assertFalse(result.exception, result.output) + frag_path = Path("foo", "newsfragments") + fragments = [f.name for f in frag_path.iterdir()] + self.assertEqual(fragments, ["123.feature.rst"]) + + result = runner.invoke(_main, ["123.feature.rst", "--section", "invalid"]) + self.assertTrue(result.exception, result.output) + self.assertIn( + "Invalid value for '--section': expected one of 'Backend', 'Frontend'", + result.output, + ) + + result = runner.invoke(_main, ["123.feature.rst", "--section", "Backend"]) + self.assertFalse(result.exception, result.output) + frag_path = Path("foo", "backend", "newsfragments") + fragments = [f.name for f in frag_path.iterdir()] + self.assertEqual(fragments, ["123.feature.rst"]) + + @with_isolated_runner + def test_sections_without_filename(self, runner: CliRunner): + """ + When multiple sections exist when the interactive prompt is used, the user is + prompted to select a section. + """ + setup_simple_project( + extra_config=""" +[[tool.towncrier.section]] +name = "Backend" +path = "" + +[[tool.towncrier.section]] +name = "Frontend" +path = "frontend" +""" + ) + with mock.patch("click.edit") as mock_edit: + mock_edit.return_value = "Edited content" + result = runner.invoke(_main, input="2\n123\nfeature\n") + self.assertFalse(result.exception, result.output) + mock_edit.assert_called_once() + expected = os.path.join( + os.getcwd(), "foo", "frontend", "newsfragments", "123.feature.rst" + ) + + self.assertEqual( + result.output, + f"""\ +Pick a section: + 1: Backend + 2: Frontend +Section (1, 2) [1]: 2 +Issue number (`+` if none): 123 +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected} +""", + ) + + @with_isolated_runner + def test_sections_without_filename_with_section_option(self, runner: CliRunner): + """ + When multiple sections exist and the section is provided via the command line, + the user isn't prompted to select a section. + """ + setup_simple_project( + extra_config=""" +[[tool.towncrier.section]] +name = "Backend" +path = "" + +[[tool.towncrier.section]] +name = "Frontend" +path = "frontend" +""" + ) + with mock.patch("click.edit") as mock_edit: + mock_edit.return_value = "Edited content" + result = runner.invoke( + _main, ["--section", "Frontend"], input="123\nfeature\n" + ) + self.assertFalse(result.exception, result.output) + mock_edit.assert_called_once() + expected = os.path.join( + os.getcwd(), "foo", "frontend", "newsfragments", "123.feature.rst" + ) + + self.assertEqual( + result.output, + f"""\ +Issue number (`+` if none): 123 +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected} +""", + ) + + @with_isolated_runner + def test_sections_all_with_paths(self, runner: CliRunner): + """ + When all sections have paths, the first is the default. + """ + setup_simple_project( + extra_config=""" +[[tool.towncrier.section]] +name = "Frontend" +path = "frontend" + +[[tool.towncrier.section]] +name = "Backend" +path = "backend" +""" + ) + result = runner.invoke(_main, ["123.feature.rst"]) + self.assertFalse(result.exception, result.output) + frag_path = Path("foo", "frontend", "newsfragments") + fragments = [f.name for f in frag_path.iterdir()] + self.assertEqual(fragments, ["123.feature.rst"]) + + @with_isolated_runner + def test_without_filename_with_message(self, runner: CliRunner): + """ + When no filename is provided, the user is prompted for one. If a message is + provided, the editor isn't opened and the message is used. + """ + setup_simple_project() + + with mock.patch("click.edit") as mock_edit: + result = runner.invoke(_main, ["-c", "Fixed this"], input="123\nfeature\n") + self.assertFalse(result.exception, result.output) + mock_edit.assert_not_called() + expected = os.path.join(os.getcwd(), "foo", "newsfragments", "123.feature.rst") + self.assertEqual( + result.output, + f"""Issue number (`+` if none): 123 +Fragment type (feature, bugfix, doc, removal, misc): feature +Created news fragment at {expected} +""", + ) + with open(expected) as f: + self.assertEqual(f.read(), "Fixed this\n") + @with_isolated_runner def test_create_orphan_fragment(self, runner: CliRunner): """ @@ -218,16 +598,18 @@ def test_create_orphan_fragment(self, runner: CliRunner): self.assertEqual(2, len(fragments)) change1, change2 = fragments - self.assertEqual(change1.suffix, ".feature") + self.assertEqual(change1.suffix, ".rst") self.assertTrue(change1.stem.startswith("+")) - # Length should be '+' character and 8 random hex characters. - self.assertEqual(len(change1.stem), 9) + self.assertTrue(change1.stem.endswith(".feature")) + # Length should be '+' character, 8 random hex characters, and ".feature". + self.assertEqual(len(change1.stem), 1 + 8 + len(".feature")) - self.assertEqual(change2.suffix, ".feature") + self.assertEqual(change2.suffix, ".rst") self.assertTrue(change2.stem.startswith("+")) + self.assertTrue(change2.stem.endswith(".feature")) self.assertEqual(change2.parent, sub_frag_path) - # Length should be '+' character and 8 random hex characters. - self.assertEqual(len(change2.stem), 9) + # Length should be '+' character, 8 random hex characters, and ".feature". + self.assertEqual(len(change2.stem), 1 + 8 + len(".feature")) @with_isolated_runner def test_create_orphan_fragment_custom_prefix(self, runner: CliRunner): @@ -245,7 +627,43 @@ def test_create_orphan_fragment_custom_prefix(self, runner: CliRunner): self.assertEqual(len(fragments), 1) change = fragments[0] self.assertTrue(change.stem.startswith("$$$")) - # Length should be '$$$' characters and 8 random hex characters. - self.assertEqual(len(change.stem), 11) + # Length should be '$$$' characters, 8 random hex characters, and ".feature". + self.assertEqual(len(change.stem), 3 + 8 + len(".feature")) # Check the remainder are all hex characters. - self.assertTrue(all(c in string.hexdigits for c in change.stem[3:])) + self.assertTrue( + all(c in string.hexdigits for c in change.stem[3 : -len(".feature")]) + ) + + @with_isolated_runner + def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner): + """ + When the `--dir` CLI argument is passed, + it will create a new file in directory that is + created by combining the `--dir` value + with the `directory` option from the configuration + file. + """ + Path("pyproject.toml").write_text( + # Important to customize `config.directory` because the default + # already supports this scenario. + "[tool.towncrier]\n" + + 'directory = "changelog.d"\n' + ) + Path("foo/foo").mkdir(parents=True) + Path("foo/foo/__init__.py").write_text("") + + result = runner.invoke( + _main, + ( + "--config", + "pyproject.toml", + "--dir", + "foo", + "--content", + "Adds levitation.", + "123.feature", + ), + ) + + self.assertEqual(0, result.exit_code) + self.assertTrue(Path("foo/changelog.d/123.feature.rst").exists()) diff --git a/src/towncrier/test/test_format.py b/src/towncrier/test/test_format.py index 61e6d88c..a1efefb5 100644 --- a/src/towncrier/test/test_format.py +++ b/src/towncrier/test/test_format.py @@ -107,6 +107,8 @@ def test_basic(self): ~~~~~~~~ - Web fixed. (#3) + + """ template = read_pkg_resource("templates/default.rst") @@ -154,6 +156,8 @@ def test_basic(self): ^^^^^^^^ - Web fixed. (#3) + + """ output = render_fragments( @@ -229,6 +233,8 @@ def test_markdown(self): (#2) - Web fixed. (#3) + + """ template = read_pkg_resource("templates/default.md") @@ -291,6 +297,8 @@ def test_markdown(self): [2]: https://github.com/twisted/towncrier/issues/2 [3]: https://github.com/twisted/towncrier/issues/3 + + """ output = render_fragments( @@ -332,6 +340,8 @@ def test_issue_format(self): ---- - xxbar, xx1, xx9, xx142 + + """ template = read_pkg_resource("templates/default.rst") @@ -386,6 +396,8 @@ def test_line_wrapping(self): - a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a (#3) + + """ template = read_pkg_resource("templates/default.rst") @@ -433,6 +445,8 @@ def test_line_wrapping_disabled(self): - asdf asdf asdf asdf looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong newsfragment. (#1) - https://google.com/q=?---------------------------------------------------------------------------------------------------- (#2) - a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a (#3) + + """ # NOQA template = read_pkg_resource("templates/default.rst") diff --git a/src/towncrier/test/test_project.py b/src/towncrier/test/test_project.py index 3412bb60..511a3039 100644 --- a/src/towncrier/test/test_project.py +++ b/src/towncrier/test/test_project.py @@ -4,14 +4,20 @@ import os import sys -from subprocess import check_output +from importlib.metadata import version as metadata_version +from unittest import mock +from click.testing import CliRunner from twisted.trial.unittest import TestCase from .._project import get_project_name, get_version +from .._shell import cli as towncrier_cli from .helpers import write +towncrier_cli.name = "towncrier" + + class VersionFetchingTests(TestCase): def test_str(self): """ @@ -39,34 +45,34 @@ def test_tuple(self): version = get_version(temp, "mytestproja") self.assertEqual(version, "1.3.12") - def test_incremental(self): - """ - An incremental-like Version __version__ is picked up. - """ - temp = self.mktemp() - os.makedirs(temp) - os.makedirs(os.path.join(temp, "mytestprojinc")) + def test_incremental(self): + """ + An incremental-like Version __version__ is picked up. + """ + temp = self.mktemp() + os.makedirs(temp) + os.makedirs(os.path.join(temp, "mytestprojinc")) - with open(os.path.join(temp, "mytestprojinc", "__init__.py"), "w") as f: - f.write( - """ -class Version: - ''' - This is emulating a Version object from incremental. - ''' + with open(os.path.join(temp, "mytestprojinc", "__init__.py"), "w") as f: + f.write( + """ + class Version: + ''' + This is emulating a Version object from incremental. + ''' - def __init__(self, *version_parts): - self.version = version_parts + def __init__(self, *version_parts): + self.version = version_parts - def base(self): - return '.'.join(map(str, self.version)) + def base(self): + return '.'.join(map(str, self.version)) -__version__ = Version(1, 3, 12, "rc1") -""" - ) - version = get_version(temp, "mytestprojinc") - self.assertEqual(version, "1.3.12rc1") + __version__ = Version(1, 3, 12, "rc1") + """ + ) + version = get_version(temp, "mytestprojinc") + self.assertEqual(version, "1.3.12rc1") def test_not_incremental(self): """ @@ -99,6 +105,13 @@ def base(self, some_arg): e.exception.args, ) + def test_version_from_metadata(self): + """ + A version from package metadata is picked up. + """ + version = get_version(".", "towncrier") + self.assertEqual(metadata_version("towncrier"), version) + def _setup_missing(self): """ Create a minimalistic project with missing metadata in a temporary @@ -120,6 +133,7 @@ def test_missing_version(self): tmp_dir = self._setup_missing() with self.assertRaises(Exception) as e: + # The 'missing' package has no __version__ string. get_version(tmp_dir, "missing") self.assertEqual( @@ -208,6 +222,7 @@ def test_dash_m(self): """ `python -m towncrier` invokes the main entrypoint. """ + runner = CliRunner() temp = self.mktemp() new_dir = os.path.join(temp, "dashm") os.makedirs(new_dir) @@ -217,9 +232,9 @@ def test_dash_m(self): with open("pyproject.toml", "w") as f: f.write("[tool.towncrier]\n" 'directory = "news"\n') os.makedirs("news") - out = check_output([sys.executable, "-m", "towncrier", "--help"]) - self.assertIn(b"[OPTIONS] COMMAND [ARGS]...", out) - self.assertRegex(out, rb".*--help\s+Show this message and exit.") + result = runner.invoke(towncrier_cli, ["--help"]) + self.assertIn("[OPTIONS] COMMAND [ARGS]...", result.stdout) + self.assertRegex(result.stdout, r".*--help\s+Show this message and exit.") finally: os.chdir(orig_dir) @@ -227,5 +242,6 @@ def test_version(self): """ `--version` command line option is available to show the current production version. """ - out = check_output(["towncrier", "--version"]) - self.assertTrue(out.startswith(b"towncrier, version 2")) + runner = CliRunner() + result = runner.invoke(towncrier_cli, ["--version"]) + self.assertTrue(result.output.startswith("towncrier, version 2")) diff --git a/src/towncrier/test/test_settings.py b/src/towncrier/test/test_settings.py index 6d1f6041..14554086 100644 --- a/src/towncrier/test/test_settings.py +++ b/src/towncrier/test/test_settings.py @@ -207,10 +207,25 @@ def test_load_no_config(self, runner: CliRunner): self.assertEqual( result.output, - f"No configuration file found.\nLooked in: {os.path.abspath(temp)}\n", + f"No configuration file found.\nLooked back from: {os.path.abspath(temp)}\n", ) self.assertEqual(result.exit_code, 1) + @with_isolated_runner + def test_load_explicit_missing_config(self, runner: CliRunner): + """ + Calling the CLI with an incorrect explicit configuration file will exit with + code 1 and an informative message is sent to standard output. + """ + config = "not-there.toml" + result = runner.invoke(cli, ("--config", config)) + + self.assertEqual(result.exit_code, 1) + self.assertEqual( + result.output, + f"Configuration file '{os.path.abspath(config)}' not found.\n", + ) + def test_missing_template(self): """ Towncrier will raise an exception saying when it can't find a template. @@ -273,6 +288,12 @@ def test_custom_types_as_tables_array_deprecated(self): directory="spam" name="Spam" showcontent=true + + [[tool.towncrier.type]] + directory="auto" + name="Automatic" + showcontent=true + check=false """ ) config = load_config(project_dir) @@ -282,6 +303,7 @@ def test_custom_types_as_tables_array_deprecated(self): { "name": "Foo", "showcontent": False, + "check": True, }, ), ( @@ -289,6 +311,15 @@ def test_custom_types_as_tables_array_deprecated(self): { "name": "Spam", "showcontent": True, + "check": True, + }, + ), + ( + "auto", + { + "name": "Automatic", + "showcontent": True, + "check": False, }, ), ] @@ -311,6 +342,9 @@ def test_custom_types_as_tables(self): [tool.towncrier.fragment.chore] name = "Other Tasks" showcontent = false + [tool.towncrier.fragment.auto] + name = "Automatic" + check = false """ ) config = load_config(project_dir) @@ -318,14 +352,22 @@ def test_custom_types_as_tables(self): "chore": { "name": "Other Tasks", "showcontent": False, + "check": True, }, "feat": { "name": "Feat", "showcontent": True, + "check": True, }, "fix": { "name": "Fix", "showcontent": True, + "check": True, + }, + "auto": { + "name": "Automatic", + "showcontent": True, + "check": False, }, } actual = config.types