# This file is part of the pycalver project # https://github.com/mbarkhau/pycalver # # Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License # SPDX-License-Identifier: MIT """Functions related to version string manipulation.""" import typing as typ import logging import datetime as dt import lexid from . import version from . import v1patterns logger = logging.getLogger("pycalver.v1version") CalInfo = typ.Union[version.V1CalendarInfo, version.V1VersionInfo] def _is_cal_gt(left: CalInfo, right: CalInfo) -> bool: """Is left > right for non-None fields.""" lvals = [] rvals = [] for field in version.V1CalendarInfo._fields: lval = getattr(left , field) rval = getattr(right, field) if not (lval is None or rval is None): lvals.append(lval) rvals.append(rval) return lvals > rvals def _ver_to_cal_info(vnfo: version.V1VersionInfo) -> version.V1CalendarInfo: return version.V1CalendarInfo( vnfo.year, vnfo.quarter, vnfo.month, vnfo.dom, vnfo.doy, vnfo.iso_week, vnfo.us_week, ) def cal_info(date: dt.date = None) -> version.V1CalendarInfo: """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 = version.TODAY kwargs = { 'year' : date.year, 'quarter' : version.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 version.V1CalendarInfo(**kwargs) FieldKey = str MatchGroupKey = str MatchGroupStr = str PatternGroups = typ.Dict[MatchGroupKey, MatchGroupStr] FieldValues = typ.Dict[FieldKey , MatchGroupStr] def _parse_field_values(field_values: FieldValues) -> version.V1VersionInfo: fvals = field_values tag = fvals.get('tag') if tag is None: tag = "final" tag = version.TAG_BY_PEP440_TAG.get(tag, tag) assert tag is not None bid = fvals['bid'] if 'bid' in fvals else "0001" year = int(fvals['year']) if 'year' in fvals else None doy = int(fvals['doy' ]) if 'doy' in fvals else None month: typ.Optional[int] dom : typ.Optional[int] if year and doy: date = version.date_from_doy(year, doy) month = date.month dom = date.day else: month = int(fvals['month']) if 'month' in fvals else None dom = int(fvals['dom' ]) if 'dom' in fvals 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(fvals['quarter']) if 'quarter' in fvals else None if quarter is None and month: quarter = version.quarter_from_month(month) major = int(fvals['major']) if 'major' in fvals else 0 minor = int(fvals['minor']) if 'minor' in fvals else 0 patch = int(fvals['patch']) if 'patch' in fvals else 0 return version.V1VersionInfo( 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 _is_calver(cinfo: CalInfo) -> 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 version.V1CalendarInfo._fields: maybe_val: typ.Any = getattr(cinfo, field, None) if isinstance(maybe_val, int): return True return False VersionInfoKW = typ.Dict[str, typ.Union[str, int, None]] def _parse_pattern_groups(pattern_groups: PatternGroups) -> FieldValues: for part_name in pattern_groups.keys(): is_valid_part_name = ( part_name in v1patterns.COMPOSITE_PART_PATTERNS or part_name in v1patterns.PATTERN_PART_FIELDS ) if not is_valid_part_name: err_msg = f"Invalid part '{part_name}'" raise version.PatternError(err_msg) field_value_items = [ (field_name, pattern_groups[part_name]) for part_name, field_name in v1patterns.PATTERN_PART_FIELDS.items() if part_name in pattern_groups.keys() ] all_fields = [field_name for field_name, _ in field_value_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 version.PatternError(err_msg) else: return dict(field_value_items) def _parse_version_info(pattern_groups: PatternGroups) -> version.V1VersionInfo: """Parse normalized V1VersionInfo 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) """ field_values = _parse_pattern_groups(pattern_groups) return _parse_field_values(field_values) def parse_version_info(version_str: str, raw_pattern: str = "{pycalver}") -> version.V1VersionInfo: """Parse normalized V1VersionInfo. >>> vnfo = parse_version_info("v201712.0033-beta", raw_pattern="{pycalver}") >>> assert vnfo == _parse_version_info({'year': 2017, 'month': 12, 'bid': "0033", 'tag': "beta"}) >>> vnfo = parse_version_info("1.23.456", raw_pattern="{semver}") >>> assert vnfo == _parse_version_info({'MAJOR': "1", 'MINOR': "23", 'PATCH': "456"}) """ pattern = v1patterns.compile_pattern(raw_pattern) match = pattern.regexp.match(version_str) if match is None: err_msg = ( f"Invalid version string '{version_str}' " f"for pattern '{raw_pattern}'/'{pattern.regexp.pattern}'" ) raise version.PatternError(err_msg) else: return _parse_version_info(match.groupdict()) def is_valid(version_str: str, raw_pattern: str = "{pycalver}") -> bool: """Check if a version matches a pattern. >>> is_valid("v201712.0033-beta", raw_pattern="{pycalver}") True >>> is_valid("v201712.0033-beta", raw_pattern="{semver}") False >>> is_valid("1.2.3", raw_pattern="{semver}") True >>> is_valid("v201712.0033-beta", raw_pattern="{semver}") False """ try: parse_version_info(version_str, raw_pattern) return True except version.PatternError: 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(vinfo: version.V1VersionInfo, raw_pattern: str) -> str: """Generate version string. >>> import datetime as dt >>> vinfo = parse_version_info("v201712.0033-beta", raw_pattern="{pycalver}") >>> vinfo_a = vinfo._replace(**cal_info(date=dt.date(2017, 1, 1))._asdict()) >>> vinfo_b = vinfo._replace(**cal_info(date=dt.date(2017, 12, 31))._asdict()) >>> vinfo_c = vinfo_b._replace(major=1, minor=2, patch=34, tag='final') >>> format_version(vinfo_a, raw_pattern="v{yy}.{BID}{release}") 'v17.33-beta' >>> format_version(vinfo_a, raw_pattern="{pep440_version}") '201701.33b0' >>> format_version(vinfo_a, raw_pattern="{pycalver}") 'v201701.0033-beta' >>> format_version(vinfo_b, raw_pattern="{pycalver}") 'v201712.0033-beta' >>> format_version(vinfo_a, raw_pattern="v{year}w{iso_week}.{BID}{release}") 'v2017w00.33-beta' >>> format_version(vinfo_b, raw_pattern="v{year}w{iso_week}.{BID}{release}") 'v2017w52.33-beta' >>> format_version(vinfo_a, raw_pattern="v{year}d{doy}.{bid}{release}") 'v2017d001.0033-beta' >>> format_version(vinfo_b, raw_pattern="v{year}d{doy}.{bid}{release}") 'v2017d365.0033-beta' >>> format_version(vinfo_c, raw_pattern="v{year}w{iso_week}.{BID}-{tag}") 'v2017w52.33-final' >>> format_version(vinfo_c, raw_pattern="v{year}w{iso_week}.{BID}{release}") 'v2017w52.33' >>> format_version(vinfo_c, raw_pattern="v{MAJOR}.{MINOR}.{PATCH}") 'v1.2.34' >>> format_version(vinfo_c, raw_pattern="v{MAJOR}.{MM}.{PPP}") 'v1.02.034' """ full_pattern = raw_pattern for part_name, full_part_format in v1patterns.FULL_PART_FORMATS.items(): full_pattern = full_pattern.replace("{" + part_name + "}", full_part_format) kwargs: typ.Dict[str, typ.Union[str, int, None]] = vinfo._asdict() tag = vinfo.tag if tag == 'final': kwargs['release' ] = "" kwargs['pep440_tag'] = "" else: kwargs['release' ] = "-" + tag kwargs['pep440_tag'] = version.PEP440_TAG_BY_TAG[tag] + "0" kwargs['release_tag'] = tag year = vinfo.year if year: kwargs['yy' ] = str(year)[-2:] kwargs['yyyy'] = year kwargs['BID'] = int(vinfo.bid, 10) for part_name, field in ID_FIELDS_BY_PART.items(): val = kwargs[field] if part_name.lower() == field.lower(): if isinstance(val, str): kwargs[part_name] = int(val, base=10) else: kwargs[part_name] = val else: assert len(set(part_name)) == 1 padded_len = len(part_name) kwargs[part_name] = str(val).zfill(padded_len) return full_pattern.format(**kwargs) def incr( old_version: str, raw_pattern: str = "{pycalver}", *, release : typ.Optional[str] = None, major : bool = False, minor : bool = False, patch : bool = False, pin_date: bool = False, ) -> typ.Optional[str]: """Increment version string. 'old_version' is assumed to be a string that matches 'pattern' """ try: old_vinfo = parse_version_info(old_version, raw_pattern) except version.PatternError as ex: logger.error(str(ex)) return None cur_cinfo = _ver_to_cal_info(old_vinfo) if pin_date else cal_info() if _is_cal_gt(old_vinfo, cur_cinfo): logger.warning(f"Old version appears to be from the future '{old_version}'") cur_vinfo = old_vinfo else: cur_vinfo = old_vinfo._replace(**cur_cinfo._asdict()) cur_vinfo = cur_vinfo._replace(bid=lexid.next_id(cur_vinfo.bid)) if release: cur_vinfo = cur_vinfo._replace(tag=release) if major: cur_vinfo = cur_vinfo._replace(major=cur_vinfo.major + 1, minor=0, patch=0) if minor: cur_vinfo = cur_vinfo._replace(minor=cur_vinfo.minor + 1, patch=0) if patch: cur_vinfo = cur_vinfo._replace(patch=cur_vinfo.patch + 1) new_version = format_version(cur_vinfo, raw_pattern) if new_version == old_version: logger.error("Invalid arguments or pattern, version did not change.") return None else: return new_version