From 00f836095d70d46eabf2ba6bdc58eb85fb8ce8eb Mon Sep 17 00:00:00 2001 From: David Pugh Date: Wed, 5 Aug 2026 14:10:03 +0100 Subject: [PATCH 1/8] fix: support ghapi 2.x and skip draft releases Two independent faults, both of which fail the whole docs build. 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; `_make_api` sets it when the installed signature declares it, so ghapi 1.x is unaffected. Draft releases were not filtered. A draft is unpublished, so GitHub returns no `published_at` -- ghapi surfaces that as an empty AttrDict, which reached `datetime.fromisoformat` and raised "argument must be str". Drafts also have an empty name, so they could only ever render as a broken, dateless entry. They are now skipped, and a published release somehow lacking a timestamp is warned about and skipped rather than failing the build. The date coercion now branches on the value's type rather than the Python version. The version check remains only for choosing how to parse a string, since fromisoformat cannot handle a trailing Z before 3.11. Verified end to end: nskit's docs build previously failed on both faults and now completes, rendering four releases with dates and excluding the draft. --- src/mkdocs_github_changelog/get_releases.py | 68 ++++++++++-- tests/unit/test_get_releases.py | 111 ++++++++++++++++++++ 2 files changed, 172 insertions(+), 7 deletions(-) diff --git a/src/mkdocs_github_changelog/get_releases.py b/src/mkdocs_github_changelog/get_releases.py index 1ac6f90..44bc814 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 @@ -24,6 +25,29 @@ 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 +119,45 @@ def github_issue_link(match_obj): return release +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): 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 + 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: @@ -124,7 +178,7 @@ def get_releases_as_markdown( 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 diff --git a/tests/unit/test_get_releases.py b/tests/unit/test_get_releases.py index 3bfc36c..863a953 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,16 @@ def mock_gh_api(func): @patch.object(get_releases, 'paged', autospec=True) @wraps(func) def mocked_call(self, paged, GhApi): + # NB: ``draft`` must be set explicitly. A bare MagicMock returns a + # truthy mock for any attribute, so an unset ``draft`` would make every + # fixture release look like an unpublished draft. The real API always + # includes the field. 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.processed = False release2_content = MagicMock() @@ -37,6 +46,7 @@ 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.processed = False def paged_mock(func, organisation_or_user, repository, *args, **kwargs): @@ -129,6 +139,107 @@ 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): + 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.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__)) + + class AutprocessGithubLinksTestCase(unittest.TestCase): def test_issues(self): From dc848e69d6fec6119b98817fd1903a008995755b Mon Sep 17 00:00:00 2001 From: David Pugh Date: Wed, 5 Aug 2026 14:51:51 +0100 Subject: [PATCH 2/8] feat: add include_prereleases option (supersedes #14) Folds in #14's prerelease flag, with three corrections. The filter reads `release.prerelease`. #14 spelt it `prelease`, and since a missing key on ghapi's AttrDict is falsy, the flag would have silently never filtered anything while appearing to work. The option is wired through. #14 added it to PluginConfig and to get_releases_as_markdown, but extension.py plucks each option individually and was not updated, so the config never reached the call. Naming is consistent: `include_prereleases` everywhere, rather than a plural config key against a singular function parameter. Drafts remain excluded regardless of the flag, since they are unpublished and carry no timestamp. The shared test fixtures now set `prerelease` explicitly, for the same reason the existing comment gives for `draft`: a bare MagicMock returns a truthy mock for any unset attribute, so an unset flag silently empties the result. One test drives the filter through an AttrDict so a misspelt attribute raises instead of quietly passing -- which is what let #14's typo through. --- docs/source/index.md | 3 + src/mkdocs_github_changelog/extension.py | 6 +- src/mkdocs_github_changelog/get_releases.py | 22 +++++- src/mkdocs_github_changelog/plugin.py | 2 + tests/unit/test_get_releases.py | 85 +++++++++++++++++++-- tests/unit/test_plugin.py | 10 +-- tests/unit/test_processor.py | 15 ++-- 7 files changed, 123 insertions(+), 20 deletions(-) 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/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 44bc814..8fae632 100644 --- a/src/mkdocs_github_changelog/get_releases.py +++ b/src/mkdocs_github_changelog/get_releases.py @@ -141,7 +141,12 @@ def _coerce_published_at(release) -> datetime | None: return None -def _process_releases(releases, match: str | None = None, autoprocess: bool = True): +def _process_releases( + releases, + match: str | None = None, + autoprocess: bool = True, + include_prereleases: bool = False, +): selected_releases = [] for release in releases: # Drafts are unpublished, so they have no published_at and an empty @@ -151,6 +156,11 @@ def _process_releases(releases, match: str | None = None, autoprocess: bool = Tr 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 @@ -172,7 +182,8 @@ 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: @@ -184,7 +195,12 @@ def get_releases_as_markdown( 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/unit/test_get_releases.py b/tests/unit/test_get_releases.py index 863a953..e0065d3 100644 --- a/tests/unit/test_get_releases.py +++ b/tests/unit/test_get_releases.py @@ -29,16 +29,18 @@ def mock_gh_api(func): @patch.object(get_releases, 'paged', autospec=True) @wraps(func) def mocked_call(self, paged, GhApi): - # NB: ``draft`` must be set explicitly. A bare MagicMock returns a - # truthy mock for any attribute, so an unset ``draft`` would make every - # fixture release look like an unpublished draft. The real API always - # includes the field. + # 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() @@ -47,6 +49,7 @@ def mocked_call(self, paged, GhApi): 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): @@ -143,13 +146,14 @@ class DraftAndMissingDateTestCase(unittest.TestCase): """Releases without a usable published_at must not break the build.""" @staticmethod - def _release(name, published_at, draft=False): + 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 @@ -376,3 +380,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..d5af5d7 100644 --- a/tests/unit/test_plugin.py +++ b/tests/unit/test_plugin.py @@ -15,13 +15,13 @@ 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): @@ -48,7 +48,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 +65,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))') From 055b588aebabd130631035c2d8f174ebbfe197ee Mon Sep 17 00:00:00 2001 From: David Pugh Date: Wed, 5 Aug 2026 16:39:19 +0100 Subject: [PATCH 3/8] fix: use sync_paged on ghapi 2.x paged() is an async generator on 2.x even with sync=True, so iterating it raised "async_generator object is not iterable". --- src/mkdocs_github_changelog/get_releases.py | 9 ++++++++- tests/unit/test_get_releases.py | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/mkdocs_github_changelog/get_releases.py b/src/mkdocs_github_changelog/get_releases.py index 8fae632..5668fd6 100644 --- a/src/mkdocs_github_changelog/get_releases.py +++ b/src/mkdocs_github_changelog/get_releases.py @@ -17,11 +17,18 @@ 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}}" diff --git a/tests/unit/test_get_releases.py b/tests/unit/test_get_releases.py index e0065d3..1141d3b 100644 --- a/tests/unit/test_get_releases.py +++ b/tests/unit/test_get_releases.py @@ -243,6 +243,22 @@ def test_installed_ghapi_yields_a_synchronous_client(self): 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): From 72826b13d17af2e8b46b0642fce3814fd61ccdbc Mon Sep 17 00:00:00 2001 From: David Pugh Date: Wed, 5 Aug 2026 17:58:02 +0100 Subject: [PATCH 4/8] test: match mkdocs fail-fast config validation Config._validate breaks on the first error, so only one is ever reported; each option is now checked individually instead. --- tests/unit/test_plugin.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/tests/unit/test_plugin.py b/tests/unit/test_plugin.py index d5af5d7..2c2b0c6 100644 --- a/tests/unit/test_plugin.py +++ b/tests/unit/test_plugin.py @@ -25,21 +25,31 @@ def test_config_overriden_ok(self): 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() From d534c1cc73ba4231d29830cc3ece0da967010dce Mon Sep 17 00:00:00 2001 From: David Pugh Date: Wed, 5 Aug 2026 17:58:09 +0100 Subject: [PATCH 5/8] ci: audit with pip-audit instead of pipenv check pipenv check delegates to safety, which prompts to install itself and needs an API key, so it fails with EOFError on CI. Also add lxml, which mypy needs for its cobertura report. --- noxfile.py | 11 ++++++++--- pyproject.toml | 5 ++++- 2 files changed, 12 insertions(+), 4 deletions(-) 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" ] From cf0669bebb9fc0c3eb11f4a8b68da8a76c7558de Mon Sep 17 00:00:00 2001 From: David Pugh Date: Wed, 5 Aug 2026 18:11:22 +0100 Subject: [PATCH 6/8] test: set draft/prerelease on functional release mocks An unset MagicMock attribute is truthy, so every mocked release looked like a draft prerelease and was filtered out of the built changelog. --- tests/functional/test_plugin.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/functional/test_plugin.py b/tests/functional/test_plugin.py index 9de3084..644065b 100644 --- a/tests/functional/test_plugin.py +++ b/tests/functional/test_plugin.py @@ -27,17 +27,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) From acb60b6e67c972ae5c0c5b89eaead74d190fc754 Mon Sep 17 00:00:00 2001 From: David Pugh Date: Wed, 5 Aug 2026 18:16:39 +0100 Subject: [PATCH 7/8] test: drop webbrowser.open from functional tests Launching a browser held the temp file open, so cleanup failed on Windows with WinError 32. --- tests/functional/test_plugin.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/functional/test_plugin.py b/tests/functional/test_plugin.py index 644065b..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 @@ -121,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) @@ -205,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) From c2fef9daa73159c53329f58646b1bc5539daec1d Mon Sep 17 00:00:00 2001 From: David Pugh Date: Wed, 5 Aug 2026 18:23:38 +0100 Subject: [PATCH 8/8] ci: unbreak 3.8/3.9 test jobs and the licence scan argcomplete 3.7.1 does not import on 3.8/3.9, so nox never started. pip 26.2 dropped an internal pip-tools relies on, so use uv to compile. --- .github/workflows/pipeline.yaml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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