bumpver/src/pycalver/version.py

478 lines
14 KiB
Python
Raw Normal View History

2018-09-02 21:48:12 +02:00
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
2018-11-06 21:45:33 +01:00
# Copyright (c) 2018 Manuel Barkhau (@mbarkhau) - MIT License
2018-09-02 21:48:12 +02:00
# SPDX-License-Identifier: MIT
"""Functions related to version string manipulation."""
2018-09-02 21:48:12 +02:00
import logging
import pkg_resources
2018-09-04 09:56:53 +02:00
import typing as typ
2018-09-02 21:48:12 +02:00
import datetime as dt
from . import lex_id
from . import patterns
2018-09-02 23:36:57 +02:00
2018-09-02 21:48:12 +02:00
log = logging.getLogger("pycalver.version")
# The test suite may replace this.
TODAY = dt.datetime.utcnow().date()
PATTERN_PART_FIELDS = {
'year' : 'year',
'month' : 'month',
'pep440_tag': 'tag',
'tag' : 'tag',
'yy' : 'year',
'yyyy' : 'year',
'quarter' : 'quarter',
'iso_week' : 'iso_week',
'us_week' : 'us_week',
'dom' : 'dom',
'doy' : 'doy',
'MAJOR' : 'major',
'MINOR' : 'minor',
'MM' : 'minor',
'MMM' : 'minor',
'MMMM' : 'minor',
'MMMMM' : 'minor',
'PP' : 'patch',
'PPP' : 'patch',
'PPPP' : 'patch',
'PPPPP' : 'patch',
'PATCH' : 'patch',
'build_no' : 'bid',
'bid' : 'bid',
'BID' : 'bid',
'BB' : 'bid',
'BBB' : 'bid',
'BBBB' : 'bid',
'BBBBB' : 'bid',
'BBBBBB' : 'bid',
'BBBBBBB' : 'bid',
}
class CalendarInfo(typ.NamedTuple):
"""Container for calendar components of version strings."""
year : int
quarter : int
month : int
dom : int
doy : int
iso_week: int
us_week : int
def _date_from_doy(year: int, doy: int) -> dt.date:
"""Parse date from year and day of year (1 indexed).
>>> cases = [
... (2016, 1), (2016, 31), (2016, 31 + 1), (2016, 31 + 29), (2016, 31 + 30),
... (2017, 1), (2017, 31), (2017, 31 + 1), (2017, 31 + 28), (2017, 31 + 29),
... ]
>>> dates = [_date_from_doy(year, month) for year, month in cases]
>>> assert [(d.month, d.day) for d in dates] == [
... (1, 1), (1, 31), (2, 1), (2, 29), (3, 1),
... (1, 1), (1, 31), (2, 1), (2, 28), (3, 1),
... ]
"""
return dt.date(year, 1, 1) + dt.timedelta(days=doy - 1)
2018-12-09 14:49:13 +01:00
def _quarter_from_month(month: int) -> int:
"""Calculate quarter (1 indexed) from month (1 indexed).
>>> [_quarter_from_month(month) for month in range(1, 13)]
[1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4]
"""
return ((month - 1) // 3) + 1
def cal_info(date: dt.date = None) -> CalendarInfo:
"""Generate calendar components for current date.
>>> from datetime import date
>>> c = cal_info(date(2019, 1, 5))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 1, 1, 5, 5, 0, 0)
>>> c = cal_info(date(2019, 1, 6))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 1, 1, 6, 6, 0, 1)
>>> c = cal_info(date(2019, 1, 7))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 1, 1, 7, 7, 1, 1)
>>> c = cal_info(date(2019, 4, 7))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 2, 4, 7, 97, 13, 14)
"""
if date is None:
date = TODAY
kw = {
'year' : date.year,
'quarter' : _quarter_from_month(date.month),
'month' : date.month,
'dom' : date.day,
'doy' : int(date.strftime("%j"), base=10),
'iso_week': int(date.strftime("%W"), base=10),
'us_week' : int(date.strftime("%U"), base=10),
}
return CalendarInfo(**kw)
2018-12-09 14:49:13 +01:00
class VersionInfo(typ.NamedTuple):
"""Container for parsed version string."""
year : typ.Optional[int]
quarter : typ.Optional[int]
month : typ.Optional[int]
dom : typ.Optional[int]
doy : typ.Optional[int]
iso_week: typ.Optional[int]
us_week : typ.Optional[int]
major : int
minor : int
patch : int
bid : str
tag : str
def _is_calver(nfo: typ.Union[CalendarInfo, VersionInfo]) -> bool:
"""Check pattern for any calendar based parts.
>>> _is_calver(cal_info())
True
>>> vnfo = _parse_version_info({'year': "2018", 'month': "11", 'bid': "0018"})
>>> _is_calver(vnfo)
True
>>> vnfo = _parse_version_info({'MAJOR': "1", 'MINOR': "023", 'PATCH': "45"})
>>> _is_calver(vnfo)
False
"""
for field in CalendarInfo._fields:
if isinstance(getattr(nfo, field, None), int):
return True
return False
TAG_ALIASES: typ.Dict[str, str] = {'a': "alpha", 'b': "beta", 'pre': "rc"}
PEP440_TAGS: typ.Dict[str, str] = {'alpha': "a", 'beta': "b", 'final': "", 'rc': "rc", 'dev': "dev"}
VersionInfoKW = typ.Dict[str, typ.Union[str, int, None]]
def _parse_pattern_groups(pattern_groups: typ.Dict[str, str]) -> typ.Dict[str, str]:
for part_name in pattern_groups.keys():
is_valid_part_name = (
part_name in patterns.COMPOSITE_PART_PATTERNS or part_name in PATTERN_PART_FIELDS
)
if not is_valid_part_name:
err_msg = f"Invalid part '{part_name}'"
raise ValueError(err_msg)
items = [
(field_name, pattern_groups[part_name])
for part_name, field_name in PATTERN_PART_FIELDS.items()
if part_name in pattern_groups.keys()
]
all_fields = [field_name for field_name, _ in items]
unique_fields = set(all_fields)
duplicate_fields = [f for f in unique_fields if all_fields.count(f) > 1]
if any(duplicate_fields):
err_msg = f"Multiple parts for same field {duplicate_fields}."
raise ValueError(err_msg)
return dict(items)
def _parse_version_info(pattern_groups: typ.Dict[str, str]) -> VersionInfo:
"""Parse normalized VersionInfo from groups of a matched pattern.
>>> vnfo = _parse_version_info({'year': "2018", 'month': "11", 'bid': "0099"})
>>> (vnfo.year, vnfo.month, vnfo.quarter, vnfo.bid, vnfo.tag)
(2018, 11, 4, '0099', 'final')
>>> vnfo = _parse_version_info({'year': "2018", 'doy': "11", 'bid': "099", 'tag': "b"})
>>> (vnfo.year, vnfo.month, vnfo.dom, vnfo.bid, vnfo.tag)
(2018, 1, 11, '099', 'beta')
>>> vnfo = _parse_version_info({'MAJOR': "1", 'MINOR': "23", 'PATCH': "45"})
>>> (vnfo.major, vnfo.minor, vnfo.patch)
(1, 23, 45)
>>> vnfo = _parse_version_info({'MAJOR': "1", 'MMM': "023", 'PPPP': "0045"})
>>> (vnfo.major, vnfo.minor, vnfo.patch)
(1, 23, 45)
"""
kw = _parse_pattern_groups(pattern_groups)
tag = kw.get('tag')
if tag is None:
tag = "final"
tag = TAG_ALIASES.get(tag, tag)
assert tag is not None
bid = kw['bid'] if 'bid' in kw else "0001"
year = int(kw['year']) if 'year' in kw else None
doy = int(kw['doy' ]) if 'doy' in kw else None
month: typ.Optional[int]
dom : typ.Optional[int]
if year and doy:
date = _date_from_doy(year, doy)
month = date.month
dom = date.day
else:
month = int(kw['month']) if 'month' in kw else None
dom = int(kw['dom' ]) if 'dom' in kw else None
iso_week: typ.Optional[int]
us_week : typ.Optional[int]
if year and month and dom:
date = dt.date(year, month, dom)
doy = int(date.strftime("%j"), base=10)
iso_week = int(date.strftime("%W"), base=10)
us_week = int(date.strftime("%U"), base=10)
else:
iso_week = None
us_week = None
quarter = int(kw['quarter']) if 'quarter' in kw else None
if quarter is None and month:
quarter = _quarter_from_month(month)
major = int(kw['major']) if 'major' in kw else 0
minor = int(kw['minor']) if 'minor' in kw else 0
patch = int(kw['patch']) if 'patch' in kw else 0
return VersionInfo(
year=year,
quarter=quarter,
month=month,
dom=dom,
doy=doy,
iso_week=iso_week,
us_week=us_week,
major=major,
minor=minor,
patch=patch,
bid=bid,
tag=tag,
)
def parse_version_info(version_str: str, pattern: str = "{pycalver}") -> VersionInfo:
"""Parse normalized VersionInfo.
>>> vnfo = parse_version_info("v201712.0033-beta", pattern="{pycalver}")
>>> assert vnfo == _parse_version_info({'year': 2017, 'month': 12, 'bid': "0033", 'tag': "beta"})
>>> vnfo = parse_version_info("1.23.456", pattern="{semver}")
>>> assert vnfo == _parse_version_info({'MAJOR': "1", 'MINOR': "23", 'PATCH': "456"})
2018-12-09 14:49:13 +01:00
"""
regex = patterns.compile_pattern(pattern)
match = regex.match(version_str)
2018-12-09 14:49:13 +01:00
if match is None:
err_msg = (
f"Invalid version string '{version_str}' for pattern '{pattern}'/'{regex.pattern}'"
)
raise ValueError(err_msg)
2018-12-09 14:49:13 +01:00
return _parse_version_info(match.groupdict())
2018-12-09 14:49:13 +01:00
def is_valid(version_str: str, pattern: str = "{pycalver}") -> bool:
"""Check if a version matches a pattern.
2018-11-06 21:45:33 +01:00
>>> is_valid("v201712.0033-beta", pattern="{pycalver}")
True
>>> is_valid("v201712.0033-beta", pattern="{semver}")
False
>>> is_valid("1.2.3", pattern="{semver}")
True
>>> is_valid("v201712.0033-beta", pattern="{semver}")
False
2018-11-06 21:45:33 +01:00
"""
try:
parse_version_info(version_str, pattern)
return True
except ValueError:
return False
ID_FIELDS_BY_PART = {
'MAJOR' : 'major',
'MINOR' : 'minor',
'MM' : 'minor',
'MMM' : 'minor',
'MMMM' : 'minor',
'MMMMM' : 'minor',
'MMMMMM' : 'minor',
'MMMMMMM': 'minor',
'PATCH' : 'patch',
'PP' : 'patch',
'PPP' : 'patch',
'PPPP' : 'patch',
'PPPPP' : 'patch',
'PPPPPP' : 'patch',
'PPPPPPP': 'patch',
'BID' : 'bid',
'BB' : 'bid',
'BBB' : 'bid',
'BBBB' : 'bid',
'BBBBB' : 'bid',
'BBBBBB' : 'bid',
'BBBBBBB': 'bid',
}
def format_version(ver_nfo: VersionInfo, pattern: str) -> str:
"""Generate version string.
>>> import datetime as dt
>>> ver_nfo = parse_version_info("v201712.0033-beta", pattern="{pycalver}")
>>> ver_nfo_a = ver_nfo._replace(**cal_info(date=dt.date(2017, 1, 1))._asdict())
>>> ver_nfo_b = ver_nfo._replace(**cal_info(date=dt.date(2017, 12, 31))._asdict())
>>> ver_nfo_c = ver_nfo_b._replace(major=1, minor=2, patch=34, tag='final')
2018-09-02 21:48:12 +02:00
>>> format_version(ver_nfo_a, pattern="v{yy}.{BID}{release}")
'v17.33-beta'
>>> format_version(ver_nfo_a, pattern="{pep440_version}")
'201701.33b0'
2018-09-02 21:48:12 +02:00
>>> format_version(ver_nfo_a, pattern="{pycalver}")
'v201701.0033-beta'
>>> format_version(ver_nfo_b, pattern="{pycalver}")
'v201712.0033-beta'
2018-11-06 21:45:33 +01:00
>>> format_version(ver_nfo_a, pattern="v{year}w{iso_week}.{BID}{release}")
'v2017w00.33-beta'
>>> format_version(ver_nfo_b, pattern="v{year}w{iso_week}.{BID}{release}")
'v2017w52.33-beta'
>>> format_version(ver_nfo_a, pattern="v{year}d{doy}.{bid}{release}")
'v2017d001.0033-beta'
>>> format_version(ver_nfo_b, pattern="v{year}d{doy}.{bid}{release}")
'v2017d365.0033-beta'
>>> format_version(ver_nfo_c, pattern="v{year}w{iso_week}.{BID}-{tag}")
'v2017w52.33-final'
>>> format_version(ver_nfo_c, pattern="v{year}w{iso_week}.{BID}{release}")
'v2017w52.33'
>>> format_version(ver_nfo_c, pattern="v{MAJOR}.{MINOR}.{PATCH}")
'v1.2.34'
>>> format_version(ver_nfo_c, pattern="v{MAJOR}.{MM}.{PPP}")
'v1.02.034'
2018-11-06 21:45:33 +01:00
"""
full_pattern = pattern
for part_name, full_part_format in patterns.FULL_PART_FORMATS.items():
full_pattern = full_pattern.replace("{" + part_name + "}", full_part_format)
2018-09-02 21:48:12 +02:00
kw = ver_nfo._asdict()
if kw['tag'] == 'final':
kw['release' ] = ""
kw['pep440_tag'] = ""
else:
kw['release' ] = "-" + kw['tag']
kw['pep440_tag'] = PEP440_TAGS[kw['tag']] + "0"
2018-09-03 22:23:51 +02:00
kw['release_tag'] = kw['tag']
2018-09-03 22:23:51 +02:00
kw['yy' ] = str(kw['year'])[-2:]
kw['yyyy'] = kw['year']
kw['BID' ] = int(kw['bid'], 10)
2018-09-04 09:56:53 +02:00
for part_name, field in ID_FIELDS_BY_PART.items():
val = kw[field]
if part_name.lower() == field.lower():
if isinstance(val, str):
kw[part_name] = int(val, base=10)
else:
kw[part_name] = val
2018-09-02 23:36:57 +02:00
else:
assert len(set(part_name)) == 1
padded_len = len(part_name)
kw[part_name] = str(val).zfill(padded_len)
return full_pattern.format(**kw)
def incr(
old_version: str,
pattern : str = "{pycalver}",
*,
release: str = None,
major : bool = False,
minor : bool = False,
patch : bool = False,
) -> typ.Optional[str]:
"""Increment version string.
'old_version' is assumed to be a string that matches 'pattern'
"""
2019-01-07 17:30:02 +01:00
try:
old_ver_nfo = parse_version_info(old_version, pattern)
except ValueError as ex:
log.error(str(ex))
return None
cur_ver_nfo = old_ver_nfo
cur_cal_nfo = cal_info()
old_date = (old_ver_nfo.year or 0, old_ver_nfo.month or 0, old_ver_nfo.dom or 0)
cur_date = (cur_cal_nfo.year , cur_cal_nfo.month , cur_cal_nfo.dom)
if old_date <= cur_date:
cur_ver_nfo = cur_ver_nfo._replace(**cur_cal_nfo._asdict())
2018-09-02 21:48:12 +02:00
else:
log.warning(f"Version appears to be from the future '{old_version}'")
cur_ver_nfo = cur_ver_nfo._replace(bid=lex_id.next_id(cur_ver_nfo.bid))
2018-09-02 21:48:12 +02:00
if major:
cur_ver_nfo = cur_ver_nfo._replace(major=cur_ver_nfo.major + 1, minor=0, patch=0)
if minor:
cur_ver_nfo = cur_ver_nfo._replace(minor=cur_ver_nfo.minor + 1, patch=0)
if patch:
cur_ver_nfo = cur_ver_nfo._replace(patch=cur_ver_nfo.patch + 1)
if release:
cur_ver_nfo = cur_ver_nfo._replace(tag=release)
new_version = format_version(cur_ver_nfo, pattern)
if new_version == old_version:
log.error("Invalid arguments or pattern, version did not change.")
return None
else:
return new_version
def to_pep440(version: str) -> str:
"""Derive pep440 compliant version string from PyCalVer version string.
>>> to_pep440("v201811.0007-beta")
'201811.7b0'
"""
return str(pkg_resources.parse_version(version))