Skip to content

version.py

Version class for lastversion

Version ¤

Bases: Version

This class abstracts handling of a project's versions. It implements the scheme defined in PEP 440. A Version instance is comparison-aware and can be compared and sorted using the standard Python interfaces.

This class is descendant from Version found in packaging.version, and implements some additional normalization during instantiation.

Parameters:

Name Type Description Default
version str

The string representation of a version which will be parsed and normalized before use.

required

Raises: InvalidVersion: If the `version`` does not conform to PEP 440 in any way, then this exception will be raised.

Source code in src/lastversion/version.py
  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
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
class Version(PackagingVersion):
    """
    This class abstracts handling of a project's versions. It implements the
    scheme defined in PEP 440. A `Version` instance is comparison-aware and
    can be compared and sorted using the standard Python interfaces.

    This class is descendant from `Version` found in `packaging.version`,
    and implements some additional normalization during instantiation.

    Args:
        version (str): The string representation of a version which will be
                      parsed and normalized before use.
    Raises:
        InvalidVersion: If the `version`` does not conform to PEP 440 in
                        any way, then this exception will be raised.
    """

    # Precompile the regular expressions
    rc_pattern = re.compile(r"^rc(\d+)\.")
    post_pattern = re.compile(r"^p(\d+)$")

    regex_dashed_substitutions = [
        (re.compile(r"-p(\d+)$"), "-post\\1"),
        (re.compile(r"-preview-(\d+)"), "-pre\\1"),
        (re.compile(r"-early-access-(\d+)"), "-alpha\\1"),
        (re.compile(r"-pre-(\d+)"), "-pre\\1"),
        (re.compile(r"-beta[-.]rc(\d+)"), "-beta\\1"),
        (re.compile(r"^pre-(.*)"), "\\1-pre0"),
    ]

    part_to_pypi_dict = {
        "devel": "dev0",
        "test": "dev0",
        "dev": "dev0",
        "alpha": "a0",
        "beta": "b0",
        "rc": "rc0",
        "preview": "rc0",
        "pre": "rc0",
    }

    def fix_letter_post_release(self, match):
        """Fix letter post release"""
        self.fixed_letter_post_release = True
        return match.group(1) + ".post" + str(ord(match.group(2)))

    def is_semver(self):
        """Check if this a (shorthand) semantic version"""
        return self.base_version.count(".") >= 1

    @staticmethod
    def part_to_pypi(part):
        """
        Convert a version part to a PyPI compatible string
        See https://peps.python.org/pep-0440/
        Helps devel releases to be correctly identified
        See https://www.python.org/dev/peps/pep-0440/#developmental-releases
        """
        # Lookup in the dictionary
        if part in Version.part_to_pypi_dict:
            return Version.part_to_pypi_dict[part]

        # Check for rc patterns
        rc_match = Version.rc_pattern.search(part)
        if rc_match:
            # rc2.windows.1 => rc2.post1
            sub_parts = part.split(".")
            part = sub_parts[0]
            for sub in sub_parts[1:]:
                if sub.isdigit():
                    part += ".post" + sub
            return part

        # Check for the post-patterns
        post_match = Version.post_pattern.sub(r"post\1", part)
        if post_match != part:
            return post_match

        # If the part contains only alphabets, set it to None
        if part.isalpha():
            return None

        return part

    @staticmethod
    def join_dashed_number_status(version):
        """
        Join status with its number when separated by dash in a version string.
        E.g., 4.27-chaos-preview-3 -> 4.27-chaos-pre3
        Helps devel releases to be correctly identified
        # https://www.python.org/dev/peps/pep-0440/#developmental-releases

        Args:
            version:

        Returns:
            str:
        """
        for regex, substitution in Version.regex_dashed_substitutions:
            version = regex.sub(substitution, version)
        return version

    def filter_relevant_parts(self, version):
        """
        Filter out irrelevant parts from version string.
        Parse out version components separated by dash.
        """
        parts = version.split("-")

        # go through parts which were separated by dash, normalize and
        # exclude irrelevant
        parts_n = []
        for part in parts:
            part = self.part_to_pypi(part)
            if part:
                parts_n.append(part)
        if not parts_n:
            raise InvalidVersion(f"Invalid version: '{version}'")
        # Remove *any* non-digits which appear at the beginning of the
        # version string e.g. Rhino1_7_13_Release does not even bother to
        # put a delimiter... such string at the beginning typically do not
        # convey stability level, so we are fine to remove them (unlike the
        # ones in the tail)
        parts_n[0] = re.sub("^[^0-9]+", "", parts_n[0], 1)

        # Remove empty elements
        parts_n = [item for item in parts_n if item != ""]

        # If more than 1 element and second element are a number, use only first
        # e.g. 1.2.3-4 -> 1.2.3
        if len(parts_n) > 1 and "." in parts_n[0] and parts_n[1].isdigit():
            parts_n = parts_n[:1]

        # go back to full string parse out
        version = ".".join(parts_n)
        return version

    def __init__(self, version, char_fix_required=False):
        """Instantiate the `Version` object.

        Args:
            version (str): The version-like string
            char_fix_required (bool): Should we treat alphanumerics as part of version
        """
        self.fixed_letter_post_release = False

        # Join status with its number, e.g., preview-3 -> pre3
        version = self.join_dashed_number_status(version)
        version = self.filter_relevant_parts(version)

        if char_fix_required:
            version = re.sub("(\\d)([a-z])$", self.fix_letter_post_release, version, 1)
        # release-3_0_2 is often seen on Mercurial holders note that the
        # above code removes "release-" already, so we are left with "3_0_2"
        if re.search(r"^(?:\d+_)+(?:\d+)", version):
            version = version.replace("_", ".")
        # finally, split by dot "delimiter", see if there are common words
        # which are definitely removable
        parts = version.split(".")
        version = []
        for p in parts:
            if p.lower() in ["release"]:
                continue
            version.append(p)
        version = ".".join(version)
        super().__init__(version)

    @property
    def epoch(self):
        # type: () -> int
        """
        An integer giving the version epoch of this Version instance
        """
        _epoch = self._version.epoch  # type: int
        return _epoch

    @property
    def release(self):
        """
        A tuple of integers giving the components of the release segment
        of this Version instance; that is, the 1.2.3 part of the version
        number, including trailing zeroes but not including the epoch or
        any prerelease/development/post-release suffixes
        """
        _release = self._version.release
        return _release

    @property
    def pre(self):
        _pre = self._version.pre
        return _pre

    @property
    def post(self):
        return self._version.post[1] if self._version.post else None

    @property
    def dev(self):
        return self._version.dev[1] if self._version.dev else None

    @property
    def local(self):
        if self._version.local:
            return ".".join(str(x) for x in self._version.local)
        return None

    @property
    def major(self):
        # type: () -> int
        return self.release[0] if len(self.release) >= 1 else 0

    @property
    def minor(self):
        # type: () -> int
        return self.release[1] if len(self.release) >= 2 else 0

    @property
    def micro(self):
        # type: () -> int
        return self.release[2] if len(self.release) >= 3 else 0

    @staticmethod
    def is_not_date(num):
        """Helper function to determine if a number is not a date"""
        num_str = str(num)
        try:
            # Attempt to parse the number as a date
            datetime.strptime(num_str, "%Y%m%d")
            return False
        except ValueError:
            # If parsing fails, the number is not a date
            return True

    @property
    def is_prerelease(self):
        """
        Version is a prerelease if it contains all the following:
        * 90+ micro component
        * no date in micro component

        Returns:
            bool:
        """
        if (
            self.major
            and self.minor
            and self.micro >= 90
            and self.is_not_date(self.micro)
        ):
            return True
        return self.dev is not None or self.pre is not None

    @property
    def even(self):
        """Check if this is an even minor version"""
        return self.minor and not self.minor % 2

    def sem_extract_base(self, level=None):
        """
        Return Version with desired semantic version level base
        E.g., for 5.9.3 it will return 5.9 (patch is None)
        """
        if level == "major":
            # get major
            return Version(str(self.major))
        if level == "minor":
            return Version(f"{self.major}.{self.minor}")
        if level == "patch":
            return Version(f"{self.major}.{self.minor}.{self.micro}")
        return self

    def __str__(self):
        # type: () -> str
        parts = []

        # Epoch
        if self.epoch != 0:
            parts.append(f"{self.epoch}!")

        # Release segment
        parts.append(".".join(str(x) for x in self.release))

        # Pre-release
        if self.pre is not None:
            parts.append("".join(str(x) for x in self.pre))

        # Post-release
        if self.post is not None:
            if self.fixed_letter_post_release:
                parts.append(f"{chr(self.post)}")
            else:
                parts.append(f".post{self.post}")

        # Development release
        if self.dev is not None:
            parts.append(f".dev{self.dev}")

        # Local version segment
        if self.local is not None:
            parts.append("+{0}".format(self.local))

        return "".join(parts)

epoch property ¤

An integer giving the version epoch of this Version instance

even property ¤

Check if this is an even minor version

is_prerelease property ¤

Version is a prerelease if it contains all the following: * 90+ micro component * no date in micro component

Returns:

Name Type Description
bool

release property ¤

A tuple of integers giving the components of the release segment of this Version instance; that is, the 1.2.3 part of the version number, including trailing zeroes but not including the epoch or any prerelease/development/post-release suffixes

__init__(version, char_fix_required=False) ¤

Instantiate the Version object.

Parameters:

Name Type Description Default
version str

The version-like string

required
char_fix_required bool

Should we treat alphanumerics as part of version

False
Source code in src/lastversion/version.py
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
def __init__(self, version, char_fix_required=False):
    """Instantiate the `Version` object.

    Args:
        version (str): The version-like string
        char_fix_required (bool): Should we treat alphanumerics as part of version
    """
    self.fixed_letter_post_release = False

    # Join status with its number, e.g., preview-3 -> pre3
    version = self.join_dashed_number_status(version)
    version = self.filter_relevant_parts(version)

    if char_fix_required:
        version = re.sub("(\\d)([a-z])$", self.fix_letter_post_release, version, 1)
    # release-3_0_2 is often seen on Mercurial holders note that the
    # above code removes "release-" already, so we are left with "3_0_2"
    if re.search(r"^(?:\d+_)+(?:\d+)", version):
        version = version.replace("_", ".")
    # finally, split by dot "delimiter", see if there are common words
    # which are definitely removable
    parts = version.split(".")
    version = []
    for p in parts:
        if p.lower() in ["release"]:
            continue
        version.append(p)
    version = ".".join(version)
    super().__init__(version)

filter_relevant_parts(version) ¤

Filter out irrelevant parts from version string. Parse out version components separated by dash.

Source code in src/lastversion/version.py
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
def filter_relevant_parts(self, version):
    """
    Filter out irrelevant parts from version string.
    Parse out version components separated by dash.
    """
    parts = version.split("-")

    # go through parts which were separated by dash, normalize and
    # exclude irrelevant
    parts_n = []
    for part in parts:
        part = self.part_to_pypi(part)
        if part:
            parts_n.append(part)
    if not parts_n:
        raise InvalidVersion(f"Invalid version: '{version}'")
    # Remove *any* non-digits which appear at the beginning of the
    # version string e.g. Rhino1_7_13_Release does not even bother to
    # put a delimiter... such string at the beginning typically do not
    # convey stability level, so we are fine to remove them (unlike the
    # ones in the tail)
    parts_n[0] = re.sub("^[^0-9]+", "", parts_n[0], 1)

    # Remove empty elements
    parts_n = [item for item in parts_n if item != ""]

    # If more than 1 element and second element are a number, use only first
    # e.g. 1.2.3-4 -> 1.2.3
    if len(parts_n) > 1 and "." in parts_n[0] and parts_n[1].isdigit():
        parts_n = parts_n[:1]

    # go back to full string parse out
    version = ".".join(parts_n)
    return version

fix_letter_post_release(match) ¤

Fix letter post release

Source code in src/lastversion/version.py
49
50
51
52
def fix_letter_post_release(self, match):
    """Fix letter post release"""
    self.fixed_letter_post_release = True
    return match.group(1) + ".post" + str(ord(match.group(2)))

is_not_date(num) staticmethod ¤

Helper function to determine if a number is not a date

Source code in src/lastversion/version.py
229
230
231
232
233
234
235
236
237
238
239
@staticmethod
def is_not_date(num):
    """Helper function to determine if a number is not a date"""
    num_str = str(num)
    try:
        # Attempt to parse the number as a date
        datetime.strptime(num_str, "%Y%m%d")
        return False
    except ValueError:
        # If parsing fails, the number is not a date
        return True

is_semver() ¤

Check if this a (shorthand) semantic version

Source code in src/lastversion/version.py
54
55
56
def is_semver(self):
    """Check if this a (shorthand) semantic version"""
    return self.base_version.count(".") >= 1

join_dashed_number_status(version) staticmethod ¤

Join status with its number when separated by dash in a version string. E.g., 4.27-chaos-preview-3 -> 4.27-chaos-pre3 Helps devel releases to be correctly identified

https://www.python.org/dev/peps/pep-0440/#developmental-releases¤

Parameters:

Name Type Description Default
version
required

Returns:

Name Type Description
str
Source code in src/lastversion/version.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
@staticmethod
def join_dashed_number_status(version):
    """
    Join status with its number when separated by dash in a version string.
    E.g., 4.27-chaos-preview-3 -> 4.27-chaos-pre3
    Helps devel releases to be correctly identified
    # https://www.python.org/dev/peps/pep-0440/#developmental-releases

    Args:
        version:

    Returns:
        str:
    """
    for regex, substitution in Version.regex_dashed_substitutions:
        version = regex.sub(substitution, version)
    return version

part_to_pypi(part) staticmethod ¤

Convert a version part to a PyPI compatible string See https://peps.python.org/pep-0440/ Helps devel releases to be correctly identified See https://www.python.org/dev/peps/pep-0440/#developmental-releases

Source code in src/lastversion/version.py
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
@staticmethod
def part_to_pypi(part):
    """
    Convert a version part to a PyPI compatible string
    See https://peps.python.org/pep-0440/
    Helps devel releases to be correctly identified
    See https://www.python.org/dev/peps/pep-0440/#developmental-releases
    """
    # Lookup in the dictionary
    if part in Version.part_to_pypi_dict:
        return Version.part_to_pypi_dict[part]

    # Check for rc patterns
    rc_match = Version.rc_pattern.search(part)
    if rc_match:
        # rc2.windows.1 => rc2.post1
        sub_parts = part.split(".")
        part = sub_parts[0]
        for sub in sub_parts[1:]:
            if sub.isdigit():
                part += ".post" + sub
        return part

    # Check for the post-patterns
    post_match = Version.post_pattern.sub(r"post\1", part)
    if post_match != part:
        return post_match

    # If the part contains only alphabets, set it to None
    if part.isalpha():
        return None

    return part

sem_extract_base(level=None) ¤

Return Version with desired semantic version level base E.g., for 5.9.3 it will return 5.9 (patch is None)

Source code in src/lastversion/version.py
265
266
267
268
269
270
271
272
273
274
275
276
277
def sem_extract_base(self, level=None):
    """
    Return Version with desired semantic version level base
    E.g., for 5.9.3 it will return 5.9 (patch is None)
    """
    if level == "major":
        # get major
        return Version(str(self.major))
    if level == "minor":
        return Version(f"{self.major}.{self.minor}")
    if level == "patch":
        return Version(f"{self.major}.{self.minor}.{self.micro}")
    return self