WIP: mostly scratch code still

This commit is contained in:
Manuel Barkhau 2018-09-02 21:48:12 +02:00
parent e2e218bce9
commit 3471560eaa
20 changed files with 1757 additions and 1 deletions

11
src/pycalver/__init__.py Normal file
View file

@ -0,0 +1,11 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# (C) 2018 Manuel Barkhau (@mbarkhau)
# SPDX-License-Identifier: MIT
import os
__version__ = "v201809.0001-beta"
DEBUG = os.environ.get("PYCALVER_DEBUG", "0") == "1"

146
src/pycalver/__main__.py Normal file
View file

@ -0,0 +1,146 @@
#!/usr/bin/env python
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# (C) 2018 Manuel Barkhau (@mbarkhau)
# SPDX-License-Identifier: MIT
import io
import os
import sys
import click
import logging
import typing as typ
from . import DEBUG
from . import vcs
from . import parse
from . import config
from . import version
from . import rewrite
log = logging.getLogger("pycalver.__main__")
def _init_loggers(verbose: bool) -> None:
if DEBUG:
log_formatter = logging.Formatter('%(levelname)s - %(name)s - %(message)s')
log_level = logging.DEBUG
elif verbose:
log_formatter = logging.Formatter('%(levelname)s - %(message)s')
log_level = logging.INFO
else:
log_formatter = logging.Formatter('%(message)s')
log_level = logging.WARNING
loggers = [log, vcs.log, parse.log, config.log, rewrite.log, version.log]
for logger in loggers:
if len(logger.handlers) == 0:
ch = logging.StreamHandler(sys.stderr)
ch.setFormatter(log_formatter)
logger.addHandler(ch)
logger.setLevel(log_level)
log.debug("Loggers initialized.")
@click.group()
def cli():
"""parse and update project versions automatically."""
@cli.command()
def show() -> None:
_init_loggers(verbose=False)
cfg = config.parse()
if cfg is None:
return
print(f"Current Version: {cfg['current_version']}")
print(f"PEP440 Version: {cfg['pep440_version']}")
@cli.command()
@click.option(
"--dry",
default=False,
is_flag=True,
help="Display diff of changes, don't rewrite files.",
)
def init(dry: bool) -> None:
"""Initialize [pycalver] configuration in setup.cfg"""
_init_loggers(verbose=False)
cfg = config.parse()
if cfg:
log.error("Configuration already initialized in setup.cfg")
return
cfg_lines = config.default_config_lines()
if dry:
print("Exiting because of '--dry'. Would have written to setup.cfg:")
print("\n " + "\n ".join(cfg_lines))
return
if os.path.exists("setup.cfg"):
cfg_content = "\n" + "\n".join(cfg_lines)
with io.open("setup.cfg", mode="at", encoding="utf-8") as fh:
fh.write(cfg_content)
print("Updated setup.cfg")
else:
cfg_content = "\n".join(cfg_lines)
with io.open("setup.cfg", mode="at", encoding="utf-8") as fh:
fh.write(cfg_content)
print("Created setup.cfg")
@cli.command()
@click.option(
"--verbose",
default=False,
is_flag=True,
help="Log applied changes to stderr",
)
@click.option(
"--dry",
default=False,
is_flag=True,
help="Display diff of changes, don't rewrite files.",
)
@click.option(
"--release",
default=None,
metavar="<name>",
help="Override release name of current_version",
)
def bump(verbose: bool, dry: bool, release: typ.Optional[str] = None) -> None:
_init_loggers(verbose)
if release and release not in parse.VALID_RELESE_VALUES:
log.error(f"Invalid argument --release={release}")
log.error(f"Valid arguments are: {', '.join(parse.VALID_RELESE_VALUES)}")
return
cfg = config.parse()
if cfg is None:
log.error("Unable to parse pycalver configuration from setup.cfg")
return
old_version = cfg["current_version"]
new_version = version.bump(old_version, release=release)
log.info(f"Old Version: {old_version}")
log.info(f"New Version: {new_version}")
matches: typ.List[parse.PatternMatch]
for filepath, patterns in cfg["file_patterns"].items():
with io.open(filepath, mode="rt", encoding="utf-8") as fh:
content = fh.read()
lines = content.splitlines()
matches = parse.parse_patterns(lines, patterns)
for m in matches:
print(m)

122
src/pycalver/config.py Normal file
View file

@ -0,0 +1,122 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# (C) 2018 Manuel Barkhau (@mbarkhau)
# SPDX-License-Identifier: MIT
import io
import os
import configparser
import pkg_resources
import typing as typ
import datetime as dt
import logging
from .parse import PYCALVER_RE
log = logging.getLogger("pycalver.config")
def parse(config_file="setup.cfg") -> typ.Optional[typ.Dict[str, typ.Any]]:
if not os.path.exists(config_file):
log.error("File not found: setup.cfg")
return None
cfg_parser = configparser.RawConfigParser("")
with io.open(config_file, mode="rt", encoding="utf-8") as fh:
cfg_parser.readfp(fh)
if "pycalver" not in cfg_parser:
log.error("setup.cfg does not contain a [pycalver] section.")
return None
cfg = dict(cfg_parser.items("pycalver"))
if "current_version" not in cfg:
log.error("setup.cfg does not have 'pycalver.current_version'")
return None
current_version = cfg["current_version"]
if PYCALVER_RE.match(current_version) is None:
log.error(f"setup.cfg 'pycalver.current_version is invalid")
log.error(f"current_version = {current_version}")
return None
cfg["pep440_version"] = str(pkg_resources.parse_version(current_version))
cfg["tag"] = cfg.get("tag", "").lower() in ("yes", "true", "1", "on")
cfg["commit"] = cfg.get("commit", "").lower() in ("yes", "true", "1", "on")
cfg["file_patterns"] = {}
for section_name in cfg_parser.sections():
if not section_name.startswith("pycalver:file:"):
continue
filepath = section_name.split(":", 2)[-1]
if not os.path.exists(filepath):
log.error(f"No such file: {filepath} from {section_name} in setup.cfg")
return None
section = dict(cfg_parser.items(section_name))
if "patterns" in section:
cfg["file_patterns"][filepath] = [
line.strip()
for line in section["patterns"].splitlines()
if line.strip()
]
else:
cfg["file_patterns"][filepath] = ["{version}", "{pep440_version}"]
if not cfg["file_patterns"]:
cfg["file_patterns"]["setup.cfg"] = ["{version}", "{pep440_version}"]
log.debug(f"Config Parsed: {cfg}")
return cfg
def default_config_lines() -> typ.List[str]:
initial_version = dt.datetime.now().strftime("v%Y%m.0001-dev")
cfg_lines = [
"[pycalver]",
f"current_version = {initial_version}",
"commit = True",
"tag = True",
"",
"[pycalver:file:setup.cfg]",
"patterns = ",
" current_version = {version}",
"",
]
if os.path.exists("setup.py"):
cfg_lines.extend([
"[pycalver:file:setup.py]",
"patterns = ",
" \"{version}\"",
" \"{pep440_version}\"",
"",
])
if os.path.exists("README.rst"):
cfg_lines.extend([
"[pycalver:file:README.rst]",
"patterns = ",
" {version}",
" {pep440_version}",
"",
])
if os.path.exists("README.md"):
cfg_lines.extend([
"[pycalver:file:README.md]",
" {version}",
" {pep440_version}",
"",
])
return cfg_lines

137
src/pycalver/lex_id.py Normal file
View file

@ -0,0 +1,137 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# (C) 2018 Manuel Barkhau (@mbarkhau)
# SPDX-License-Identifier: MIT
"""
This is a simple scheme for numerical ids which are ordered both
numerically and lexically.
Throughout the sequence this expression remains true, whether you
are dealing with integers or strings:
older_id < newer_id
The left most character/digit is only used to maintain lexical
order, so that the position in the sequence is maintained in the
remaining digits.
sequence_pos = int(idval[1:], 10)
lexical sequence_pos
0 0
11 1
12 2
...
19 9
220 20
221 21
...
298 98
299 99
3300 300
3301 301
...
3998 998
3999 999
44000 4000
44001 4001
...
899999998 99999998
899999999 99999999
9900000000 900000000
9900000001 900000001
...
9999999998 999999998
9999999999 999999999 # maximum value
You can add leading zeros to delay the expansion and/or increase
the maximum possible value.
lexical sequence_pos
0001 1
0002 2
0003 3
...
0999 999
11000 1000
11001 1001
11002 1002
...
19998 9998
19999 9999
220000 20000
220001 20001
...
899999999998 99999999998
899999999999 99999999999
9900000000000 900000000000
9900000000001 900000000001
...
9999999999998 999999999998
9999999999999 999999999999 # maximum value
This scheme is useful when you just want an ordered sequence of
numbers, but the numbers don't have any particular meaning or
arithmetical relation. The only relation they have to each other
is that numbers generated later in the sequence are greater than
ones generated earlier.
"""
MINIMUM_ID = "0"
def next_id(prev_id: str) -> str:
num_digits = len(prev_id)
_prev_id = int(prev_id, 10)
_next_id = int(_prev_id) + 1
next_id = f"{_next_id:0{num_digits}}"
if prev_id[0] != next_id[0]:
next_id = str(_next_id * 11)
return next_id
def ord_val(lex_id: str) -> int:
if len(lex_id) == 1:
return int(lex_id, 10)
return int(lex_id[1:], 10)
def main():
_curr_id = "01"
print(f"{'lexical':<13} {'numerical':>12}")
while True:
print(f"{_curr_id:<13} {ord_val(_curr_id):>12}")
_next_id = next_id(_curr_id)
assert _curr_id < next_id
assert int(_curr_id, 10) < int(next_id, 10)
assert ord_val(_curr_id) < ord_val(next_id)
# while next_id.startswith("0") and int(next_id) < 1000:
# _next_id = next_id(_next_id)
if next_id.count("9") == len(next_id):
# all nines, we're done
print(f"{next_id:<13} {ord_val(next_id):>12}")
break
if _next_id[0] != _curr_id[0] and len(_curr_id) > 1:
print(f"{_next_id:<13} {ord_val(_next_id):>12}")
_next_id = next_id(_next_id)
print(f"{_next_id:<13} {ord_val(_next_id):>12}")
_next_id = next_id(_next_id)
print("...")
# skip ahead
_next_id = _next_id[:1] + "9" * (len(_next_id) - 2) + "8"
_curr_id = _next_id
if __name__ == '__main__':
main()

94
src/pycalver/parse.py Normal file
View file

@ -0,0 +1,94 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# (C) 2018 Manuel Barkhau (@mbarkhau)
# SPDX-License-Identifier: MIT
import re
import io
import os
import sys
import logging
import typing as typ
import datetime as dt
from pkg_resources import parse_version
from . import lex_id
log = logging.getLogger("pycalver.parse")
VALID_RELESE_VALUES = ("alpha", "beta", "dev", "rc", "post")
PYCALVER_RE: typ.re.Pattern[str] = re.compile(r"""
\b
(?P<version>
(?P<calver>
v # "v" version prefix
(?P<year>\d{4})
(?P<month>\d{2})
)
(?:
\. # "." build nr prefix
(?P<build>\d{4,})
)
(?:
\- # "-" release prefix
(?P<release>
alpha|beta|dev|rc|post
)
)?
)(?:\s|$)
""", flags=re.VERBOSE)
RE_PATTERN_PARTS = {
"pep440_version" : r"\d{6}\.\d+(a|b|dev|rc|post)?\d*",
"version" : r"v\d{6}\.\d{4,}\-(?:alpha|beta|dev|rc|post)",
"calver" : r"v\d{6}",
"build" : r"\.\d{4,}",
"release" : r"(\-(?:alpha|beta|dev|rc|post))?",
}
class PatternMatch(typ.NamedTuple):
lineno : int
line : str
pattern : str
span : typ.Tuple[int, int]
match : str
MaybeMatch = typ.Optional[typ.re.Match[str]]
PyCalVerInfo = typ.Dict[str, str]
def iter_pattern_matches(lines: typ.List[str], pattern: str) -> typ.Iterable[PatternMatch]:
# The pattern is escaped, so that everything besides the format
# string variables is treated literally.
pattern_re = re.compile(
pattern
.replace("\\", "\\\\")
.replace("-", "\\-")
.replace(".", "\\.")
.replace("+", "\\+")
.replace("*", "\\*")
.replace("[", "\\[")
.replace("(", "\\(")
.format(**RE_PATTERN_PARTS)
)
for i, line in enumerate(lines):
match = pattern_re.search(line)
if match:
lineno = i + 1
yield PatternMatch(lineno, line, pattern, match.span(), match.group(0))
def parse_patterns(lines: typ.List[str], patterns: typ.List[str]) -> typ.List[PatternMatch]:
all_matches: typ.List[PatternMatch] = []
for pattern in patterns:
all_matches.extend(iter_pattern_matches(lines, pattern))
return all_matches

21
src/pycalver/rewrite.py Normal file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env python
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# (C) 2018 Manuel Barkhau (@mbarkhau)
# SPDX-License-Identifier: MIT
import logging
import difflib
log = logging.getLogger("pycalver.rewrite")
def rewrite_file(file: str, pattern: str, dry=False) -> None:
difflib.unified_diff(
file_content_before.splitlines(),
file_content_after.splitlines(),
lineterm="",
fromfile="a/" + file,
tofile="b/" + file,
)

111
src/pycalver/vcs.py Normal file
View file

@ -0,0 +1,111 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# (C) 2018 Manuel Barkhau (@mbarkhau)
# SPDX-License-Identifier: MIT
#
# pycalver/vcs.py (this file) is based on code from the
# bumpversion project: https://github.com/peritus/bumpversion
# MIT License - (C) 2013-2014 Filip Noetzel
import os
import logging
import tempfile
import subprocess as sp
log = logging.getLogger("pycalver.vcs")
class WorkingDirectoryIsDirtyException(Exception):
def __init__(self, message):
self.message = message
class BaseVCS:
@classmethod
def commit(cls, message):
f = tempfile.NamedTemporaryFile("wb", delete=False)
f.write(message.encode("utf-8"))
f.close()
cmd = cls._COMMIT_COMMAND + [f.name]
env_items = list(os.environ.items()) + [(b"HGENCODING", b"utf-8")]
sp.check_output(cmd, env=dict(env_items))
os.unlink(f.name)
@classmethod
def is_usable(cls):
try:
return sp.call(
cls._TEST_USABLE_COMMAND,
stderr=sp.PIPE,
stdout=sp.PIPE,
) == 0
except OSError as e:
if e.errno == 2:
# mercurial is not installed then, ok.
return False
raise
@classmethod
def assert_nondirty(cls):
status_output = sp.check_output(cls._STATUS_COMMAND)
lines = [
line.strip()
for line in status_output.splitlines()
if not line.strip().startswith(b"??")
]
if lines:
cleaned_output = b"\n".join(lines)
cls_name = cls.__name__
raise WorkingDirectoryIsDirtyException(
f"{cls_name} working directory is not clean:\n{cleaned_output}"
)
class Git(BaseVCS):
_TEST_USABLE_COMMAND = ["git", "rev-parse", "--git-dir"]
_COMMIT_COMMAND = ["git", "commit", "-F"]
_STATUS_COMMAND = ["git", "status", "--porcelain"]
@classmethod
def tag(cls, name):
sp.check_output(["git", "tag", name])
class Mercurial(BaseVCS):
_TEST_USABLE_COMMAND = ["hg", "root"]
_COMMIT_COMMAND = ["hg", "commit", "--logfile"]
_STATUS_COMMAND = ["hg", "status", "-mard"]
@classmethod
def tag(cls, name):
sp.check_output(["hg", "tag", name])
VCS = [Git, Mercurial]
def get_vcs(allow_dirty=False):
for vcs in VCS:
if not vcs.is_usable():
continue
if not allow_dirty:
try:
vcs.assert_nondirty()
except WorkingDirectoryIsDirtyException as e:
log.warn(
f"{e.message}\n\n"
f"Use --allow-dirty to override this if you know what you're doing."
)
raise
return vcs
return None

71
src/pycalver/version.py Normal file
View file

@ -0,0 +1,71 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# (C) 2018 Manuel Barkhau (@mbarkhau)
# SPDX-License-Identifier: MIT
import logging
import datetime as dt
from . import lex_id
log = logging.getLogger("pycalver.version")
def current_calver() -> str:
return dt.datetime.utcnow().strftime("v%Y%m")
def bump(old_version: str, release: str=None) -> str:
# old_version is assumed to be a valid calver string,
# validated in pycalver.config.parse.
old_calver, rest = old_version.split(".")
old_build, old_release = rest.split("-")
new_calver = current_calver()
new_build = lex_id.next_id(old_build)
if release is None:
# preserve existing release
new_release = old_release
else:
new_release = release
new_version = new_calver + "." + new_build
if new_release:
new_version += "-" + new_release
return new_version
def incr_version(old_version: str, *, tag: str="__sentinel__") -> str:
maybe_match: MaybeMatch = VERSION_RE.search(old_version)
if maybe_match is None:
raise ValueError(f"Invalid version string: {old_version}")
prev_version_info: PyCalVerInfo = maybe_match.groupdict()
prev_calver: str = prev_version_info["calver"]
next_calver: str = current_calver()
prev_build: str = prev_version_info["build"]
if prev_calver > next_calver:
log.warning(
f"'incr_version' called with '{old_version}', " +
f"which is from the future, " +
f"maybe your system clock is out of sync."
)
next_calver = prev_calver # leave calver as is
next_build = lex_id.next_id(prev_build)
new_version = f"{next_calver}.{next_build}"
if tag != "__sentinel__":
if tag is None:
pass # tag explicitly ignored/removed
else:
new_version += "-" + tag
elif "tag" in prev_version_info:
# preserve previous tag
new_version += "-" + prev_version_info["tag"]
assert old_version < new_version, f"{old_version} {new_version}"
return new_version