├── .github ├── FUNDING.yml ├── zizmor.yml ├── dependabot.yml └── workflows │ └── tests.yml ├── tests ├── __init__.py ├── test_process.py ├── helpers.py ├── test_faker.py ├── test_changelog.py ├── test_linkcheck.py ├── test_util.py ├── test_gitinfo.py ├── conftest.py ├── test_print.py ├── faker.py ├── test_github.py ├── test_literals.py ├── test_format_md.py ├── test_ghrel.py └── test_create.py ├── docs ├── changelog.rst ├── include │ └── links.rst ├── _static │ └── theme_overrides.css ├── concepts.rst ├── Makefile ├── philosophy.rst ├── index.rst └── commands.rst ├── changelog.d ├── README.txt ├── 20251118_071744_nedbat.rst └── ghrel_template.md.j2 ├── src └── scriv │ ├── __init__.py │ ├── __main__.py │ ├── exceptions.py │ ├── templates │ ├── new_fragment.md.j2 │ └── new_fragment.rst.j2 │ ├── optional.py │ ├── cli.py │ ├── linkcheck.py │ ├── create.py │ ├── shell.py │ ├── format.py │ ├── print.py │ ├── gitinfo.py │ ├── github.py │ ├── collect.py │ ├── format_md.py │ ├── scriv.py │ ├── util.py │ ├── literals.py │ ├── changelog.py │ ├── ghrel.py │ └── format_rst.py ├── requirements ├── tox.in ├── base.in ├── dev.in ├── doc.in ├── test.in ├── constraints.in ├── quality.in ├── base.txt ├── tox.txt ├── test.txt ├── doc.txt └── quality.txt ├── .mailmap ├── .ignore ├── .readthedocs.yaml ├── MANIFEST.in ├── .editorconfig ├── .gitignore ├── pyproject.toml ├── tox.ini ├── README.rst ├── Makefile ├── pylintrc └── LICENSE.txt /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: nedbat 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """The tests for scriv.""" 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /changelog.d/README.txt: -------------------------------------------------------------------------------- 1 | This directory will hold the changelog entries managed by scriv. 2 | -------------------------------------------------------------------------------- /src/scriv/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Scriv changelog management tool. 3 | """ 4 | 5 | __version__ = "1.7.0" 6 | -------------------------------------------------------------------------------- /docs/include/links.rst: -------------------------------------------------------------------------------- 1 | .. Links to be used elsewhere in the docs 2 | 3 | .. _Jinja: https://jinja.palletsprojects.com 4 | -------------------------------------------------------------------------------- /src/scriv/__main__.py: -------------------------------------------------------------------------------- 1 | """Enable 'python -m scriv'.""" 2 | 3 | from .cli import cli 4 | 5 | cli(prog_name="scriv") 6 | -------------------------------------------------------------------------------- /requirements/tox.in: -------------------------------------------------------------------------------- 1 | # Tox and related requirements. 2 | 3 | tox # Virtualenv management for tests 4 | -------------------------------------------------------------------------------- /changelog.d/20251118_071744_nedbat.rst: -------------------------------------------------------------------------------- 1 | Changed 2 | ....... 3 | 4 | - Dropped support for Python 3.9 and declared support for Python 3.14. 5 | -------------------------------------------------------------------------------- /requirements/base.in: -------------------------------------------------------------------------------- 1 | # Core requirements for using this application 2 | 3 | attrs 4 | click 5 | click-log 6 | jinja2>=2.7 7 | markdown-it-py 8 | requests 9 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | # https://git-scm.com/docs/gitmailmap 2 | # This file isn't supported by GitHub (yet?) but maybe someday 3 | 4 | -------------------------------------------------------------------------------- /src/scriv/exceptions.py: -------------------------------------------------------------------------------- 1 | """Specialized exceptions for scriv.""" 2 | 3 | 4 | class ScrivException(Exception): 5 | """Any exception raised by scriv.""" 6 | -------------------------------------------------------------------------------- /.ignore: -------------------------------------------------------------------------------- 1 | # .ignore: control what files get searched. 2 | build 3 | htmlcov 4 | .tox* 5 | .coverage* 6 | _build 7 | _spell 8 | *.egg 9 | *.egg-info 10 | .mypy_cache 11 | .pytest_cache 12 | -------------------------------------------------------------------------------- /.github/zizmor.yml: -------------------------------------------------------------------------------- 1 | # Rules for checking workflows 2 | # https://woodruffw.github.io/zizmor 3 | 4 | rules: 5 | unpinned-uses: 6 | config: 7 | policies: 8 | actions/*: hash-pin 9 | -------------------------------------------------------------------------------- /changelog.d/ghrel_template.md.j2: -------------------------------------------------------------------------------- 1 | :arrow_right:  PyPI page: [scriv {{version}}](https://pypi.org/project/scriv/{{version}}). 2 | :arrow_right:  To install: `python3 -m pip install scriv=={{version}}` 3 | 4 | {{body}} 5 | -------------------------------------------------------------------------------- /requirements/dev.in: -------------------------------------------------------------------------------- 1 | # Additional requirements for development of this application 2 | 3 | -r quality.txt # Core and quality check dependencies 4 | -r tox.txt # tox and related dependencies 5 | 6 | build # For kitting 7 | -------------------------------------------------------------------------------- /requirements/doc.in: -------------------------------------------------------------------------------- 1 | # Requirements for documentation validation 2 | 3 | -r test.txt # Core and testing dependencies for this package 4 | 5 | cogapp 6 | doc8 # reStructuredText style checker 7 | Sphinx # Documentation builder 8 | sphinx-rtd-theme # To make it look like readthedocs 9 | -------------------------------------------------------------------------------- /src/scriv/templates/new_fragment.md.j2: -------------------------------------------------------------------------------- 1 | 7 | 8 | {% for cat in config.categories -%} 9 | 15 | {% endfor -%} 16 | -------------------------------------------------------------------------------- /docs/_static/theme_overrides.css: -------------------------------------------------------------------------------- 1 | /* override table width restrictions */ 2 | .wy-table-responsive table td, .wy-table-responsive table th { 3 | /* !important prevents the common CSS stylesheets from 4 | overriding this as on RTD they are loaded after this stylesheet */ 5 | white-space: normal !important; 6 | } 7 | 8 | .wy-table-responsive { 9 | overflow: visible !important; 10 | } 11 | -------------------------------------------------------------------------------- /tests/test_process.py: -------------------------------------------------------------------------------- 1 | """Tests of the process behavior of scriv.""" 2 | 3 | import sys 4 | 5 | from scriv import __version__ 6 | from scriv.shell import run_command 7 | 8 | 9 | def test_dashm(): 10 | ok, output = run_command([sys.executable, "-m", "scriv", "--help"]) 11 | print(output) 12 | assert ok 13 | assert "Usage: scriv [OPTIONS] COMMAND [ARGS]..." in output 14 | assert "Version " + __version__ in output 15 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # ReadTheDocs configuration. 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html 3 | 4 | version: 2 5 | 6 | build: 7 | os: ubuntu-22.04 8 | tools: 9 | python: "3.11" 10 | 11 | sphinx: 12 | builder: html 13 | configuration: docs/conf.py 14 | 15 | # Build all the formats 16 | formats: all 17 | 18 | python: 19 | install: 20 | - requirements: requirements/doc.txt 21 | - method: pip 22 | path: . 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include .editorconfig 2 | include .ignore 3 | include .mailmap 4 | include .readthedocs.yaml 5 | include CHANGELOG.rst 6 | include LICENSE.txt 7 | include Makefile 8 | include pylintrc 9 | include README.rst 10 | include tox.ini 11 | 12 | recursive-include changelog.d * 13 | recursive-include docs Makefile *.py *.rst 14 | recursive-include docs/_static * 15 | recursive-include requirements *.in *.txt 16 | recursive-include tests *.py 17 | 18 | prune doc/_build 19 | -------------------------------------------------------------------------------- /requirements/test.in: -------------------------------------------------------------------------------- 1 | # Requirements for test runs. 2 | 3 | -r base.txt # Core dependencies for this package 4 | 5 | coverage # for measuring coverage 6 | freezegun # for mocking datetime 7 | pudb # for when we need to debug 8 | pylint-pytest # Understanding of pytest fixtures. 9 | pytest 10 | pytest-mock # pytest wrapper around mock 11 | responses # mock requests 12 | pyyaml 13 | -------------------------------------------------------------------------------- /src/scriv/templates/new_fragment.rst.j2: -------------------------------------------------------------------------------- 1 | .. A new scriv changelog fragment. 2 | {% if config.categories -%} 3 | .. 4 | .. Uncomment the section that is right (remove the leading dots). 5 | .. For top level release notes, leave all the headers commented out. 6 | .. 7 | {% for cat in config.categories -%} 8 | .. {{ cat }} 9 | .. {{ config.rst_header_chars[1] * (cat|length) }} 10 | .. 11 | .. - A bullet item for the {{ cat }} category. 12 | .. 13 | {% endfor -%} 14 | {% else %} 15 | - A bullet item for this fragment. EDIT ME! 16 | {% endif -%} 17 | -------------------------------------------------------------------------------- /src/scriv/optional.py: -------------------------------------------------------------------------------- 1 | """ 2 | Third-party modules that might or might not be available. 3 | """ 4 | 5 | # pylint: disable=unused-import 6 | 7 | from types import ModuleType 8 | from typing import Optional 9 | 10 | tomllib: Optional[ModuleType] 11 | 12 | try: 13 | try: 14 | import tomllib # type: ignore[no-redef] 15 | except ModuleNotFoundError: 16 | import tomli as tomllib # type: ignore[no-redef] 17 | except ImportError: 18 | tomllib = None 19 | 20 | 21 | yaml: Optional[ModuleType] 22 | 23 | try: 24 | import yaml # type: ignore[no-redef] 25 | except ImportError: 26 | yaml = None 27 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | insert_final_newline = true 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | max_line_length = 80 8 | trim_trailing_whitespace = true 9 | 10 | [{Makefile, *.mk}] 11 | indent_style = tab 12 | indent_size = 8 13 | 14 | [*.{yml,yaml,json}] 15 | indent_size = 2 16 | 17 | [*.js] 18 | indent_size = 2 19 | 20 | [*.diff] 21 | trim_trailing_whitespace = false 22 | 23 | [.git/*] 24 | trim_trailing_whitespace = false 25 | 26 | [*.rst] 27 | max_line_length = 79 28 | 29 | [requirements/*.{in,txt}] 30 | # No idea why, but pip-tools puts comments on 2-space indents. 31 | indent_size = 2 32 | -------------------------------------------------------------------------------- /requirements/constraints.in: -------------------------------------------------------------------------------- 1 | # Version constraints for pip-installation. 2 | # 3 | # This file doesn't install any packages. It specifies version constraints 4 | # that will be applied if a package is needed. 5 | # 6 | # When pinning something here, please provide an explanation of why. Ideally, 7 | # link to other information that will help people in the future to remove the 8 | # pin when possible. Writing an issue against the offending project and 9 | # linking to it here is good. 10 | 11 | # I don't understand why uv wouldn't upgrade to the latest theme, but putting 12 | # this constraint makes it work with no complaints. 13 | sphinx-rtd-theme>3.0.0 14 | -------------------------------------------------------------------------------- /requirements/quality.in: -------------------------------------------------------------------------------- 1 | # Requirements for code quality checks 2 | 3 | -r test.txt # Core and testing dependencies for this package 4 | -r doc.txt # Need doc packages for full linting 5 | 6 | black # Uncompromising code formatting 7 | check-manifest # are we packaging files properly? 8 | isort # to standardize order of imports 9 | mypy # Static type checking 10 | pycodestyle # PEP 8 compliance validation 11 | pydocstyle # PEP 257 compliance validation 12 | pylint 13 | twine # For checking distributions 14 | types-freezegun 15 | types-requests 16 | types-toml 17 | types-pyyaml 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # From: 2 | # https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/keeping-your-actions-up-to-date-with-dependabot 3 | # Set update schedule for GitHub Actions 4 | 5 | version: 2 6 | updates: 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | # Check for updates to GitHub Actions once a week 11 | interval: "weekly" 12 | cooldown: 13 | # https://blog.yossarian.net/2025/11/21/We-should-all-be-using-dependency-cooldowns 14 | default-days: 7 15 | groups: 16 | action-dependencies: 17 | patterns: 18 | - "*" 19 | commit-message: 20 | prefix: "chore" 21 | -------------------------------------------------------------------------------- /src/scriv/cli.py: -------------------------------------------------------------------------------- 1 | """Scriv command-line interface.""" 2 | 3 | import logging 4 | 5 | import click 6 | import click_log 7 | 8 | from . import __version__ 9 | from .collect import collect 10 | from .create import create 11 | from .ghrel import github_release 12 | from .print import print_ 13 | 14 | click_log.basic_config(logging.getLogger()) 15 | 16 | 17 | @click.group( 18 | help=f"""\ 19 | Manage changelogs. https://scriv.readthedocs.io/ 20 | 21 | Version {__version__} 22 | """ 23 | ) 24 | @click.version_option() 25 | def cli() -> None: # noqa: D401 26 | """The main entry point for the scriv command.""" 27 | 28 | 29 | cli.add_command(create) 30 | cli.add_command(collect) 31 | cli.add_command(github_release) 32 | cli.add_command(print_) 33 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # make upgrade 3 | attrs==25.4.0 4 | # via -r requirements/base.in 5 | certifi==2025.11.12 6 | # via requests 7 | charset-normalizer==3.4.4 8 | # via requests 9 | click==8.3.1 10 | # via 11 | # -r requirements/base.in 12 | # click-log 13 | click-log==0.4.0 14 | # via -r requirements/base.in 15 | colorama==0.4.6 ; sys_platform == 'win32' 16 | # via click 17 | idna==3.11 18 | # via requests 19 | jinja2==3.1.6 20 | # via -r requirements/base.in 21 | markdown-it-py==4.0.0 22 | # via -r requirements/base.in 23 | markupsafe==3.0.3 24 | # via jinja2 25 | mdurl==0.1.2 26 | # via markdown-it-py 27 | requests==2.32.5 28 | # via -r requirements/base.in 29 | urllib3==2.6.2 30 | # via requests 31 | -------------------------------------------------------------------------------- /requirements/tox.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # make upgrade 3 | cachetools==6.2.4 4 | # via tox 5 | chardet==5.2.0 6 | # via tox 7 | colorama==0.4.6 8 | # via tox 9 | distlib==0.4.0 10 | # via virtualenv 11 | filelock==3.20.1 12 | # via 13 | # tox 14 | # virtualenv 15 | packaging==25.0 16 | # via 17 | # pyproject-api 18 | # tox 19 | platformdirs==4.5.1 20 | # via 21 | # tox 22 | # virtualenv 23 | pluggy==1.6.0 24 | # via tox 25 | pyproject-api==1.10.0 26 | # via tox 27 | tomli==2.3.0 ; python_full_version < '3.11' 28 | # via 29 | # pyproject-api 30 | # tox 31 | tox==4.32.0 32 | # via -r requirements/tox.in 33 | typing-extensions==4.15.0 ; python_full_version < '3.11' 34 | # via 35 | # tox 36 | # virtualenv 37 | virtualenv==20.35.4 38 | # via tox 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | .pytest_cache 4 | .mypy_cache 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Packages 10 | *.egg 11 | *.egg-info 12 | dist 13 | build 14 | eggs 15 | parts 16 | bin 17 | var 18 | sdist 19 | develop-eggs 20 | .installed.cfg 21 | lib 22 | lib64 23 | 24 | # Installer logs 25 | pip-log.txt 26 | 27 | # Unit test / coverage reports 28 | .cache/ 29 | .pytest_cache/ 30 | .coverage 31 | .coverage.* 32 | .tox 33 | coverage.json 34 | coverage.xml 35 | htmlcov/ 36 | 37 | # IDEs and text editors 38 | *~ 39 | *.swp 40 | .idea/ 41 | .project 42 | .pycharm_helpers/ 43 | .pydevproject 44 | 45 | # The Silver Searcher 46 | .agignore 47 | 48 | # OS X artifacts 49 | *.DS_Store 50 | 51 | # Logging 52 | log/ 53 | logs/ 54 | chromedriver.log 55 | ghostdriver.log 56 | 57 | # Complexity 58 | output/*.html 59 | output/*/index.html 60 | 61 | # Sphinx 62 | docs/_build 63 | docs/modules.rst 64 | docs/journo.rst 65 | docs/journo.*.rst 66 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | """Testing helpers.""" 2 | 3 | from unittest import mock 4 | 5 | 6 | def without_module(using_module, missing_module_name: str): 7 | """ 8 | Hide a module for testing. 9 | 10 | Use this in a test function to make an optional module unavailable during 11 | the test:: 12 | 13 | with without_module(scriv.something, 'toml'): 14 | use_toml_somehow() 15 | 16 | Arguments: 17 | using_module: a module in which to hide `missing_module_name`. 18 | missing_module_name: the name of the module to hide. 19 | 20 | """ 21 | return mock.patch.object(using_module, missing_module_name, None) 22 | 23 | 24 | def check_logs(caplog, expected): 25 | """ 26 | Compare log records from caplog. 27 | 28 | Only caplog records from a logger mentioned in expected are considered. 29 | """ 30 | logger_names = {r[0] for r in expected} 31 | records = [r for r in caplog.record_tuples if r[0] in logger_names] 32 | assert records == expected 33 | -------------------------------------------------------------------------------- /docs/concepts.rst: -------------------------------------------------------------------------------- 1 | ######## 2 | Concepts 3 | ######## 4 | 5 | .. _fragments: 6 | 7 | Fragments 8 | ========= 9 | 10 | Fragments are files describing your latest work, created by the 11 | ":ref:`cmd_create`" command. The files are created in the changelog.d directory 12 | (settable with :ref:`config_fragment_directory`). Typically, they are 13 | committed with the code change itself, then later aggregated into the changelog 14 | file with ":ref:`cmd_collect`". 15 | 16 | 17 | .. _categories: 18 | 19 | Categories 20 | ========== 21 | 22 | Changelog entries can be categorized, for example as additions, fixes, 23 | removals, and breaking changes. The list of categories is settable with 24 | the :ref:`config_categories` setting. 25 | 26 | If you are using categories in your project, new fragments will be 27 | pre-populated with all the categories, commented out. While editing the 28 | fragment, you provide your change information in the appropriate category. 29 | When the fragments are collected, they are grouped by category into a single 30 | changelog entry. 31 | 32 | Any fragments that do not specify a category are included as top-level 33 | release notes directly under the release heading. 34 | 35 | You can choose not to use categories by setting the :ref:`config_categories` 36 | setting to empty (all notes will appear as top-level release notes). 37 | 38 | 39 | .. _entries: 40 | 41 | Entries 42 | ======= 43 | 44 | Fragments are collected into changelog entries with the ":ref:`cmd_collect`" 45 | command. The fragments are combined in each category, in chronological order. 46 | The entry is given a header with version and date. 47 | -------------------------------------------------------------------------------- /tests/test_faker.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests of tests/faker.py 3 | 4 | Mostly error paths, since the happy paths are covered by other tests. 5 | """ 6 | 7 | import re 8 | 9 | import pytest 10 | 11 | from scriv import shell 12 | 13 | 14 | def test_no_such_command(fake_run_command): 15 | assert shell.run_command("wut") == ( 16 | False, 17 | "no fake command handler: ['wut']", 18 | ) 19 | 20 | 21 | def test_no_such_git_command(fake_git): 22 | assert shell.run_command("git hello") == ( 23 | False, 24 | "no fake git command: ['git', 'hello']", 25 | ) 26 | assert shell.run_command("git config --wut") == ( 27 | False, 28 | "no fake git command: ['git', 'config', '--wut']", 29 | ) 30 | 31 | 32 | @pytest.mark.parametrize( 33 | "name", 34 | [ 35 | "foo.bar", 36 | "foo.bar12", 37 | "foo.bar-", 38 | "foo.bar-bar", 39 | "foo-foo.bar", 40 | "section.subsection.bar", 41 | "section.some/sub_section!ok.bar", 42 | ], 43 | ) 44 | def test_git_set_config_good_names(name, fake_git): 45 | val = "xyzzy plugh!?" 46 | fake_git.set_config(name, val) 47 | result = shell.run_command(f"git config --get {name}") 48 | assert result == (True, val + "\n") 49 | 50 | 51 | @pytest.mark.parametrize( 52 | "name", 53 | [ 54 | "bar", 55 | "foo.12bar", 56 | "foo.bar_bar", 57 | "foo_foo.bar", 58 | ], 59 | ) 60 | def test_git_set_config_bad_names(name, fake_git): 61 | with pytest.raises(ValueError, match=re.escape(f"invalid key: {name!r}")): 62 | fake_git.set_config(name, "hello there") 63 | -------------------------------------------------------------------------------- /src/scriv/linkcheck.py: -------------------------------------------------------------------------------- 1 | """Extracting and checking links.""" 2 | 3 | import concurrent.futures 4 | import logging 5 | from collections.abc import Iterable 6 | 7 | import markdown_it 8 | import requests 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def find_links(markdown_text: str) -> Iterable[str]: 14 | """Find all the URLs in some Markdown text.""" 15 | 16 | def walk_tokens(tokens): 17 | for token in tokens: 18 | if token.type == "link_open": 19 | yield token.attrs["href"] 20 | if token.children: 21 | yield from walk_tokens(token.children) 22 | 23 | yield from walk_tokens(markdown_it.MarkdownIt().parse(markdown_text)) 24 | 25 | 26 | def check_markdown_links(markdown_text: str) -> None: 27 | """ 28 | Check if the URLs in `markdown_text` are reachable. 29 | 30 | Returns None. Logs warnings for unreachable links. 31 | """ 32 | links = set(find_links(markdown_text)) 33 | with concurrent.futures.ThreadPoolExecutor() as executor: 34 | executor.map(check_one_link, links) 35 | 36 | 37 | def check_one_link(url: str) -> None: 38 | """Check if a URL is reachable. Logs a warning if not.""" 39 | try: 40 | resp = requests.head(url, timeout=60, allow_redirects=True) 41 | except Exception as exc: # pylint: disable=broad-exception-caught 42 | logger.warning(f"Failed check for {url!r}: {exc}") 43 | return 44 | 45 | if resp.status_code == 200: 46 | logger.debug(f"OK link: {url!r}") 47 | else: 48 | logger.warning( 49 | f"Failed check for {url!r}: status code {resp.status_code}" 50 | ) 51 | -------------------------------------------------------------------------------- /src/scriv/create.py: -------------------------------------------------------------------------------- 1 | """Creating fragments.""" 2 | 3 | import logging 4 | import sys 5 | from typing import Optional 6 | 7 | import click 8 | 9 | from .gitinfo import git_add, git_config_bool, git_edit 10 | from .scriv import Scriv 11 | from .util import scriv_command 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | @click.command() 17 | @click.option( 18 | "--add/--no-add", default=None, help="'git add' the created file." 19 | ) 20 | @click.option( 21 | "--edit/--no-edit", 22 | default=None, 23 | help="Open the created file in your text editor.", 24 | ) 25 | @scriv_command 26 | def create(add: Optional[bool], edit: Optional[bool]) -> None: 27 | """ 28 | Create a new changelog fragment. 29 | """ 30 | if add is None: 31 | add = git_config_bool("scriv.create.add") 32 | if edit is None: 33 | edit = git_config_bool("scriv.create.edit") 34 | 35 | scriv = Scriv() 36 | frag = scriv.new_fragment() 37 | file_path = frag.path 38 | if not file_path.parent.exists(): 39 | sys.exit( 40 | f"Output directory {str(file_path.parent)!r} doesn't exist," 41 | + " please create it." 42 | ) 43 | 44 | if file_path.exists(): 45 | sys.exit(f"File {file_path} already exists, not overwriting") 46 | 47 | logger.info(f"Creating {file_path}") 48 | frag.write() 49 | 50 | if edit: 51 | git_edit(file_path) 52 | sections = scriv.sections_from_fragment(frag) 53 | if not sections: 54 | logger.info("Empty fragment, aborting...") 55 | file_path.unlink() 56 | sys.exit() 57 | 58 | if add: 59 | git_add(file_path) 60 | -------------------------------------------------------------------------------- /tests/test_changelog.py: -------------------------------------------------------------------------------- 1 | """Tests of scriv/changelog.py""" 2 | 3 | import pytest 4 | 5 | from scriv.changelog import Changelog 6 | from scriv.config import Config 7 | 8 | A = """\ 9 | Hello 10 | Goodbye 11 | """ 12 | 13 | B = """\ 14 | Now 15 | more than 16 | ever. 17 | """ 18 | 19 | BODY = """\ 20 | 2022-09-13 21 | ========== 22 | 23 | Added 24 | ----- 25 | 26 | - Wrote tests for Changelog. 27 | 28 | 2022-02-25 29 | ========== 30 | 31 | Added 32 | ----- 33 | 34 | - Now you can send email with this tool. 35 | 36 | Fixed 37 | ----- 38 | 39 | - Launching missiles no longer targets ourselves. 40 | 41 | - Typos corrected. 42 | """ 43 | 44 | BODY_SECTIONS = { 45 | "2022-09-13": [ 46 | "Added\n-----", 47 | "- Wrote tests for Changelog.", 48 | ], 49 | "2022-02-25": [ 50 | "Added\n-----", 51 | "- Now you can send email with this tool.", 52 | "Fixed\n-----", 53 | "- Launching missiles no longer targets ourselves.", 54 | "- Typos corrected.", 55 | ], 56 | } 57 | 58 | 59 | @pytest.mark.parametrize( 60 | "text", 61 | [ 62 | BODY, 63 | ".. INSERT\n" + BODY, 64 | A + "(INSERT)\n" + BODY, 65 | A + "INSERT\n" + BODY + ".. END\n", 66 | A + ".. INSERT\n" + BODY + "(END)\n" + B, 67 | BODY + ".. END\n", 68 | BODY + ".. END\n" + B, 69 | ], 70 | ) 71 | def test_round_trip(text, temp_dir): 72 | path = temp_dir / "foo.rst" 73 | config = Config(start_marker="INSERT", end_marker="END") 74 | path.write_text(text) 75 | changelog = Changelog(path, config) 76 | changelog.read() 77 | assert changelog.entries() == BODY_SECTIONS 78 | changelog.write() 79 | assert path.read_text() == text 80 | -------------------------------------------------------------------------------- /src/scriv/shell.py: -------------------------------------------------------------------------------- 1 | """Helpers for using subprocesses.""" 2 | 3 | import logging 4 | import shlex 5 | import subprocess 6 | from typing import Union 7 | 8 | # The return value of run_command. 9 | CmdResult = tuple[bool, str] 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def run_command(cmd: Union[str, list[str]]) -> CmdResult: 15 | """ 16 | Run a command line (with no shell). 17 | 18 | Returns a tuple: 19 | bool: true if the command succeeded. 20 | str: the output of the command. 21 | 22 | """ 23 | logger.debug(f"Running command {cmd!r}") 24 | if isinstance(cmd, str): 25 | cmd = shlex.split(cmd, posix=False) 26 | proc = subprocess.run( 27 | cmd, 28 | shell=False, 29 | check=False, 30 | stdout=subprocess.PIPE, 31 | stderr=subprocess.STDOUT, 32 | ) 33 | output = proc.stdout.decode("utf-8") 34 | logger.debug( 35 | f"Command exited with {proc.returncode} status. Output: {output!r}" 36 | ) 37 | 38 | return proc.returncode == 0, output 39 | 40 | 41 | def run_simple_command(cmd: Union[str, list[str]]) -> str: 42 | """ 43 | Run a command and return its output, or "" if it fails. 44 | """ 45 | ok, out = run_command(cmd) 46 | if not ok: 47 | return "" 48 | return out.strip() 49 | 50 | 51 | def run_shell_command(cmd: str) -> CmdResult: 52 | """ 53 | Run a command line with a shell. 54 | """ 55 | logger.debug(f"Running shell command {cmd!r}") 56 | proc = subprocess.run( 57 | cmd, 58 | shell=True, 59 | check=False, 60 | stdout=subprocess.PIPE, 61 | stderr=subprocess.STDOUT, 62 | ) 63 | output = proc.stdout.decode("utf-8") 64 | logger.debug( 65 | f"Command exited with {proc.returncode} status. Output: {output!r}" 66 | ) 67 | return proc.returncode == 0, output 68 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help 18 | help: 19 | @echo "Please use \`make ' where is one of" 20 | @echo " html to make standalone HTML files" 21 | @echo " dirhtml to make HTML files named index.html in directories" 22 | @echo " singlehtml to make a single large HTML file" 23 | @echo " linkcheck to check all external links for integrity" 24 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 25 | @echo " coverage to run coverage check of the documentation (if enabled)" 26 | @echo " dummy to check syntax errors of document sources" 27 | 28 | .PHONY: clean 29 | clean: 30 | rm -rf $(BUILDDIR)/ 31 | 32 | .PHONY: html 33 | html: 34 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 35 | @echo 36 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 37 | 38 | .PHONY: dirhtml 39 | dirhtml: 40 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 43 | 44 | .PHONY: singlehtml 45 | singlehtml: 46 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 47 | @echo 48 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 49 | 50 | .PHONY: linkcheck 51 | linkcheck: 52 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 53 | @echo 54 | @echo "Link check complete; look for any errors in the above output " \ 55 | "or in $(BUILDDIR)/linkcheck/output.txt." 56 | 57 | .PHONY: doctest 58 | doctest: 59 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 60 | @echo "Testing of doctests in the sources finished, look at the " \ 61 | "results in $(BUILDDIR)/doctest/output.txt." 62 | -------------------------------------------------------------------------------- /tests/test_linkcheck.py: -------------------------------------------------------------------------------- 1 | """Tests of scriv/linkcheck.py""" 2 | 3 | import logging 4 | import textwrap 5 | 6 | import pytest 7 | 8 | from scriv.linkcheck import check_markdown_links, find_links 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "markdown_text, links", 13 | [ 14 | ("Hello", []), 15 | ( 16 | """\ 17 | [one](https://two.com/hello) and 18 | [two](https://one.com/xyzzy). 19 | """, 20 | ["https://one.com/xyzzy", "https://two.com/hello"], 21 | ), 22 | ( 23 | """\ 24 | This is [an example](http://example1.com/ "Title") inline link. 25 | This is [an example] [id] reference-style link. 26 | 27 | [id]: http://example2.com/ "Optional Title Here" 28 | """, 29 | ["http://example1.com/", "http://example2.com/"], 30 | ), 31 | ], 32 | ) 33 | def test_find_links(markdown_text, links): 34 | found_links = sorted(find_links(textwrap.dedent(markdown_text))) 35 | assert links == found_links 36 | 37 | 38 | def test_check_markdown_link(caplog, responses): 39 | caplog.set_level(logging.DEBUG, logger="scriv.linkcheck") 40 | responses.head("https://nedbat.com") 41 | check_markdown_links("""[hey](https://nedbat.com)!""") 42 | assert caplog.record_tuples == [ 43 | ( 44 | "scriv.linkcheck", 45 | logging.DEBUG, 46 | "OK link: 'https://nedbat.com'", 47 | ) 48 | ] 49 | 50 | 51 | def test_check_404_markdown_link(caplog, responses): 52 | responses.head("https://nedbat.com", status=404) 53 | check_markdown_links("""[hey](https://nedbat.com)!""") 54 | assert caplog.record_tuples == [ 55 | ( 56 | "scriv.linkcheck", 57 | logging.WARNING, 58 | "Failed check for 'https://nedbat.com': status code 404", 59 | ) 60 | ] 61 | 62 | 63 | def test_check_failing_markdown_link(caplog, responses): 64 | responses.head("https://nedbat.com", body=Exception("Buh?")) 65 | check_markdown_links("""[hey](https://nedbat.com)!""") 66 | assert caplog.record_tuples == [ 67 | ( 68 | "scriv.linkcheck", 69 | logging.WARNING, 70 | "Failed check for 'https://nedbat.com': Buh?", 71 | ) 72 | ] 73 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | """Tests of scriv/util.py""" 2 | 3 | import pytest 4 | 5 | from scriv.util import Version, partition_lines 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "text, ver", 10 | [ 11 | ("v1.2.3 -- 2022-04-06", "v1.2.3"), 12 | ("Oops, fixed on 6/16/2021.", None), 13 | ("2022-Apr-06: 12.3-alpha0 finally", "12.3-alpha0"), 14 | ("2.7.19beta1, 2022-04-08", "2.7.19beta1"), 15 | ], 16 | ) 17 | def test_version_from_text(text, ver): 18 | if ver is not None: 19 | ver = Version(ver) 20 | assert Version.from_text(text) == ver 21 | 22 | 23 | @pytest.mark.parametrize( 24 | "version", 25 | [ 26 | "v1.2.3", 27 | "17.4.1.3", 28 | ], 29 | ) 30 | def test_is_not_prerelease_version(version): 31 | assert not Version(version).is_prerelease() 32 | 33 | 34 | @pytest.mark.parametrize( 35 | "version", 36 | [ 37 | "v1.2.3a1", 38 | "17.4.1.3-beta.2", 39 | ], 40 | ) 41 | def test_is_prerelease_version(version): 42 | assert Version(version).is_prerelease() 43 | 44 | 45 | VERSION_EQUALITIES = [ 46 | ("v1.2.3a1", "v1.2.3a1", True), 47 | ("1.2.3a1", "v1.2.3a1", True), 48 | ("v1.2.3a1", "1.2.3a1", True), 49 | ("1.2.3a1", "1.2.3a1", True), 50 | ("1.2", "1.2.0", False), 51 | ("1.2.3", "1.2.3a1", False), 52 | ("1.2.3a1", "1.2.3b1", False), 53 | ("v1.2.3", "1.2.3a1", False), 54 | ] 55 | 56 | 57 | @pytest.mark.parametrize("ver1, ver2, equal", VERSION_EQUALITIES) 58 | def test_version_equality(ver1, ver2, equal): 59 | assert (Version(ver1) == Version(ver2)) is equal 60 | 61 | 62 | @pytest.mark.parametrize("ver1, ver2, equal", VERSION_EQUALITIES) 63 | def test_version_hashing(ver1, ver2, equal): 64 | assert len({Version(ver1), Version(ver2)}) == (1 if equal else 2) 65 | 66 | 67 | @pytest.mark.parametrize( 68 | "text, result", 69 | [ 70 | ("one\ntwo\nthree\n", ("one\ntwo\nthree\n", "", "")), 71 | ("oXe\ntwo\nthree\n", ("", "oXe\n", "two\nthree\n")), 72 | ("one\ntXo\nthree\n", ("one\n", "tXo\n", "three\n")), 73 | ("one\ntwo\ntXree\n", ("one\ntwo\n", "tXree\n", "")), 74 | ("one\ntXo\ntXree\n", ("one\n", "tXo\n", "tXree\n")), 75 | ], 76 | ) 77 | def test_partition_lines(text, result): 78 | assert partition_lines(text, "X") == result 79 | -------------------------------------------------------------------------------- /src/scriv/format.py: -------------------------------------------------------------------------------- 1 | """Dispatcher for format-based knowledge.""" 2 | 3 | import abc 4 | from typing import Optional 5 | 6 | from .config import Config 7 | 8 | # When collecting changelog fragments, we group them by their category into 9 | # Sections. A SectionDict maps category names to a list of the paragraphs in 10 | # that section. For projects not using categories, the key will be None. 11 | SectionDict = dict[Optional[str], list[str]] 12 | 13 | 14 | class FormatTools(abc.ABC): 15 | """Methods and data about specific formats.""" 16 | 17 | def __init__(self, config: Optional[Config] = None): 18 | """Create a FormatTools with the specified configuration.""" 19 | self.config = config or Config() 20 | 21 | @abc.abstractmethod 22 | def parse_text(self, text: str) -> SectionDict: 23 | """ 24 | Parse text to find sections. 25 | 26 | Args: 27 | text: the marked-up text. 28 | 29 | Returns: 30 | A dict mapping section headers to a list of the paragraphs in each 31 | section. 32 | """ 33 | 34 | @abc.abstractmethod 35 | def format_header(self, text: str, anchor: Optional[str] = None) -> str: 36 | """ 37 | Format the header for a new changelog entry. 38 | """ 39 | 40 | @abc.abstractmethod 41 | def format_sections(self, sections: SectionDict) -> str: 42 | """ 43 | Format a series of sections into marked-up text. 44 | """ 45 | 46 | @abc.abstractmethod 47 | def convert_to_markdown( 48 | self, text: str, name: str = "", fail_if_warn: bool = False 49 | ) -> str: 50 | """ 51 | Convert this format to Markdown. 52 | """ 53 | 54 | 55 | def get_format_tools(fmt: str, config: Config) -> FormatTools: 56 | """ 57 | Return the FormatTools to use. 58 | 59 | Args: 60 | fmt: One of the supported formats ("rst" or "md"). 61 | config: The configuration settings to use. 62 | 63 | """ 64 | if fmt == "rst": 65 | from . import ( # pylint: disable=cyclic-import,import-outside-toplevel 66 | format_rst, 67 | ) 68 | 69 | return format_rst.RstTools(config) 70 | else: 71 | assert fmt == "md" 72 | from . import ( # pylint: disable=cyclic-import,import-outside-toplevel 73 | format_md, 74 | ) 75 | 76 | return format_md.MdTools(config) 77 | -------------------------------------------------------------------------------- /src/scriv/print.py: -------------------------------------------------------------------------------- 1 | """Collecting fragments.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import os 7 | import pathlib 8 | import sys 9 | 10 | import click 11 | 12 | from .scriv import Scriv 13 | from .util import Version, scriv_command 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | @click.command(name="print") 19 | @click.option( 20 | "--version", 21 | default=None, 22 | help="The version of the changelog entry to extract.", 23 | ) 24 | @click.option( 25 | "--output", 26 | type=click.Path(), 27 | default=None, 28 | help="The path to a file to write the output to.", 29 | ) 30 | @scriv_command 31 | def print_( 32 | version: str | None, 33 | output: pathlib.Path | None, 34 | ) -> None: 35 | """ 36 | Print collected fragments, or print an entry from the changelog. 37 | """ 38 | scriv = Scriv() 39 | changelog = scriv.changelog() 40 | newline: str = os.linesep 41 | 42 | if version is None: 43 | logger.info(f"Generating entry from {scriv.config.fragment_directory}") 44 | frags = scriv.fragments_to_combine() 45 | if not frags: 46 | logger.info("No changelog fragments to collect") 47 | sys.exit(2) 48 | contents = changelog.entry_text(scriv.combine_fragments(frags)).strip() 49 | else: 50 | logger.info(f"Extracting entry for {version} from {changelog.path}") 51 | changelog.read() 52 | newline = changelog.newline 53 | target_version = Version(version) 54 | for etitle, sections in changelog.entries().items(): 55 | eversion = Version.from_text(str(etitle)) 56 | if eversion is None: 57 | continue 58 | if eversion == target_version: 59 | contents = f"{changelog.newline * 2}".join(sections).strip() 60 | break 61 | else: 62 | logger.info(f"Unable to find version {version} in the changelog") 63 | sys.exit(2) 64 | 65 | if output: 66 | # Standardize newlines to match either the platform default 67 | # or to match the existing newlines found in the CHANGELOG. 68 | contents_raw = newline.join(contents.splitlines()).encode("utf-8") 69 | with open(output, "wb") as file: 70 | file.write(contents_raw) 71 | else: 72 | # Standardize newlines to just '\n' when writing to STDOUT. 73 | contents = "\n".join(contents.splitlines()) 74 | print(contents) 75 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # scriv's pyproject.toml 2 | 3 | [project] 4 | name = "scriv" 5 | description = "Scriv changelog management tool" 6 | authors = [ 7 | {name = "Ned Batchelder", email = "ned@nedbatchelder.com"}, 8 | ] 9 | license = "Apache-2.0" 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Intended Audience :: Developers", 13 | "Programming Language :: Python", 14 | "Programming Language :: Python :: 3", 15 | "Programming Language :: Python :: 3.10", 16 | "Programming Language :: Python :: 3.11", 17 | "Programming Language :: Python :: 3.12", 18 | "Programming Language :: Python :: 3.13", 19 | "Programming Language :: Python :: 3.14", 20 | ] 21 | 22 | requires-python = ">= 3.10" 23 | 24 | dynamic = ["readme", "version", "dependencies"] 25 | 26 | [project.urls] 27 | "Mastodon" = "https://hachyderm.io/@nedbat" 28 | "Funding" = "https://github.com/sponsors/nedbat" 29 | "Issues" = "https://github.com/nedbat/scriv/issues" 30 | "Source" = "https://github.com/nedbat/scriv" 31 | "Home" = "https://github.com/nedbat/scriv" 32 | "Documentation" = "https://scriv.readthedocs.io" 33 | 34 | [project.scripts] 35 | scriv = "scriv.cli:cli" 36 | 37 | [project.optional-dependencies] 38 | toml = [ 39 | 'tomli; python_version < "3.11"' 40 | ] 41 | yaml = [ 42 | "pyyaml" 43 | ] 44 | 45 | [build-system] 46 | requires = ["setuptools"] 47 | build-backend = "setuptools.build_meta" 48 | 49 | [tool.setuptools.packages.find] 50 | where = ["src"] 51 | 52 | [tool.setuptools.package-data] 53 | scriv = [ 54 | "templates/*.*", 55 | ] 56 | 57 | [tool.setuptools.dynamic] 58 | version.attr = "scriv.__version__" 59 | readme.file = ["README.rst", "CHANGELOG.rst"] 60 | dependencies.file = ["requirements/core.txt"] 61 | 62 | [tool.scriv] 63 | ghrel_template = "file: ghrel_template.md.j2" 64 | rst_header_chars = "-." 65 | version = "literal: src/scriv/__init__.py: __version__" 66 | 67 | [tool.isort] 68 | indent = " " 69 | line_length = 80 70 | multi_line_output = 3 71 | include_trailing_comma = true 72 | 73 | [tool.mypy] 74 | python_version = "3.10" 75 | show_column_numbers = true 76 | show_error_codes = true 77 | ignore_missing_imports = true 78 | check_untyped_defs = true 79 | warn_return_any = true 80 | 81 | [tool.doc8] 82 | max-line-length = 80 83 | 84 | [tool.pydocstyle] 85 | # D105 = Missing docstring in magic method 86 | # D200 = One-line docstring should fit on one line with quotes 87 | # D203 = 1 blank line required before class docstring 88 | # D212 = Multi-line docstring summary should start at the first line 89 | # D406 = Section name should end with a newline (numpy style) 90 | # D407 = Missing dashed underline after section (numpy style) 91 | # D413 = Missing blank line after last section (numpy style) 92 | ignore = ["D105", "D200", "D203", "D212", "D406", "D407", "D413"] 93 | -------------------------------------------------------------------------------- /src/scriv/gitinfo.py: -------------------------------------------------------------------------------- 1 | """Get information from git.""" 2 | 3 | import logging 4 | import os 5 | import re 6 | import subprocess 7 | import sys 8 | from pathlib import Path 9 | 10 | import click 11 | 12 | from .shell import run_simple_command 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def user_nick() -> str: 18 | """ 19 | Get a short name for the current user. 20 | """ 21 | nick = git_config("scriv.user-nick") 22 | if nick: 23 | return nick 24 | 25 | nick = git_config("github.user") 26 | if nick: 27 | return nick 28 | 29 | email = git_config("user.email") 30 | if email: 31 | nick = email.partition("@")[0] 32 | return nick 33 | 34 | return os.getenv("USER", "somebody") 35 | 36 | 37 | def current_branch_name() -> str: 38 | """ 39 | Get the current branch name. 40 | """ 41 | return run_simple_command("git rev-parse --abbrev-ref HEAD") 42 | 43 | 44 | def git_config(option: str) -> str: 45 | """ 46 | Return a git config value. 47 | """ 48 | return run_simple_command(f"git config --get {option}") 49 | 50 | 51 | def git_config_bool(option: str) -> bool: 52 | """ 53 | Return a boolean git config value, defaulting to False. 54 | """ 55 | return git_config(option) == "true" 56 | 57 | 58 | def git_edit(filename: Path) -> None: 59 | """Edit a file using the same editor Git chooses.""" 60 | git_editor = run_simple_command("git var GIT_EDITOR") 61 | click.edit(filename=str(filename), editor=git_editor) 62 | 63 | 64 | def git_add(filename: Path) -> None: 65 | """Git add a file. If it fails, sys.exit.""" 66 | ret = subprocess.call(["git", "add", str(filename)]) 67 | if ret == 0: 68 | logger.info(f"Added {filename}") 69 | else: 70 | logger.error(f"Couldn't add {filename}") 71 | sys.exit(ret) 72 | 73 | 74 | def git_rm(filename: Path) -> None: 75 | """Git rm a file. If it fails, sys.exit.""" 76 | ret = subprocess.call(["git", "rm", str(filename)]) 77 | if ret == 0: 78 | logger.info(f"Removed {filename}") 79 | else: 80 | logger.error(f"Couldn't remove {filename}") 81 | sys.exit(ret) 82 | 83 | 84 | def get_github_repos() -> set[str]: 85 | """ 86 | Find the GitHub name/repos for this project. 87 | 88 | Returns a set of "name/repo" addresses for GitHub repos. 89 | """ 90 | urls = run_simple_command("git remote -v").splitlines() 91 | github_repos = set() 92 | for url in urls: 93 | m = re.search(r"github.com[:/]([^/]+/\S+)", url) 94 | if m: 95 | repo = m[1] 96 | # It might or might not have .git appended. 97 | if repo.endswith(".git"): 98 | repo = repo[:-4] 99 | github_repos.add(repo) 100 | return github_repos 101 | -------------------------------------------------------------------------------- /tests/test_gitinfo.py: -------------------------------------------------------------------------------- 1 | """Tests of gitinfo.py""" 2 | 3 | import re 4 | 5 | from scriv.gitinfo import current_branch_name, get_github_repos, user_nick 6 | 7 | 8 | def test_user_nick_from_scriv_user_nick(fake_git): 9 | fake_git.set_config("scriv.user-nick", "joedev") 10 | assert user_nick() == "joedev" 11 | 12 | 13 | def test_user_nick_from_github(fake_git): 14 | fake_git.set_config("github.user", "joedev") 15 | assert user_nick() == "joedev" 16 | 17 | 18 | def test_user_nick_from_git(fake_git): 19 | fake_git.set_config("user.email", "joesomeone@somewhere.org") 20 | assert user_nick() == "joesomeone" 21 | 22 | 23 | def test_user_nick_from_env(fake_git, monkeypatch): 24 | monkeypatch.setenv("USER", "joseph") 25 | assert user_nick() == "joseph" 26 | 27 | 28 | def test_user_nick_from_nowhere(fake_git, monkeypatch): 29 | # With no git information, and no USER env var, 30 | # we just call the user "somebody" 31 | monkeypatch.delenv("USER", raising=False) 32 | assert user_nick() == "somebody" 33 | 34 | 35 | def test_current_branch_name(fake_git): 36 | fake_git.set_branch("joedev/feature-123") 37 | assert current_branch_name() == "joedev/feature-123" 38 | 39 | 40 | def test_get_github_repos_no_remotes(fake_git): 41 | assert get_github_repos() == set() 42 | 43 | 44 | def test_get_github_repos_one_github_remote(fake_git): 45 | fake_git.add_remote("mygithub", "git@github.com:joe/myproject.git") 46 | assert get_github_repos() == {"joe/myproject"} 47 | 48 | 49 | def test_get_github_repos_one_github_remote_no_extension(fake_git): 50 | fake_git.add_remote("mygithub", "git@github.com:joe/myproject") 51 | assert get_github_repos() == {"joe/myproject"} 52 | 53 | 54 | def test_get_github_repos_two_github_remotes(fake_git): 55 | fake_git.add_remote("mygithub", "git@github.com:joe/myproject.git") 56 | fake_git.add_remote("upstream", "git@github.com:psf/myproject.git") 57 | assert get_github_repos() == {"joe/myproject", "psf/myproject"} 58 | 59 | 60 | def test_get_github_repos_one_github_plus_others(fake_git): 61 | fake_git.add_remote("mygithub", "git@github.com:joe/myproject.git") 62 | fake_git.add_remote("upstream", "git@gitlab.com:psf/myproject.git") 63 | assert get_github_repos() == {"joe/myproject"} 64 | 65 | 66 | def test_get_github_repos_no_github_remotes(fake_git): 67 | fake_git.add_remote("mygitlab", "git@gitlab.com:joe/myproject.git") 68 | fake_git.add_remote("upstream", "git@gitlab.com:psf/myproject.git") 69 | assert get_github_repos() == set() 70 | 71 | 72 | def test_real_get_github_repos(): 73 | # Since we don't know the name of this repo (forks could be anything), 74 | # we can't be sure what we get, except it should be word/word, and not end 75 | # with .git 76 | repos = get_github_repos() 77 | assert len(repos) >= 1 78 | repo = repos.pop() 79 | assert re.fullmatch(r"[\w-]+/[\w-]+", repo) 80 | assert not repo.endswith(".git") 81 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Fixture definitions.""" 2 | 3 | import os 4 | import sys 5 | import traceback 6 | from collections.abc import Iterable 7 | from pathlib import Path 8 | 9 | import pytest 10 | import responses 11 | from click.testing import CliRunner 12 | 13 | # We want to be able to test scriv without any extras installed. But responses 14 | # installs PyYaml. If we are testing the no-extras scenario, then: after we've 15 | # imported responses above, and before we import any scriv modules below, 16 | # clobber the yaml module so that scriv's import will fail, simulating PyYaml 17 | # not being available. 18 | if os.getenv("SCRIV_TEST_NO_EXTRAS", ""): 19 | sys.modules["yaml"] = None # type: ignore[assignment] 20 | 21 | # pylint: disable=wrong-import-position 22 | 23 | from scriv.cli import cli as scriv_cli 24 | 25 | from .faker import FakeGit, FakeRunCommand 26 | 27 | # Pytest will rewrite assertions in test modules, but not elsewhere. 28 | # This tells pytest to also rewrite assertions in these files: 29 | pytest.register_assert_rewrite("tests.helpers") 30 | 31 | 32 | @pytest.fixture() 33 | def fake_run_command(mocker): 34 | """Replace gitinfo.run_command with a fake.""" 35 | return FakeRunCommand(mocker) 36 | 37 | 38 | @pytest.fixture() 39 | def fake_git(fake_run_command) -> FakeGit: 40 | """Get a FakeGit to use in tests.""" 41 | return FakeGit(fake_run_command) 42 | 43 | 44 | @pytest.fixture() 45 | def temp_dir(tmpdir) -> Iterable[Path]: 46 | """Make and change into the tmpdir directory, as a Path.""" 47 | old_dir = os.getcwd() 48 | tmpdir.chdir() 49 | try: 50 | yield Path(str(tmpdir)) 51 | finally: 52 | os.chdir(old_dir) 53 | 54 | 55 | @pytest.fixture() 56 | def cli_invoke(temp_dir: Path): 57 | """ 58 | Produce a function to invoke the Scriv cli with click.CliRunner. 59 | 60 | The test will run in a temp directory. 61 | """ 62 | 63 | def invoke(command, expect_ok=True): 64 | runner = CliRunner() 65 | result = runner.invoke(scriv_cli, command) 66 | print(result.stdout, end="") 67 | print(result.stderr, end="", file=sys.stderr) 68 | if result.exception: 69 | traceback.print_exception( 70 | None, result.exception, result.exception.__traceback__ 71 | ) 72 | if expect_ok: 73 | assert result.exception is None 74 | assert result.exit_code == 0 75 | return result 76 | 77 | return invoke 78 | 79 | 80 | @pytest.fixture() 81 | def changelog_d(temp_dir: Path) -> Path: 82 | """Make a changelog.d directory, and return a Path() to it.""" 83 | the_changelog_d = temp_dir / "changelog.d" 84 | the_changelog_d.mkdir() 85 | return the_changelog_d 86 | 87 | 88 | @pytest.fixture(autouse=True, name="responses") 89 | def no_http_requests(): 90 | """Activate `responses` for all tests, so no real HTTP happens.""" 91 | with responses.RequestsMock() as rsps: 92 | yield rsps 93 | -------------------------------------------------------------------------------- /docs/philosophy.rst: -------------------------------------------------------------------------------- 1 | ########## 2 | Philosophy 3 | ########## 4 | 5 | .. _philosophy: 6 | 7 | Scriv's design is guided by a few principles: 8 | 9 | - Changelogs should be captured in a file in the repository. Scriv writes a 10 | CHANGELOG file. 11 | 12 | - Writing about changes to code should happen close in time to the changes 13 | themselves. Scriv encourages writing fragment files to be committed when you 14 | commit your code changes. 15 | 16 | - How you describe a change depends on who you are describing it for. You may 17 | need multiple descriptions of the same change. Scriv encourages writing 18 | changelog entries directly, rather than copying text from commit messages or 19 | pull requests. 20 | 21 | - The changelog file in the repo should be the source of truth. The 22 | information can also be published elsewhere, like GitHub releases. 23 | 24 | - Different projects have different needs; flexibility is a plus. Scriv doesn't 25 | assume any particular issue tracker or packaging system, and allows either 26 | .rst or .md files. 27 | 28 | 29 | .. _other_tools: 30 | 31 | Other Tools 32 | =========== 33 | 34 | Scriv is not the first tool to help manage changelogs, there have been many. 35 | None fully embodied scriv's philopsophy. 36 | 37 | Tools most similar to scriv: 38 | 39 | - `towncrier`_: built for Twisted, with some unusual specifics: fragment type 40 | is the file extension, issue numbers in the file name. Defaults to using 41 | ``.rst`` files, but can be configured to produce Markdown or any other 42 | output format, provided enough configuration. 43 | 44 | - `blurb`_: built for CPython development, specific to their workflow: issue 45 | numbers from bugs.python.org, only .rst files. 46 | 47 | - `setuptools-changelog`_: particular to Python projects (uses a setup.py 48 | command), and only supports .rst files. 49 | 50 | - `gitchangelog`_: collects git commit messages into a changelog file. 51 | 52 | Tools that only read GitHub issues, or only write GitHub releases: 53 | 54 | - `Chronicler`_: a web hook that watched for merged pull requests, then appends 55 | the pull request message to the most recent draft GitHub release. 56 | 57 | - `fastrelease`_: reads information from GitHub issues, and writes GitHub 58 | releases. 59 | 60 | - `Release Drafter`_: adds text from merged pull requests to the latest draft 61 | GitHub release. 62 | 63 | Other release note tools: 64 | 65 | - `reno`_: built for Open Stack. It stores changelogs forever as fragment 66 | files, only combining for publication. 67 | 68 | .. _towncrier: https://github.com/hawkowl/towncrier 69 | .. _blurb: https://github.com/python/core-workflow/tree/master/blurb 70 | .. _setuptools-changelog: https://pypi.org/project/setuptools-changelog/ 71 | .. _gitchangelog: https://pypi.org/project/gitchangelog/ 72 | .. _fastrelease: https://fastrelease.fast.ai/ 73 | .. _Chronicler: https://github.com/NYTimes/Chronicler 74 | .. _Release Drafter: https://probot.github.io/apps/release-drafter/ 75 | .. _reno: https://docs.openstack.org/reno/latest/user/usage.html 76 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # make upgrade 3 | astroid==4.0.2 4 | # via pylint 5 | attrs==25.4.0 6 | # via -r requirements/base.txt 7 | certifi==2025.11.12 8 | # via 9 | # -r requirements/base.txt 10 | # requests 11 | charset-normalizer==3.4.4 12 | # via 13 | # -r requirements/base.txt 14 | # requests 15 | click==8.3.1 16 | # via 17 | # -r requirements/base.txt 18 | # click-log 19 | click-log==0.4.0 20 | # via -r requirements/base.txt 21 | colorama==0.4.6 ; sys_platform == 'win32' 22 | # via 23 | # -r requirements/base.txt 24 | # click 25 | # pylint 26 | # pytest 27 | coverage==7.13.0 28 | # via -r requirements/test.in 29 | dill==0.4.0 30 | # via pylint 31 | exceptiongroup==1.3.1 ; python_full_version < '3.11' 32 | # via pytest 33 | freezegun==1.5.5 34 | # via -r requirements/test.in 35 | idna==3.11 36 | # via 37 | # -r requirements/base.txt 38 | # requests 39 | iniconfig==2.3.0 40 | # via pytest 41 | isort==7.0.0 42 | # via pylint 43 | jedi==0.19.2 44 | # via pudb 45 | jinja2==3.1.6 46 | # via -r requirements/base.txt 47 | markdown-it-py==4.0.0 48 | # via -r requirements/base.txt 49 | markupsafe==3.0.3 50 | # via 51 | # -r requirements/base.txt 52 | # jinja2 53 | mccabe==0.7.0 54 | # via pylint 55 | mdurl==0.1.2 56 | # via 57 | # -r requirements/base.txt 58 | # markdown-it-py 59 | packaging==25.0 60 | # via 61 | # pudb 62 | # pytest 63 | parso==0.8.5 64 | # via jedi 65 | platformdirs==4.5.1 66 | # via pylint 67 | pluggy==1.6.0 68 | # via pytest 69 | pudb==2025.1.5 70 | # via -r requirements/test.in 71 | pygments==2.19.2 72 | # via pudb 73 | pylint==4.0.4 74 | # via pylint-pytest 75 | pylint-pytest==1.1.8 76 | # via -r requirements/test.in 77 | pytest==8.2.0 78 | # via 79 | # -r requirements/test.in 80 | # pylint-pytest 81 | # pytest-mock 82 | pytest-mock==3.15.1 83 | # via -r requirements/test.in 84 | python-dateutil==2.9.0.post0 85 | # via freezegun 86 | pyyaml==6.0.3 87 | # via 88 | # -r requirements/test.in 89 | # responses 90 | requests==2.32.5 91 | # via 92 | # -r requirements/base.txt 93 | # responses 94 | responses==0.25.8 95 | # via -r requirements/test.in 96 | six==1.17.0 97 | # via python-dateutil 98 | tomli==2.3.0 ; python_full_version < '3.11' 99 | # via 100 | # pylint 101 | # pytest 102 | tomlkit==0.13.3 103 | # via pylint 104 | typing-extensions==4.15.0 105 | # via 106 | # astroid 107 | # exceptiongroup 108 | # pudb 109 | urllib3==2.6.2 110 | # via 111 | # -r requirements/base.txt 112 | # requests 113 | # responses 114 | urwid==3.0.4 115 | # via 116 | # pudb 117 | # urwid-readline 118 | urwid-readline==0.15.1 119 | # via pudb 120 | wcwidth==0.2.14 121 | # via urwid 122 | -------------------------------------------------------------------------------- /tests/test_print.py: -------------------------------------------------------------------------------- 1 | """Test print logic.""" 2 | 3 | import freezegun 4 | import pytest 5 | 6 | CHANGELOG_HEADER = """\ 7 | 8 | 1.2 - 2020-02-25 9 | ================ 10 | """ 11 | 12 | 13 | FRAG = """\ 14 | Fixed 15 | ----- 16 | 17 | - Launching missiles no longer targets ourselves. 18 | """ 19 | 20 | 21 | @pytest.mark.parametrize("newline", ("\r\n", "\n")) 22 | def test_print_fragment(newline, cli_invoke, changelog_d, temp_dir, capsys): 23 | fragment = FRAG.replace("\n", newline).encode("utf-8") 24 | (changelog_d / "20170616_nedbat.rst").write_bytes(fragment) 25 | 26 | with freezegun.freeze_time("2020-02-25T15:18:19"): 27 | cli_invoke(["print"]) 28 | 29 | std = capsys.readouterr() 30 | assert std.out == FRAG 31 | 32 | 33 | @pytest.mark.parametrize("newline", ("\r\n", "\n")) 34 | def test_print_fragment_output( 35 | newline, cli_invoke, changelog_d, temp_dir, capsys 36 | ): 37 | fragment = FRAG.replace("\n", newline).encode("utf-8") 38 | (changelog_d / "20170616_nedbat.rst").write_bytes(fragment) 39 | output_file = temp_dir / "output.txt" 40 | 41 | with freezegun.freeze_time("2020-02-25T15:18:19"): 42 | cli_invoke(["print", "--output", output_file]) 43 | 44 | std = capsys.readouterr() 45 | assert std.out == "" 46 | assert output_file.read_text().strip() == FRAG.strip() 47 | 48 | 49 | @pytest.mark.parametrize("newline", ("\r\n", "\n")) 50 | def test_print_changelog(newline, cli_invoke, changelog_d, temp_dir, capsys): 51 | changelog = (CHANGELOG_HEADER + FRAG).replace("\n", newline).encode("utf-8") 52 | (temp_dir / "CHANGELOG.rst").write_bytes(changelog) 53 | 54 | with freezegun.freeze_time("2020-02-25T15:18:19"): 55 | cli_invoke(["print", "--version", "1.2"]) 56 | 57 | std = capsys.readouterr() 58 | assert std.out == FRAG 59 | 60 | 61 | @pytest.mark.parametrize("newline", ("\r\n", "\n")) 62 | def test_print_changelog_output( 63 | newline, cli_invoke, changelog_d, temp_dir, capsys 64 | ): 65 | changelog = (CHANGELOG_HEADER + FRAG).replace("\n", newline).encode("utf-8") 66 | (temp_dir / "CHANGELOG.rst").write_bytes(changelog) 67 | output_file = temp_dir / "output.txt" 68 | 69 | with freezegun.freeze_time("2020-02-25T15:18:19"): 70 | cli_invoke(["print", "--version", "1.2", "--output", output_file]) 71 | 72 | std = capsys.readouterr() 73 | assert std.out == "" 74 | assert output_file.read_bytes().decode() == FRAG.strip().replace( 75 | "\n", newline 76 | ) 77 | 78 | 79 | def test_print_no_fragments(cli_invoke): 80 | result = cli_invoke(["print"], expect_ok=False) 81 | 82 | assert result.exit_code == 2 83 | assert "No changelog fragments to collect" in result.stderr 84 | 85 | 86 | def test_print_version_not_in_changelog(cli_invoke, changelog_d, temp_dir): 87 | (temp_dir / "CHANGELOG.rst").write_bytes(b"BOGUS\n=====\n\n1.0\n===") 88 | 89 | result = cli_invoke(["print", "--version", "123.456"], expect_ok=False) 90 | 91 | assert result.exit_code == 2 92 | assert "Unable to find version 123.456 in the changelog" in result.stderr 93 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | # Show OS dependencies 4 | deps, 5 | # Run on all our Pythons: 6 | py3{10,11,12,13,14}, 7 | # Run with no extras on lowest and highest version: 8 | py3{10,14}-no_extras, 9 | # And the rest: 10 | pypy3, coverage, docs, quality 11 | labels = 12 | ci-tests = deps,py3{10,11,12,13,14},py3{10,14}-no_extras,pypy3 13 | 14 | [testenv] 15 | package = wheel 16 | wheel_build_env = .pkg 17 | deps = 18 | -r{toxinidir}/requirements/test.txt 19 | no_extras: pip 20 | extras = 21 | !no_extras: toml,yaml 22 | allowlist_externals = 23 | make 24 | rm 25 | passenv = 26 | COVERAGE_* 27 | setenv = 28 | no_extras: SCRIV_TEST_NO_EXTRAS=1 29 | commands = 30 | no_extras: python -m pip uninstall -q -y tomli 31 | coverage run -p -m pytest -Wd -c tox.ini {posargs} 32 | 33 | [testenv:.pkg] 34 | # Force wheels to be built with the latest pip, wheel, and setuptools. 35 | set_env = 36 | VIRTUALENV_DOWNLOAD=1 37 | 38 | [testenv:deps] 39 | allowlist_externals = 40 | pandoc 41 | commands = 42 | pandoc --version 43 | 44 | [testenv:coverage] 45 | depends = py310,py311,py312,py313,py314,pypy3 46 | basepython = python3.12 47 | commands = 48 | coverage combine -q 49 | coverage report -m --skip-covered 50 | coverage html 51 | coverage json 52 | parallel_show_output = true 53 | 54 | [testenv:docs] 55 | setenv = 56 | PYTHONPATH = {toxinidir} 57 | deps = 58 | -r{toxinidir}/requirements/doc.txt 59 | commands = 60 | make -C docs clean html 61 | doc8 -q --ignore-path docs/include README.rst docs 62 | 63 | [testenv:quality] 64 | deps = 65 | -r{toxinidir}/requirements/quality.txt 66 | commands = 67 | black --check --diff --line-length=80 src/scriv tests docs 68 | python -m cogapp -cP --check --verbosity=1 docs/*.rst 69 | mypy src/scriv tests 70 | pylint src/scriv tests docs 71 | pycodestyle src/scriv tests docs 72 | pydocstyle src/scriv tests docs 73 | isort --check-only --diff -p scriv tests src/scriv 74 | python -Im build 75 | twine check --strict dist/* 76 | 77 | [testenv:upgrade] 78 | commands = 79 | python -m pip install -U pip 80 | make upgrade 81 | 82 | # Tools needed for running tests need to be configured in tox.ini instead of 83 | # pyproject.toml so that we can run tests without tomli installed on Pythons 84 | # before 3.11. 85 | # 86 | # The py*-no_extras tox environment uninstalls tomli so that we can ensure 87 | # scriv works properly in that world without tomli. Tools like pytest and 88 | # coverage will find settings in pyproject.toml, but then fail because 89 | # they can't import tomli to read the settings. 90 | 91 | [pytest] 92 | addopts = -rfe 93 | norecursedirs = .* docs requirements 94 | 95 | [coverage:run] 96 | branch = True 97 | source = 98 | scriv 99 | tests 100 | omit = 101 | */__main__.py 102 | 103 | [coverage:report] 104 | precision = 2 105 | exclude_also = 106 | def __repr__ 107 | 108 | [coverage:paths] 109 | source = 110 | src 111 | */site-packages 112 | 113 | others = 114 | . 115 | */scriv 116 | 117 | # Pycodestyle doesn't read from pyproject.toml, so configure it here. 118 | 119 | [pycodestyle] 120 | exclude = .git,.tox 121 | ; E203 = whitespace before ':' 122 | ; E501 line too long 123 | ; W503 line break before binary operator 124 | ignore = E203,E501,W503 125 | -------------------------------------------------------------------------------- /src/scriv/github.py: -------------------------------------------------------------------------------- 1 | """Helpers for the GitHub REST API.""" 2 | 3 | import logging 4 | import os 5 | from collections.abc import Iterable 6 | from typing import Any 7 | 8 | import requests 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | # Only wait up to a minute for GitHub to respond. 14 | TIMEOUT = 60 15 | 16 | 17 | def check_ok(resp): 18 | """ 19 | Check that the Requests response object was successful. 20 | 21 | Raise an exception if not. 22 | """ 23 | if not resp: 24 | logger.error(f"text: {resp.text!r}") 25 | resp.raise_for_status() 26 | 27 | 28 | def auth_headers() -> dict[str, str]: 29 | """ 30 | Get the authorization headers needed for GitHub. 31 | 32 | Will read the GITHUB_TOKEN environment variable. 33 | """ 34 | headers = {} 35 | token = os.environ.get("GITHUB_TOKEN", "") 36 | if token: 37 | headers["Authorization"] = f"Bearer {token}" 38 | return headers 39 | 40 | 41 | def github_paginated(url: str) -> Iterable[dict[str, Any]]: 42 | """ 43 | Get all the results from a paginated GitHub url. 44 | """ 45 | while True: 46 | resp = requests.get(url, headers=auth_headers(), timeout=TIMEOUT) 47 | check_ok(resp) 48 | yield from resp.json() 49 | next_link = resp.links.get("next", None) 50 | if not next_link: 51 | break 52 | url = next_link["url"] 53 | 54 | 55 | RELEASES_URL = "https://api.github.com/repos/{repo}/releases" 56 | 57 | 58 | def get_releases(repo: str) -> dict[str, dict[str, Any]]: 59 | """ 60 | Get all the releases from a name/project repo. 61 | 62 | Returns: 63 | A dict mapping tag names to release dictionaries. 64 | """ 65 | url = RELEASES_URL.format(repo=repo) 66 | releases = {r["tag_name"]: r for r in github_paginated(url)} 67 | return releases 68 | 69 | 70 | def create_release(repo: str, release_data: dict[str, Any]) -> None: 71 | """ 72 | Create a GitHub release. 73 | 74 | Arguments: 75 | repo: A user/repo string, like "nedbat/scriv". 76 | release_data: A dict with the data needed to create the release. 77 | It should have these keys: 78 | body: the markdown description of the release. 79 | name: the name of the release 80 | tag_name: the Git tag for the release 81 | draft: a boolean 82 | prerelease: a boolean 83 | """ 84 | logger.info(f"Creating release {release_data['name']}") 85 | url = RELEASES_URL.format(repo=repo) 86 | resp = requests.post( 87 | url, json=release_data, headers=auth_headers(), timeout=TIMEOUT 88 | ) 89 | check_ok(resp) 90 | 91 | 92 | def update_release( 93 | release: dict[str, Any], release_data: dict[str, Any] 94 | ) -> None: 95 | """ 96 | Update a GitHub release. 97 | 98 | Arguments: 99 | release: the full data from GitHub for the release to update. 100 | release_data: a dict with the data we want to update. 101 | See create_release for the accepted keys. 102 | """ 103 | logger.info(f"Updating release {release_data['name']}") 104 | resp = requests.patch( 105 | release["url"], 106 | json=release_data, 107 | headers=auth_headers(), 108 | timeout=TIMEOUT, 109 | ) 110 | check_ok(resp) 111 | -------------------------------------------------------------------------------- /src/scriv/collect.py: -------------------------------------------------------------------------------- 1 | """Collecting fragments.""" 2 | 3 | import logging 4 | import sys 5 | from typing import Optional 6 | 7 | import click 8 | 9 | from .gitinfo import git_add, git_config_bool, git_edit, git_rm 10 | from .scriv import Scriv 11 | from .util import Version, scriv_command 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | @click.command() 17 | @click.option( 18 | "--add/--no-add", 19 | default=None, 20 | help="'git add' the updated changelog file and removed fragments.", 21 | ) 22 | @click.option( 23 | "--edit/--no-edit", 24 | default=None, 25 | help="Open the changelog file in your text editor.", 26 | ) 27 | @click.option( 28 | "--title", default=None, help="The title text to use for this entry." 29 | ) 30 | @click.option( 31 | "--keep", is_flag=True, help="Keep the fragment files that are collected." 32 | ) 33 | @click.option( 34 | "--version", default=None, help="The version name to use for this entry." 35 | ) 36 | @scriv_command 37 | def collect( 38 | add: Optional[bool], 39 | edit: Optional[bool], 40 | title: str, 41 | keep: bool, 42 | version: str, 43 | ) -> None: 44 | """ 45 | Collect and combine fragments into the changelog. 46 | """ 47 | if title is not None and version is not None: 48 | sys.exit("Can't provide both --title and --version.") 49 | 50 | if add is None: 51 | add = git_config_bool("scriv.collect.add") 52 | if edit is None: 53 | edit = git_config_bool("scriv.collect.edit") 54 | 55 | scriv = Scriv() 56 | logger.info(f"Collecting from {scriv.config.fragment_directory}") 57 | frags = scriv.fragments_to_combine() 58 | if not frags: 59 | logger.info("No changelog fragments to collect") 60 | sys.exit(2) 61 | 62 | changelog = scriv.changelog() 63 | changelog.read() 64 | 65 | if title is None: 66 | version = Version(version or scriv.config.version) 67 | if version: 68 | # Check that we haven't used this version before. 69 | for etitle in changelog.entries().keys(): 70 | if etitle is None: 71 | continue 72 | eversion = Version.from_text(etitle) 73 | if eversion is None: 74 | sys.exit( 75 | f"Entry {etitle!r} is not a valid version! " 76 | + "If scriv should ignore this heading, add " 77 | + "'scriv-end-here' somewhere before it." 78 | ) 79 | if eversion == version: 80 | sys.exit( 81 | f"Entry {etitle!r} already uses " 82 | + f"version {str(version)!r}." 83 | ) 84 | new_header = changelog.entry_header(version=version) 85 | else: 86 | new_header = changelog.format_tools().format_header(title) 87 | 88 | new_text = changelog.entry_text(scriv.combine_fragments(frags)) 89 | changelog.add_entry(new_header, new_text) 90 | changelog.write() 91 | 92 | if edit: 93 | git_edit(changelog.path) 94 | 95 | if add: 96 | git_add(changelog.path) 97 | 98 | if not keep: 99 | for frag in frags: 100 | logger.info(f"Deleting fragment file {str(frag.path)!r}") 101 | if add: 102 | git_rm(frag.path) 103 | else: 104 | frag.path.unlink() 105 | -------------------------------------------------------------------------------- /src/scriv/format_md.py: -------------------------------------------------------------------------------- 1 | """Markdown text knowledge for scriv.""" 2 | 3 | import re 4 | from typing import Optional 5 | 6 | from .format import FormatTools, SectionDict 7 | 8 | 9 | class MdTools(FormatTools): 10 | """Specifics about how to work with Markdown.""" 11 | 12 | def parse_text( 13 | self, text 14 | ) -> SectionDict: # noqa: D102 (inherited docstring) 15 | lines = text.splitlines() 16 | 17 | # If there's an insert marker, start there. 18 | for lineno, line in enumerate(lines): 19 | if self.config.start_marker in line: 20 | lines = lines[lineno + 1 :] 21 | break 22 | 23 | sections: SectionDict = {} 24 | in_comment = False 25 | paragraphs = None 26 | section_mark = None 27 | 28 | for line in lines: 29 | line = line.rstrip() 30 | if in_comment: 31 | if re.search(r"-->$", line): 32 | in_comment = False 33 | else: 34 | if re.search(r"^\s*$", line): 35 | # A one-line comment, skip it. 36 | continue 37 | if re.search(r"""^$""", line): 38 | # An anchor, we don't need those. 39 | continue 40 | if re.search(r"^\s* 24 | 25 | # Added 26 | 27 | - This thing was added. 28 | And we liked it. 29 | 30 | 31 | 32 | - Another thing we added. 33 | 34 | 39 | """, 40 | { 41 | "Added": [ 42 | "- This thing was added.\n And we liked it.", 43 | "- Another thing we added.", 44 | ] 45 | }, 46 | id="comments_ignored", 47 | ), 48 | # Multiple section headers. 49 | pytest.param( 50 | """\ 51 | # Added 52 | 53 | - This thing was added. 54 | And we liked it. 55 | 56 | 57 | # Fixed 58 | 59 | - This thing was fixed. 60 | 61 | - Another thing was fixed. 62 | 63 | # Added 64 | 65 | - Also added 66 | this thing 67 | that is very important. 68 | 69 | """, 70 | { 71 | "Added": [ 72 | "- This thing was added.\n And we liked it.", 73 | "- Also added\n this thing\n that is very important.", 74 | ], 75 | "Fixed": [ 76 | "- This thing was fixed.", 77 | "- Another thing was fixed.", 78 | ], 79 | }, 80 | id="multiple_headers", 81 | ), 82 | # Multiple section headers at a different level. 83 | pytest.param( 84 | """\ 85 | ### Added 86 | 87 | - This thing was added. 88 | And we liked it. 89 | 90 | 91 | ### Fixed or Something 92 | 93 | - This thing was fixed. 94 | 95 | - Another thing was fixed. 96 | 97 | ### Added 98 | 99 | - Also added 100 | this thing 101 | that is very important. 102 | 103 | """, 104 | { 105 | "Added": [ 106 | "- This thing was added.\n And we liked it.", 107 | "- Also added\n this thing\n that is very important.", 108 | ], 109 | "Fixed or Something": [ 110 | "- This thing was fixed.", 111 | "- Another thing was fixed.", 112 | ], 113 | }, 114 | id="multiple_headers_2", 115 | ), 116 | # It's fine to have no header at all. 117 | pytest.param( 118 | """\ 119 | - No header at all. 120 | """, 121 | {None: ["- No header at all."]}, 122 | id="no_header", 123 | ), 124 | # It's fine to have comments with no header, and multiple bulllets. 125 | pytest.param( 126 | """\ 127 | 128 | 129 | - No header at all. 130 | 131 | - Just plain bullets. 132 | """, 133 | {None: ["- No header at all.", "- Just plain bullets."]}, 134 | id="no_header_2", 135 | ), 136 | # A file with only comments and blanks will produce nothing. 137 | pytest.param( 138 | """\ 139 | 141 | 142 | 143 | 144 | 145 | """, 146 | {}, 147 | id="empty", 148 | ), 149 | # Multiple levels of headings only splits on the top-most one. 150 | pytest.param( 151 | """\ 152 | # The big title 153 | 154 | Ignore this stuff 155 | 156 | 157 | 158 | (prelude) 159 | 160 | 161 | ## Section one 162 | 163 | ### subhead 164 | 165 | In the sub 166 | 167 | ### subhead 2 168 | 169 | Also sub 170 | 171 | 172 | ## Section two 173 | 174 | In section two. 175 | 176 | ### subhead 3 177 | s2s3 178 | """, 179 | { 180 | None: ["(prelude)"], 181 | "Section one": [ 182 | "### subhead", 183 | "In the sub", 184 | "### subhead 2", 185 | "Also sub", 186 | ], 187 | "Section two": [ 188 | "In section two.", 189 | "### subhead 3\ns2s3", 190 | ], 191 | }, 192 | id="multilevel", 193 | ), 194 | ], 195 | ) 196 | def test_parse_text(text, parsed): 197 | actual = MdTools().parse_text(textwrap.dedent(text)) 198 | assert actual == parsed 199 | 200 | 201 | @pytest.mark.parametrize( 202 | "sections, expected", 203 | [ 204 | pytest.param( 205 | [ 206 | ( 207 | "Added", 208 | [ 209 | "- This thing was added.\n And we liked it.", 210 | "- Also added\n this thing\n that is very important.", 211 | ], 212 | ), 213 | ( 214 | "Fixed", 215 | ["- This thing was fixed.", "- Another thing was fixed."], 216 | ), 217 | ], 218 | """\ 219 | 220 | ### Added 221 | 222 | - This thing was added. 223 | And we liked it. 224 | 225 | - Also added 226 | this thing 227 | that is very important. 228 | 229 | ### Fixed 230 | 231 | - This thing was fixed. 232 | 233 | - Another thing was fixed. 234 | """, 235 | id="one", 236 | ), 237 | pytest.param( 238 | [ 239 | ( 240 | None, 241 | [ 242 | "- This thing was added.\n And we liked it.", 243 | "- Also added\n this thing\n that is very important.", 244 | ], 245 | ), 246 | ], 247 | """\ 248 | 249 | - This thing was added. 250 | And we liked it. 251 | 252 | - Also added 253 | this thing 254 | that is very important. 255 | """, 256 | id="two", 257 | ), 258 | ], 259 | ) 260 | def test_format_sections(sections, expected): 261 | sections = collections.OrderedDict(sections) 262 | actual = MdTools(Config(md_header_level="2")).format_sections(sections) 263 | assert actual == textwrap.dedent(expected) 264 | 265 | 266 | @pytest.mark.parametrize( 267 | "config_kwargs, text, fh_kwargs, result", 268 | [ 269 | ({}, "2020-07-26", {}, "\n# 2020-07-26\n"), 270 | ({"md_header_level": "3"}, "2020-07-26", {}, "\n### 2020-07-26\n"), 271 | ( 272 | {}, 273 | "2022-04-03", 274 | {"anchor": "here"}, 275 | "\n\n# 2022-04-03\n", 276 | ), 277 | ], 278 | ) 279 | def test_format_header(config_kwargs, text, fh_kwargs, result): 280 | actual = MdTools(Config(**config_kwargs)).format_header(text, **fh_kwargs) 281 | assert actual == result 282 | 283 | 284 | def test_convert_to_markdown(): 285 | # Markdown's convert_to_markdown is a no-op. 286 | md = "# Nonsense\ndoesn't matter\n- whatever ``more ** junk---" 287 | converted = MdTools().convert_to_markdown(md) 288 | assert converted == md 289 | -------------------------------------------------------------------------------- /docs/commands.rst: -------------------------------------------------------------------------------- 1 | ######## 2 | Commands 3 | ######## 4 | 5 | .. [[[cog 6 | # Force help text to be wrapped narrow enough to not trigger doc8 warnings. 7 | import os 8 | os.environ["COLUMNS"] = "78" 9 | 10 | import contextlib 11 | import io 12 | import textwrap 13 | from scriv.cli import cli 14 | 15 | def show_help(cmd): 16 | with contextlib.redirect_stdout(io.StringIO()) as help_out: 17 | with contextlib.suppress(SystemExit): 18 | cli([cmd, "--help"]) 19 | help_text = help_out.getvalue() 20 | help_text = help_text.replace("python -m cogapp", "scriv") 21 | print("\n.. code::\n") 22 | print(f" $ scriv {cmd} --help") 23 | print(textwrap.indent(help_text, " ").rstrip()) 24 | .. ]]] 25 | .. [[[end]]] (checksum: d41d8cd98f00b204e9800998ecf8427e) 26 | 27 | .. _cmd_create: 28 | 29 | scriv create 30 | ============ 31 | 32 | .. [[[cog show_help("create") ]]] 33 | 34 | .. code:: 35 | 36 | $ scriv create --help 37 | Usage: scriv create [OPTIONS] 38 | 39 | Create a new changelog fragment. 40 | 41 | Options: 42 | --add / --no-add 'git add' the created file. 43 | --edit / --no-edit Open the created file in your text editor. 44 | -v, --verbosity LVL Either CRITICAL, ERROR, WARNING, INFO or DEBUG 45 | --help Show this message and exit. 46 | .. [[[end]]] (checksum: 45edec1fd1ebc343358cbf774ba5a49c) 47 | 48 | The create command creates new :ref:`fragments `. 49 | 50 | File creation 51 | ------------- 52 | 53 | Fragments are created in the changelog.d directory. The name of the directory 54 | can be configured with the :ref:`config_fragment_directory` setting. 55 | 56 | The file name starts with the current date and time, so that entries can later 57 | be collected in chronological order. To help make the files understandable, the 58 | file name also includes the creator's git name, and the branch name you are 59 | working on. "Main" branch names aren't included, to cut down on uninteresting 60 | noise. The branch names considered uninteresting are settable with the 61 | :ref:`config_main_branches` setting. 62 | 63 | The initial contents of the fragment file are populated from the 64 | :ref:`config_new_fragment_template` template. The format is either 65 | reStructuredText or Markdown, selectable with the :ref:`config_format` 66 | setting. 67 | 68 | The default new fragment templates create empty sections for each 69 | :ref:`category `. Uncomment the one you want to use, and create a 70 | bullet for the changes you are describing. If you need a different template 71 | for new fragments, you can create a `Jinja`_ template and name it in the 72 | :ref:`config_new_fragment_template` setting. 73 | 74 | Editing 75 | ------- 76 | 77 | If ``--edit`` is provided, or if ``scriv.create.edit`` is set to true in your 78 | :ref:`git settings `, scriv will launch an editor for you to edit 79 | the new fragment. Scriv uses the same editor that git launches for commit 80 | messages. 81 | 82 | The format of the fragment should be sections for the categories, with bullets 83 | for each change. The file is re-parsed when it is collected, so the specifics 84 | of things like header underlines don't have to match the changelog file, that 85 | will be adjusted later. 86 | 87 | Once you save and exit the editor, scriv will continue working on the file. If 88 | the file is empty because you removed all of the non-comment content, scriv 89 | will stop. 90 | 91 | Adding 92 | ------ 93 | 94 | If ``--add`` is provided, or if ``scriv.create.add`` is set to true in your 95 | :ref:`git settings `, scriv will "git add" the new file so that 96 | it is ready to commit. 97 | 98 | 99 | .. _cmd_collect: 100 | 101 | scriv collect 102 | ============= 103 | 104 | .. [[[cog show_help("collect") ]]] 105 | 106 | .. code:: 107 | 108 | $ scriv collect --help 109 | Usage: scriv collect [OPTIONS] 110 | 111 | Collect and combine fragments into the changelog. 112 | 113 | Options: 114 | --add / --no-add 'git add' the updated changelog file and removed 115 | fragments. 116 | --edit / --no-edit Open the changelog file in your text editor. 117 | --title TEXT The title text to use for this entry. 118 | --keep Keep the fragment files that are collected. 119 | --version TEXT The version name to use for this entry. 120 | -v, --verbosity LVL Either CRITICAL, ERROR, WARNING, INFO or DEBUG 121 | --help Show this message and exit. 122 | .. [[[end]]] (checksum: e93ca778396310ce406f1cc439cefdd4) 123 | 124 | The collect command aggregates all the current fragments into the changelog 125 | file. 126 | 127 | Entry Creation 128 | -------------- 129 | 130 | All of the .rst or .md files in the fragment directory are read, parsed, and 131 | re-assembled into a changelog entry. The entry's title is determined by the 132 | :ref:`config_entry_title_template` setting. The default uses the version string 133 | (if one is specified in the :ref:`config_version` setting) and the current 134 | date. 135 | 136 | Instead of using the title template, you can provide an exact title to use for 137 | the new entry with the ``--title`` option. 138 | 139 | The output file is specified by the :ref:`config_changelog` setting. Scriv 140 | looks in the file for a special marker (usually in a comment) to determine 141 | where to insert the new entry. The marker is "scriv-insert-here", but can be 142 | changed with the :ref:`config_start_marker` setting. Using a marker like 143 | this, you can have your changelog be just part of a larger README file. If 144 | there is no marker in the file, the new entry is inserted at the top of the 145 | file. 146 | 147 | 148 | Fragment Deletion 149 | ----------------- 150 | 151 | The fragment files that are read will be deleted, because they are no longer 152 | needed. If you would prefer to keep the fragment files, use the ``--keep`` 153 | option. 154 | 155 | Editing 156 | ------- 157 | 158 | If ``--edit`` is provided, or if ``scriv.collect.edit`` is set to true in your 159 | :ref:`git settings `, scriv will launch an editor for you to edit 160 | the changelog file. Mostly you shouldn't need to do this, but you might want 161 | to make some tweaks. Scriv uses the same editor that git launches for commit 162 | messages. 163 | 164 | Adding 165 | ------ 166 | 167 | If ``--add`` is provided, or if ``scriv.collect.add`` is set to true in your 168 | :ref:`git settings `, scriv will "git add" the updates to the 169 | changelog file, and the fragment file deletions, so that they are ready to 170 | commit. 171 | 172 | 173 | .. _cmd_github_release: 174 | 175 | scriv github-release 176 | ==================== 177 | 178 | .. [[[cog show_help("github-release") ]]] 179 | 180 | .. code:: 181 | 182 | $ scriv github-release --help 183 | Usage: scriv github-release [OPTIONS] 184 | 185 | Create GitHub releases from the changelog. 186 | 187 | Only the most recent changelog entry is used, unless --all is provided. 188 | 189 | Options: 190 | --all Use all of the changelog entries. 191 | --check-links Check that links are valid (EXPERIMENTAL). 192 | --dry-run Don't post to GitHub, just show what would be done. 193 | --fail-if-warn Fail if a conversion generates warnings. 194 | --repo TEXT The GitHub repo (owner/reponame) to create the 195 | release in. 196 | -v, --verbosity LVL Either CRITICAL, ERROR, WARNING, INFO or DEBUG 197 | --help Show this message and exit. 198 | .. [[[end]]] (checksum: ec63a3f79902b40a74e633cdeb1bf3dc) 199 | 200 | The ``github-release`` command reads the changelog file, parses it into 201 | entries, and then creates or updates GitHub releases to match. Only the most 202 | recent changelog entry is used, unless ``--all`` is provided. 203 | 204 | An entry must have a version number in the title, and that version number must 205 | correspond to a git tag. For example, this changelog entry with the title 206 | ``v1.2.3 -- 2022-04-06`` will be processed and the version number will be 207 | "v1.2.3". If there's a "v1.2.3" git tag, then the entry is a valid release. 208 | If there's no detectable version number in the header, or there isn't a git 209 | tag with the same number, then the entry can't be created as a GitHub release. 210 | 211 | The ``--fail-if-warn`` option will end the command if a format conversion 212 | generates a warning, usually because of a missing reference. The 213 | ``--check-links`` option will find the URLs in the release description, and 214 | check if they are valid. Warnings are displayed for invalid URLs, but the 215 | command still creates the release. 216 | 217 | This command is independent of the other commands. It can be used with a 218 | hand-edited changelog file that wasn't created with scriv. 219 | 220 | For writing to GitHub, you need a GitHub personal access token, either stored 221 | in your .netrc file, or in the GITHUB_TOKEN environment variable. 222 | 223 | The GitHub repo will be determined by examining the git remotes. If there 224 | is just one GitHub repo in the remotes, it will be used to create the release. 225 | You can explicitly specify a repo in ``owner/reponame`` form with the 226 | ``--repo=`` option if needed. 227 | 228 | If your changelog file is in reStructuredText format, you will need `pandoc`_ 229 | 2.11.2 or later installed for the command to work. 230 | 231 | .. _pandoc: https://pandoc.org/ 232 | 233 | scriv print 234 | =========== 235 | 236 | .. [[[cog show_help("print") ]]] 237 | 238 | .. code:: 239 | 240 | $ scriv print --help 241 | Usage: scriv print [OPTIONS] 242 | 243 | Print collected fragments, or print an entry from the changelog. 244 | 245 | Options: 246 | --version TEXT The version of the changelog entry to extract. 247 | --output PATH The path to a file to write the output to. 248 | -v, --verbosity LVL Either CRITICAL, ERROR, WARNING, INFO or DEBUG 249 | --help Show this message and exit. 250 | .. [[[end]]] (checksum: f652a3470da5f726b13ba076471b2444) 251 | 252 | The ``print`` command writes a changelog entry to standard out. 253 | 254 | If ``--output`` is provided, the changelog entry is written to the given file. 255 | 256 | If ``--version`` is given, the requested changelog entry is extracted 257 | from the CHANGELOG. 258 | If not, then the changelog entry is generated from uncollected fragment files. 259 | 260 | .. include:: include/links.rst 261 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | Apache License 4 | Version 2.0, January 2004 5 | http://www.apache.org/licenses/ 6 | 7 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 8 | 9 | 1. Definitions. 10 | 11 | "License" shall mean the terms and conditions for use, reproduction, 12 | and distribution as defined by Sections 1 through 9 of this document. 13 | 14 | "Licensor" shall mean the copyright owner or entity authorized by 15 | the copyright owner that is granting the License. 16 | 17 | "Legal Entity" shall mean the union of the acting entity and all 18 | other entities that control, are controlled by, or are under common 19 | control with that entity. For the purposes of this definition, 20 | "control" means (i) the power, direct or indirect, to cause the 21 | direction or management of such entity, whether by contract or 22 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 23 | outstanding shares, or (iii) beneficial ownership of such entity. 24 | 25 | "You" (or "Your") shall mean an individual or Legal Entity 26 | exercising permissions granted by this License. 27 | 28 | "Source" form shall mean the preferred form for making modifications, 29 | including but not limited to software source code, documentation 30 | source, and configuration files. 31 | 32 | "Object" form shall mean any form resulting from mechanical 33 | transformation or translation of a Source form, including but 34 | not limited to compiled object code, generated documentation, 35 | and conversions to other media types. 36 | 37 | "Work" shall mean the work of authorship, whether in Source or 38 | Object form, made available under the License, as indicated by a 39 | copyright notice that is included in or attached to the work 40 | (an example is provided in the Appendix below). 41 | 42 | "Derivative Works" shall mean any work, whether in Source or Object 43 | form, that is based on (or derived from) the Work and for which the 44 | editorial revisions, annotations, elaborations, or other modifications 45 | represent, as a whole, an original work of authorship. For the purposes 46 | of this License, Derivative Works shall not include works that remain 47 | separable from, or merely link (or bind by name) to the interfaces of, 48 | the Work and Derivative Works thereof. 49 | 50 | "Contribution" shall mean any work of authorship, including 51 | the original version of the Work and any modifications or additions 52 | to that Work or Derivative Works thereof, that is intentionally 53 | submitted to Licensor for inclusion in the Work by the copyright owner 54 | or by an individual or Legal Entity authorized to submit on behalf of 55 | the copyright owner. For the purposes of this definition, "submitted" 56 | means any form of electronic, verbal, or written communication sent 57 | to the Licensor or its representatives, including but not limited to 58 | communication on electronic mailing lists, source code control systems, 59 | and issue tracking systems that are managed by, or on behalf of, the 60 | Licensor for the purpose of discussing and improving the Work, but 61 | excluding communication that is conspicuously marked or otherwise 62 | designated in writing by the copyright owner as "Not a Contribution." 63 | 64 | "Contributor" shall mean Licensor and any individual or Legal Entity 65 | on behalf of whom a Contribution has been received by Licensor and 66 | subsequently incorporated within the Work. 67 | 68 | 2. Grant of Copyright License. Subject to the terms and conditions of 69 | this License, each Contributor hereby grants to You a perpetual, 70 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 71 | copyright license to reproduce, prepare Derivative Works of, 72 | publicly display, publicly perform, sublicense, and distribute the 73 | Work and such Derivative Works in Source or Object form. 74 | 75 | 3. Grant of Patent License. Subject to the terms and conditions of 76 | this License, each Contributor hereby grants to You a perpetual, 77 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 78 | (except as stated in this section) patent license to make, have made, 79 | use, offer to sell, sell, import, and otherwise transfer the Work, 80 | where such license applies only to those patent claims licensable 81 | by such Contributor that are necessarily infringed by their 82 | Contribution(s) alone or by combination of their Contribution(s) 83 | with the Work to which such Contribution(s) was submitted. If You 84 | institute patent litigation against any entity (including a 85 | cross-claim or counterclaim in a lawsuit) alleging that the Work 86 | or a Contribution incorporated within the Work constitutes direct 87 | or contributory patent infringement, then any patent licenses 88 | granted to You under this License for that Work shall terminate 89 | as of the date such litigation is filed. 90 | 91 | 4. Redistribution. You may reproduce and distribute copies of the 92 | Work or Derivative Works thereof in any medium, with or without 93 | modifications, and in Source or Object form, provided that You 94 | meet the following conditions: 95 | 96 | (a) You must give any other recipients of the Work or 97 | Derivative Works a copy of this License; and 98 | 99 | (b) You must cause any modified files to carry prominent notices 100 | stating that You changed the files; and 101 | 102 | (c) You must retain, in the Source form of any Derivative Works 103 | that You distribute, all copyright, patent, trademark, and 104 | attribution notices from the Source form of the Work, 105 | excluding those notices that do not pertain to any part of 106 | the Derivative Works; and 107 | 108 | (d) If the Work includes a "NOTICE" text file as part of its 109 | distribution, then any Derivative Works that You distribute must 110 | include a readable copy of the attribution notices contained 111 | within such NOTICE file, excluding those notices that do not 112 | pertain to any part of the Derivative Works, in at least one 113 | of the following places: within a NOTICE text file distributed 114 | as part of the Derivative Works; within the Source form or 115 | documentation, if provided along with the Derivative Works; or, 116 | within a display generated by the Derivative Works, if and 117 | wherever such third-party notices normally appear. The contents 118 | of the NOTICE file are for informational purposes only and 119 | do not modify the License. You may add Your own attribution 120 | notices within Derivative Works that You distribute, alongside 121 | or as an addendum to the NOTICE text from the Work, provided 122 | that such additional attribution notices cannot be construed 123 | as modifying the License. 124 | 125 | You may add Your own copyright statement to Your modifications and 126 | may provide additional or different license terms and conditions 127 | for use, reproduction, or distribution of Your modifications, or 128 | for any such Derivative Works as a whole, provided Your use, 129 | reproduction, and distribution of the Work otherwise complies with 130 | the conditions stated in this License. 131 | 132 | 5. Submission of Contributions. Unless You explicitly state otherwise, 133 | any Contribution intentionally submitted for inclusion in the Work 134 | by You to the Licensor shall be under the terms and conditions of 135 | this License, without any additional terms or conditions. 136 | Notwithstanding the above, nothing herein shall supersede or modify 137 | the terms of any separate license agreement you may have executed 138 | with Licensor regarding such Contributions. 139 | 140 | 6. Trademarks. This License does not grant permission to use the trade 141 | names, trademarks, service marks, or product names of the Licensor, 142 | except as required for reasonable and customary use in describing the 143 | origin of the Work and reproducing the content of the NOTICE file. 144 | 145 | 7. Disclaimer of Warranty. Unless required by applicable law or 146 | agreed to in writing, Licensor provides the Work (and each 147 | Contributor provides its Contributions) on an "AS IS" BASIS, 148 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 149 | implied, including, without limitation, any warranties or conditions 150 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 151 | PARTICULAR PURPOSE. You are solely responsible for determining the 152 | appropriateness of using or redistributing the Work and assume any 153 | risks associated with Your exercise of permissions under this License. 154 | 155 | 8. Limitation of Liability. In no event and under no legal theory, 156 | whether in tort (including negligence), contract, or otherwise, 157 | unless required by applicable law (such as deliberate and grossly 158 | negligent acts) or agreed to in writing, shall any Contributor be 159 | liable to You for damages, including any direct, indirect, special, 160 | incidental, or consequential damages of any character arising as a 161 | result of this License or out of the use or inability to use the 162 | Work (including but not limited to damages for loss of goodwill, 163 | work stoppage, computer failure or malfunction, or any and all 164 | other commercial damages or losses), even if such Contributor 165 | has been advised of the possibility of such damages. 166 | 167 | 9. Accepting Warranty or Additional Liability. While redistributing 168 | the Work or Derivative Works thereof, You may choose to offer, 169 | and charge a fee for, acceptance of support, warranty, indemnity, 170 | or other liability obligations and/or rights consistent with this 171 | License. However, in accepting such obligations, You may act only 172 | on Your own behalf and on Your sole responsibility, not on behalf 173 | of any other Contributor, and only if You agree to indemnify, 174 | defend, and hold each Contributor harmless for any liability 175 | incurred by, or claims asserted against, such Contributor by reason 176 | of your accepting any such warranty or additional liability. 177 | 178 | END OF TERMS AND CONDITIONS 179 | 180 | 181 | -------------------------------------------------------------------------------- /tests/test_ghrel.py: -------------------------------------------------------------------------------- 1 | """Tests of scriv/ghrel.py.""" 2 | 3 | import json 4 | import logging 5 | from typing import Any 6 | from unittest.mock import call 7 | 8 | import pytest 9 | 10 | from .helpers import check_logs 11 | 12 | CHANGELOG1 = """\ 13 | Some text before 14 | 15 | v1.2.3 -- 2022-04-21 16 | -------------------- 17 | 18 | A good release 19 | 20 | Some fixes 21 | ---------- 22 | 23 | No version number in this section. 24 | 25 | v1.0 -- 2020-02-20 26 | ------------------ 27 | 28 | Nothing to say. 29 | 30 | v0.9a7 -- 2017-06-16 31 | -------------------- 32 | 33 | A beginning 34 | 35 | v0.1 -- 2010-01-01 36 | ------------------ 37 | 38 | Didn't bother to tag this one. 39 | 40 | v0.0.1 -- 2001-01-01 41 | -------------------- 42 | 43 | Very first. 44 | 45 | """ 46 | 47 | RELEASES1 = { 48 | "v1.0": { 49 | "url": "https://api.github.com/repos/joe/project/releases/120", 50 | "body": "Nothing to say.\n", 51 | }, 52 | "v0.9a7": { 53 | "url": "https://api.github.com/repos/joe/project/releases/123", 54 | "body": "original body", 55 | }, 56 | "v0.0.1": { 57 | "url": "https://api.github.com/repos/joe/project/releases/123", 58 | "body": "original body", 59 | }, 60 | } 61 | 62 | V123_REL = { 63 | "body": "A good release\n", 64 | "name": "v1.2.3", 65 | "tag_name": "v1.2.3", 66 | "draft": False, 67 | "prerelease": False, 68 | } 69 | 70 | V097_REL = { 71 | "body": "A beginning\n", 72 | "name": "v0.9a7", 73 | "tag_name": "v0.9a7", 74 | "draft": False, 75 | "prerelease": True, 76 | } 77 | 78 | V001_REL = { 79 | "body": "Very first.\n", 80 | "name": "v0.0.1", 81 | "tag_name": "v0.0.1", 82 | "draft": False, 83 | "prerelease": False, 84 | } 85 | 86 | 87 | @pytest.fixture() 88 | def scenario1(temp_dir, fake_git, mocker): 89 | """A common scenario for the tests.""" 90 | fake_git.add_remote("origin", "git@github.com:joe/project.git") 91 | fake_git.add_tags(["v1.2.3", "v1.0", "v0.9a7", "v0.0.1"]) 92 | (temp_dir / "CHANGELOG.rst").write_text(CHANGELOG1) 93 | mock_get_releases = mocker.patch("scriv.ghrel.get_releases") 94 | mock_get_releases.return_value = RELEASES1 95 | 96 | 97 | @pytest.fixture() 98 | def mock_create_release(mocker): 99 | """Create a mock create_release that checks arguments.""" 100 | 101 | def _create_release(repo: str, release_data: dict[str, Any]) -> None: 102 | assert repo 103 | assert release_data["name"] 104 | assert json.dumps(release_data)[0] == "{" 105 | 106 | return mocker.patch( 107 | "scriv.ghrel.create_release", side_effect=_create_release 108 | ) 109 | 110 | 111 | @pytest.fixture() 112 | def mock_update_release(mocker): 113 | """Create a mock update_release that checks arguments.""" 114 | 115 | def _update_release( 116 | release: dict[str, Any], release_data: dict[str, Any] 117 | ) -> None: 118 | assert release_data["name"] 119 | assert release["url"] 120 | assert json.dumps(release_data)[0] == "{" 121 | 122 | return mocker.patch( 123 | "scriv.ghrel.update_release", side_effect=_update_release 124 | ) 125 | 126 | 127 | def test_default( 128 | cli_invoke, scenario1, mock_create_release, mock_update_release, caplog 129 | ): 130 | cli_invoke(["github-release"]) 131 | 132 | assert mock_create_release.mock_calls == [call("joe/project", V123_REL)] 133 | assert mock_update_release.mock_calls == [] 134 | assert caplog.record_tuples == [ 135 | ( 136 | "scriv.changelog", 137 | logging.INFO, 138 | "Reading changelog CHANGELOG.rst", 139 | ), 140 | ] 141 | 142 | 143 | def test_dash_all( 144 | cli_invoke, scenario1, mock_create_release, mock_update_release, caplog 145 | ): 146 | cli_invoke(["github-release", "--all"]) 147 | 148 | assert mock_create_release.mock_calls == [call("joe/project", V123_REL)] 149 | assert mock_update_release.mock_calls == [ 150 | call(RELEASES1["v0.9a7"], V097_REL), 151 | call(RELEASES1["v0.0.1"], V001_REL), 152 | ] 153 | assert caplog.record_tuples == [ 154 | ( 155 | "scriv.changelog", 156 | logging.INFO, 157 | "Reading changelog CHANGELOG.rst", 158 | ), 159 | ( 160 | "scriv.ghrel", 161 | logging.WARNING, 162 | "Entry 'Some fixes' has no version, skipping.", 163 | ), 164 | ( 165 | "scriv.ghrel", 166 | logging.WARNING, 167 | "Version v0.1 has no tag. No release will be made.", 168 | ), 169 | ] 170 | 171 | 172 | def test_explicit_repo( 173 | cli_invoke, scenario1, fake_git, mock_create_release, mock_update_release 174 | ): 175 | # Add another GitHub remote, now there are two. 176 | fake_git.add_remote("upstream", "git@github.com:psf/project.git") 177 | 178 | cli_invoke(["github-release", "--repo=xyzzy/plugh"]) 179 | 180 | assert mock_create_release.mock_calls == [call("xyzzy/plugh", V123_REL)] 181 | assert mock_update_release.mock_calls == [] 182 | 183 | 184 | @pytest.mark.parametrize( 185 | "repo", ["xyzzy", "https://github.com/xyzzy/plugh.git"] 186 | ) 187 | def test_bad_explicit_repo(cli_invoke, repo): 188 | result = cli_invoke(["github-release", f"--repo={repo}"], expect_ok=False) 189 | assert result.exit_code == 1 190 | assert str(result.exception) == f"Repo must be owner/reponame: {repo!r}" 191 | 192 | 193 | def test_check_links(cli_invoke, scenario1, mocker): 194 | mock_check_links = mocker.patch("scriv.ghrel.check_markdown_links") 195 | cli_invoke(["github-release", "--all", "--dry-run", "--check-links"]) 196 | assert mock_check_links.mock_calls == [ 197 | call("A good release\n"), 198 | call("Nothing to say.\n"), 199 | call("A beginning\n"), 200 | call("Very first.\n"), 201 | ] 202 | 203 | 204 | @pytest.fixture() 205 | def no_actions(mock_create_release, mock_update_release, responses): 206 | """Check that nothing really happened.""" 207 | 208 | yield 209 | 210 | assert mock_create_release.mock_calls == [] 211 | assert mock_update_release.mock_calls == [] 212 | assert len(responses.calls) == 0 213 | 214 | 215 | def test_default_dry_run(cli_invoke, scenario1, no_actions, caplog): 216 | cli_invoke(["github-release", "--dry-run"]) 217 | check_logs( 218 | caplog, 219 | [ 220 | ( 221 | "scriv.changelog", 222 | logging.INFO, 223 | "Reading changelog CHANGELOG.rst", 224 | ), 225 | ("scriv.ghrel", logging.INFO, "Would create release v1.2.3"), 226 | ], 227 | ) 228 | 229 | 230 | def test_dash_all_dry_run(cli_invoke, scenario1, no_actions, caplog): 231 | cli_invoke(["github-release", "--all", "--dry-run"]) 232 | check_logs( 233 | caplog, 234 | [ 235 | ( 236 | "scriv.changelog", 237 | logging.INFO, 238 | "Reading changelog CHANGELOG.rst", 239 | ), 240 | ("scriv.ghrel", logging.INFO, "Would create release v1.2.3"), 241 | ( 242 | "scriv.ghrel", 243 | logging.WARNING, 244 | "Entry 'Some fixes' has no version, skipping.", 245 | ), 246 | ("scriv.ghrel", logging.INFO, "Would update release v0.9a7"), 247 | ( 248 | "scriv.ghrel", 249 | logging.WARNING, 250 | "Version v0.1 has no tag. No release will be made.", 251 | ), 252 | ("scriv.ghrel", 20, "Would update release v0.0.1"), 253 | ], 254 | ) 255 | 256 | 257 | def test_dash_all_dry_run_debug(cli_invoke, scenario1, no_actions, caplog): 258 | cli_invoke(["github-release", "--all", "--dry-run", "--verbosity=debug"]) 259 | check_logs( 260 | caplog, 261 | [ 262 | ( 263 | "scriv.changelog", 264 | logging.INFO, 265 | "Reading changelog CHANGELOG.rst", 266 | ), 267 | ( 268 | "scriv.ghrel", 269 | logging.DEBUG, 270 | "Creating release, data = {'body': 'A good release\\n', 'name': 'v1.2.3', " 271 | + "'tag_name': 'v1.2.3', 'draft': False, 'prerelease': False}", 272 | ), 273 | ("scriv.ghrel", logging.INFO, "Would create release v1.2.3"), 274 | ("scriv.ghrel", logging.DEBUG, "Body:\nA good release\n"), 275 | ( 276 | "scriv.ghrel", 277 | logging.WARNING, 278 | "Entry 'Some fixes' has no version, skipping.", 279 | ), 280 | ( 281 | "scriv.ghrel", 282 | logging.DEBUG, 283 | "Updating release v0.9a7, data = {'body': 'A beginning\\n', 'name': " 284 | + "'v0.9a7', 'tag_name': 'v0.9a7', 'draft': False, 'prerelease': True}", 285 | ), 286 | ("scriv.ghrel", logging.INFO, "Would update release v0.9a7"), 287 | ("scriv.ghrel", logging.DEBUG, "Old body:\noriginal body"), 288 | ("scriv.ghrel", logging.DEBUG, "New body:\nA beginning\n"), 289 | ( 290 | "scriv.ghrel", 291 | logging.WARNING, 292 | "Version v0.1 has no tag. No release will be made.", 293 | ), 294 | ( 295 | "scriv.ghrel", 296 | logging.DEBUG, 297 | "Updating release v0.0.1, data = {'body': 'Very first.\\n', 'name': " 298 | + "'v0.0.1', 'tag_name': 'v0.0.1', 'draft': False, 'prerelease': False}", 299 | ), 300 | ("scriv.ghrel", logging.INFO, "Would update release v0.0.1"), 301 | ("scriv.ghrel", logging.DEBUG, "Old body:\noriginal body"), 302 | ("scriv.ghrel", logging.DEBUG, "New body:\nVery first.\n"), 303 | ], 304 | ) 305 | 306 | 307 | def test_no_github_repo(cli_invoke, scenario1, fake_git): 308 | fake_git.remove_remote("origin") 309 | result = cli_invoke(["github-release"], expect_ok=False) 310 | assert result.exit_code == 1 311 | assert result.output == "Couldn't find a GitHub repo\n" 312 | 313 | 314 | def test_no_clear_github_repo(cli_invoke, scenario1, fake_git): 315 | # Add another GitHub remote, now there are two. 316 | fake_git.add_remote("upstream", "git@github.com:psf/project.git") 317 | result = cli_invoke(["github-release"], expect_ok=False) 318 | assert result.exit_code == 1 319 | assert result.output == ( 320 | "More than one GitHub repo found: joe/project, psf/project\n" 321 | ) 322 | 323 | 324 | def test_with_template(cli_invoke, temp_dir, scenario1, mock_create_release): 325 | (temp_dir / "setup.cfg").write_text( 326 | """ 327 | [scriv] 328 | ghrel_template = |{{title}}|{{body}}|{{config.format}}|{{version}}| 329 | """ 330 | ) 331 | cli_invoke(["github-release"]) 332 | 333 | expected = dict(V123_REL) 334 | expected["body"] = "|v1.2.3 -- 2022-04-21|A good release\n|rst|v1.2.3|" 335 | 336 | assert mock_create_release.mock_calls == [call("joe/project", expected)] 337 | -------------------------------------------------------------------------------- /requirements/quality.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # make upgrade 3 | alabaster==1.0.0 4 | # via 5 | # -r requirements/doc.txt 6 | # sphinx 7 | astroid==4.0.2 8 | # via 9 | # -r requirements/doc.txt 10 | # -r requirements/test.txt 11 | # pylint 12 | attrs==25.4.0 13 | # via 14 | # -r requirements/doc.txt 15 | # -r requirements/test.txt 16 | babel==2.17.0 17 | # via 18 | # -r requirements/doc.txt 19 | # sphinx 20 | backports-tarfile==1.2.0 ; python_full_version < '3.12' and platform_machine != 'ppc64le' and platform_machine != 's390x' 21 | # via jaraco-context 22 | black==25.12.0 23 | # via -r requirements/quality.in 24 | build==1.3.0 25 | # via check-manifest 26 | certifi==2025.11.12 27 | # via 28 | # -r requirements/doc.txt 29 | # -r requirements/test.txt 30 | # requests 31 | cffi==2.0.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux' 32 | # via cryptography 33 | charset-normalizer==3.4.4 34 | # via 35 | # -r requirements/doc.txt 36 | # -r requirements/test.txt 37 | # requests 38 | check-manifest==0.51 39 | # via -r requirements/quality.in 40 | click==8.3.1 41 | # via 42 | # -r requirements/doc.txt 43 | # -r requirements/test.txt 44 | # black 45 | # click-log 46 | click-log==0.4.0 47 | # via 48 | # -r requirements/doc.txt 49 | # -r requirements/test.txt 50 | cogapp==3.6.0 51 | # via -r requirements/doc.txt 52 | colorama==0.4.6 ; os_name == 'nt' or sys_platform == 'win32' 53 | # via 54 | # -r requirements/doc.txt 55 | # -r requirements/test.txt 56 | # build 57 | # click 58 | # pylint 59 | # pytest 60 | # sphinx 61 | coverage==7.13.0 62 | # via 63 | # -r requirements/doc.txt 64 | # -r requirements/test.txt 65 | cryptography==46.0.3 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux' 66 | # via secretstorage 67 | dill==0.4.0 68 | # via 69 | # -r requirements/doc.txt 70 | # -r requirements/test.txt 71 | # pylint 72 | doc8==2.0.0 73 | # via -r requirements/doc.txt 74 | docutils==0.21.2 75 | # via 76 | # -r requirements/doc.txt 77 | # doc8 78 | # readme-renderer 79 | # restructuredtext-lint 80 | # sphinx 81 | # sphinx-rtd-theme 82 | exceptiongroup==1.3.1 ; python_full_version < '3.11' 83 | # via 84 | # -r requirements/doc.txt 85 | # -r requirements/test.txt 86 | # pytest 87 | freezegun==1.5.5 88 | # via 89 | # -r requirements/doc.txt 90 | # -r requirements/test.txt 91 | id==1.5.0 92 | # via twine 93 | idna==3.11 94 | # via 95 | # -r requirements/doc.txt 96 | # -r requirements/test.txt 97 | # requests 98 | imagesize==1.4.1 99 | # via 100 | # -r requirements/doc.txt 101 | # sphinx 102 | importlib-metadata==8.7.0 ; (python_full_version < '3.10.2' and platform_machine == 'ppc64le') or (python_full_version < '3.10.2' and platform_machine == 's390x') or (python_full_version < '3.12' and platform_machine != 'ppc64le' and platform_machine != 's390x') 103 | # via 104 | # build 105 | # keyring 106 | iniconfig==2.3.0 107 | # via 108 | # -r requirements/doc.txt 109 | # -r requirements/test.txt 110 | # pytest 111 | isort==7.0.0 112 | # via 113 | # -r requirements/doc.txt 114 | # -r requirements/quality.in 115 | # -r requirements/test.txt 116 | # pylint 117 | jaraco-classes==3.4.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' 118 | # via keyring 119 | jaraco-context==6.0.1 ; platform_machine != 'ppc64le' and platform_machine != 's390x' 120 | # via keyring 121 | jaraco-functools==4.3.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' 122 | # via keyring 123 | jedi==0.19.2 124 | # via 125 | # -r requirements/doc.txt 126 | # -r requirements/test.txt 127 | # pudb 128 | jeepney==0.9.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux' 129 | # via 130 | # keyring 131 | # secretstorage 132 | jinja2==3.1.6 133 | # via 134 | # -r requirements/doc.txt 135 | # -r requirements/test.txt 136 | # sphinx 137 | keyring==25.7.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' 138 | # via twine 139 | librt==0.7.4 ; platform_python_implementation != 'PyPy' 140 | # via mypy 141 | markdown-it-py==4.0.0 142 | # via 143 | # -r requirements/doc.txt 144 | # -r requirements/test.txt 145 | # rich 146 | markupsafe==3.0.3 147 | # via 148 | # -r requirements/doc.txt 149 | # -r requirements/test.txt 150 | # jinja2 151 | mccabe==0.7.0 152 | # via 153 | # -r requirements/doc.txt 154 | # -r requirements/test.txt 155 | # pylint 156 | mdurl==0.1.2 157 | # via 158 | # -r requirements/doc.txt 159 | # -r requirements/test.txt 160 | # markdown-it-py 161 | more-itertools==10.8.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' 162 | # via 163 | # jaraco-classes 164 | # jaraco-functools 165 | mypy==1.19.1 166 | # via -r requirements/quality.in 167 | mypy-extensions==1.1.0 168 | # via 169 | # black 170 | # mypy 171 | nh3==0.3.2 172 | # via readme-renderer 173 | packaging==25.0 174 | # via 175 | # -r requirements/doc.txt 176 | # -r requirements/test.txt 177 | # black 178 | # build 179 | # pudb 180 | # pytest 181 | # sphinx 182 | # twine 183 | parso==0.8.5 184 | # via 185 | # -r requirements/doc.txt 186 | # -r requirements/test.txt 187 | # jedi 188 | pathspec==0.12.1 189 | # via 190 | # black 191 | # mypy 192 | platformdirs==4.5.1 193 | # via 194 | # -r requirements/doc.txt 195 | # -r requirements/test.txt 196 | # black 197 | # pylint 198 | pluggy==1.6.0 199 | # via 200 | # -r requirements/doc.txt 201 | # -r requirements/test.txt 202 | # pytest 203 | pudb==2025.1.5 204 | # via 205 | # -r requirements/doc.txt 206 | # -r requirements/test.txt 207 | pycodestyle==2.14.0 208 | # via -r requirements/quality.in 209 | pycparser==2.23 ; implementation_name != 'PyPy' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux' 210 | # via cffi 211 | pydocstyle==6.3.0 212 | # via -r requirements/quality.in 213 | pygments==2.19.2 214 | # via 215 | # -r requirements/doc.txt 216 | # -r requirements/test.txt 217 | # doc8 218 | # pudb 219 | # readme-renderer 220 | # rich 221 | # sphinx 222 | pylint==4.0.4 223 | # via 224 | # -r requirements/doc.txt 225 | # -r requirements/quality.in 226 | # -r requirements/test.txt 227 | # pylint-pytest 228 | pylint-pytest==1.1.8 229 | # via 230 | # -r requirements/doc.txt 231 | # -r requirements/test.txt 232 | pyproject-hooks==1.2.0 233 | # via build 234 | pytest==8.2.0 235 | # via 236 | # -r requirements/doc.txt 237 | # -r requirements/test.txt 238 | # pylint-pytest 239 | # pytest-mock 240 | pytest-mock==3.15.1 241 | # via 242 | # -r requirements/doc.txt 243 | # -r requirements/test.txt 244 | python-dateutil==2.9.0.post0 245 | # via 246 | # -r requirements/doc.txt 247 | # -r requirements/test.txt 248 | # freezegun 249 | pytokens==0.3.0 250 | # via black 251 | pywin32-ctypes==0.2.3 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'win32' 252 | # via keyring 253 | pyyaml==6.0.3 254 | # via 255 | # -r requirements/doc.txt 256 | # -r requirements/test.txt 257 | # responses 258 | readme-renderer==44.0 259 | # via twine 260 | requests==2.32.5 261 | # via 262 | # -r requirements/doc.txt 263 | # -r requirements/test.txt 264 | # id 265 | # requests-toolbelt 266 | # responses 267 | # sphinx 268 | # twine 269 | requests-toolbelt==1.0.0 270 | # via twine 271 | responses==0.25.8 272 | # via 273 | # -r requirements/doc.txt 274 | # -r requirements/test.txt 275 | restructuredtext-lint==2.0.2 276 | # via 277 | # -r requirements/doc.txt 278 | # doc8 279 | rfc3986==2.0.0 280 | # via twine 281 | rich==14.2.0 282 | # via twine 283 | roman-numerals==4.0.0 ; python_full_version >= '3.11' 284 | # via 285 | # -r requirements/doc.txt 286 | # roman-numerals-py 287 | roman-numerals-py==4.0.0 ; python_full_version >= '3.11' 288 | # via 289 | # -r requirements/doc.txt 290 | # sphinx 291 | secretstorage==3.5.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux' 292 | # via keyring 293 | setuptools==80.9.0 294 | # via check-manifest 295 | six==1.17.0 296 | # via 297 | # -r requirements/doc.txt 298 | # -r requirements/test.txt 299 | # python-dateutil 300 | snowballstemmer==3.0.1 301 | # via 302 | # -r requirements/doc.txt 303 | # pydocstyle 304 | # sphinx 305 | sphinx==8.1.3 ; python_full_version < '3.11' 306 | # via 307 | # -r requirements/doc.txt 308 | # sphinx-rtd-theme 309 | # sphinxcontrib-jquery 310 | sphinx==8.2.3 ; python_full_version >= '3.11' 311 | # via 312 | # -r requirements/doc.txt 313 | # sphinx-rtd-theme 314 | # sphinxcontrib-jquery 315 | sphinx-rtd-theme==3.0.2 316 | # via 317 | # -c requirements/constraints.in 318 | # -r requirements/doc.txt 319 | sphinxcontrib-applehelp==2.0.0 320 | # via 321 | # -r requirements/doc.txt 322 | # sphinx 323 | sphinxcontrib-devhelp==2.0.0 324 | # via 325 | # -r requirements/doc.txt 326 | # sphinx 327 | sphinxcontrib-htmlhelp==2.1.0 328 | # via 329 | # -r requirements/doc.txt 330 | # sphinx 331 | sphinxcontrib-jquery==4.1 332 | # via 333 | # -r requirements/doc.txt 334 | # sphinx-rtd-theme 335 | sphinxcontrib-jsmath==1.0.1 336 | # via 337 | # -r requirements/doc.txt 338 | # sphinx 339 | sphinxcontrib-qthelp==2.0.0 340 | # via 341 | # -r requirements/doc.txt 342 | # sphinx 343 | sphinxcontrib-serializinghtml==2.0.0 344 | # via 345 | # -r requirements/doc.txt 346 | # sphinx 347 | stevedore==5.6.0 348 | # via 349 | # -r requirements/doc.txt 350 | # doc8 351 | tomli==2.3.0 ; python_full_version < '3.11' 352 | # via 353 | # -r requirements/doc.txt 354 | # -r requirements/test.txt 355 | # black 356 | # build 357 | # check-manifest 358 | # doc8 359 | # mypy 360 | # pylint 361 | # pytest 362 | # sphinx 363 | tomlkit==0.13.3 364 | # via 365 | # -r requirements/doc.txt 366 | # -r requirements/test.txt 367 | # pylint 368 | twine==6.2.0 369 | # via -r requirements/quality.in 370 | types-freezegun==1.1.10 371 | # via -r requirements/quality.in 372 | types-pyyaml==6.0.12.20250915 373 | # via -r requirements/quality.in 374 | types-requests==2.32.4.20250913 375 | # via -r requirements/quality.in 376 | types-toml==0.10.8.20240310 377 | # via -r requirements/quality.in 378 | typing-extensions==4.15.0 379 | # via 380 | # -r requirements/doc.txt 381 | # -r requirements/test.txt 382 | # astroid 383 | # black 384 | # cryptography 385 | # exceptiongroup 386 | # mypy 387 | # pudb 388 | urllib3==2.6.2 389 | # via 390 | # -r requirements/doc.txt 391 | # -r requirements/test.txt 392 | # requests 393 | # responses 394 | # twine 395 | # types-requests 396 | urwid==3.0.4 397 | # via 398 | # -r requirements/doc.txt 399 | # -r requirements/test.txt 400 | # pudb 401 | # urwid-readline 402 | urwid-readline==0.15.1 403 | # via 404 | # -r requirements/doc.txt 405 | # -r requirements/test.txt 406 | # pudb 407 | wcwidth==0.2.14 408 | # via 409 | # -r requirements/doc.txt 410 | # -r requirements/test.txt 411 | # urwid 412 | zipp==3.23.0 ; (python_full_version < '3.10.2' and platform_machine == 'ppc64le') or (python_full_version < '3.10.2' and platform_machine == 's390x') or (python_full_version < '3.12' and platform_machine != 'ppc64le' and platform_machine != 's390x') 413 | # via importlib-metadata 414 | -------------------------------------------------------------------------------- /tests/test_create.py: -------------------------------------------------------------------------------- 1 | """Test creation logic.""" 2 | 3 | import os.path 4 | from pathlib import Path 5 | 6 | import freezegun 7 | 8 | from scriv.config import Config 9 | from scriv.scriv import Scriv 10 | 11 | 12 | class TestNewFragmentPath: 13 | """ 14 | Tests of the paths of new fragments. 15 | """ 16 | 17 | @freezegun.freeze_time("2012-10-01T07:08:09") 18 | def test_new_fragment_path(self, fake_git): 19 | fake_git.set_config("github.user", "joedev") 20 | fake_git.set_branch("master") 21 | scriv = Scriv(config=Config(fragment_directory="notes")) 22 | assert scriv.new_fragment().path == Path( 23 | "notes/20121001_070809_joedev.rst" 24 | ) 25 | 26 | @freezegun.freeze_time("2012-10-01T07:08:09") 27 | def test_new_fragment_path_with_custom_main(self, fake_git): 28 | fake_git.set_config("github.user", "joedev") 29 | fake_git.set_branch("mainline") 30 | scriv = Scriv( 31 | config=Config( 32 | fragment_directory="notes", main_branches=["main", "mainline"] 33 | ) 34 | ) 35 | assert scriv.new_fragment().path == Path( 36 | "notes/20121001_070809_joedev.rst" 37 | ) 38 | 39 | @freezegun.freeze_time("2013-02-25T15:16:17") 40 | def test_new_fragment_path_with_branch(self, fake_git): 41 | fake_git.set_config("github.user", "joedev") 42 | fake_git.set_branch("joedeveloper/feature-123.4") 43 | scriv = Scriv(config=Config(fragment_directory="notes")) 44 | assert scriv.new_fragment().path == Path( 45 | "notes/20130225_151617_joedev_feature_123_4.rst" 46 | ) 47 | 48 | 49 | class TestNewFragmentContent: 50 | """ 51 | Tests of the content of new fragments. 52 | """ 53 | 54 | def test_new_fragment_contents_rst(self): 55 | scriv = Scriv(config=Config(format="rst")) 56 | content = scriv.new_fragment().content 57 | assert content.startswith(".. ") 58 | assert ".. A new scriv changelog fragment" in content 59 | assert ".. Added\n.. -----\n" in content 60 | assert all(cat in content for cat in scriv.config.categories) 61 | 62 | def test_new_fragment_contents_rst_with_customized_header(self): 63 | scriv = Scriv(config=Config(format="rst", rst_header_chars="#~")) 64 | content = scriv.new_fragment().content 65 | assert content.startswith(".. ") 66 | assert ".. A new scriv changelog fragment" in content 67 | assert ".. Added\n.. ~~~~~\n" in content 68 | assert all(cat in content for cat in scriv.config.categories) 69 | 70 | def test_no_categories_rst(self, changelog_d): 71 | # If the project isn't using categories, then the new fragment is 72 | # simpler with no heading. 73 | scriv = Scriv(config=Config(categories=[])) 74 | content = scriv.new_fragment().content 75 | assert ".. A new scriv changelog fragment." in content 76 | assert "- A bullet item for this fragment. EDIT ME!" in content 77 | assert "Uncomment the header that is right" not in content 78 | assert ".. Added" not in content 79 | 80 | def test_new_fragment_contents_md(self): 81 | scriv = Scriv(config=Config(format="md")) 82 | content = scriv.new_fragment().content 83 | assert content.startswith("