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,215 +7,370 @@
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:
def _debug_str(cfg: Config) -> str:
cfg_str_parts = [ cfg_str_parts = [
f"Config Parsed: Config(", f"Config Parsed: Config(",
f"current_version='{self.current_version}'", f"current_version='{cfg.current_version}'",
f"tag={self.tag}", f"pep440_version='{cfg.pep440_version}'",
f"commit={self.commit}", f"tag={cfg.tag}",
f"commit={cfg.commit}",
f"push={cfg.push}",
f"file_patterns={{", f"file_patterns={{",
] ]
for filename, patterns in self.file_patterns.items(): for filepath, patterns in cfg.file_patterns.items():
for pattern in patterns: for pattern in patterns:
cfg_str_parts.append(f"\n '{filename}': '{pattern}'") cfg_str_parts.append(f"\n '{filepath}': '{pattern}'")
cfg_str_parts += ["\n})"] cfg_str_parts += ["\n})"]
return ", ".join(cfg_str_parts) return ", ".join(cfg_str_parts)
@property
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))
MaybeConfig = typ.Optional[Config] 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" return None
elif os.path.exists("setup.cfg"):
config_filepath = "setup.cfg" raw_cfg: typ.Optional[RawConfig]
try:
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: else:
log.error("File not found: pycalver.cfg or setup.cfg")
return None return None
if not os.path.exists(config_filepath): return _parse_config(raw_cfg)
log.error(f"File not found: {config_filepath}") except ValueError as ex:
log.error(f"Error parsing {ctx.config_filepath}: {str(ex)}")
return None return None
cfg_buffer = io.StringIO()
with io.open(config_filepath, mode="rt", encoding="utf-8") as fh:
cfg_buffer.write(fh.read())
cfg_buffer.seek(0) DEFAULT_CONFIGPARSER_BASE_STR = """
return _parse_buffer(cfg_buffer, config_filepath)
DEFAULT_CONFIG_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")
@ -90,29 +90,21 @@ class VersionInfo(typ.NamedTuple):
"""Container for parsed version string.""" """Container for parsed version string."""
version : str version : str
pep440_version: str
calver : str calver : str
year : str year : str
month : str month : str
build : str build : str
release : typ.Optional[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
'201712.33b0'
"""
return str(pkg_resources.parse_version(self.version))
@staticmethod
def parse(version: str) -> 'VersionInfo':
"""Parse a PyCalVer string. """Parse a PyCalVer string.
>>> vnfo = VersionInfo.parse("v201712.0033-beta") >>> vnfo = parse_version_info("v201712.0033-beta")
>>> assert vnfo == VersionInfo( >>> assert vnfo == VersionInfo(
... version="v201712.0033-beta", ... version="v201712.0033-beta",
... pep440_version="201712.33b0",
... calver="v201712", ... calver="v201712",
... year="2017", ... year="2017",
... month="12", ... month="12",
@ -120,11 +112,13 @@ class VersionInfo(typ.NamedTuple):
... release="-beta", ... release="-beta",
... ) ... )
""" """
match = PYCALVER_RE.match(version) match = PYCALVER_RE.match(version_str)
if match is None: if match is None:
raise ValueError(f"Invalid pycalver: {version}") raise ValueError(f"Invalid PyCalVer string: {version_str}")
return VersionInfo(**match.groupdict()) kwargs = match.groupdict()
kwargs['pep440_version'] = version.pycalver_to_pep440(kwargs['version'])
return VersionInfo(**kwargs)
class PatternMatch(typ.NamedTuple): class PatternMatch(typ.NamedTuple):
@ -136,8 +130,11 @@ 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']: PatternMatches = typ.Iterable[PatternMatch]
def _iter_for_pattern(lines: typ.List[str], pattern: str) -> PatternMatches:
# 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.
@ -153,13 +150,13 @@ class PatternMatch(typ.NamedTuple):
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']: def iter_matches(lines: typ.List[str], patterns: typ.List[str]) -> PatternMatches:
"""Iterate over all matches of any pattern on any line. """Iterate over all matches of any pattern on any line.
>>> lines = ["__version__ = 'v201712.0002-alpha'"] >>> lines = ["__version__ = 'v201712.0002-alpha'"]
>>> patterns = ["{version}", "{pep440_version}"] >>> patterns = ["{version}", "{pep440_version}"]
>>> matches = list(PatternMatch.iter_matches(lines, patterns)) >>> matches = list(iter_matches(lines, patterns))
>>> assert matches[0] == PatternMatch( >>> assert matches[0] == PatternMatch(
... lineno = 0, ... lineno = 0,
... line = "__version__ = 'v201712.0002-alpha'", ... line = "__version__ = 'v201712.0002-alpha'",
@ -169,5 +166,5 @@ class PatternMatch(typ.NamedTuple):
... ) ... )
""" """
for pattern in patterns: for pattern in patterns:
for match in PatternMatch._iter_for_pattern(lines, pattern): for match in _iter_for_pattern(lines, pattern):
yield match 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,55 +69,30 @@ 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>",
... 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. r"""Rewrite pattern occurrences with version string.
>>> patterns = ['__version__ = "{version}"'] >>> patterns = ['__version__ = "{version}"']
>>> content = '__version__ = "v201809.0001-alpha"' >>> content = '__version__ = "v201809.0001-alpha"'
>>> rwd = RewrittenFileData.from_content(patterns, "v201809.0123", content) >>> rfd = rfd_from_content(patterns, "v201809.0123", content)
>>> assert rwd.new_lines == ['__version__ = "v201809.0123"'] >>> assert rfd.new_lines == ['__version__ = "v201809.0123"']
""" """
line_sep = detect_line_sep(content) line_sep = detect_line_sep(content)
old_lines = content.split(line_sep) old_lines = content.split(line_sep)
new_lines = rewrite_lines(patterns, new_version, old_lines) new_lines = rewrite_lines(patterns, new_version, old_lines)
return RewrittenFileData("<path>", line_sep, old_lines, new_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',
... '#', ... '#',
@ -133,34 +109,59 @@ class RewrittenFileData(typ.NamedTuple):
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))