Code quality updates

This commit is contained in:
Manuel Barkhau 2018-11-15 22:16:16 +01:00
parent 95234dfd0b
commit 54a681bf34
14 changed files with 413 additions and 187 deletions

View file

@ -84,7 +84,7 @@ regular expression:
import re import re
# https://regex101.com/r/fnj60p/10 # https://regex101.com/r/fnj60p/10
pycalver_re = re.compile(r""" PYCALVER_PATTERN = r"""
\b \b
(?P<version> (?P<version>
(?P<calver> (?P<calver>
@ -101,10 +101,11 @@ pycalver_re = re.compile(r"""
(?:alpha|beta|dev|rc|post) (?:alpha|beta|dev|rc|post)
)? )?
)(?:\s|$) )(?:\s|$)
""", flags=re.VERBOSE) """
PYCALVER_RE = re.compile(PYCALVER_PATTERN, flags=re.VERBOSE)
version_str = "v201712.0001-alpha" version_str = "v201712.0001-alpha"
version_info = pycalver_re.match(version_str).groupdict() version_info = PYCALVER_RE.match(version_str).groupdict()
assert version_info == { assert version_info == {
"version" : "v201712.0001-alpha", "version" : "v201712.0001-alpha",
@ -116,7 +117,7 @@ assert version_info == {
} }
version_str = "v201712.0033" version_str = "v201712.0033"
version_info = pycalver_re.match(version_str).groupdict() version_info = PYCALVER_RE.match(version_str).groupdict()
assert version_info == { assert version_info == {
"version" : "v201712.0033", "version" : "v201712.0033",

View file

@ -17,7 +17,8 @@ ENV CONDA_DIR /opt/conda
ENV PATH $CONDA_DIR/bin:$PATH ENV PATH $CONDA_DIR/bin:$PATH
ENV SHELL /bin/bash ENV SHELL /bin/bash
RUN apk add --no-cache bash make sed grep gawk curl git bzip2 unzip RUN apk add --no-cache bash make sed grep gawk curl bzip2 unzip
RUN apk add --no-cache git mercurial
CMD [ "/bin/bash" ] CMD [ "/bin/bash" ]

View file

@ -320,6 +320,7 @@ check: fmt lint mypy test
env: env:
@bash --init-file <(echo '\ @bash --init-file <(echo '\
source $$HOME/.bashrc; \ source $$HOME/.bashrc; \
source $(CONDA_ROOT)/etc/profile.d/conda.sh \
export ENV=${ENV-dev}; \ export ENV=${ENV-dev}; \
export PYTHONPATH="src/:vendor/:$$PYTHONPATH"; \ export PYTHONPATH="src/:vendor/:$$PYTHONPATH"; \
conda activate $(DEV_ENV_NAME) \ conda activate $(DEV_ENV_NAME) \

View file

@ -43,6 +43,8 @@ ignore =
# D101 # D101
# Missing docstring on __init__ # Missing docstring on __init__
D107 D107
# No blank lines allowed after function docstring
D202
# First line should be in imperative mood # First line should be in imperative mood
D401 D401
select = A,AAA,D,C,E,F,W,H,B,D212,D404,D405,D406,B901,B950 select = A,AAA,D,C,E,F,W,H,B,D212,D404,D405,D406,B901,B950

View file

@ -30,7 +30,7 @@ _VERBOSE = 0
log = logging.getLogger("pycalver.cli") log = logging.getLogger("pycalver.cli")
def _init_loggers(verbose: int = 0) -> None: def _init_logging(verbose: int = 0) -> None:
if verbose >= 2: if verbose >= 2:
log_format = "%(asctime)s.%(msecs)03d %(levelname)-7s %(name)-15s - %(message)s" log_format = "%(asctime)s.%(msecs)03d %(levelname)-7s %(name)-15s - %(message)s"
log_level = logging.DEBUG log_level = logging.DEBUG
@ -42,7 +42,7 @@ def _init_loggers(verbose: int = 0) -> None:
log_level = logging.WARNING log_level = logging.WARNING
logging.basicConfig(level=log_level, format=log_format, datefmt="%Y-%m-%dT%H:%M:%S") logging.basicConfig(level=log_level, format=log_format, datefmt="%Y-%m-%dT%H:%M:%S")
log.debug("Loggers initialized.") log.debug("Logging initialized.")
@click.group() @click.group()
@ -84,7 +84,7 @@ def _update_cfg_from_vcs(cfg: config.Config, fetch: bool) -> config.Config:
def show(verbose: int = 0, fetch: bool = True) -> None: def show(verbose: int = 0, fetch: bool = True) -> None:
"""Show current version.""" """Show current version."""
verbose = max(_VERBOSE, verbose) verbose = max(_VERBOSE, verbose)
_init_loggers(verbose=verbose) _init_logging(verbose=verbose)
cfg: config.MaybeConfig = config.parse() cfg: config.MaybeConfig = config.parse()
if cfg is None: if cfg is None:
@ -106,7 +106,7 @@ def show(verbose: int = 0, fetch: bool = True) -> None:
def incr(old_version: str, verbose: int = 0, release: str = None) -> None: def incr(old_version: str, verbose: int = 0, release: str = None) -> None:
"""Increment a version number for demo purposes.""" """Increment a version number for demo purposes."""
verbose = max(_VERBOSE, verbose) verbose = max(_VERBOSE, verbose)
_init_loggers(verbose) _init_logging(verbose)
if release and release not in parse.VALID_RELESE_VALUES: if release and release not in parse.VALID_RELESE_VALUES:
log.error(f"Invalid argument --release={release}") log.error(f"Invalid argument --release={release}")
@ -114,7 +114,7 @@ def incr(old_version: str, verbose: int = 0, release: str = None) -> None:
sys.exit(1) sys.exit(1)
new_version = version.incr(old_version, release=release) new_version = version.incr(old_version, release=release)
new_version_nfo = parse.parse_version_info(new_version) new_version_nfo = parse.VersionInfo.parse(new_version)
print("PyCalVer Version:", new_version) print("PyCalVer Version:", new_version)
print("PEP440 Version:" , new_version_nfo.pep440_version) print("PEP440 Version:" , new_version_nfo.pep440_version)
@ -128,7 +128,7 @@ def incr(old_version: str, verbose: int = 0, release: str = None) -> None:
def init(verbose: int = 0, dry: bool = False) -> None: def init(verbose: int = 0, dry: bool = False) -> None:
"""Initialize [pycalver] configuration.""" """Initialize [pycalver] configuration."""
verbose = max(_VERBOSE, verbose) verbose = max(_VERBOSE, verbose)
_init_loggers(verbose) _init_logging(verbose)
cfg : config.MaybeConfig = config.parse() cfg : config.MaybeConfig = config.parse()
if cfg: if cfg:
@ -174,6 +174,35 @@ def _assert_not_dirty(vcs, filepaths: typ.Set[str], allow_dirty: bool):
sys.exit(1) sys.exit(1)
def _bump(cfg: config.Config, new_version: str, allow_dirty: bool = False) -> None:
_vcs: typ.Optional[vcs.VCS]
try:
_vcs = vcs.get_vcs()
except OSError:
log.warn("Version Control System not found, aborting commit.")
_vcs = None
filepaths = set(cfg.file_patterns.keys())
if _vcs:
_assert_not_dirty(_vcs, filepaths, allow_dirty)
rewrite.rewrite(new_version, cfg.file_patterns)
if _vcs is None or not cfg.commit:
return
for filepath in filepaths:
_vcs.add(filepath)
_vcs.commit(f"bump version to {new_version}")
if cfg.tag:
_vcs.tag(new_version)
_vcs.push(new_version)
@cli.command() @cli.command()
@click.option("-v", "--verbose" , count=True , help="Control log level. -vv for debug level.") @click.option("-v", "--verbose" , count=True , help="Control log level. -vv for debug level.")
@click.option('-f', "--fetch/--no-fetch", is_flag=True, default=True) @click.option('-f', "--fetch/--no-fetch", is_flag=True, default=True)
@ -202,7 +231,7 @@ def bump(
) -> None: ) -> None:
"""Increment the current version string and update project files.""" """Increment the current version string and update project files."""
verbose = max(_VERBOSE, verbose) verbose = max(_VERBOSE, verbose)
_init_loggers(verbose) _init_logging(verbose)
if release and release not in parse.VALID_RELESE_VALUES: if release and release not in parse.VALID_RELESE_VALUES:
log.error(f"Invalid argument --release={release}") log.error(f"Invalid argument --release={release}")
@ -223,33 +252,10 @@ def bump(
log.info(f"Old Version: {old_version}") log.info(f"Old Version: {old_version}")
log.info(f"New Version: {new_version}") log.info(f"New Version: {new_version}")
if dry or verbose:
print(rewrite.diff(new_version, cfg.file_patterns))
if dry: if dry:
log.info("Running with '--dry', showing diffs instead of updating files.")
file_patterns = cfg.file_patterns
filepaths = set(file_patterns.keys())
_vcs: typ.Optional[vcs.VCS]
try:
_vcs = vcs.get_vcs()
except OSError:
log.warn("Version Control System not found, aborting commit.")
_vcs = None
# if _vcs:
# _assert_not_dirty(_vcs, filepaths, allow_dirty)
rewrite.rewrite(new_version, file_patterns, dry=dry, verbose=verbose)
if dry or _vcs is None or not cfg.commit:
return return
for filepath in filepaths: _bump(cfg, new_version, allow_dirty)
_vcs.add(filepath)
_vcs.commit(f"bump version to {new_version}")
if cfg.tag:
_vcs.tag(new_version)
_vcs.push(new_version)

View file

@ -3,7 +3,7 @@
# #
# Copyright (c) 2018 Manuel Barkhau (@mbarkhau) - MIT License # Copyright (c) 2018 Manuel Barkhau (@mbarkhau) - MIT License
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""Parsing code for setup.cfg or pycalver.cfg""" """Parse setup.cfg or pycalver.cfg files."""
import io import io
import os import os
@ -18,15 +18,18 @@ from .parse import PYCALVER_RE
log = logging.getLogger("pycalver.config") log = logging.getLogger("pycalver.config")
PatternsByFilePath = typ.Dict[str, typ.List[str]]
class Config(typ.NamedTuple): class Config(typ.NamedTuple):
"""Represents a parsed config."""
current_version: str current_version: str
tag : bool tag : bool
commit: bool commit: bool
file_patterns: typ.Dict[str, typ.List[str]] file_patterns: PatternsByFilePath
def _debug_str(self) -> str: def _debug_str(self) -> str:
cfg_str_parts = [ cfg_str_parts = [
@ -46,6 +49,12 @@ class Config(typ.NamedTuple):
@property @property
def pep440_version(self) -> str: def pep440_version(self) -> str:
"""Derive pep440 compliant version string from PyCalVer version string.
>>> cfg = Config("v201811.0007-beta", True, True, [])
>>> cfg.pep440_version
'201811.7b0'
"""
return str(pkg_resources.parse_version(self.current_version)) return str(pkg_resources.parse_version(self.current_version))
@ -131,26 +140,27 @@ def _parse_buffer(cfg_buffer: io.StringIO, config_filename: str = "<pycalver.cfg
return cfg return cfg
def parse(config_filename: str = None) -> MaybeConfig: def parse(config_filepath: str = None) -> MaybeConfig:
if config_filename is None: """Parse config file using configparser."""
if config_filepath is None:
if os.path.exists("pycalver.cfg"): if os.path.exists("pycalver.cfg"):
config_filename = "pycalver.cfg" config_filepath = "pycalver.cfg"
elif os.path.exists("setup.cfg"): elif os.path.exists("setup.cfg"):
config_filename = "setup.cfg" config_filepath = "setup.cfg"
else: else:
log.error("File not found: pycalver.cfg or setup.cfg") log.error("File not found: pycalver.cfg or setup.cfg")
return None return None
if not os.path.exists(config_filename): if not os.path.exists(config_filepath):
log.error(f"File not found: {config_filename}") log.error(f"File not found: {config_filepath}")
return None return None
cfg_buffer = io.StringIO() cfg_buffer = io.StringIO()
with io.open(config_filename, mode="rt", encoding="utf-8") as fh: with io.open(config_filepath, mode="rt", encoding="utf-8") as fh:
cfg_buffer.write(fh.read()) cfg_buffer.write(fh.read())
cfg_buffer.seek(0) cfg_buffer.seek(0)
return _parse_buffer(cfg_buffer, config_filename) return _parse_buffer(cfg_buffer, config_filepath)
DEFAULT_CONFIG_BASE_STR = """ DEFAULT_CONFIG_BASE_STR = """
@ -190,6 +200,7 @@ patterns =
def default_config_lines() -> typ.List[str]: def default_config_lines() -> typ.List[str]:
"""Generate initial default config based on PWD and current date."""
initial_version = dt.datetime.now().strftime("v%Y%m.0001-dev") initial_version = dt.datetime.now().strftime("v%Y%m.0001-dev")
cfg_str = DEFAULT_CONFIG_BASE_STR.format(initial_version=initial_version) cfg_str = DEFAULT_CONFIG_BASE_STR.format(initial_version=initial_version)

View file

@ -4,9 +4,7 @@
# Copyright (c) 2018 Manuel Barkhau (@mbarkhau) - MIT License # Copyright (c) 2018 Manuel Barkhau (@mbarkhau) - MIT License
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
""" """A scheme for lexically ordered numerical ids.
This is a simple scheme for numerical ids which are ordered both
numerically and lexically.
Throughout the sequence this expression remains true, whether you Throughout the sequence this expression remains true, whether you
are dealing with integers or strings: are dealing with integers or strings:
@ -84,25 +82,56 @@ MINIMUM_ID = "0"
def next_id(prev_id: str) -> str: def next_id(prev_id: str) -> str:
"""Generate next lexical id.
Increments by one and adds padding if required.
>>> next_id("0098")
'0099'
>>> next_id("0099")
'0100'
>>> next_id("0999")
'11000'
>>> next_id("11000")
'11001'
"""
num_digits = len(prev_id) num_digits = len(prev_id)
if prev_id.count("9") == num_digits: if prev_id.count("9") == num_digits:
raise OverflowError("max lexical version reached: " + prev_id) raise OverflowError("max lexical version reached: " + prev_id)
_prev_id = int(prev_id, 10) _prev_id_val = int(prev_id, 10)
_next_id = int(_prev_id) + 1 _next_id_val = int(_prev_id_val) + 1
next_id = f"{_next_id:0{num_digits}}" _next_id_str = f"{_next_id_val:0{num_digits}}"
if prev_id[0] != next_id[0]: if prev_id[0] != _next_id_str[0]:
next_id = str(_next_id * 11) _next_id_str = str(_next_id_val * 11)
return next_id return _next_id_str
def ord_val(lex_id: str) -> int: def ord_val(lex_id: str) -> int:
"""Parse the ordinal value of a lexical id.
The ordinal value is the position in the sequence,
from repeated calls to next_id.
>>> ord_val("0098")
98
>>> ord_val("0099")
99
>>> ord_val("0100")
100
>>> ord_val("11000")
1000
>>> ord_val("11001")
1001
"""
if len(lex_id) == 1: if len(lex_id) == 1:
return int(lex_id, 10) return int(lex_id, 10)
return int(lex_id[1:], 10) return int(lex_id[1:], 10)
def main() -> None: def _main() -> None:
_curr_id = "01" _curr_id = "01"
print(f"{'lexical':<13} {'numerical':>12}") print(f"{'lexical':<13} {'numerical':>12}")
@ -130,4 +159,4 @@ def main() -> None:
if __name__ == '__main__': if __name__ == '__main__':
main() _main()

View file

@ -3,6 +3,28 @@
# #
# Copyright (c) 2018 Manuel Barkhau (@mbarkhau) - MIT License # Copyright (c) 2018 Manuel Barkhau (@mbarkhau) - MIT License
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""Parse PyCalVer strings.
>>> version_info = PYCALVER_RE.match("v201712.0123-alpha").groupdict()
>>> assert version_info == {
... "version" : "v201712.0123-alpha",
... "calver" : "v201712",
... "year" : "2017",
... "month" : "12",
... "build" : ".0123",
... "release" : "-alpha",
... }
>>>
>>> version_info = PYCALVER_RE.match("v201712.0033").groupdict()
>>> assert version_info == {
... "version" : "v201712.0033",
... "calver" : "v201712",
... "year" : "2017",
... "month" : "12",
... "build" : ".0033",
... "release" : None,
... }
"""
import re import re
import logging import logging
@ -64,18 +86,9 @@ RE_PATTERN_PARTS = {
} }
class PatternMatch(typ.NamedTuple):
lineno : int # zero based
line : str
pattern: str
span : typ.Tuple[int, int]
match : str
class VersionInfo(typ.NamedTuple): class VersionInfo(typ.NamedTuple):
"""Container for parsed version string."""
pep440_version: str
version: str version: str
calver : str calver : str
year : str year : str
@ -83,16 +96,48 @@ class VersionInfo(typ.NamedTuple):
build : str build : str
release: typ.Optional[str] release: typ.Optional[str]
@property
def pep440_version(self) -> str:
"""Generate pep440 compliant version string.
def parse_version_info(version: str) -> VersionInfo: >>> vnfo = VersionInfo.parse("v201712.0033-beta")
>>> vnfo.pep440_version
'201712.33b0'
"""
return str(pkg_resources.parse_version(self.version))
@staticmethod
def parse(version: str) -> 'VersionInfo':
"""Parse a PyCalVer string.
>>> vnfo = VersionInfo.parse("v201712.0033-beta")
>>> assert vnfo == VersionInfo(
... version="v201712.0033-beta",
... calver="v201712",
... year="2017",
... month="12",
... build=".0033",
... release="-beta",
... )
"""
match = PYCALVER_RE.match(version) match = PYCALVER_RE.match(version)
if match is None: if match is None:
raise ValueError(f"Invalid pycalver: {version}") raise ValueError(f"Invalid pycalver: {version}")
pep440_version = str(pkg_resources.parse_version(version))
return VersionInfo(pep440_version=pep440_version, **match.groupdict()) return VersionInfo(**match.groupdict())
def _iter_pattern_matches(lines: typ.List[str], pattern: str) -> typ.Iterable[PatternMatch]: class PatternMatch(typ.NamedTuple):
"""Container to mark a version string in a file."""
lineno : int # zero based
line : str
pattern: str
span : typ.Tuple[int, int]
match : str
@staticmethod
def _iter_for_pattern(lines: typ.List[str], pattern: str) -> typ.Iterable['PatternMatch']:
# The pattern is escaped, so that everything besides the format # The pattern is escaped, so that everything besides the format
# string variables is treated literally. # string variables is treated literally.
@ -108,9 +153,21 @@ def _iter_pattern_matches(lines: typ.List[str], pattern: str) -> typ.Iterable[Pa
if match: if match:
yield PatternMatch(lineno, line, pattern, match.span(), match.group(0)) yield PatternMatch(lineno, line, pattern, match.span(), match.group(0))
@staticmethod
def iter_matches(lines: typ.List[str], patterns: typ.List[str]) -> typ.Iterable['PatternMatch']:
"""Iterate over all matches of any pattern on any line.
def parse_patterns(lines: typ.List[str], patterns: typ.List[str]) -> typ.List[PatternMatch]: >>> lines = ["__version__ = 'v201712.0002-alpha'"]
all_matches: typ.List[PatternMatch] = [] >>> patterns = ["{version}", "{pep440_version}"]
>>> matches = list(PatternMatch.iter_matches(lines, patterns))
>>> assert matches[0] == PatternMatch(
... lineno = 0,
... line = "__version__ = 'v201712.0002-alpha'",
... pattern= "{version}",
... span = (15, 33),
... match = "v201712.0002-alpha",
... )
"""
for pattern in patterns: for pattern in patterns:
all_matches.extend(_iter_pattern_matches(lines, pattern)) for match in PatternMatch._iter_for_pattern(lines, pattern):
return all_matches yield match

View file

@ -3,6 +3,7 @@
# #
# (C) 2018 Manuel Barkhau (@mbarkhau) # (C) 2018 Manuel Barkhau (@mbarkhau)
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""Rewrite files, updating occurences of version strings."""
import io import io
import difflib import difflib
@ -10,11 +11,23 @@ import logging
import typing as typ import typing as typ
from . import parse from . import parse
from . import config
log = logging.getLogger("pycalver.rewrite") log = logging.getLogger("pycalver.rewrite")
def _detect_line_sep(content: str) -> str: def detect_line_sep(content: str) -> str:
r"""Parse line separator from content.
>>> detect_line_sep('\r\n')
'\r\n'
>>> detect_line_sep('\r')
'\r'
>>> detect_line_sep('\n')
'\n'
>>> detect_line_sep('')
'\n'
"""
if "\r\n" in content: if "\r\n" in content:
return "\r\n" return "\r\n"
elif "\r" in content: elif "\r" in content:
@ -24,16 +37,21 @@ def _detect_line_sep(content: str) -> str:
def rewrite_lines( def rewrite_lines(
old_lines: typ.List[str], patterns: typ.List[str], new_version: str patterns: typ.List[str], new_version: str, old_lines: typ.List[str]
) -> typ.List[str]: ) -> typ.List[str]:
new_version_nfo = parse.parse_version_info(new_version) """Replace occurances of patterns in old_lines with new_version.
>>> old_lines = ['__version__ = "v201809.0002-beta"']
>>> patterns = ['__version__ = "{version}"']
>>> new_lines = rewrite_lines(patterns, "v201811.0123-beta", old_lines)
>>> assert new_lines == ['__version__ = "v201811.0123-beta"']
"""
new_version_nfo = parse.VersionInfo.parse(new_version)
new_version_fmt_kwargs = new_version_nfo._asdict() new_version_fmt_kwargs = new_version_nfo._asdict()
new_lines = old_lines.copy() new_lines = old_lines.copy()
matches: typ.List[parse.PatternMatch] = parse.parse_patterns(old_lines, patterns) for m in parse.PatternMatch.iter_matches(old_lines, patterns):
for m in matches:
replacement = m.pattern.format(**new_version_fmt_kwargs) replacement = m.pattern.format(**new_version_fmt_kwargs)
span_l, span_r = m.span span_l, span_r = m.span
new_line = m.line[:span_l] + replacement + m.line[span_r:] new_line = m.line[:span_l] + replacement + m.line[span_r:]
@ -42,26 +60,107 @@ def rewrite_lines(
return new_lines return new_lines
def rewrite( class RewrittenFileData(typ.NamedTuple):
new_version: str, file_patterns: typ.Dict[str, typ.List[str]], dry=False, verbose: int = 0 """Container for line-wise content of rewritten files."""
) -> None:
path : str
line_sep : str
old_lines: typ.List[str]
new_lines: typ.List[str]
@property
def diff_lines(self) -> typ.List[str]:
r"""Generate unified diff.
>>> rwd = RewrittenFileData(
... path = "<path>",
... line_sep = "\n",
... old_lines = ["foo"],
... new_lines = ["bar"],
... )
>>> rwd.diff_lines
['--- <path>', '+++ <path>', '@@ -1 +1 @@', '-foo', '+bar']
"""
return list(
difflib.unified_diff(
a=self.old_lines,
b=self.new_lines,
lineterm="",
fromfile=self.path,
tofile=self.path,
)
)
@staticmethod
def from_content(
patterns: typ.List[str], new_version: str, content: str
) -> 'RewrittenFileData':
r"""Rewrite pattern occurrences with version string.
>>> patterns = ['__version__ = "{version}"']
>>> content = '__version__ = "v201809.0001-alpha"'
>>> rwd = RewrittenFileData.from_content(patterns, "v201809.0123", content)
>>> assert rwd.new_lines == ['__version__ = "v201809.0123"']
"""
line_sep = detect_line_sep(content)
old_lines = content.split(line_sep)
new_lines = rewrite_lines(patterns, new_version, old_lines)
return RewrittenFileData("<path>", line_sep, old_lines, new_lines)
@staticmethod
def iter_rewritten(
file_patterns: config.PatternsByFilePath, new_version: str
) -> typ.Iterable['RewrittenFileData']:
r'''Iterate over files with version string replaced.
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{version}"']}
>>> rewritten_datas = RewrittenFileData.iter_rewritten(file_patterns, "v201809.0123")
>>> rwd = list(rewritten_datas)[0]
>>> assert rwd.new_lines == [
... '# This file is part of the pycalver project',
... '# https://gitlab.com/mbarkhau/pycalver',
... '#',
... '# Copyright (c) 2018 Manuel Barkhau (@mbarkhau) - MIT License',
... '# SPDX-License-Identifier: MIT',
... '"""PyCalVer: Automatic CalVer Versioning for Python Packages."""',
... '',
... '__version__ = "v201809.0123"',
... '',
... ]
>>>
'''
for filepath, patterns in file_patterns.items(): for filepath, patterns in file_patterns.items():
with io.open(filepath, mode="rt", encoding="utf-8") as fh: with io.open(filepath, mode="rt", encoding="utf-8") as fh:
content = fh.read() content = fh.read()
line_sep = _detect_line_sep(content) rfd = RewrittenFileData.from_content(patterns, new_version, content)
old_lines = content.split(line_sep) yield rfd._replace(path=filepath)
new_lines = rewrite_lines(old_lines, patterns, new_version)
if dry or verbose:
diff_lines = difflib.unified_diff(
old_lines, new_lines, lineterm="", fromfile="a/" + filepath, tofile="b/" + filepath
)
print("\n".join(diff_lines))
if dry: def diff(new_version: str, file_patterns: config.PatternsByFilePath) -> str:
continue r"""Generate diffs of rewritten files.
new_content = line_sep.join(new_lines) >>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{version}"']}
with io.open(filepath, mode="wt", encoding="utf-8") as fh: >>> diff_lines = diff("v201809.0123", file_patterns).split("\n")
>>> diff_lines[:2]
['--- src/pycalver/__init__.py', '+++ src/pycalver/__init__.py']
>>> assert diff_lines[6].startswith('-__version__ = "v2')
>>> assert not diff_lines[6].startswith('-__version__ = "v201809.0123"')
>>> diff_lines[7]
'+__version__ = "v201809.0123"'
"""
diff_lines: typ.List[str] = []
for rwd in RewrittenFileData.iter_rewritten(file_patterns, new_version):
diff_lines += rwd.diff_lines
return "\n".join(diff_lines)
def rewrite(new_version: str, file_patterns: config.PatternsByFilePath) -> None:
"""Rewrite project files, updating each with the new version."""
for file_data in RewrittenFileData.iter_rewritten(file_patterns, new_version):
new_content = file_data.line_sep.join(file_data.new_lines)
with io.open(file_data.path, mode="wt", encoding="utf-8") as fh:
fh.write(new_content) fh.write(new_content)

View file

@ -7,6 +7,12 @@
# pycalver/vcs.py (this file) is based on code from the # pycalver/vcs.py (this file) is based on code from the
# bumpversion project: https://github.com/peritus/bumpversion # bumpversion project: https://github.com/peritus/bumpversion
# Copyright (c) 2013-2014 Filip Noetzel - MIT License # Copyright (c) 2013-2014 Filip Noetzel - MIT License
"""Minimal Git and Mercirial API.
If terminology for similar concepts differs between git and
mercurial, then the git terms are used. For example "fetch"
(git) instead of "pull" (hg) .
"""
import os import os
import logging import logging
@ -42,7 +48,7 @@ VCS_SUBCOMMANDS_BY_NAME = {
class VCS: class VCS:
"""Version Control System absraction for git and mercurial""" """VCS absraction for git and mercurial."""
def __init__(self, name: str, subcommands: typ.Dict[str, str] = None): def __init__(self, name: str, subcommands: typ.Dict[str, str] = None):
self.name = name self.name = name
@ -51,13 +57,19 @@ class VCS:
else: else:
self.subcommands = subcommands self.subcommands = subcommands
def __call__(self, cmd_name: str, env=None, **kwargs: str) -> bytes: def __call__(self, cmd_name: str, env=None, **kwargs: str) -> str:
"""Invoke subcommand and return output."""
cmd_str = self.subcommands[cmd_name] cmd_str = self.subcommands[cmd_name]
cmd_parts = cmd_str.format(**kwargs).split() cmd_parts = cmd_str.format(**kwargs).split()
return sp.check_output(cmd_parts, env=env) output_data = sp.check_output(cmd_parts, env=env)
# TODO (mb 2018-11-15): Detect encoding of output?
_encoding = "utf-8"
return output_data.decode(_encoding)
@property @property
def is_usable(self) -> bool: def is_usable(self) -> bool:
"""Detect availability of subcommand."""
cmd = self.subcommands['is_usable'].split() cmd = self.subcommands['is_usable'].split()
try: try:
@ -70,28 +82,31 @@ class VCS:
raise raise
def fetch(self) -> None: def fetch(self) -> None:
"""Fetch updates from remote origin."""
self('fetch') self('fetch')
def status(self) -> typ.List[str]: def status(self) -> typ.List[str]:
"""Get status lines."""
status_output = self('status') status_output = self('status')
return [ return [
line.decode("utf-8")[2:].strip() line[2:].strip()
for line in status_output.splitlines() for line in status_output.splitlines()
if not line.strip().startswith(b"??") if not line.strip().startswith("??")
] ]
def ls_tags(self) -> typ.List[str]: def ls_tags(self) -> typ.List[str]:
"""List vcs tags on all branches."""
ls_tag_lines = self('ls_tags').splitlines() ls_tag_lines = self('ls_tags').splitlines()
log.debug(f"ls_tags output {ls_tag_lines}") log.debug(f"ls_tags output {ls_tag_lines}")
return [ return [line.strip() for line in ls_tag_lines if line.strip().startswith("v")]
line.decode("utf-8").strip() for line in ls_tag_lines if line.strip().startswith(b"v")
]
def add(self, path) -> None: def add(self, path: str) -> None:
"""Add updates to be included in next commit."""
log.info(f"{self.name} add {path}") log.info(f"{self.name} add {path}")
self('add_path', path=path) self('add_path', path=path)
def commit(self, message: str) -> None: def commit(self, message: str) -> None:
"""Commit added files."""
log.info(f"{self.name} commit -m '{message}'") log.info(f"{self.name} commit -m '{message}'")
message_data = message.encode("utf-8") message_data = message.encode("utf-8")
@ -108,17 +123,24 @@ class VCS:
self('commit', env=env, path=tmp_file.name) self('commit', env=env, path=tmp_file.name)
os.unlink(tmp_file.name) os.unlink(tmp_file.name)
def tag(self, tag_name) -> None: def tag(self, tag_name: str) -> None:
"""Create an annotated tag."""
self('tag', tag=tag_name) self('tag', tag=tag_name)
def push(self, tag_name) -> None: def push(self, tag_name: str) -> None:
"""Push changes to origin."""
self('push_tag', tag=tag_name) self('push_tag', tag=tag_name)
def __repr__(self) -> str: def __repr__(self) -> str:
"""Generate string representation."""
return f"VCS(name='{self.name}')" return f"VCS(name='{self.name}')"
def get_vcs() -> VCS: def get_vcs() -> VCS:
"""Detect the appropriate VCS for a repository.
raises OSError if the directory doesn't use a supported VCS.
"""
for vcs_name in VCS_SUBCOMMANDS_BY_NAME.keys(): for vcs_name in VCS_SUBCOMMANDS_BY_NAME.keys():
vcs = VCS(name=vcs_name) vcs = VCS(name=vcs_name)
if vcs.is_usable: if vcs.is_usable:

View file

@ -29,7 +29,7 @@ def incr(old_version: str, *, release: str = None) -> str:
Old_version is assumed to be a valid calver string, Old_version is assumed to be a valid calver string,
already validated in pycalver.config.parse. already validated in pycalver.config.parse.
""" """
old_ver = parse.parse_version_info(old_version) old_ver = parse.VersionInfo.parse(old_version)
new_calver = current_calver() new_calver = current_calver()

View file

@ -35,7 +35,7 @@ def test_ord_val():
def test_main(capsys): def test_main(capsys):
lex_id.main() lex_id._main()
captured = capsys.readouterr() captured = capsys.readouterr()
assert len(captured.err) == 0 assert len(captured.err) == 0

View file

@ -75,7 +75,7 @@ def test_re_pattern_parts():
def test_parse_version_info(): def test_parse_version_info():
version_str = "v201712.0001-alpha" version_str = "v201712.0001-alpha"
version_nfo = parse.parse_version_info(version_str) version_nfo = parse.VersionInfo.parse(version_str)
assert version_nfo.pep440_version == "201712.1a0" assert version_nfo.pep440_version == "201712.1a0"
assert version_nfo.version == "v201712.0001-alpha" assert version_nfo.version == "v201712.0001-alpha"
@ -86,7 +86,7 @@ def test_parse_version_info():
assert version_nfo.release == "-alpha" assert version_nfo.release == "-alpha"
version_str = "v201712.0001" version_str = "v201712.0001"
version_nfo = parse.parse_version_info(version_str) version_nfo = parse.VersionInfo.parse(version_str)
assert version_nfo.pep440_version == "201712.1" assert version_nfo.pep440_version == "201712.1"
assert version_nfo.version == "v201712.0001" assert version_nfo.version == "v201712.0001"
@ -97,23 +97,25 @@ def test_parse_version_info():
assert version_nfo.release is None assert version_nfo.release is None
def test_default_parse_patterns(): SETUP_PY_FIXTURE = """
lines = [ # setup.py
"# setup.py", import setuptools
"import setuptools", __version__ = 'v201712.0002-alpha'
"__version__ = 'v201712.0002-alpha'", setuptools.setup(
"setuptools.setup(", ...
"...", version='201712.2a0',
" version='201712.2a0',", """
]
def test_default_parse_patterns():
lines = SETUP_PY_FIXTURE.splitlines()
patterns = ["{version}", "{pep440_version}"] patterns = ["{version}", "{pep440_version}"]
matches = parse.parse_patterns(lines, patterns) matches = list(parse.PatternMatch.iter_matches(lines, patterns))
assert len(matches) == 2 assert len(matches) == 2
assert matches[0].lineno == 2 assert matches[0].lineno == 3
assert matches[1].lineno == 5 assert matches[1].lineno == 6
assert matches[0].pattern == patterns[0] assert matches[0].pattern == patterns[0]
assert matches[1].pattern == patterns[1] assert matches[1].pattern == patterns[1]
@ -123,22 +125,15 @@ def test_default_parse_patterns():
def test_explicit_parse_patterns(): def test_explicit_parse_patterns():
lines = [ lines = SETUP_PY_FIXTURE.splitlines()
"# setup.py",
"import setuptools",
"__version__ = 'v201712.0002-alpha'",
"setuptools.setup(",
"...",
" version='201712.2a0',",
]
patterns = ["__version__ = '{version}'", "version='{pep440_version}'"] patterns = ["__version__ = '{version}'", "version='{pep440_version}'"]
matches = parse.parse_patterns(lines, patterns) matches = list(parse.PatternMatch.iter_matches(lines, patterns))
assert len(matches) == 2 assert len(matches) == 2
assert matches[0].lineno == 2 assert matches[0].lineno == 3
assert matches[1].lineno == 5 assert matches[1].lineno == 6
assert matches[0].pattern == patterns[0] assert matches[0].pattern == patterns[0]
assert matches[1].pattern == patterns[1] assert matches[1].pattern == patterns[1]
@ -147,23 +142,25 @@ def test_explicit_parse_patterns():
assert matches[1].match == "version='201712.2a0'" assert matches[1].match == "version='201712.2a0'"
README_RST_FIXTURE = """
:alt: PyPI version
.. |version| image:: https://img.shields.io/badge/CalVer-v201809.0002--beta-blue.svg
:target: https://calver.org/
:alt: CalVer v201809.0002-beta
"""
def test_badge_parse_patterns(): def test_badge_parse_patterns():
lines = [ lines = README_RST_FIXTURE.splitlines()
":alt: PyPI version",
"",
".. |version| image:: https://img.shields.io/badge/CalVer-v201809.0002--beta-blue.svg",
":target: https://calver.org/",
":alt: CalVer v201809.0002-beta",
"",
]
patterns = ["badge/CalVer-{calver}{build}-{release}-blue.svg", ":alt: CalVer {version}"] patterns = ["badge/CalVer-{calver}{build}-{release}-blue.svg", ":alt: CalVer {version}"]
matches = parse.parse_patterns(lines, patterns) matches = list(parse.PatternMatch.iter_matches(lines, patterns))
assert len(matches) == 2 assert len(matches) == 2
assert matches[0].lineno == 2 assert matches[0].lineno == 3
assert matches[1].lineno == 4 assert matches[1].lineno == 5
assert matches[0].pattern == patterns[0] assert matches[0].pattern == patterns[0]
assert matches[1].pattern == patterns[1] assert matches[1].pattern == patterns[1]
@ -174,19 +171,19 @@ def test_badge_parse_patterns():
def test_parse_error(): def test_parse_error():
try: try:
parse.parse_version_info("") parse.VersionInfo.parse("")
assert False assert False
except ValueError as err: except ValueError as err:
pass pass
try: try:
parse.parse_version_info("201809.0002") parse.VersionInfo.parse("201809.0002")
assert False assert False
except ValueError as err: except ValueError as err:
pass pass
try: try:
parse.parse_version_info("v201809.2b0") parse.VersionInfo.parse("v201809.2b0")
assert False assert False
except ValueError as err: except ValueError as err:
pass pass

View file

@ -1,22 +1,22 @@
from pycalver import rewrite from pycalver import rewrite
REWRITE_FIXTURE = """
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# (C) 2018 Manuel Barkhau (@mbarkhau)
# SPDX-License-Identifier: MIT
__version__ = "v201809.0002-beta"
"""
def test_rewrite_lines(): def test_rewrite_lines():
old_lines = [ old_lines = REWRITE_FIXTURE.splitlines()
"# 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.0002-beta"',
'DEBUG = os.environ.get("PYDEBUG", "0") == "1"',
]
patterns = ['__version__ = "{version}"'] patterns = ['__version__ = "{version}"']
new_version = "v201809.0003" new_version = "v201809.0003"
new_lines = rewrite.rewrite_lines(old_lines, patterns, new_version) new_lines = rewrite.rewrite_lines(patterns, new_version, old_lines)
assert len(new_lines) == len(old_lines) assert len(new_lines) == len(old_lines)
assert new_version not in "\n".join(old_lines) assert new_version not in "\n".join(old_lines)