diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 0a9c6fc..bc2b0c8 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -165,7 +165,11 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -U nox "tomli;python_version<'3.11'" + # argcomplete 3.7.1 declares Requires-Python >=3.8 but annotates class + # bodies with PEP 604 unions and subscripted collections.abc types + # without `from __future__ import annotations`, so it fails to import on + # 3.8 and 3.9 and takes nox down with it before any session runs. + pip install -U nox "tomli;python_version<'3.11'" "argcomplete!=3.7.1;python_version<'3.10'" - name: Run Test Suite run: nox -t test -- unit functional env: @@ -235,9 +239,12 @@ jobs: - name: compile requirements.txt run: | + # uv replaces pip-compile here: pip-tools reaches into pip internals + # (pip._internal.utils.compat.stdlib_pkgs), which pip 26.2 removed, so + # `pip install --upgrade pip` and `pip-compile` cannot both succeed. python -m pip install --upgrade pip - pip install pip-tools pipenv - pip-compile -o requirements.txt + pip install uv + uv pip compile pyproject.toml -o requirements.txt cat requirements.txt - name: Run FOSSA scan and upload build data diff --git a/docs/source/index.md b/docs/source/index.md index 9a68094..facca5a 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -31,6 +31,8 @@ plugins: # Regex string for matching the release name. autoprocess: True # Autoprocess the body for user and issue/pull request links + include_prereleases: False + # Include prereleases (draft releases are always excluded) enabled: True # Enable or disable the plugin. ``` @@ -50,6 +52,7 @@ markdown github_api_url: release_template: match: '[0-9+].[0-9+].[0-9]+' + include_prereleases: false autoprocess: true ``` diff --git a/noxfile.py b/noxfile.py index 5540685..0d8c028 100644 --- a/noxfile.py +++ b/noxfile.py @@ -40,10 +40,15 @@ def lint(session): @nox.session(reuse_venv=True, tags=['lint']) def security(session): - install_dependencies(session, required=False, optional=['dev', 'dev-security']) + # Dependencies are installed (required=True) so that the audit below sees the + # runtime requirements and not just the tooling. + install_dependencies(session, required=True, optional=['dev', 'dev-security']) Path('reports').mkdir(exist_ok=True) - session.run('pipenv', 'lock') - session.run('pipenv', 'check') + # Replaces `pipenv lock` + `pipenv check`: current pipenv delegates checking to + # safety, which prompts to install itself and then wants an API key, so on CI it + # dies with `EOFError: EOF when reading a line`. pip-audit needs neither, and + # audits the session environment directly rather than via a generated lockfile. + session.run('pip-audit', '--progress-spinner', 'off') session.run('bandit', '-r', 'src') session.run('bandit', '-r', 'src', '--format', 'xml', '--output', 'reports/security-results.xml') diff --git a/pyproject.toml b/pyproject.toml index d794c5e..bcdbed1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,11 +59,14 @@ dev-lint = [ "flake8-noqa>=1.3.1" ] dev-security = [ - "pipenv", + "pip-audit", "bandit" ] dev-types = [ "mypy", + # Required by mypy's --cobertura-xml-report, which otherwise aborts with an + # INTERNAL ERROR rather than a missing-dependency message. + "lxml", "types-colorama", "types-setuptools" ] diff --git a/src/mkdocs_github_changelog/extension.py b/src/mkdocs_github_changelog/extension.py index 490dfa5..ab835f1 100644 --- a/src/mkdocs_github_changelog/extension.py +++ b/src/mkdocs_github_changelog/extension.py @@ -109,8 +109,9 @@ def _process_block( release_template = config.get('release_template', self._config.get('release_template', None)) match = config.get('match', self._config.get('match', None)) autoprocess = config.get('autoprocess', self._config.get('autoprocess', True)) + include_prereleases = config.get('include_prereleases', self._config.get('include_prereleases', False)) logger.info('Getting releases for {org}/{repo}') - logger.debug('Config:: \nrelease_template: {release_template}\ngithub_api_url: {github_api_url}\nmatch: {match}\nautoprocess: {autoprocess}') + logger.debug('Config:: \nrelease_template: {release_template}\ngithub_api_url: {github_api_url}\nmatch: {match}\nautoprocess: {autoprocess}\ninclude_prereleases: {include_prereleases}') block = '\n\n'.join(get_releases_as_markdown( organisation_or_user=org, repository=repo, @@ -118,7 +119,8 @@ def _process_block( release_template=release_template, github_api_url=github_api_url, match=match, - autoprocess=autoprocess + autoprocess=autoprocess, + include_prereleases=include_prereleases )) # We need to decrease/increase the base indent level if base_indent > 0: diff --git a/src/mkdocs_github_changelog/get_releases.py b/src/mkdocs_github_changelog/get_releases.py index 1ac6f90..5668fd6 100644 --- a/src/mkdocs_github_changelog/get_releases.py +++ b/src/mkdocs_github_changelog/get_releases.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime +import inspect import json import os import re @@ -16,14 +17,44 @@ from dateutil.parser import parse -from ghapi.all import GhApi, paged +import ghapi.all +from ghapi.all import GhApi from jinja2 import Environment from mkdocs_github_changelog import logger +# On ghapi 2.x, paged() is an async generator even against a synchronous client, +# so iterating it raises "'async_generator' object is not iterable"; sync_paged() +# is its synchronous form. On 1.x, paged() is already synchronous and sync_paged +# does not exist. Bound to one name so the call site is version-agnostic. +paged = getattr(ghapi.all, 'sync_paged', ghapi.all.paged) + RELEASE_TEMPLATE = "# [{{release.name}}]({{release.html_url}})\n*Released at {{release.published_at.isoformat()}}*\n\n{{release.body}}" +def _supports_sync() -> bool: + """Whether the installed ghapi accepts the ``sync`` constructor flag.""" + return 'sync' in inspect.signature(GhApi.__init__).parameters + + +def _make_api(token: str | None, github_api_url: str | None) -> GhApi: + """Build a GhApi that returns results rather than coroutines. + + ghapi 2.0 made operation calls asynchronous by default, so ``paged(...)`` + yields an async generator and iterating it raises ``'async_generator' object + is not iterable``. The same release added a ``sync`` flag selecting a + synchronous transport. + + The flag does not exist on ghapi 1.x, which accepts arbitrary keyword + arguments without necessarily ignoring them, so it is passed only when the + installed signature declares it. + """ + kwargs = {'token': token, 'gh_host': github_api_url} + if _supports_sync(): + kwargs['sync'] = True + return GhApi(**kwargs) + + class _EnvironmentFactory(): """Jinja2 Environment Factory to allow for extension/customisation. @@ -95,15 +126,55 @@ def github_issue_link(match_obj): return release -def _process_releases(releases, match: str | None = None, autoprocess: bool = True): +def _coerce_published_at(release) -> datetime | None: + """Return a release's ``published_at`` as a datetime, or None if it has none. + + The value is checked by type rather than by Python version. An unpublished + release has no timestamp at all: the API returns ``null``, which ghapi + surfaces as an empty ``AttrDict`` rather than ``None``, so neither a + ``datetime`` nor ``str`` check matches and there is nothing to parse. + + The version branch below is only about *how* to parse a string: + ``fromisoformat`` cannot handle the trailing ``Z`` of a GitHub timestamp + before 3.11, so dateutil is used there. + """ + value = getattr(release, 'published_at', None) + if isinstance(value, datetime): + return value + if isinstance(value, str) and value: + if sys.version_info.major >= 3 and sys.version_info.minor < 11: + return parse(value) + return datetime.fromisoformat(value) + return None + + +def _process_releases( + releases, + match: str | None = None, + autoprocess: bool = True, + include_prereleases: bool = False, +): selected_releases = [] for release in releases: - # Convert the published_at to datetime object - if not isinstance(release.published_at, datetime): - if sys.version_info.major >= 3 and sys.version_info.minor < 11: - release.published_at = parse(release.published_at) - else: - release.published_at = datetime.fromisoformat(release.published_at) + # Drafts are unpublished, so they have no published_at and an empty + # name; they render as a broken, dateless entry and do not belong in a + # changelog. Skipping them also avoids failing the whole build on the + # missing timestamp. + if getattr(release, 'draft', False): + logger.debug(f'Skipping draft release {release.html_url}') + continue + # A prerelease is published, so it renders fine, but it is usually noise + # in a changelog -- excluded unless asked for. + if not include_prereleases and getattr(release, 'prerelease', False): + logger.debug(f'Skipping prerelease {release.html_url}') + continue + published_at = _coerce_published_at(release) + if published_at is None: + # Defensive: a published release should always carry a timestamp, so + # warn rather than fail the build if one somehow does not. + logger.warning(f'Skipping release with no published_at: {release.html_url}') + continue + release.published_at = published_at if autoprocess is None or autoprocess: autoprocess_github_links(release) if (match and re.match(match, release.name) is not None) or not match: @@ -118,19 +189,25 @@ def get_releases_as_markdown( release_template: str | None = RELEASE_TEMPLATE, github_api_url: str | None = None, match: str | None = None, - autoprocess: bool | None = True + autoprocess: bool | None = True, + include_prereleases: bool | None = False ): """Get the releases from github as a list of rendered markdown strings.""" if github_api_url is not None: github_api_url = github_api_url.rstrip('/') logger.info('Getting releases from github') - api = GhApi(token=token, gh_host=github_api_url) + api = _make_api(token, github_api_url) releases = [] for page in paged(api.repos.list_releases, organisation_or_user, repository, per_page=100): releases += page logger.info(f'Processing releases from github, {len(releases)} found') jinja_environment = JINJA_ENVIRONMENT_FACTORY.environment - selected_releases = _process_releases(releases, match=match, autoprocess=autoprocess) + selected_releases = _process_releases( + releases, + match=match, + autoprocess=autoprocess, + include_prereleases=include_prereleases, + ) if release_template is None: release_template = RELEASE_TEMPLATE logger.info(f'Rendering releases from github, {len(releases)} selected') diff --git a/src/mkdocs_github_changelog/plugin.py b/src/mkdocs_github_changelog/plugin.py index 53281c8..f3b2da4 100644 --- a/src/mkdocs_github_changelog/plugin.py +++ b/src/mkdocs_github_changelog/plugin.py @@ -30,6 +30,8 @@ class PluginConfig(Config): """Regex string for matching the rleease name.""" autoprocess = opt.Type(bool, default=True) """Autoprocess the release bodies for issue and username links.""" + include_prereleases = opt.Type(bool, default=False) + """Include prereleases in the changelog.""" enabled = opt.Type(bool, default=True) """Enable or disable the plugin.""" diff --git a/tests/functional/test_plugin.py b/tests/functional/test_plugin.py index 9de3084..e5a6248 100644 --- a/tests/functional/test_plugin.py +++ b/tests/functional/test_plugin.py @@ -3,7 +3,6 @@ from pathlib import Path import unittest from unittest.mock import MagicMock, patch -import webbrowser from click.testing import CliRunner from fastcore.net import HTTP403ForbiddenError, HTTP404NotFoundError @@ -27,17 +26,24 @@ def mock_gh_api(func): @patch.object(get_releases, 'paged', autospec=True) @wraps(func) def mocked_call(self, paged, GhApi): + # draft and prerelease must be set explicitly: an unset MagicMock + # attribute is truthy, so leaving them off makes every release look like + # a draft prerelease and it gets filtered out of the changelog. release1_content = MagicMock() release1_content.body = RELEASE_1 release1_content.name = '0.2.0' release1_content.html_url = 'https://www.google.com' release1_content.published_at = datetime(2023, 12, 1, 13, 46).astimezone().isoformat() + release1_content.draft = False + release1_content.prerelease = False release2_content = MagicMock() release2_content.body = RELEASE_2 release2_content.name = '0.1.0' release2_content.html_url = 'https://www.google.com' release2_content.published_at = datetime(2023, 11, 1, 13, 46).astimezone().isoformat() + release2_content.draft = False + release2_content.prerelease = False def paged_mock(func, organisation_or_user, repository, *args, **kwargs): print('Mocked', organisation_or_user, repository) @@ -114,9 +120,7 @@ def test_mkdocs(self, *args): resp = runner.invoke(build_command, catch_exceptions=False) self.assertEqual(resp.exit_code, 0, resp.exc_info) self.assertTrue(Path('html').exists()) - # webbrowser.open(str(Path('html', 'index.html').absolute())) index_html = Path('html', 'index.html') - webbrowser.open(str(index_html.absolute())) contents = index_html.read_text(encoding="utf8") self.assertIn('

0.2.0

\n

Released at 2023-12-01T13:46:00+00:00', contents) self.assertIn('

0.2.0

\n

Released at 2023-12-01T13:46:00+00:00', contents) @@ -198,7 +202,6 @@ def test_mkdocs(self, *args): self.assertTrue(Path('html').exists()) index_html = Path('html', 'index.html') - webbrowser.open(str(index_html.absolute())) contents = index_html.read_text(encoding="utf8") self.assertIn('

Release 0.1.22

', contents) self.assertIn('

Released at 2022-04-17T14:22:48+00:00', contents) diff --git a/tests/unit/test_get_releases.py b/tests/unit/test_get_releases.py index 3bfc36c..1141d3b 100644 --- a/tests/unit/test_get_releases.py +++ b/tests/unit/test_get_releases.py @@ -1,16 +1,20 @@ from datetime import datetime from functools import wraps +import inspect import json import unittest from unittest.mock import call, DEFAULT, MagicMock, patch +from fastcore.basics import AttrDict from fastcore.net import HTTP404NotFoundError from jinja2 import Environment from nskit.common.contextmanagers import Env, TestExtension from mkdocs_github_changelog import get_releases from mkdocs_github_changelog.get_releases import ( + _coerce_published_at, _EnvironmentFactory, + _process_releases, autoprocess_github_links, get_releases_as_markdown, RELEASE_TEMPLATE, @@ -25,11 +29,18 @@ def mock_gh_api(func): @patch.object(get_releases, 'paged', autospec=True) @wraps(func) def mocked_call(self, paged, GhApi): + # NB: every flag the filters read must be set explicitly. A bare + # MagicMock returns a truthy mock for any attribute, so an unset + # ``draft`` or ``prerelease`` would make every fixture release look + # unpublished or pre-release and silently empty the result. The real API + # always includes both fields. release1_content = MagicMock() release1_content.body = RELEASE_1 release1_content.name = '0.2.0' release1_content.html_url = 'https://www.google.com/releases/0.2.0' release1_content.published_at = datetime(2023, 12, 1, 13, 46).astimezone().isoformat() + release1_content.draft = False + release1_content.prerelease = False release1_content.processed = False release2_content = MagicMock() @@ -37,6 +48,8 @@ def mocked_call(self, paged, GhApi): release2_content.name = '0.1.0' release2_content.html_url = 'https://www.google.com/releases/0.2.0' release2_content.published_at = datetime(2023, 11, 1, 13, 46).astimezone().isoformat() + release2_content.draft = False + release2_content.prerelease = False release2_content.processed = False def paged_mock(func, organisation_or_user, repository, *args, **kwargs): @@ -129,6 +142,124 @@ def test_get_releases_as_markdown_no_autoprocess(self, paged, GhApi, release1, r +class DraftAndMissingDateTestCase(unittest.TestCase): + """Releases without a usable published_at must not break the build.""" + + @staticmethod + def _release(name, published_at, draft=False, prerelease=False): + release = MagicMock() + release.body = RELEASE_1 + release.name = name + release.html_url = 'https://www.google.com/releases/' + (name or 'draft') + release.published_at = published_at + release.draft = draft + release.prerelease = prerelease + release.processed = False + return release + + def test_draft_release_is_skipped(self): + """A draft is unpublished, so it is left out of the changelog. + + GitHub returns no published_at for a draft; ghapi surfaces that as an + empty AttrDict, which previously reached datetime.fromisoformat and + raised "argument must be str", failing the whole docs build. + """ + published = self._release('1.0.0', datetime(2023, 12, 1, 13, 46).astimezone().isoformat()) + draft = self._release('', AttrDict({}), draft=True) + selected = _process_releases([draft, published]) + self.assertEqual([r.name for r in selected], ['1.0.0']) + + def test_release_with_empty_attrdict_date_is_skipped(self): + """A non-draft with no timestamp is skipped rather than raising.""" + selected = _process_releases([self._release('1.0.0', AttrDict({}))]) + self.assertEqual(selected, []) + + def test_release_with_none_date_is_skipped(self): + """A ``None`` timestamp is also tolerated.""" + selected = _process_releases([self._release('1.0.0', None)]) + self.assertEqual(selected, []) + + def test_string_date_is_parsed(self): + """An ISO string is converted to a datetime.""" + selected = _process_releases([self._release('1.0.0', '2023-12-01T13:46:00Z')]) + self.assertEqual(len(selected), 1) + self.assertIsInstance(selected[0].published_at, datetime) + + def test_datetime_date_is_left_alone(self): + """An already-parsed datetime is passed through untouched.""" + when = datetime(2023, 12, 1, 13, 46).astimezone() + selected = _process_releases([self._release('1.0.0', when)]) + self.assertEqual(selected[0].published_at, when) + + def test_coerce_published_at_returns_none_for_unusable_values(self): + """The coercion reports "no date" rather than raising.""" + for value in (AttrDict({}), None, '', {}): + with self.subTest(value=value): + release = self._release('1.0.0', value) + self.assertIsNone(_coerce_published_at(release)) + + +class SyncTransportTestCase(unittest.TestCase): + """ghapi 2.x returns coroutines unless the sync transport is selected.""" + + def test_sync_flag_passed_when_supported(self): + """ghapi 2.x, which declares ``sync``, is asked for a sync client. + + Without this, ``paged(...)`` yields an async generator and iterating it + raises "'async_generator' object is not iterable". + """ + captured = {} + + class FakeGhApi: + def __init__(self, token=None, gh_host=None, sync=False): + captured.update(token=token, gh_host=gh_host, sync=sync) + + with patch.object(get_releases, 'GhApi', FakeGhApi): + get_releases._make_api('tok', 'https://api.github.com') + self.assertEqual(captured, {'token': 'tok', 'gh_host': 'https://api.github.com', 'sync': True}) + + def test_sync_flag_withheld_when_unsupported(self): + """ghapi 1.x does not declare ``sync``, so it is not passed. + + 1.x accepts arbitrary keyword arguments but does not necessarily ignore + them, so the flag is withheld rather than relied upon. + """ + captured = {} + + class FakeGhApi: + def __init__(self, token=None, gh_host=None, **kwargs): + captured.update(token=token, gh_host=gh_host, kwargs=kwargs) + + with patch.object(get_releases, 'GhApi', FakeGhApi): + get_releases._make_api('tok', None) + self.assertEqual(captured['kwargs'], {}) + + def test_installed_ghapi_yields_a_synchronous_client(self): + """Against the real library, paged results are iterable. + + Exercises the actual regression rather than a mock: a client whose + operations return coroutines cannot be iterated by ``paged``. + """ + api = get_releases._make_api(None, None) + self.assertFalse(inspect.iscoroutinefunction(api.repos.list_releases.__call__)) + + def test_paged_is_the_synchronous_pager(self): + """The ``paged`` name must not resolve to an async generator function. + + On ghapi 2.x ``paged`` is async even against a sync client, so the + module binds ``sync_paged`` instead; the bare name is what the call site + iterates. + """ + self.assertFalse(inspect.isasyncgenfunction(get_releases.paged)) + + def test_paged_prefers_sync_paged_when_available(self): + """Where the installed ghapi offers ``sync_paged``, that is what is used.""" + import ghapi.all + + expected = getattr(ghapi.all, 'sync_paged', ghapi.all.paged) + self.assertIs(get_releases.paged, expected) + + class AutprocessGithubLinksTestCase(unittest.TestCase): def test_issues(self): @@ -265,3 +396,74 @@ def test_default_environment(self): # Check loader is correct environment = _EnvironmentFactory.default_environment() self.assertIsInstance(environment, Environment) + + +class PrereleaseTestCase(unittest.TestCase): + """Prereleases are excluded unless explicitly asked for.""" + + @staticmethod + def _release(name, prerelease=False, draft=False): + release = MagicMock() + release.body = RELEASE_1 + release.name = name + release.html_url = 'https://www.google.com/releases/' + name + release.published_at = datetime(2023, 12, 1, 13, 46).astimezone().isoformat() + release.draft = draft + release.prerelease = prerelease + release.processed = False + return release + + def test_prerelease_excluded_by_default(self): + """A prerelease is left out unless requested.""" + selected = _process_releases([ + self._release('1.0.0'), + self._release('2.0.0rc1', prerelease=True), + ]) + self.assertEqual([r.name for r in selected], ['1.0.0']) + + def test_prerelease_included_when_requested(self): + """include_prereleases=True keeps them, newest first as returned.""" + selected = _process_releases( + [self._release('1.0.0'), self._release('2.0.0rc1', prerelease=True)], + include_prereleases=True, + ) + self.assertEqual([r.name for r in selected], ['1.0.0', '2.0.0rc1']) + + def test_stable_releases_are_never_affected(self): + """The flag does not change which stable releases are selected.""" + for include in (True, False): + with self.subTest(include_prereleases=include): + selected = _process_releases( + [self._release('1.0.0')], include_prereleases=include + ) + self.assertEqual([r.name for r in selected], ['1.0.0']) + + def test_drafts_excluded_even_when_prereleases_included(self): + """A draft is unpublished, so it stays out regardless of the flag.""" + selected = _process_releases( + [self._release('1.0.0'), self._release('draft', draft=True)], + include_prereleases=True, + ) + self.assertEqual([r.name for r in selected], ['1.0.0']) + + def test_flag_reads_the_prerelease_attribute(self): + """The filter must read ``prerelease``, not a misspelling. + + A missing key on ghapi's AttrDict is falsy, so a typo such as + ``release.prelease`` would silently never filter anything and the flag + would appear to work while doing nothing. Using a mapping here means an + incorrect attribute name raises rather than quietly passing. + """ + release = AttrDict({ + 'name': '2.0.0rc1', + 'body': RELEASE_1, + 'html_url': 'https://www.google.com/releases/2.0.0rc1', + 'published_at': datetime(2023, 12, 1, 13, 46).astimezone().isoformat(), + 'draft': False, + 'prerelease': True, + }) + self.assertEqual(_process_releases([release]), []) + self.assertEqual( + [r.name for r in _process_releases([release], include_prereleases=True)], + ['2.0.0rc1'], + ) diff --git a/tests/unit/test_plugin.py b/tests/unit/test_plugin.py index ef26a2d..2c2b0c6 100644 --- a/tests/unit/test_plugin.py +++ b/tests/unit/test_plugin.py @@ -15,31 +15,41 @@ class MkdocsGithubChangelogPluginTestCase(unittest.TestCase): def test_config_defaults(self): plugin = MkdocsGithubChangelogPlugin() resp = plugin.load_config({}) - self.assertEqual(plugin.config, {'token': None, 'github_api_url': None, 'release_template': None, 'autoprocess': True, 'enabled': True, 'match': None}) + self.assertEqual(plugin.config, {'token': None, 'github_api_url': None, 'release_template': None, 'autoprocess': True, 'include_prereleases': False, 'enabled': True, 'match': None}) self.assertEqual(resp, ([], [])) def test_config_overriden_ok(self): plugin = MkdocsGithubChangelogPlugin() - resp = plugin.load_config({'token': 'abc', 'github_api_url': 'https://api.github.com', 'release_template': '123', 'autoprocess': False, 'match': 'a.b.c', 'enabled': False}) - self.assertEqual(plugin.config, {'token': 'abc', 'github_api_url': 'https://api.github.com', 'release_template': '123', 'autoprocess': False, 'match': 'a.b.c', 'enabled': False}) + resp = plugin.load_config({'token': 'abc', 'github_api_url': 'https://api.github.com', 'release_template': '123', 'autoprocess': False, 'include_prereleases': False, 'match': 'a.b.c', 'enabled': False}) + self.assertEqual(plugin.config, {'token': 'abc', 'github_api_url': 'https://api.github.com', 'release_template': '123', 'autoprocess': False, 'include_prereleases': False, 'match': 'a.b.c', 'enabled': False}) self.assertEqual(resp, ([], [])) def test_config_overriden_bad(self): + # mkdocs.config.base.Config._validate breaks out of its loop on the + # first ValidationError, so only the first failing option in schema + # order is ever reported, however many are invalid. plugin = MkdocsGithubChangelogPlugin() resp = plugin.load_config({'token': ['x', 'y'], 'github_api_url': 'the quick brown fox', 'release_template': ['a', 'b'], 'autoprocess': 'a', 'match': ['x', 'y'], 'enabled': 'x'}) - self.assertEqual(len(resp[0]), 6) + self.assertEqual(len(resp[0]), 1) self.assertEqual(resp[0][0][0], 'token') self.assertIsInstance(resp[0][0][1], ValidationError) - self.assertEqual(resp[0][1][0], 'github_api_url') - self.assertIsInstance(resp[0][1][1], ValidationError) - self.assertEqual(resp[0][2][0], 'release_template') - self.assertIsInstance(resp[0][2][1], ValidationError) - self.assertEqual(resp[0][3][0], 'match') - self.assertIsInstance(resp[0][3][1], ValidationError) - self.assertEqual(resp[0][4][0], 'autoprocess') - self.assertIsInstance(resp[0][4][1], ValidationError) - self.assertEqual(resp[0][5][0], 'enabled') - self.assertIsInstance(resp[0][5][1], ValidationError) + + def test_config_each_option_validated(self): + # Because validation is fail-fast, each option has to be exercised on + # its own to show it is actually validated and not merely unreached. + for key, value in ( + ('token', ['x', 'y']), + ('github_api_url', 'the quick brown fox'), + ('release_template', ['a', 'b']), + ('match', ['x', 'y']), + ('autoprocess', 'a'), + ('include_prereleases', 'a'), + ('enabled', 'x'), + ): + with self.subTest(key=key): + resp = MkdocsGithubChangelogPlugin().load_config({key: value}) + self.assertEqual([k for k, _ in resp[0]], [key]) + self.assertIsInstance(resp[0][0][1], ValidationError) def test_on_config(self): plugin = MkdocsGithubChangelogPlugin() @@ -48,7 +58,7 @@ def test_on_config(self): plugin.on_config(config) self.assertIsInstance(config.markdown_extensions[-1], GithubReleaseChangelogExtension) ext = config.markdown_extensions[-1] - self.assertEqual(ext._config, {'token': None, 'github_api_url': None, 'release_template': None, 'autoprocess': True, 'match': None, 'enabled': True}) + self.assertEqual(ext._config, {'token': None, 'github_api_url': None, 'release_template': None, 'autoprocess': True, 'include_prereleases': False, 'match': None, 'enabled': True}) def test_on_config_from_env(self): with Env(override={'GITHUB_TEST_TOKEN': 'abc'}): @@ -65,4 +75,4 @@ def test_on_config_from_env(self): plugin.on_config(config) self.assertIsInstance(config.markdown_extensions[-1], GithubReleaseChangelogExtension) ext = config.markdown_extensions[-1] - self.assertEqual(ext._config, {'token': 'abc', 'github_api_url': None, 'release_template': None, 'autoprocess': True, 'match': None, 'enabled': True}) + self.assertEqual(ext._config, {'token': 'abc', 'github_api_url': None, 'release_template': None, 'autoprocess': True, 'include_prereleases': False, 'match': None, 'enabled': True}) diff --git a/tests/unit/test_processor.py b/tests/unit/test_processor.py index 80e71b4..f32c5ca 100644 --- a/tests/unit/test_processor.py +++ b/tests/unit/test_processor.py @@ -52,7 +52,8 @@ def test_process_block_simple(self, get_releases_as_markdown): release_template=None, github_api_url=None, match=None, - autoprocess=True + autoprocess=True, + include_prereleases=False ) # Patch get_releases_as_markdown to return the release info @@ -70,7 +71,8 @@ def test_process_block_with_global_config(self, get_releases_as_markdown): release_template='xyz', github_api_url=None, match='*.*.*', - autoprocess=False + autoprocess=False, + include_prereleases=False ) # Patch get_releases_as_markdown to return the release info @@ -87,7 +89,8 @@ def test_process_block_with_local_config(self, get_releases_as_markdown): release_template='ghi', github_api_url='https://microsoft.com', match='a.b.c', - autoprocess=False + autoprocess=False, + include_prereleases=False ) self.assertEqual(result, '#### 0.1.0\n\n##### Features\n Hello World ([#1](https://www.google.com))') @@ -106,7 +109,8 @@ def test_process_block_with_env(self, get_releases_as_markdown): release_template='ghi', github_api_url='https://microsoft.com', match='a.b.c', - autoprocess=True + autoprocess=True, + include_prereleases=False ) self.assertEqual(result, '#### 0.1.0\n\n##### Features\n Hello World ([#1](https://www.google.com))') @@ -125,7 +129,8 @@ def test_process_block_with_heading_level(self, get_releases_as_markdown): release_template=None, github_api_url=None, match=None, - autoprocess=True + autoprocess=True, + include_prereleases=False ) self.assertEqual(result, '#### 0.1.0\n\n##### Features\n Hello World ([#1](https://www.google.com))')