Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

feat(changelog): add sort_numerically filter function to template environment #1146

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions 26 docs/changelog_templates.rst
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,31 @@ The filters provided vary based on the VCS configured and available features:
{% set prev_changelog_contents = prev_changelog_file | read_file | safe %}


* ``sort_numerically (Callable[[Iterable[str], bool], list[str]])``: given a
sequence of strings with possibly some non-number characters as a prefix or suffix,
sort the strings as if they were just numbers from lowest to highest. This filter
is useful when you want to sort issue numbers or other strings that have a numeric
component in them but cannot be cast to a number directly to sort them. If you want
to sort the strings in reverse order, you can pass a boolean value of ``True`` as the
second argument.

*Introduced in v9.16.0.*

**Example Usage:**

.. code:: jinja

{{ ["#222", "#1023", "#444"] | sort_numerically }}
{{ ["#222", "#1023", "#444"] | sort_numerically(True) }}

**Markdown Output:**

.. code:: markdown

['#222', '#444', '#1023']
['#1023', '#444', '#222']


Availability of the documented filters can be found in the table below:

====================== ========= ===== ====== ======
Expand All @@ -846,6 +871,7 @@ issue_url ❌ ✅ ✅ ✅
merge_request_url ❌ ❌ ❌ ✅
pull_request_url ✅ ✅ ✅ ✅
read_file ✅ ✅ ✅ ✅
sort_numerically ✅ ✅ ✅ ✅
====================== ========= ===== ====== ======

.. seealso::
Expand Down
3 changes: 3 additions & 0 deletions 3 src/semantic_release/changelog/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from re import compile as regexp
from typing import TYPE_CHECKING, Any, Callable, Literal

from semantic_release.helpers import sort_numerically

if TYPE_CHECKING: # pragma: no cover
from jinja2 import Environment

Expand Down Expand Up @@ -87,6 +89,7 @@ def make_changelog_context(
read_file,
convert_md_to_rst,
autofit_text_width,
sort_numerically,
),
)

Expand Down
7 changes: 6 additions & 1 deletion 7 src/semantic_release/cli/changelog_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
)
from semantic_release.cli.util import noop_report
from semantic_release.errors import InternalError
from semantic_release.helpers import sort_numerically

if TYPE_CHECKING: # pragma: no cover
from jinja2 import Environment
Expand Down Expand Up @@ -254,7 +255,11 @@ def generate_release_notes(
version=release["version"],
release=release,
mask_initial_release=mask_initial_release,
filters=(*hvcs_client.get_changelog_context_filters(), autofit_text_width),
filters=(
*hvcs_client.get_changelog_context_filters(),
autofit_text_width,
sort_numerically,
),
).bind_to_environment(
# Use a new, non-configurable environment for release notes -
# not user-configurable at the moment
Expand Down
7 changes: 2 additions & 5 deletions 7 src/semantic_release/commit_parser/angular.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,10 @@
ParseError,
ParseResult,
)
from semantic_release.commit_parser.util import (
breaking_re,
parse_paragraphs,
sort_numerically,
)
from semantic_release.commit_parser.util import breaking_re, parse_paragraphs
from semantic_release.enums import LevelBump
from semantic_release.errors import InvalidParserOptions
from semantic_release.helpers import sort_numerically

if TYPE_CHECKING: # pragma: no cover
from git.objects.commit import Commit
Expand Down
3 changes: 2 additions & 1 deletion 3 src/semantic_release/commit_parser/emoji.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@
ParsedMessageResult,
ParseResult,
)
from semantic_release.commit_parser.util import parse_paragraphs, sort_numerically
from semantic_release.commit_parser.util import parse_paragraphs
from semantic_release.enums import LevelBump
from semantic_release.errors import InvalidParserOptions
from semantic_release.helpers import sort_numerically

logger = logging.getLogger(__name__)

Expand Down
13 changes: 6 additions & 7 deletions 13 src/semantic_release/commit_parser/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@
from re import MULTILINE, compile as regexp
from typing import TYPE_CHECKING

# TODO: remove in v10
from semantic_release.helpers import (
sort_numerically, # noqa: F401 # TODO: maintained for compatibility
)

if TYPE_CHECKING: # pragma: no cover
from re import Pattern
from typing import Sequence, TypedDict
from typing import TypedDict

class RegexReplaceDef(TypedDict):
pattern: Pattern
repl: str


number_pattern = regexp(r"(\d+)")

breaking_re = regexp(r"BREAKING[ -]CHANGE:\s?(.*)")

un_word_wrap: RegexReplaceDef = {
Expand Down Expand Up @@ -71,7 +74,3 @@ def parse_paragraphs(text: str) -> list[str]:
],
)
)


def sort_numerically(iterable: Sequence[str] | set[str]) -> list[str]:
return sorted(iterable, key=lambda x: int((number_pattern.search(x) or [-1])[0]))
75 changes: 73 additions & 2 deletions 75 src/semantic_release/helpers.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,87 @@
from __future__ import annotations

import importlib.util
import logging
import os
import re
import string
import sys
from functools import lru_cache, wraps
from functools import lru_cache, reduce, wraps
from pathlib import Path, PurePosixPath
from typing import Any, Callable, NamedTuple, TypeVar
from re import IGNORECASE, compile as regexp
from typing import TYPE_CHECKING, Any, Callable, NamedTuple, TypeVar
from urllib.parse import urlsplit

if TYPE_CHECKING: # pragma: no cover
from typing import Iterable


log = logging.getLogger(__name__)

number_pattern = regexp(r"(?P<prefix>\S*?)(?P<number>\d[\d,]*)\b")
hex_number_pattern = regexp(
r"(?P<prefix>\S*?)(?:0x)?(?P<number>[0-9a-f]+)\b", IGNORECASE
)


def get_number_from_str(
string: str, default: int = -1, interpret_hex: bool = False
) -> int:
if interpret_hex and (match := hex_number_pattern.search(string)):
return abs(int(match.group("number"), 16))

if match := number_pattern.search(string):
return int(match.group("number"))

return default


def sort_numerically(
iterable: Iterable[str], reverse: bool = False, allow_hex: bool = False
) -> list[str]:
# Alphabetically sort prefixes first, then sort by number
alphabetized_list = sorted(iterable)

# Extract prefixes in order to group items by prefix
unmatched_items = []
prefixes: dict[str, list[str]] = {}
for item in alphabetized_list:
if not (
pattern_match := (
(hex_number_pattern.search(item) if allow_hex else None)
or number_pattern.search(item)
)
):
unmatched_items.append(item)
continue

prefix = prefix if (prefix := pattern_match.group("prefix")) else ""

if prefix not in prefixes:
prefixes[prefix] = []

prefixes[prefix].append(item)

# Sort prefixes and items by number mixing in unmatched items as alphabetized with other prefixes
return reduce(
lambda acc, next_item: acc + next_item,
[
(
sorted(
prefixes[prefix],
key=lambda x: get_number_from_str(
x, default=-1, interpret_hex=allow_hex
),
reverse=reverse,
)
if prefix in prefixes
else [prefix]
)
for prefix in sorted([*prefixes.keys(), *unmatched_items])
],
[],
)


def format_arg(value: Any) -> str:
"""Helper to format an argument an argument for logging"""
Expand Down
88 changes: 88 additions & 0 deletions 88 tests/unit/semantic_release/changelog/test_changelog_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,3 +497,91 @@ def test_changelog_context_autofit_text_width_w_indent(

# Evaluate
assert expected_changelog == actual_changelog


def test_changelog_context_sort_numerically(
example_git_https_url: str,
artificial_release_history: ReleaseHistory,
changelog_md_file: Path,
):
changelog_tpl = dedent(
"""\
{{ [
".. _#5: link",
".. _PR#3: link",
".. _PR#10: link",
".. _#100: link"
] | sort_numerically | join("\\n")
}}
"""
)

expected_changelog = dedent(
"""\
.. _#5: link
.. _#100: link
.. _PR#3: link
.. _PR#10: link
"""
)

env = environment(trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True)
context = make_changelog_context(
hvcs_client=Gitlab(example_git_https_url),
release_history=artificial_release_history,
mode=ChangelogMode.UPDATE,
prev_changelog_file=changelog_md_file,
insertion_flag="",
mask_initial_release=False,
)
context.bind_to_environment(env)

# Create changelog from template with environment
actual_changelog = env.from_string(changelog_tpl).render()

# Evaluate
assert expected_changelog == actual_changelog


def test_changelog_context_sort_numerically_reverse(
example_git_https_url: str,
artificial_release_history: ReleaseHistory,
changelog_md_file: Path,
):
changelog_tpl = dedent(
"""\
{{ [
".. _#5: link",
".. _PR#3: link",
".. _PR#10: link",
".. _#100: link"
] | sort_numerically(reverse=True) | join("\\n")
}}
"""
)

expected_changelog = dedent(
"""\
.. _#100: link
.. _#5: link
.. _PR#10: link
.. _PR#3: link
"""
)

env = environment(trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True)
context = make_changelog_context(
hvcs_client=Gitlab(example_git_https_url),
release_history=artificial_release_history,
mode=ChangelogMode.UPDATE,
prev_changelog_file=changelog_md_file,
insertion_flag="",
mask_initial_release=False,
)
context.bind_to_environment(env)

# Create changelog from template with environment
actual_changelog = env.from_string(changelog_tpl).render()

# Evaluate
assert expected_changelog == actual_changelog
Loading
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.