File: tags.py

package info (click to toggle)
commitizen 4.10.1-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 2,892 kB
  • sloc: python: 17,728; makefile: 15
file content (267 lines) | stat: -rw-r--r-- 9,471 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
from __future__ import annotations

import re
import warnings
from dataclasses import dataclass, field
from functools import cached_property
from itertools import chain
from string import Template
from typing import TYPE_CHECKING, NamedTuple

from commitizen import out
from commitizen.defaults import DEFAULT_SETTINGS, Settings, get_tag_regexes
from commitizen.git import GitTag
from commitizen.version_schemes import (
    DEFAULT_SCHEME,
    InvalidVersion,
    Version,
    VersionScheme,
    get_version_scheme,
)

if TYPE_CHECKING:
    import sys
    from collections.abc import Iterable, Sequence

    from commitizen.version_schemes import VersionScheme

    # Self is Python 3.11+ but backported in typing-extensions
    if sys.version_info < (3, 11):
        from typing_extensions import Self
    else:
        from typing import Self


class VersionTag(NamedTuple):
    """Represent a version and its matching tag form."""

    version: str
    tag: str


@dataclass
class TagRules:
    """
    Encapsulate tag-related rules.

    It allows to filter or match tags according to rules provided in settings:
    - `tag_format`: the current format of the tags generated on `bump`
    - `legacy_tag_formats`: previous known formats of the tag
    - `ignored_tag_formats`: known formats that should be ignored
    - `merge_prereleases`: if `True`, prereleases will be merged with their release counterpart
    - `version_scheme`: the version scheme to use, which will be used to parse and format versions

    This class is meant to abstract and centralize all the logic related to tags.
    To ensure consistency, it is recommended to use this class to handle tags.

    Example:

    ```python
    settings = DEFAULT_SETTINGS.clone()
    settings.update(
        {
            "tag_format": "v{version}",
            "legacy_tag_formats": ["version{version}", "ver{version}"],
            "ignored_tag_formats": ["ignored{version}"],
        }
    )

    rules = TagRules.from_settings(settings)

    assert rules.is_version_tag("v1.0.0")
    assert rules.is_version_tag("version1.0.0")
    assert rules.is_version_tag("ver1.0.0")
    assert not rules.is_version_tag("ignored1.0.0", warn=True)  # Does not warn
    assert not rules.is_version_tag("warn1.0.0", warn=True)  # Does warn

    assert rules.search_version("# My v1.0.0 version").version == "1.0.0"
    assert rules.extract_version("v1.0.0") == Version("1.0.0")
    try:
        assert rules.extract_version("not-a-v1.0.0")
    except InvalidVersion:
        print("Does not match a tag format")
    ```
    """

    scheme: VersionScheme = DEFAULT_SCHEME
    tag_format: str = DEFAULT_SETTINGS["tag_format"]
    legacy_tag_formats: Sequence[str] = field(default_factory=list)
    ignored_tag_formats: Sequence[str] = field(default_factory=list)
    merge_prereleases: bool = False

    @property
    def tag_formats(self) -> Iterable[str]:
        return chain([self.tag_format], self.legacy_tag_formats)

    @cached_property
    def version_regexes(self) -> list[re.Pattern]:
        """Regexes for all legit tag formats, current and legacy"""
        return [re.compile(self._format_regex(f)) for f in self.tag_formats]

    @cached_property
    def ignored_regexes(self) -> list[re.Pattern]:
        """Regexes for known but ignored tag formats"""
        return [
            re.compile(self._format_regex(f, star=True))
            for f in self.ignored_tag_formats
        ]

    def _format_regex(self, tag_pattern: str, star: bool = False) -> str:
        """
        Format a tag pattern into a regex pattern.

        If star is `True`, the `*` character will be considered as a wildcard.
        """
        tag_regexes = get_tag_regexes(self.scheme.parser.pattern)
        format_regex = tag_pattern.replace("*", "(?:.*?)") if star else tag_pattern
        for pattern, regex in tag_regexes.items():
            format_regex = format_regex.replace(pattern, regex)
        return format_regex

    def _version_tag_error(self, tag: str) -> str:
        """Format the error message for an invalid version tag"""
        return f"Invalid version tag: '{tag}' does not match any configured tag format"

    def is_version_tag(self, tag: str | GitTag, warn: bool = False) -> bool:
        """
        True if a given tag is a legit version tag.

        if `warn` is `True`, it will print a warning message if the tag is not a version tag.
        """
        tag = tag.name if isinstance(tag, GitTag) else tag
        is_legit = any(regex.fullmatch(tag) for regex in self.version_regexes)
        if warn and not is_legit and not self.is_ignored_tag(tag):
            out.warn(self._version_tag_error(tag))
        return is_legit

    def is_ignored_tag(self, tag: str | GitTag) -> bool:
        """True if a given tag can be ignored"""
        tag = tag.name if isinstance(tag, GitTag) else tag
        return any(regex.match(tag) for regex in self.ignored_regexes)

    def get_version_tags(
        self, tags: Iterable[GitTag], warn: bool = False
    ) -> list[GitTag]:
        """Filter in version tags and warn on unexpected tags"""
        return [tag for tag in tags if self.is_version_tag(tag, warn)]

    def extract_version(self, tag: GitTag) -> Version:
        """
        Extract a version from the tag as defined in tag formats.

        Raises `InvalidVersion` if the tag does not match any format.
        """
        candidates = (
            m for regex in self.version_regexes if (m := regex.fullmatch(tag.name))
        )
        if not (m := next(candidates, None)):
            raise InvalidVersion(self._version_tag_error(tag.name))
        if "version" in m.groupdict():
            return self.scheme(m.group("version"))

        parts = m.groupdict()
        version = parts["major"]

        if minor := parts.get("minor"):
            version = f"{version}.{minor}"
        if patch := parts.get("patch"):
            version = f"{version}.{patch}"

        if parts.get("prerelease"):
            version = f"{version}-{parts['prerelease']}"
        if parts.get("devrelease"):
            version = f"{version}{parts['devrelease']}"
        return self.scheme(version)

    def include_in_changelog(self, tag: GitTag) -> bool:
        """Check if a tag should be included in the changelog"""
        try:
            version = self.extract_version(tag)
        except InvalidVersion:
            return False
        return not (self.merge_prereleases and version.is_prerelease)

    def search_version(self, text: str, last: bool = False) -> VersionTag | None:
        """
        Search the first or last version tag occurrence in text.

        It searches for complete versions only (aka `major`, `minor` and `patch`)
        """
        candidates = (
            m for regex in self.version_regexes if len(m := list(regex.finditer(text)))
        )
        if not (matches := next(candidates, [])):
            return None

        match = matches[-1 if last else 0]

        if "version" in match.groupdict():
            return VersionTag(match.group("version"), match.group(0))

        parts = match.groupdict()
        try:
            version = f"{parts['major']}.{parts['minor']}.{parts['patch']}"
        except KeyError:
            return None

        if parts.get("prerelease"):
            version = f"{version}-{parts['prerelease']}"
        if parts.get("devrelease"):
            version = f"{version}{parts['devrelease']}"
        return VersionTag(version, match.group(0))

    def normalize_tag(
        self, version: Version | str, tag_format: str | None = None
    ) -> str:
        """
        The tag and the software version might be different.

        That's why this function exists.

        Example:
        | tag | version (PEP 0440) |
        | --- | ------- |
        | v0.9.0 | 0.9.0 |
        | ver1.0.0 | 1.0.0 |
        | ver1.0.0.a0 | 1.0.0a0 |
        """
        version = self.scheme(version) if isinstance(version, str) else version
        tag_format = tag_format or self.tag_format

        major, minor, patch = version.release
        prerelease = version.prerelease or ""

        t = Template(tag_format)
        return t.safe_substitute(
            version=version,
            major=major,
            minor=minor,
            patch=patch,
            prerelease=prerelease,
        )

    def find_tag_for(
        self, tags: Iterable[GitTag], version: Version | str
    ) -> GitTag | None:
        """Find the first matching tag for a given version."""
        version = self.scheme(version) if isinstance(version, str) else version
        possible_tags = set(self.normalize_tag(version, f) for f in self.tag_formats)
        candidates = [t for t in tags if t.name in possible_tags]
        if len(candidates) > 1:
            warnings.warn(
                UserWarning(
                    f"Multiple tags found for version {version}: {', '.join(t.name for t in candidates)}"
                )
            )
        return next(iter(candidates), None)

    @classmethod
    def from_settings(cls, settings: Settings) -> Self:
        """Extract tag rules from settings"""
        return cls(
            scheme=get_version_scheme(settings),
            tag_format=settings["tag_format"],
            legacy_tag_formats=settings["legacy_tag_formats"],
            ignored_tag_formats=settings["ignored_tag_formats"],
            merge_prereleases=settings["changelog_merge_prerelease"],
        )