wip: refuctoring on the road to v201812

This commit is contained in:
Manuel Barkhau 2018-12-05 09:38:27 +01:00
parent 70f4e01104
commit fe06833764
5 changed files with 468 additions and 302 deletions

View file

@ -5,14 +5,15 @@
# Copyright (c) 2018 Manuel Barkhau (@mbarkhau) - MIT License # Copyright (c) 2018 Manuel Barkhau (@mbarkhau) - MIT License
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
""" """
CLI module for pycalver. CLI module for PyCalVer.
Provided subcommands: show, init, incr, bump Provided subcommands: show, incr, init, bump
""" """
import io import io
import os import os
import sys import sys
import toml
import click import click
import logging import logging
import typing as typ import typing as typ
@ -27,6 +28,16 @@ from . import rewrite
_VERBOSE = 0 _VERBOSE = 0
try:
import backtrace
# To enable pretty tracebacks:
# echo "export ENABLE_BACKTRACE=1;" >> ~/.bashrc
backtrace.hook(align=True, strip_path=True, enable_on_envvar_only=True)
except ImportError:
pass
log = logging.getLogger("pycalver.cli") log = logging.getLogger("pycalver.cli")
@ -53,12 +64,34 @@ def cli(verbose: int = 0):
_VERBOSE = verbose _VERBOSE = verbose
@cli.command()
@click.argument("old_version")
@click.option('-v', '--verbose', count=True, help="Control log level. -vv for debug level.")
@click.option(
"--release", default=None, metavar="<name>", help="Override release name of current_version"
)
def incr(old_version: str, verbose: int = 0, release: str = None) -> None:
"""Increment a version number for demo purposes."""
_init_logging(verbose=max(_VERBOSE, 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)}")
sys.exit(1)
new_version = version.incr(old_version, release=release)
pep440_version = version.pycalver_to_pep440(new_version)
print("PyCalVer Version:", new_version)
print("PEP440 Version:" , pep440_version)
def _update_cfg_from_vcs(cfg: config.Config, fetch: bool) -> config.Config: def _update_cfg_from_vcs(cfg: config.Config, fetch: bool) -> config.Config:
try: try:
_vcs = vcs.get_vcs() _vcs = vcs.get_vcs()
log.debug(f"vcs found: {_vcs.name}") log.debug(f"vcs found: {_vcs.name}")
if fetch: if fetch:
log.debug(f"fetching from remote") log.info(f"fetching tags from remote")
_vcs.fetch() _vcs.fetch()
version_tags = [tag for tag in _vcs.ls_tags() if parse.PYCALVER_RE.match(tag)] version_tags = [tag for tag in _vcs.ls_tags() if parse.PYCALVER_RE.match(tag)]
@ -83,10 +116,11 @@ def _update_cfg_from_vcs(cfg: config.Config, fetch: bool) -> config.Config:
@click.option('-f', "--fetch/--no-fetch", is_flag=True, default=True) @click.option('-f', "--fetch/--no-fetch", is_flag=True, default=True)
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) _init_logging(verbose=max(_VERBOSE, verbose))
_init_logging(verbose=verbose)
ctx: config.ProjectContext = config.init_project_ctx(project_path=".")
cfg: config.MaybeConfig = config.parse(ctx)
cfg: config.MaybeConfig = config.parse()
if cfg is None: if cfg is None:
log.error("Could not parse configuration from setup.cfg") log.error("Could not parse configuration from setup.cfg")
sys.exit(1) sys.exit(1)
@ -97,29 +131,6 @@ def show(verbose: int = 0, fetch: bool = True) -> None:
print(f"PEP440 Version : {cfg.pep440_version}") print(f"PEP440 Version : {cfg.pep440_version}")
@cli.command()
@click.argument("old_version")
@click.option('-v', '--verbose', count=True, help="Control log level. -vv for debug level.")
@click.option(
"--release", default=None, metavar="<name>", help="Override release name of current_version"
)
def incr(old_version: str, verbose: int = 0, release: str = None) -> None:
"""Increment a version number for demo purposes."""
verbose = max(_VERBOSE, verbose)
_init_logging(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)}")
sys.exit(1)
new_version = version.incr(old_version, release=release)
new_version_nfo = parse.VersionInfo.parse(new_version)
print("PyCalVer Version:", new_version)
print("PEP440 Version:" , new_version_nfo.pep440_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( @click.option(
@ -127,31 +138,22 @@ 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) _init_logging(verbose=max(_VERBOSE, verbose))
_init_logging(verbose)
ctx: config.ProjectContext = config.init_project_ctx(project_path=".")
cfg: config.MaybeConfig = config.parse(ctx)
cfg : config.MaybeConfig = config.parse()
if cfg: if cfg:
log.error("Configuration already initialized in setup.cfg") log.error("Configuration already initialized in {cfg.filename}")
sys.exit(1) sys.exit(1)
cfg_lines = config.default_config_lines()
if dry: if dry:
print("Exiting because of '--dry'. Would have written to setup.cfg:") print("Exiting because of '--dry'. Would have written to setup.cfg:")
cfg_lines = config.default_config(output_fmt)
print("\n " + "\n ".join(cfg_lines)) print("\n " + "\n ".join(cfg_lines))
return return
if os.path.exists("setup.cfg"): config.write_default_config()
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")
def _assert_not_dirty(vcs, filepaths: typ.Set[str], allow_dirty: bool): def _assert_not_dirty(vcs, filepaths: typ.Set[str], allow_dirty: bool):
@ -238,7 +240,8 @@ def bump(
log.error(f"Valid arguments are: {', '.join(parse.VALID_RELESE_VALUES)}") log.error(f"Valid arguments are: {', '.join(parse.VALID_RELESE_VALUES)}")
sys.exit(1) sys.exit(1)
cfg: config.MaybeConfig = config.parse() ctx: config.ProjectContext = config.init_project_ctx(project_path=".")
cfg: config.MaybeConfig = config.parse(ctx)
if cfg is None: if cfg is None:
log.error("Could not parse configuration from setup.cfg") log.error("Could not parse configuration from setup.cfg")

View file

@ -7,55 +7,91 @@
import io import io
import os import os
import toml
import configparser import configparser
import pkg_resources
import typing as typ import typing as typ
import pathlib2 as pl
import datetime as dt import datetime as dt
import logging import logging
from .parse import PYCALVER_RE from .parse import PYCALVER_RE
from . import version
log = logging.getLogger("pycalver.config") log = logging.getLogger("pycalver.config")
PatternsByFilePath = typ.Dict[str, typ.List[str]] PatternsByFilePath = typ.Dict[str, typ.List[str]]
class ProjectContext(typ.NamedTuple):
"""Container class for project info."""
path : pl.Path
config_filepath: pl.Path
config_format : str
vcs_type : typ.Optional[str]
def init_project_ctx(project_path: typ.Union[str, pl.Path, None] = ".") -> ProjectContext:
"""Initialize ProjectContext from a path."""
if isinstance(project_path, str):
path = pl.Path(project_path)
else:
path = project_path
if (path / "pyproject.toml").exists():
config_filepath = path / "pyproject.toml"
config_format = 'toml'
if (path / "setup.cfg").exists():
config_filepath = path / "setup.cfg"
config_format = 'cfg'
else:
config_filepath = path / "pycalver.toml"
config_format = 'toml'
if (path / ".git").exists():
vcs_type = 'git'
elif (path / ".hg").exists():
vcs_type = 'hg'
else:
vcs_type = None
return ProjectContext(path, config_filepath, config_format, vcs_type)
RawConfig = typ.Dict[str, typ.Any]
class Config(typ.NamedTuple): class Config(typ.NamedTuple):
"""Represents a parsed config.""" """Container for parameters parsed from a config file."""
current_version: str current_version: str
pep440_version : str
tag : bool tag : bool
commit: bool commit: bool
push: bool
file_patterns: PatternsByFilePath file_patterns: PatternsByFilePath
def _debug_str(self) -> str:
cfg_str_parts = [
f"Config Parsed: Config(",
f"current_version='{self.current_version}'",
f"tag={self.tag}",
f"commit={self.commit}",
f"file_patterns={{",
]
for filename, patterns in self.file_patterns.items(): def _debug_str(cfg: Config) -> str:
for pattern in patterns: cfg_str_parts = [
cfg_str_parts.append(f"\n '{filename}': '{pattern}'") f"Config Parsed: Config(",
f"current_version='{cfg.current_version}'",
f"pep440_version='{cfg.pep440_version}'",
f"tag={cfg.tag}",
f"commit={cfg.commit}",
f"push={cfg.push}",
f"file_patterns={{",
]
cfg_str_parts += ["\n})"] for filepath, patterns in cfg.file_patterns.items():
return ", ".join(cfg_str_parts) for pattern in patterns:
cfg_str_parts.append(f"\n '{filepath}': '{pattern}'")
@property cfg_str_parts += ["\n})"]
def pep440_version(self) -> str: return ", ".join(cfg_str_parts)
"""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))
MaybeConfig = typ.Optional[Config] MaybeConfig = typ.Optional[Config]
@ -63,159 +99,278 @@ MaybeConfig = typ.Optional[Config]
FilePatterns = typ.Dict[str, typ.List[str]] FilePatterns = typ.Dict[str, typ.List[str]]
def _parse_file_patterns( def _parse_cfg_file_patterns(
cfg_parser: configparser.RawConfigParser, config_filename: str cfg_parser: configparser.RawConfigParser,
) -> typ.Optional[FilePatterns]: ) -> FilePatterns:
file_patterns: FilePatterns = {} file_patterns: FilePatterns = {}
section_name: str for filepath, patterns_str in cfg_parser.items("pycalver:file_patterns"):
for section_name in cfg_parser.sections(): patterns: typ.List[str] = []
if not section_name.startswith("pycalver:file:"): for line in patterns_str.splitlines():
continue pattern = line.strip()
if pattern:
patterns.append(pattern)
filepath = section_name.split(":", 2)[-1] file_patterns[filepath] = patterns
if not os.path.exists(filepath):
log.error(f"No such file: {filepath} from {section_name} in {config_filename}")
return None
section: typ.Dict[str, str] = dict(cfg_parser.items(section_name))
patterns = section.get("patterns")
if patterns is None:
file_patterns[filepath] = ["{version}", "{pep440_version}"]
else:
file_patterns[filepath] = [
line.strip() for line in patterns.splitlines() if line.strip()
]
if not file_patterns:
file_patterns[f"{config_filename}"] = ["{version}", "{pep440_version}"]
return file_patterns return file_patterns
def _parse_buffer(cfg_buffer: io.StringIO, config_filename: str = "<pycalver.cfg>") -> MaybeConfig: def _parse_cfg_option(option_name):
# preserve uppercase filenames
return option_name
def _parse_cfg(cfg_buffer: typ.TextIO) -> RawConfig:
cfg_parser = configparser.RawConfigParser() cfg_parser = configparser.RawConfigParser()
cfg_parser.optionxform = _parse_cfg_option
if hasattr(cfg_parser, 'read_file'): if hasattr(cfg_parser, 'read_file'):
cfg_parser.read_file(cfg_buffer) cfg_parser.read_file(cfg_buffer)
else: else:
cfg_parser.readfp(cfg_buffer) cfg_parser.readfp(cfg_buffer) # python2 compat
if not cfg_parser.has_section("pycalver"): if not cfg_parser.has_section("pycalver"):
log.error(f"{config_filename} does not contain a [pycalver] section.") log.error("Missing [pycalver] section.")
return None return None
base_cfg = dict(cfg_parser.items("pycalver")) raw_cfg = dict(cfg_parser.items("pycalver"))
if "current_version" not in base_cfg: raw_cfg['commit'] = raw_cfg.get('commit', False)
log.error(f"{config_filename} does not have 'pycalver.current_version'") raw_cfg['tag' ] = raw_cfg.get('tag' , None)
return None raw_cfg['push' ] = raw_cfg.get('push' , None)
current_version = base_cfg['current_version'] if isinstance(raw_cfg['commit'], str):
raw_cfg['commit'] = raw_cfg['commit'].lower() in ("yes", "true", "1", "on")
if isinstance(raw_cfg['tag'], str):
raw_cfg['tag'] = raw_cfg['tag'].lower() in ("yes", "true", "1", "on")
if isinstance(raw_cfg['push'], str):
raw_cfg['push'] = raw_cfg['push'].lower() in ("yes", "true", "1", "on")
if PYCALVER_RE.match(current_version) is None: raw_cfg['file_patterns'] = _parse_cfg_file_patterns(cfg_parser)
log.error(f"{config_filename} 'pycalver.current_version is invalid")
log.error(f"current_version = {current_version}")
return None
tag = base_cfg.get("tag" , "").lower() in ("yes", "true", "1", "on") return raw_cfg
commit = base_cfg.get("commit", "").lower() in ("yes", "true", "1", "on")
file_patterns = _parse_file_patterns(cfg_parser, config_filename)
if file_patterns is None: def _parse_toml(cfg_buffer: typ.TextIO) -> RawConfig:
return None raw_full_cfg = toml.load(cfg_buffer)
raw_cfg = raw_full_cfg.get('pycalver', {})
raw_cfg['commit'] = raw_cfg.get('commit', False)
raw_cfg['tag' ] = raw_cfg.get('tag' , None)
raw_cfg['push' ] = raw_cfg.get('push' , None)
return raw_cfg
def _parse_config(raw_cfg: RawConfig) -> Config:
if 'current_version' not in raw_cfg:
raise ValueError("Missing 'pycalver.current_version'")
version_str = raw_cfg['current_version']
version_str = raw_cfg['current_version'] = version_str.strip("'\" ")
if PYCALVER_RE.match(version_str) is None:
raise ValueError(f"Invalid current_version = {version_str}")
pep440_version = version.pycalver_to_pep440(version_str)
commit = raw_cfg['commit']
tag = raw_cfg['tag']
push = raw_cfg['push']
if tag is None:
tag = raw_cfg['tag'] = False
if push is None:
push = raw_cfg['push'] = False
if tag and not commit: if tag and not commit:
log.error(f"Invalid configuration in {config_filename}") raise ValueError("pycalver.commit = true required if pycalver.tag = true")
log.error(" pycalver.commit = True required if pycalver.tag = True")
return None
cfg = Config(current_version, tag, commit, file_patterns) if push and not commit:
raise ValueError("pycalver.commit = true required if pycalver.push = true")
log.debug(cfg._debug_str()) file_patterns = raw_cfg['file_patterns']
for filepath in file_patterns.keys():
if not os.path.exists(filepath):
log.warning(f"Invalid configuration, no such file: {filepath}")
cfg = Config(version_str, pep440_version, tag, commit, push, file_patterns)
log.debug(_debug_str(cfg))
return cfg return cfg
def parse(config_filepath: str = None) -> MaybeConfig: def parse(ctx: ProjectContext) -> MaybeConfig:
"""Parse config file using configparser.""" """Parse config file if available."""
if config_filepath is None: if not ctx.config_filepath.exists():
if os.path.exists("pycalver.cfg"): log.error(f"File not found: {ctx.config_filepath}")
config_filepath = "pycalver.cfg"
elif os.path.exists("setup.cfg"):
config_filepath = "setup.cfg"
else:
log.error("File not found: pycalver.cfg or setup.cfg")
return None
if not os.path.exists(config_filepath):
log.error(f"File not found: {config_filepath}")
return None return None
cfg_buffer = io.StringIO() raw_cfg: typ.Optional[RawConfig]
with io.open(config_filepath, mode="rt", encoding="utf-8") as fh:
cfg_buffer.write(fh.read())
cfg_buffer.seek(0) try:
return _parse_buffer(cfg_buffer, config_filepath) with ctx.config_filepath.open(mode="rt", encoding="utf-8") as fh:
if ctx.config_format == 'toml':
raw_cfg = _parse_toml(fh)
elif ctx.config_format == 'cfg':
raw_cfg = _parse_cfg(fh)
else:
return None
return _parse_config(raw_cfg)
except ValueError as ex:
log.error(f"Error parsing {ctx.config_filepath}: {str(ex)}")
return None
DEFAULT_CONFIG_BASE_STR = """ DEFAULT_CONFIGPARSER_BASE_STR = """
[pycalver] [pycalver]
current_version = {initial_version} current_version = "{initial_version}"
commit = True commit = True
tag = True tag = True
push = True
[pycalver:file:setup.cfg] [pycalver:file_patterns]
patterns =
current_version = {{version}}
""" """
DEFAULT_CONFIG_SETUP_PY_STR = """ DEFAULT_CONFIGPARSER_SETUP_CFG_STR = """
[pycalver:file:setup.py] setup.cfg =
patterns = current_version = "{{version}}"
"""
DEFAULT_CONFIGPARSER_SETUP_PY_STR = """
setup.py =
"{version}" "{version}"
"{pep440_version}" "{pep440_version}"
""" """
DEFAULT_CONFIG_README_RST_STR = """ DEFAULT_CONFIGPARSER_README_RST_STR = """
[pycalver:file:README.rst] README.rst =
patterns = "{version}"
{version} "{pep440_version}"
{pep440_version}
""" """
DEFAULT_CONFIG_README_MD_STR = """ DEFAULT_CONFIGPARSER_README_MD_STR = """
[pycalver:file:README.md] README.md =
patterns = "{version}"
{version} "{pep440_version}"
{pep440_version}
""" """
def default_config_lines() -> typ.List[str]: DEFAULT_TOML_BASE_STR = """
"""Generate initial default config based on PWD and current date.""" [pycalver]
current_version = "{initial_version}"
commit = true
tag = true
push = true
[pycalver.file_patterns]
"""
DEFAULT_TOML_PYCALVER_STR = """
"pycalver.toml" = [
'current_version = "{{version}}"',
]
"""
DEFAULT_TOML_PYPROJECT_STR = """
"pyproject.toml" = [
'current_version = "{{version}}"',
]
"""
DEFAULT_TOML_SETUP_PY_STR = """
"setup.py" = [
"{version}",
"{pep440_version}",
]
"""
DEFAULT_TOML_README_RST_STR = """
"README.rst" = [
"{version}",
"{pep440_version}",
]
"""
DEFAULT_TOML_README_MD_STR = """
"README.md" = [
"{version}",
"{pep440_version}",
]
"""
def default_config(ctx: ProjectContext) -> str:
"""Generate initial default config."""
if ctx.config_format == 'cfg':
base_str = DEFAULT_CONFIGPARSER_BASE_STR
default_pattern_strs_by_filename = {
"setup.cfg" : DEFAULT_CONFIGPARSER_SETUP_CFG_STR,
"setup.py" : DEFAULT_CONFIGPARSER_SETUP_PY_STR,
"README.rst": DEFAULT_CONFIGPARSER_README_RST_STR,
"README.md" : DEFAULT_CONFIGPARSER_README_MD_STR,
}
elif ctx.config_format == 'toml':
base_str = DEFAULT_TOML_BASE_STR
default_pattern_strs_by_filename = {
"pyproject.toml": DEFAULT_TOML_PYPROJECT_STR,
"pycalver.toml" : DEFAULT_TOML_PYCALVER_STR,
"setup.py" : DEFAULT_TOML_SETUP_PY_STR,
"README.rst" : DEFAULT_TOML_README_RST_STR,
"README.md" : DEFAULT_TOML_README_MD_STR,
}
else:
raise ValueError(f"Invalid fmt='{fmt}', must be either 'toml' or 'cfg'.")
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 = base_str.format(initial_version=initial_version)
cfg_lines = cfg_str.splitlines() for filename, default_str in default_pattern_strs_by_filename.items():
if (ctx.path / filename).exists():
cfg_str += default_str
if os.path.exists("setup.py"): has_config_file = (
cfg_lines.extend(DEFAULT_CONFIG_SETUP_PY_STR.splitlines()) (ctx.path / "setup.cfg").exists() or
(ctx.path / "pyproject.toml").exists() or
(ctx.path / "pycalver.toml").exists()
)
if os.path.exists("README.rst"): if not has_config_file:
cfg_lines.extend(DEFAULT_CONFIG_README_RST_STR.splitlines()) if ctx.config_format == 'cfg':
cfg_str += DEFAULT_CONFIGPARSER_SETUP_CFG_STR
if ctx.config_format == 'toml':
cfg_str += DEFAULT_TOML_PYCALVER_STR
if os.path.exists("README.md"): cfg_str += "\n"
cfg_lines.extend(DEFAULT_CONFIG_README_MD_STR.splitlines())
cfg_lines += [""] return cfg_str
return cfg_lines
def write_content(cfg: Config) -> None:
cfg_content = "\n" + "\n".join(cfg_lines)
if os.path.exists("pyproject.toml"):
with io.open("pyproject.toml", mode="at", encoding="utf-8") as fh:
fh.write(cfg_content)
print("Updated pyproject.toml")
elif os.path.exists("setup.cfg"):
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("pycalver.toml", mode="at", encoding="utf-8") as fh:
fh.write(cfg_content)
print("Created pycalver.toml")

View file

@ -29,8 +29,8 @@
import re import re
import logging import logging
import typing as typ import typing as typ
import pkg_resources
from . import version
log = logging.getLogger("pycalver.parse") log = logging.getLogger("pycalver.parse")
@ -89,42 +89,36 @@ RE_PATTERN_PARTS = {
class VersionInfo(typ.NamedTuple): class VersionInfo(typ.NamedTuple):
"""Container for parsed version string.""" """Container for parsed version string."""
version: str version : str
calver : str pep440_version: str
year : str calver : str
month : str year : str
build : str month : str
release: typ.Optional[str] build : str
release : typ.Optional[str]
@property
def pep440_version(self) -> str:
"""Generate pep440 compliant version string.
>>> vnfo = VersionInfo.parse("v201712.0033-beta") def parse_version_info(version_str: str) -> VersionInfo:
>>> vnfo.pep440_version """Parse a PyCalVer string.
'201712.33b0'
"""
return str(pkg_resources.parse_version(self.version))
@staticmethod >>> vnfo = parse_version_info("v201712.0033-beta")
def parse(version: str) -> 'VersionInfo': >>> assert vnfo == VersionInfo(
"""Parse a PyCalVer string. ... version="v201712.0033-beta",
... pep440_version="201712.33b0",
... calver="v201712",
... year="2017",
... month="12",
... build=".0033",
... release="-beta",
... )
"""
match = PYCALVER_RE.match(version_str)
if match is None:
raise ValueError(f"Invalid PyCalVer string: {version_str}")
>>> vnfo = VersionInfo.parse("v201712.0033-beta") kwargs = match.groupdict()
>>> assert vnfo == VersionInfo( kwargs['pep440_version'] = version.pycalver_to_pep440(kwargs['version'])
... version="v201712.0033-beta", return VersionInfo(**kwargs)
... calver="v201712",
... year="2017",
... month="12",
... build=".0033",
... release="-beta",
... )
"""
match = PYCALVER_RE.match(version)
if match is None:
raise ValueError(f"Invalid pycalver: {version}")
return VersionInfo(**match.groupdict())
class PatternMatch(typ.NamedTuple): class PatternMatch(typ.NamedTuple):
@ -136,38 +130,41 @@ class PatternMatch(typ.NamedTuple):
span : typ.Tuple[int, int] span : typ.Tuple[int, int]
match : str 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
# string variables is treated literally.
pattern_tmpl = pattern PatternMatches = typ.Iterable[PatternMatch]
for char, escaped in PATTERN_ESCAPES:
pattern_tmpl = pattern_tmpl.replace(char, escaped)
pattern_str = pattern_tmpl.format(**RE_PATTERN_PARTS) def _iter_for_pattern(lines: typ.List[str], pattern: str) -> PatternMatches:
pattern_re = re.compile(pattern_str) # The pattern is escaped, so that everything besides the format
for lineno, line in enumerate(lines): # string variables is treated literally.
match = pattern_re.search(line)
if match:
yield PatternMatch(lineno, line, pattern, match.span(), match.group(0))
@staticmethod pattern_tmpl = pattern
def iter_matches(lines: typ.List[str], patterns: typ.List[str]) -> typ.Iterable['PatternMatch']:
"""Iterate over all matches of any pattern on any line.
>>> lines = ["__version__ = 'v201712.0002-alpha'"] for char, escaped in PATTERN_ESCAPES:
>>> patterns = ["{version}", "{pep440_version}"] pattern_tmpl = pattern_tmpl.replace(char, escaped)
>>> matches = list(PatternMatch.iter_matches(lines, patterns))
>>> assert matches[0] == PatternMatch( pattern_str = pattern_tmpl.format(**RE_PATTERN_PARTS)
... lineno = 0, pattern_re = re.compile(pattern_str)
... line = "__version__ = 'v201712.0002-alpha'", for lineno, line in enumerate(lines):
... pattern= "{version}", match = pattern_re.search(line)
... span = (15, 33), if match:
... match = "v201712.0002-alpha", yield PatternMatch(lineno, line, pattern, match.span(), match.group(0))
... )
"""
for pattern in patterns: def iter_matches(lines: typ.List[str], patterns: typ.List[str]) -> PatternMatches:
for match in PatternMatch._iter_for_pattern(lines, pattern): """Iterate over all matches of any pattern on any line.
yield match
>>> lines = ["__version__ = 'v201712.0002-alpha'"]
>>> patterns = ["{version}", "{pep440_version}"]
>>> matches = list(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 match in _iter_for_pattern(lines, pattern):
yield match

View file

@ -13,6 +13,7 @@ import typing as typ
from . import parse from . import parse
from . import config from . import config
log = logging.getLogger("pycalver.rewrite") log = logging.getLogger("pycalver.rewrite")
@ -46,12 +47,12 @@ def rewrite_lines(
>>> new_lines = rewrite_lines(patterns, "v201811.0123-beta", old_lines) >>> new_lines = rewrite_lines(patterns, "v201811.0123-beta", old_lines)
>>> assert new_lines == ['__version__ = "v201811.0123-beta"'] >>> assert new_lines == ['__version__ = "v201811.0123-beta"']
""" """
new_version_nfo = parse.VersionInfo.parse(new_version) new_version_nfo = parse.parse_version_info(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()
for m in parse.PatternMatch.iter_matches(old_lines, patterns): for m in parse.iter_matches(old_lines, patterns):
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:]
@ -68,99 +69,99 @@ class RewrittenFileData(typ.NamedTuple):
old_lines: typ.List[str] old_lines: typ.List[str]
new_lines: typ.List[str] new_lines: typ.List[str]
@property
def diff_lines(self) -> typ.List[str]:
r"""Generate unified diff.
>>> rwd = RewrittenFileData( def rfd_from_content(patterns: typ.List[str], new_version: str, content: str) -> RewrittenFileData:
... path = "<path>", r"""Rewrite pattern occurrences with version string.
... 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 >>> patterns = ['__version__ = "{version}"']
def from_content( >>> content = '__version__ = "v201809.0001-alpha"'
patterns: typ.List[str], new_version: str, content: str >>> rfd = rfd_from_content(patterns, "v201809.0123", content)
) -> 'RewrittenFileData': >>> assert rfd.new_lines == ['__version__ = "v201809.0123"']
r"""Rewrite pattern occurrences with version string. """
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)
>>> 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(
def iter_rewritten( file_patterns: config.PatternsByFilePath, new_version: str
file_patterns: config.PatternsByFilePath, new_version: str ) -> typ.Iterable[RewrittenFileData]:
) -> typ.Iterable['RewrittenFileData']: r'''Iterate over files with version string replaced.
r'''Iterate over files with version string replaced.
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{version}"']} >>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{version}"']}
>>> rewritten_datas = RewrittenFileData.iter_rewritten(file_patterns, "v201809.0123") >>> rewritten_datas = iter_rewritten(file_patterns, "v201809.0123")
>>> rwd = list(rewritten_datas)[0] >>> rfd = list(rewritten_datas)[0]
>>> assert rwd.new_lines == [ >>> assert rfd.new_lines == [
... '# This file is part of the pycalver project', ... '# This file is part of the pycalver project',
... '# https://gitlab.com/mbarkhau/pycalver', ... '# https://gitlab.com/mbarkhau/pycalver',
... '#', ... '#',
... '# Copyright (c) 2018 Manuel Barkhau (@mbarkhau) - MIT License', ... '# Copyright (c) 2018 Manuel Barkhau (@mbarkhau) - MIT License',
... '# SPDX-License-Identifier: MIT', ... '# SPDX-License-Identifier: MIT',
... '"""PyCalVer: Automatic CalVer Versioning for Python Packages."""', ... '"""PyCalVer: Automatic CalVer Versioning for Python Packages."""',
... '', ... '',
... '__version__ = "v201809.0123"', ... '__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()
rfd = RewrittenFileData.from_content(patterns, new_version, content) rfd = rfd_from_content(patterns, new_version, content)
yield rfd._replace(path=filepath) yield rfd._replace(path=filepath)
def diff_lines(rfd: RewrittenFileData) -> typ.List[str]:
r"""Generate unified diff.
>>> rfd = RewrittenFileData(
... path = "<path>",
... line_sep = "\n",
... old_lines = ["foo"],
... new_lines = ["bar"],
... )
>>> diff_lines(rfd)
['--- <path>', '+++ <path>', '@@ -1 +1 @@', '-foo', '+bar']
"""
lines = difflib.unified_diff(
a=rfd.old_lines, b=rfd.new_lines, lineterm="", fromfile=rfd.path, tofile=rfd.path
)
return list(lines)
def diff(new_version: str, file_patterns: config.PatternsByFilePath) -> str: def diff(new_version: str, file_patterns: config.PatternsByFilePath) -> str:
r"""Generate diffs of rewritten files. r"""Generate diffs of rewritten files.
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{version}"']} >>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{version}"']}
>>> diff_lines = diff("v201809.0123", file_patterns).split("\n") >>> diff_str = diff("v201809.0123", file_patterns)
>>> diff_lines[:2] >>> lines = diff_str.split("\n")
>>> lines[:2]
['--- src/pycalver/__init__.py', '+++ src/pycalver/__init__.py'] ['--- src/pycalver/__init__.py', '+++ src/pycalver/__init__.py']
>>> assert diff_lines[6].startswith('-__version__ = "v2') >>> assert lines[6].startswith('-__version__ = "v2')
>>> assert not diff_lines[6].startswith('-__version__ = "v201809.0123"') >>> assert not lines[6].startswith('-__version__ = "v201809.0123"')
>>> diff_lines[7] >>> lines[7]
'+__version__ = "v201809.0123"' '+__version__ = "v201809.0123"'
""" """
diff_lines: typ.List[str] = []
for rwd in RewrittenFileData.iter_rewritten(file_patterns, new_version): full_diff = ""
diff_lines += rwd.diff_lines file_path: str
for file_path, patterns in file_patterns.items():
with io.open(file_path, mode="rt", encoding="utf-8") as fh:
content = fh.read()
return "\n".join(diff_lines) rfd = rfd_from_content(patterns, new_version, content)
full_diff += "\n".join(diff_lines(rfd)) + "\n"
full_diff = full_diff.rstrip("\n")
return full_diff
def rewrite(new_version: str, file_patterns: config.PatternsByFilePath) -> None: def rewrite(new_version: str, file_patterns: config.PatternsByFilePath) -> None:
"""Rewrite project files, updating each with the new version.""" """Rewrite project files, updating each with the new version."""
for file_data in RewrittenFileData.iter_rewritten(file_patterns, new_version): for file_data in iter_rewritten(file_patterns, new_version):
new_content = file_data.line_sep.join(file_data.new_lines) new_content = file_data.line_sep.join(file_data.new_lines)
with io.open(file_data.path, mode="wt", encoding="utf-8") as fh: with io.open(file_data.path, mode="wt", encoding="utf-8") as fh:
fh.write(new_content) fh.write(new_content)

View file

@ -6,6 +6,7 @@
"""Functions related to version string manipulation.""" """Functions related to version string manipulation."""
import logging import logging
import pkg_resources
import typing as typ import typing as typ
import datetime as dt import datetime as dt
@ -29,7 +30,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.VersionInfo.parse(old_version) old_ver = parse.parse_version_info(old_version)
new_calver = current_calver() new_calver = current_calver()
@ -60,3 +61,12 @@ def incr(old_version: str, *, release: str = None) -> str:
if new_release: if new_release:
new_version += "-" + new_release new_version += "-" + new_release
return new_version return new_version
def pycalver_to_pep440(version: str) -> str:
"""Derive pep440 compliant version string from PyCalVer version string.
>>> pycalver_to_pep440("v201811.0007-beta")
'201811.7b0'
"""
return str(pkg_resources.parse_version(version))