diff --git a/src/pycalver/__main__.py b/src/pycalver/__main__.py index 72abe55..309e33a 100644 --- a/src/pycalver/__main__.py +++ b/src/pycalver/__main__.py @@ -5,14 +5,15 @@ # Copyright (c) 2018 Manuel Barkhau (@mbarkhau) - MIT License # 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 os import sys +import toml import click import logging import typing as typ @@ -27,6 +28,16 @@ from . import rewrite _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") @@ -53,12 +64,34 @@ def cli(verbose: int = 0): _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="", 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: try: _vcs = vcs.get_vcs() log.debug(f"vcs found: {_vcs.name}") if fetch: - log.debug(f"fetching from remote") + log.info(f"fetching tags from remote") _vcs.fetch() 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) def show(verbose: int = 0, fetch: bool = True) -> None: """Show current version.""" - verbose = max(_VERBOSE, verbose) - _init_logging(verbose=verbose) + _init_logging(verbose=max(_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: log.error("Could not parse configuration from setup.cfg") sys.exit(1) @@ -97,29 +131,6 @@ def show(verbose: int = 0, fetch: bool = True) -> None: 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="", 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() @click.option('-v', '--verbose', count=True, help="Control log level. -vv for debug level.") @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: """Initialize [pycalver] configuration.""" - verbose = max(_VERBOSE, verbose) - _init_logging(verbose) + _init_logging(verbose=max(_VERBOSE, verbose)) + + ctx: config.ProjectContext = config.init_project_ctx(project_path=".") + cfg: config.MaybeConfig = config.parse(ctx) - cfg : config.MaybeConfig = config.parse() if cfg: - log.error("Configuration already initialized in setup.cfg") + log.error("Configuration already initialized in {cfg.filename}") sys.exit(1) - cfg_lines = config.default_config_lines() - if dry: print("Exiting because of '--dry'. Would have written to setup.cfg:") + cfg_lines = config.default_config(output_fmt) print("\n " + "\n ".join(cfg_lines)) return - if os.path.exists("setup.cfg"): - cfg_content = "\n" + "\n".join(cfg_lines) - with io.open("setup.cfg", mode="at", encoding="utf-8") as fh: - fh.write(cfg_content) - print("Updated setup.cfg") - else: - cfg_content = "\n".join(cfg_lines) - with io.open("setup.cfg", mode="at", encoding="utf-8") as fh: - fh.write(cfg_content) - print("Created setup.cfg") + config.write_default_config() 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)}") 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: log.error("Could not parse configuration from setup.cfg") diff --git a/src/pycalver/config.py b/src/pycalver/config.py index 94468ef..bde7c88 100644 --- a/src/pycalver/config.py +++ b/src/pycalver/config.py @@ -7,55 +7,91 @@ import io import os +import toml import configparser -import pkg_resources import typing as typ +import pathlib2 as pl import datetime as dt import logging from .parse import PYCALVER_RE +from . import version log = logging.getLogger("pycalver.config") 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): - """Represents a parsed config.""" + """Container for parameters parsed from a config file.""" current_version: str + pep440_version : str tag : bool commit: bool + push: bool 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(): - for pattern in patterns: - cfg_str_parts.append(f"\n '{filename}': '{pattern}'") +def _debug_str(cfg: Config) -> str: + cfg_str_parts = [ + 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})"] - return ", ".join(cfg_str_parts) + for filepath, patterns in cfg.file_patterns.items(): + for pattern in patterns: + cfg_str_parts.append(f"\n '{filepath}': '{pattern}'") - @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)) + cfg_str_parts += ["\n})"] + return ", ".join(cfg_str_parts) MaybeConfig = typ.Optional[Config] @@ -63,159 +99,278 @@ MaybeConfig = typ.Optional[Config] FilePatterns = typ.Dict[str, typ.List[str]] -def _parse_file_patterns( - cfg_parser: configparser.RawConfigParser, config_filename: str -) -> typ.Optional[FilePatterns]: +def _parse_cfg_file_patterns( + cfg_parser: configparser.RawConfigParser, +) -> FilePatterns: file_patterns: FilePatterns = {} - section_name: str - for section_name in cfg_parser.sections(): - if not section_name.startswith("pycalver:file:"): - continue + for filepath, patterns_str in cfg_parser.items("pycalver:file_patterns"): + patterns: typ.List[str] = [] + for line in patterns_str.splitlines(): + pattern = line.strip() + if pattern: + patterns.append(pattern) - filepath = section_name.split(":", 2)[-1] - 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}"] + file_patterns[filepath] = patterns return file_patterns -def _parse_buffer(cfg_buffer: io.StringIO, config_filename: str = "") -> 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.optionxform = _parse_cfg_option if hasattr(cfg_parser, 'read_file'): cfg_parser.read_file(cfg_buffer) else: - cfg_parser.readfp(cfg_buffer) + cfg_parser.readfp(cfg_buffer) # python2 compat 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 - base_cfg = dict(cfg_parser.items("pycalver")) + raw_cfg = dict(cfg_parser.items("pycalver")) - if "current_version" not in base_cfg: - log.error(f"{config_filename} does not have 'pycalver.current_version'") - return None + raw_cfg['commit'] = raw_cfg.get('commit', False) + raw_cfg['tag' ] = raw_cfg.get('tag' , 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: - log.error(f"{config_filename} 'pycalver.current_version is invalid") - log.error(f"current_version = {current_version}") - return None + raw_cfg['file_patterns'] = _parse_cfg_file_patterns(cfg_parser) - tag = base_cfg.get("tag" , "").lower() in ("yes", "true", "1", "on") - commit = base_cfg.get("commit", "").lower() in ("yes", "true", "1", "on") + return raw_cfg - file_patterns = _parse_file_patterns(cfg_parser, config_filename) - if file_patterns is None: - return None +def _parse_toml(cfg_buffer: typ.TextIO) -> RawConfig: + 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: - log.error(f"Invalid configuration in {config_filename}") - log.error(" pycalver.commit = True required if pycalver.tag = True") - return None + raise ValueError("pycalver.commit = true required if pycalver.tag = true") - 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 -def parse(config_filepath: str = None) -> MaybeConfig: - """Parse config file using configparser.""" - if config_filepath is None: - if os.path.exists("pycalver.cfg"): - 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}") +def parse(ctx: ProjectContext) -> MaybeConfig: + """Parse config file if available.""" + if not ctx.config_filepath.exists(): + log.error(f"File not found: {ctx.config_filepath}") return None - cfg_buffer = io.StringIO() - with io.open(config_filepath, mode="rt", encoding="utf-8") as fh: - cfg_buffer.write(fh.read()) + raw_cfg: typ.Optional[RawConfig] - cfg_buffer.seek(0) - return _parse_buffer(cfg_buffer, config_filepath) + 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: + 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] -current_version = {initial_version} +current_version = "{initial_version}" commit = True tag = True - -[pycalver:file:setup.cfg] -patterns = - current_version = {{version}} +push = True +[pycalver:file_patterns] """ -DEFAULT_CONFIG_SETUP_PY_STR = """ -[pycalver:file:setup.py] -patterns = +DEFAULT_CONFIGPARSER_SETUP_CFG_STR = """ +setup.cfg = + current_version = "{{version}}" +""" + + +DEFAULT_CONFIGPARSER_SETUP_PY_STR = """ +setup.py = "{version}" "{pep440_version}" """ -DEFAULT_CONFIG_README_RST_STR = """ -[pycalver:file:README.rst] -patterns = - {version} - {pep440_version} +DEFAULT_CONFIGPARSER_README_RST_STR = """ +README.rst = + "{version}" + "{pep440_version}" """ -DEFAULT_CONFIG_README_MD_STR = """ -[pycalver:file:README.md] -patterns = - {version} - {pep440_version} +DEFAULT_CONFIGPARSER_README_MD_STR = """ +README.md = + "{version}" + "{pep440_version}" """ -def default_config_lines() -> typ.List[str]: - """Generate initial default config based on PWD and current date.""" +DEFAULT_TOML_BASE_STR = """ +[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") - 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"): - cfg_lines.extend(DEFAULT_CONFIG_SETUP_PY_STR.splitlines()) + has_config_file = ( + (ctx.path / "setup.cfg").exists() or + (ctx.path / "pyproject.toml").exists() or + (ctx.path / "pycalver.toml").exists() + ) - if os.path.exists("README.rst"): - cfg_lines.extend(DEFAULT_CONFIG_README_RST_STR.splitlines()) + if not has_config_file: + 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_lines.extend(DEFAULT_CONFIG_README_MD_STR.splitlines()) + cfg_str += "\n" - 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") diff --git a/src/pycalver/parse.py b/src/pycalver/parse.py index b574275..da666ff 100644 --- a/src/pycalver/parse.py +++ b/src/pycalver/parse.py @@ -29,8 +29,8 @@ import re import logging import typing as typ -import pkg_resources +from . import version log = logging.getLogger("pycalver.parse") @@ -89,42 +89,36 @@ RE_PATTERN_PARTS = { class VersionInfo(typ.NamedTuple): """Container for parsed version string.""" - version: str - calver : str - year : str - month : str - build : str - release: typ.Optional[str] + version : str + pep440_version: str + calver : str + year : str + month : str + build : str + release : typ.Optional[str] - @property - def pep440_version(self) -> str: - """Generate pep440 compliant version string. - >>> vnfo = VersionInfo.parse("v201712.0033-beta") - >>> vnfo.pep440_version - '201712.33b0' - """ - return str(pkg_resources.parse_version(self.version)) +def parse_version_info(version_str: str) -> VersionInfo: + """Parse a PyCalVer string. - @staticmethod - def parse(version: str) -> 'VersionInfo': - """Parse a PyCalVer string. + >>> vnfo = parse_version_info("v201712.0033-beta") + >>> assert vnfo == VersionInfo( + ... 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") - >>> assert vnfo == VersionInfo( - ... version="v201712.0033-beta", - ... 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()) + kwargs = match.groupdict() + kwargs['pep440_version'] = version.pycalver_to_pep440(kwargs['version']) + return VersionInfo(**kwargs) class PatternMatch(typ.NamedTuple): @@ -136,38 +130,41 @@ class PatternMatch(typ.NamedTuple): 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 - # 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) - pattern_re = re.compile(pattern_str) - for lineno, line in enumerate(lines): - match = pattern_re.search(line) - if match: - yield PatternMatch(lineno, line, pattern, match.span(), match.group(0)) +def _iter_for_pattern(lines: typ.List[str], pattern: str) -> PatternMatches: + # The pattern is escaped, so that everything besides the format + # string variables is treated literally. - @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. + pattern_tmpl = pattern - >>> lines = ["__version__ = 'v201712.0002-alpha'"] - >>> 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 match in PatternMatch._iter_for_pattern(lines, pattern): - yield match + for char, escaped in PATTERN_ESCAPES: + pattern_tmpl = pattern_tmpl.replace(char, escaped) + + pattern_str = pattern_tmpl.format(**RE_PATTERN_PARTS) + pattern_re = re.compile(pattern_str) + for lineno, line in enumerate(lines): + match = pattern_re.search(line) + if match: + yield PatternMatch(lineno, line, pattern, match.span(), match.group(0)) + + +def iter_matches(lines: typ.List[str], patterns: typ.List[str]) -> PatternMatches: + """Iterate over all matches of any pattern on any line. + + >>> 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 diff --git a/src/pycalver/rewrite.py b/src/pycalver/rewrite.py index ef536e3..b37cd3f 100644 --- a/src/pycalver/rewrite.py +++ b/src/pycalver/rewrite.py @@ -13,6 +13,7 @@ import typing as typ from . import parse from . import config + log = logging.getLogger("pycalver.rewrite") @@ -46,12 +47,12 @@ def rewrite_lines( >>> 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_nfo = parse.parse_version_info(new_version) new_version_fmt_kwargs = new_version_nfo._asdict() 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) span_l, span_r = m.span new_line = m.line[:span_l] + replacement + m.line[span_r:] @@ -68,99 +69,99 @@ class RewrittenFileData(typ.NamedTuple): 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 = "", - ... line_sep = "\n", - ... old_lines = ["foo"], - ... new_lines = ["bar"], - ... ) - >>> rwd.diff_lines - ['--- ', '+++ ', '@@ -1 +1 @@', '-foo', '+bar'] - """ - return list( - difflib.unified_diff( - a=self.old_lines, - b=self.new_lines, - lineterm="", - fromfile=self.path, - tofile=self.path, - ) - ) +def rfd_from_content(patterns: typ.List[str], new_version: str, content: str) -> RewrittenFileData: + r"""Rewrite pattern occurrences with version string. - @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"' + >>> rfd = rfd_from_content(patterns, "v201809.0123", content) + >>> assert rfd.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("", 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("", 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. +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(): - with io.open(filepath, mode="rt", encoding="utf-8") as fh: - content = fh.read() + >>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{version}"']} + >>> rewritten_datas = iter_rewritten(file_patterns, "v201809.0123") + >>> rfd = list(rewritten_datas)[0] + >>> assert rfd.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(): + with io.open(filepath, mode="rt", encoding="utf-8") as fh: + content = fh.read() - rfd = RewrittenFileData.from_content(patterns, new_version, content) - yield rfd._replace(path=filepath) + rfd = rfd_from_content(patterns, new_version, content) + yield rfd._replace(path=filepath) + + +def diff_lines(rfd: RewrittenFileData) -> typ.List[str]: + r"""Generate unified diff. + + >>> rfd = RewrittenFileData( + ... path = "", + ... line_sep = "\n", + ... old_lines = ["foo"], + ... new_lines = ["bar"], + ... ) + >>> diff_lines(rfd) + ['--- ', '+++ ', '@@ -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: r"""Generate diffs of rewritten files. >>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{version}"']} - >>> diff_lines = diff("v201809.0123", file_patterns).split("\n") - >>> diff_lines[:2] + >>> diff_str = diff("v201809.0123", file_patterns) + >>> lines = diff_str.split("\n") + >>> 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] + >>> assert lines[6].startswith('-__version__ = "v2') + >>> assert not lines[6].startswith('-__version__ = "v201809.0123"') + >>> 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 + full_diff = "" + 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: """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) with io.open(file_data.path, mode="wt", encoding="utf-8") as fh: fh.write(new_content) diff --git a/src/pycalver/version.py b/src/pycalver/version.py index 13e34b8..24de239 100644 --- a/src/pycalver/version.py +++ b/src/pycalver/version.py @@ -6,6 +6,7 @@ """Functions related to version string manipulation.""" import logging +import pkg_resources import typing as typ 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, 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() @@ -60,3 +61,12 @@ def incr(old_version: str, *, release: str = None) -> str: if new_release: new_version += "-" + new_release 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))