Source code for semvermanager

"""
semvermanager
=================
`semvermamager` exports a single class Version which implements
a restricted subset of the  SEMVER_ standard.

.. _SEMVER: http://semver.org/

Version defines a Semantic version using the following field
structure:

.. code-block:: python

    # MAJOR.MINOR.PATCH-TAG

    int MAJOR  # 0 to N
    int MINOR  # 0 to N
    int PATCH  # 0 to N
    str TAG    # one of "alpha", "beta".
    int TAG_VERSION # appended to the tag if the tag is alpha or beta.

Versions may be bumped by a single increment using any of the
`bump` functions. Bumping a PATCH value simply increments it.
Bumping a MINOR value zeros the PATCH value and bumping a MAJOR
zeros the MINOR and the PATCH value.

`semvermanager` only supports Python 3.6 and greater.
"""

import os
import re
import sys
import argparse

from typing import List
from .command import Command,  Query, QueryError,  CommandError, OperationRunner, EchoCommand


[docs]class VersionError(ValueError): """Exception for handling errors in Version Class""" pass
[docs]class Version: """ Handle creation and storage of SEMVER version numbers. In this case SEMVERs must be of the form a.b.c-tag, Where a,b and c are integers in the range 0-n and tag is one of `Version.TAGS`. Version numbers may be bumped by using the various bump functions. Bumping minor zeros patch, bumping major zeros minor. """ TAGS = {0: "alpha", 1: "beta", 2: ""} FIELDS = ["major", "minor", "patch", "tag", "tag_version"] FILENAME = "VERSION" def __init__(self, major=0, minor=0, patch=0, tag="alpha", tag_version=0, lhs="VERSION", separator="="): """ :param major: 0-n :param minor: 0-n :param patch: 0-n :param tag: member of Version.TAGs.values() :param tag_version: The version of the tag value (e.g. alpha0, alpha1 etc.) :param lhs : str The candidate str for the lhs of a VERSION line :param separator: str the seperator string between the field name and the version """ if isinstance(lhs, str): self._lhs = lhs else: raise VersionError(f"{lhs} is not a str type") if isinstance(major, int) and major >= 0: self._major = major else: raise VersionError(f"{major} is not an int type or is a negative int") if isinstance(minor, int) and minor >= 0: self._minor = minor else: raise VersionError(f"{minor} is not an int type or is a negative int") if isinstance(patch, int) and patch >= 0: self._patch = patch else: raise VersionError(f"{patch} is not an int type or is a negative int") self._separator = separator self._tag_index = None self._tag = None for k, v in Version.TAGS.items(): if tag == v: self._tag = v self._tag_index = k if isinstance(tag_version, int) and tag_version >= 0 : self._tag_version = tag_version else: raise VersionError(f"{tag_version} is not an int or is negative") if self._tag_index is None: raise VersionError(f"'{tag}' is not a valid version tag") def bump(self, field): self.bump_map()[field]() def bump_major(self): self._patch = 0 self._minor = 0 self._major += 1 def bump_minor(self): self._patch = 0 self._minor += 1 def bump_patch(self): self._patch += 1 def bump_tag(self): if self._tag_index == len(Version.TAGS) - 1: self._tag_index = 0 else: self._tag_index += 1 self._tag = Version.TAGS[self._tag_index] if self._tag == "": # prod self._tag_version = 0 def bump_tag_version(self): if self._tag != "": self.tag_version = self.tag_version + 1 else: raise VersionError("tag is not 'alpha' or 'beta' no bumping allowed for tag_version") return self._tag_version @property def lhs(self): return self._lhs @property def major(self): return self._major @major.setter def major(self, value): assert isinstance(value, int) and value >= 0 self._major = value @property def minor(self): return self._minor @minor.setter def minor(self, value): assert isinstance(value, int) and value >= 0 self._minor = value @property def patch(self): return self._patch @patch.setter def patch(self, value): assert isinstance(value, int) and value >= 0 self._patch = value @property def tag(self): return self._tag @tag.setter def tag(self, value): assert self._tag in self.TAGS.values() self._tag = value @property def tag_version(self): return self._tag_version @tag_version.setter def tag_version(self, value): assert isinstance(value, int) and value >= 0 self._tag_version = value
[docs] def bump_map(self): """ a mapping of field names to corresponding bump methods :return: a dict of field names to bump methods """ return {"major": self.bump_major, "minor": self.bump_minor, "patch": self.bump_patch, "tag": self.bump_tag, "tag_version": self.bump_tag_version}
[docs] def field_map(self): """ Mapping of field names to field values. :return: A dict of field names to their properties. """ return {"major": self.major, "minor": self.minor, "patch": self.patch, "tag": self.tag, "tag_version": self._tag_version}
[docs] def field(self, field): """ Return the mapping from a field to its corresponding property. :param field: str in Version.FIELDS :return: """ if field not in self.FIELDS: raise VersionError(f"No such field name'{field}'") return self.field_map()[field]
[docs] @staticmethod def update(filename, version, lhs="VERSION", separator="="): """ Find any line starting with "VERSION" and replace that line with the new `version`. :param filename: A path to a file containing at least one VERSION line :param version: The new version object :param lhs: The label string :param separator: label<seperator>value :return: A tuple (number of lines updated, list(line_numbers)) """ count = 0 # Number of replacements lines: List[int] = [] # line numbers of replacement lines with open(filename, "r") as input_file: with open(filename+".temp", "w") as output_file: for i, line in enumerate(input_file, 1): candidate = line.strip() if candidate.startswith(lhs): try: v = Version.parse_version(line, lhs, separator=separator) if v: output_file.write(f"{str(version)}\n") lines.append(i) count = count + 1 except VersionError: output_file.write(line) else: output_file.write(line) os.rename(filename, filename+".old") os.rename(filename+".temp", filename) return filename, lines
[docs] def write(self, filename): """ Write a single line containing the version object to filename. This will overwrite the existing file if it exists. :param filename: The file to create with the new version object :return: A tuple of the filename and the version object """ with open(filename, "w") as file: file.write(f"{str(self)}\n") return filename, self
[docs] @staticmethod def find(filename, lhs="VERSION", separator="="): """Look for the first instance of a VERSION definition in a file and try and parse it as a `Version`""" version = None with open(filename, "r") as file: for line in file: line = line.strip() if line.startswith(lhs): version = Version.parse_version(line, lhs=lhs, separator=separator) break return version
[docs] def read(self, filename, lhs=None, separator=None): """ Read a single line from filename and parse it as version string. :param filename: a file containing a single line VERSION string. :param lhs : override the class field string :param separator: the character seperating the VERSION label from the value :return: a Version object :raises VersionError if it fails to parse the file. """ with open(filename, "r") as file: line = file.readline() line.rstrip() if not lhs: lhs = self._lhs if not separator: separator = self._separator return self.parse_version(line, lhs, separator)
# try: # _, rhs = line.split(self._separator) # except ValueError as e: # raise VersionError(e) # # try: # version, tag = rhs.split("-") # tag = tag.strip() # tag = tag.strip("\"\'") # version = version.strip() # whitespace # version = version.strip("\"\'") # quotes # except ValueError as e: # raise VersionError(e) # # try: # major, minor, patch = [int(x) for x in version.split('.')] # except ValueError as e: # raise VersionError(e) # # return Version(major, minor, patch, tag, tag_version=t separator=separator) @staticmethod def parse_version(line: str, lhs: str = "VERSION", separator="=") -> object: tag_version = 0 line = line.strip() if line.startswith(lhs): try: version_label, rhs = line.split(separator) version_label = version_label.strip() rhs = rhs.strip() if version_label != lhs: raise VersionError(f"{line} has wrong left hand side {version_label}") except ValueError as e: raise VersionError(f"{e} : in '{line}'") else: rhs = line try: if "-" in rhs: version, tag = rhs.split("-") tag = tag.strip() tag = tag.strip("\"\'") match = re.match(r"([a-z]+)([0-9]+)", tag, re.I) if match: tag, tag_version = match.groups() tag_version = int(tag_version) version = version.strip() version = version.strip("\"\'") else: version = rhs.strip() version = version.strip("\"\'") tag = "" tag_version = 0 except ValueError as e: raise VersionError(f"{e} : in '{rhs}'") try: major, minor, patch = [int(x) for x in version.split('.')] except ValueError as e: raise VersionError(f"{e} : in {lhs} '{version}'") return Version(major, minor, patch, tag, tag_version, lhs=lhs, separator=separator) def __eq__(self, other): return self.major == other.major and \ self.minor == other.minor and \ self.patch == other.patch and \ self.tag == other.tag and \ self.tag_version == other.tag_version def __str__(self): if self.tag == "": return f"{self._lhs} {self._separator} '{self._major}.{self._minor}.{self._patch}'" else: return f"{self._lhs} {self._separator} '{self._major}.{self._minor}.{self._patch}-{self._tag}{self._tag_version}'" def __repr__(self): return f"{self.__class__.__qualname__}({self.major}, {self.minor}, {self.patch}, '{self.tag}', {self.tag_version}, '{self._lhs}', '{self._separator}')" @property def bare_version(self): if self.tag == "": return f'{self._major}.{self._minor}.{self._patch}' else: return f'{self._major}.{self._minor}.{self._patch}-{self._tag}{self.tag_version}'
class BumpCommand(Command): def __call__(self, filename, label, separator, bump_field): if not os.path.isfile(filename): return False, f"No such file:'{filename}' can't bump {bump_field} version" v = Version.find(filename, label, separator) if v: v.bump(bump_field) Version.update(filename, v, label, separator) self.q.put((filename, v)) else: raise VersionError(f"No label or version in {filename}") class UpdateCommand(Command): def __call__(self, filename, version, label, separator): if not os.path.isfile(filename): raise CommandError(f"No such file:'{filename}' can't bump {label} version") filename, lines = Version.update(filename=filename, version=version, lhs=label) self.q.put((filename, lines)) return self class MakeCommand(Command): def __init__(self, overwrite): super().__init__() self._overwrite = overwrite def __call__(self, filename, version_label, separator): v = Version(lhs=version_label, separator=separator) f=filename if self._overwrite or not os.path.isfile(filename): f, v = v.write(filename) elif os.path.isfile(filename): answer = input(f"Overwrite file '{filename}' (Y/N [N]: ") if len(answer) > 0 and answer.strip().lower() == 'y': f, v = v.write(filename) return True, f"Made new version {v} in file: '{filename}'" else: f = filename v = None return f, v class GetVersionQuery(Query): def __call__(self, filename): try: if os.path.isfile(filename): v = Version.find(filename) self.q.put((filename,v)) except FileNotFoundError as e: raise QueryError(e) def main(args=None): if args is None: args = sys.argv parser = argparse.ArgumentParser() parser.add_argument( "--version", help="Specify a version in the form major.minor.patch-tag<tag_version>" ) parser.add_argument( "--make", default=False, action="store_true", help="Make a new version file") parser.add_argument( "--bump", choices=Version.FIELDS, help=f"Bump a version field based on the arg {Version.FIELDS}") parser.add_argument( "--getversion", default=False, action="store_true", help="Report the current version in the specified files") parser.add_argument( "--bareversion", default=False, action="store_true", help="Return the unquoted version string with 'VERSION=' removed") parser.add_argument( "--overwrite", default=False, action="store_true", help="overwrite files without checking [default: %(default)s]" ) parser.add_argument( "--update", default=False, action="store_true", help="Update multiple version strings in file" ) parser.add_argument( "--label", default="VERSION", help="field used to determine which line is the version line [default: %(default)s]" ) parser.add_argument( "--separator", default="=", help="Character used to separate the version label from the version [default: %(default)s]" ) parser.add_argument( "filenames", nargs='*', help="Files to use as version file" ) args = parser.parse_args(args) if args.version: version = Version.parse_version("VERSION=" + args.version, lhs=args.label) if args.make: cmd_runner = OperationRunner(MakeCommand(args.overwrite)) for f, v in cmd_runner(args.filenames, args.label, args.separator): if v: print(f"Created version {v} in '{f}'") else: print(f"Failed to create version file '{f}'") if args.getversion: cmd_runner = OperationRunner(GetVersionQuery()) for cmd in cmd_runner(args.filenames): for filename, item in cmd.items(): if args.bareversion: print(f"Version in {filename} is {item.bareversion}") else: print(f"Version in {filename} is {item}") if args.bump: if args.bump in Version.FIELDS: cmd_runner = OperationRunner(BumpCommand()) for cmd in cmd_runner(args.filenames, args.label, args.separator, args.bump): for filename, version in cmd.items(): if version: print(f"Processed version {version} in file : '{filename}'") else: print(f"Could not process '{filename}'") else: print(f"{args.bump} is not a valid version field, choose one of {Version.FIELDS}") sys.exit(1) if args.update: cmd_runner = OperationRunner(UpdateCommand()) for cmd in cmd_runner(args.filename, version, args.label): for filename, lines in cmd.items(): print(f"Processed {version} in {filename} at lines {lines}") if __name__ == "__main__": main(sys.argv[1:]) # clip off the program name