module reorg

This commit is contained in:
Manuel Barkhau 2020-09-19 22:35:48 +00:00
parent e1aaf7629b
commit 8af5047244
23 changed files with 1658 additions and 1532 deletions

View file

@ -8,7 +8,6 @@
- Better support for optional parts.
- New: Start `BUILD` parts at `1000` to avoid leading zero truncation.
- New gitlab #10: `--pin-date` to keep date parts unchanged, and only increment non-date parts.
- New: enable globs for filenames in `pycalver:file_patterns`
- Fix gitlab #8: Push tags only pushed tags, not actual commit.
- Fix gitlab #9: Make commit message configurable.

View file

@ -23,7 +23,8 @@ The recommended approach to using `pylint-ignore` is:
# Overview
- [W0511: fixme (7x)](#w0511-fixme)
- [E1123: unexpected-keyword-arg (1x)](#e1123-unexpected-keyword-arg)
- [W0511: fixme (9x)](#w0511-fixme)
- [W0703: broad-except (1x)](#w0703-broad-except)
@ -36,7 +37,7 @@ The recommended approach to using `pylint-ignore` is:
- `date : 2020-09-18T17:01:05`
```
12: import pycalver2.patterns as v2patterns
12: from pycalver import v2patterns
13:
> 14: # TODO (mb 2020-09-06): test for v2patterns
15:
@ -61,107 +62,138 @@ The recommended approach to using `pylint-ignore` is:
```
## File test/test_version.py - Line 167 - W0511 (fixme)
## File test/test_config.py - Line 156 - W0511 (fixme)
- `message: TODO (mb 2020-09-18):`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-09-18T19:04:06`
```
143: def test_parse_v2_cfg():
...
154: assert "setup.py" in cfg.file_patterns
155: assert "setup.cfg" in cfg.file_patterns
> 156: # TODO (mb 2020-09-18):
157: # assert cfg.file_patterns["setup.py" ] == ["vYYYY0M.BUILD[-TAG]", "YYYY0M.BLD[PYTAGNUM]"]
158: # assert cfg.file_patterns["setup.cfg" ] == ['current_version = "vYYYY0M.BUILD[-TAG]"']
```
## File test/test_version.py - Line 168 - W0511 (fixme)
- `message: TODO (mb 2020-09-06): add tests for new style patterns`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-09-18T17:01:05`
```
162: def vnfo(**field_values):
163: def vnfo(**field_values):
...
165:
166: PARSE_VERSION_TEST_CASES = [
> 167: # TODO (mb 2020-09-06): add tests for new style patterns
168: # ["YYYY.MM.DD" , "2017.06.07", vnfo(year="2017", month="06", dom="07")],
169: ["{year}.{month}.{dom}" , "2017.06.07", vnfo(year="2017", month="06", dom="07")],
166:
167: PARSE_VERSION_TEST_CASES = [
> 168: # TODO (mb 2020-09-06): add tests for new style patterns
169: # ["YYYY.MM.DD" , "2017.06.07", vnfo(year="2017", month="06", dom="07")],
170: ["{year}.{month}.{dom}" , "2017.06.07", vnfo(year="2017", month="06", dom="07")],
```
## File src/pycalver/__main__.py - Line 229 - W0511 (fixme)
## File src/pycalver/v1patterns.py - Line 212 - W0511 (fixme)
- `message: TODO (mb 2020-09-05): version switch`
- `message: TODO (mb 2020-09-19): replace {version} etc with version_pattern`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-09-05T14:30:17`
- `date : 2020-09-19T16:24:10`
```
209: def _bump(
199: def _compile_pattern_re(version_pattern: str, raw_pattern: str) -> typ.Pattern[str]:
...
227:
228: try:
> 229: # TODO (mb 2020-09-05): version switch
230: v1cli.rewrite(cfg, new_version)
231: # v2cli.rewrite(cfg, new_version)
210: escaped_pattern = escaped_pattern.replace(char, escaped)
211:
> 212: # TODO (mb 2020-09-19): replace {version} etc with version_pattern
213: pattern_str = _replace_pattern_parts(escaped_pattern)
214: return re.compile(pattern_str)
```
## File src/pycalver/config.py - Line 236 - W0511 (fixme)
## File src/pycalver/__main__.py - Line 247 - W0511 (fixme)
- `message: TODO (mb 2020-09-06): new style pattern by default`
- `message: TODO (mb 2020-09-18): Investigate error messages`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-09-18T17:01:05`
- `date : 2020-09-19T16:24:10`
```
227: def _parse_config(raw_cfg: RawConfig) -> Config:
219: def _bump(
...
234: version_str = raw_cfg['current_version'] = version_str.strip("'\" ")
235:
> 236: # TODO (mb 2020-09-06): new style pattern by default
237: # version_pattern: str = raw_cfg.get('version_pattern', "vYYYY0M.BUILD[-TAG]")
238: version_pattern: str = raw_cfg.get('version_pattern', "{pycalver}")
245: sys.exit(1)
246: except Exception as ex:
> 247: # TODO (mb 2020-09-18): Investigate error messages
248: logger.error(str(ex))
249: sys.exit(1)
```
## File src/pycalver/__main__.py - Line 285 - W0511 (fixme)
## File src/pycalver/v2patterns.py - Line 256 - W0511 (fixme)
- `message: TODO (mb 2020-09-05): version switch`
- `message: TODO (mb 2020-09-19): replace {version} etc with version_pattern`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-09-05T14:30:17`
- `date : 2020-09-19T16:24:10`
```
282: def _update_cfg_from_vcs(cfg: config.Config, fetch: bool) -> config.Config:
240: def _compile_pattern_re(version_pattern: str, raw_pattern: str) -> typ.Pattern[str]:
...
283: all_tags = vcs.get_tags(fetch=fetch)
284:
> 285: # TODO (mb 2020-09-05): version switch
286: cfg = v1cli.update_cfg_from_vcs(cfg, all_tags)
287: # cfg = v2cli.update_cfg_from_vcs(cfg, all_tags)
254: print("<<<<", (normalized_pattern,))
255:
> 256: # TODO (mb 2020-09-19): replace {version} etc with version_pattern
257: pattern_str = _replace_pattern_parts(escaped_pattern)
258: return re.compile(pattern_str)
```
## File src/pycalver/__main__.py - Line 392 - W0511 (fixme)
## File src/pycalver/config.py - Line 264 - W0511 (fixme)
- `message: # TODO (mb 2020-09-05): format from config`
- `message: TODO (mb 2020-09-18): Validate Pattern`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-09-05T14:30:17`
- `date : 2020-09-18T19:04:06`
```
336: def bump(
250: def _parse_config(raw_cfg: RawConfig) -> Config:
...
390: return
391:
> 392: # # TODO (mb 2020-09-05): format from config
393: # commit_message_kwargs = {
394: # new_version
262: is_new_pattern = "{" not in version_pattern and "}" not in version_pattern
263:
> 264: # TODO (mb 2020-09-18): Validate Pattern
265: # detect YY with WW or UU -> suggest GG with VV
266: # detect YYMM -> suggest YY0M
```
## File test/test_cli.py - Line 536 - W0511 (fixme)
- `message: # TODO (mb 2020-09-18):`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-09-18T19:35:32`
```
534:
535: # def test_custom_commit_message(runner):
> 536: # # TODO (mb 2020-09-18):
537: # assert False
```
# W0703: broad-except
## File src/pycalver/__main__.py - Line 232 - W0703 (broad-except)
## File src/pycalver/__main__.py - Line 246 - W0703 (broad-except)
- `message: Catching too general exception Exception`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-09-05T14:30:17`
```
209: def _bump(
219: def _bump(
...
230: v1cli.rewrite(cfg, new_version)
231: # v2cli.rewrite(cfg, new_version)
> 232: except Exception as ex:
233: logger.error(str(ex))
234: sys.exit(1)
244: logger.error(str(ex))
245: sys.exit(1)
> 246: except Exception as ex:
247: # TODO (mb 2020-09-18): Investigate error messages
248: logger.error(str(ex))
```

View file

@ -16,14 +16,14 @@ import subprocess as sp
import click
import pycalver.cli as v1cli
import pycalver2.cli as v2cli
import pycalver.version as v1version
import pycalver2.version as v2version
import pycalver.rewrite as v1rewrite
from pycalver import vcs
from pycalver import config
from . import vcs
from . import v1cli
from . import v2cli
from . import config
from . import rewrite
from . import version
from . import v1version
from . import v2version
_VERBOSE = 0
@ -110,7 +110,7 @@ def test(
new_version = _incr(
old_version,
pattern=pattern,
raw_pattern=pattern,
release=release,
major=major,
minor=minor,
@ -121,9 +121,7 @@ def test(
logger.error(f"Invalid version '{old_version}' and/or pattern '{pattern}'.")
sys.exit(1)
# TODO (mb 2020-09-05): version switch
pep440_version = v1version.to_pep440(new_version)
# pep440_version = v2version.to_pep440(new_version)
pep440_version = version.to_pep440(new_version)
click.echo(f"New Version: {new_version}")
click.echo(f"PEP440 : {pep440_version}")
@ -150,7 +148,7 @@ def show(verbose: int = 0, fetch: bool = True) -> None:
click.echo(f"PEP440 : {cfg.pep440_version}")
def _print_diff(diff: str) -> None:
def _print_diff_str(diff: str) -> None:
if sys.stdout.isatty():
for line in diff.splitlines():
if line.startswith("+++") or line.startswith("---"):
@ -167,9 +165,27 @@ def _print_diff(diff: str) -> None:
click.echo(diff)
def _print_diff(cfg: config.Config, new_version: str) -> None:
try:
if cfg.is_new_pattern:
diff = v2cli.get_diff(cfg, new_version)
else:
diff = v1cli.get_diff(cfg, new_version)
_print_diff_str(diff)
except rewrite.NoPatternMatch as ex:
logger.error(str(ex))
sys.exit(1)
except Exception as ex:
# pylint:disable=broad-except; Mostly we expect IOError here, but
# could be other things and there's no option to recover anyway.
logger.error(str(ex))
sys.exit(1)
def _incr(
old_version: str,
pattern : str = "{pycalver}",
raw_pattern: str = "{pycalver}",
*,
release : str = None,
major : bool = False,
@ -177,11 +193,11 @@ def _incr(
patch : bool = False,
pin_date: bool = False,
) -> typ.Optional[str]:
is_v1_pattern = "{" in pattern
if is_v1_pattern:
return v1version.incr(
is_new_pattern = "{" in raw_pattern and "}" in raw_pattern
if is_new_pattern:
return v2version.incr(
old_version,
pattern=pattern,
raw_pattern=raw_pattern,
release=release,
major=major,
minor=minor,
@ -189,9 +205,9 @@ def _incr(
pin_date=pin_date,
)
else:
return v2version.incr(
return v1version.incr(
old_version,
pattern=pattern,
raw_pattern=raw_pattern,
release=release,
major=major,
minor=minor,
@ -221,10 +237,10 @@ def _bump(
try:
if cfg.is_new_pattern:
v2cli.rewrite(cfg, new_version)
v2cli.rewrite_files(cfg, new_version)
else:
v1cli.rewrite(cfg, new_version)
except v1rewrite.NoPatternMatch as ex:
v1cli.rewrite_files(cfg, new_version)
except rewrite.NoPatternMatch as ex:
logger.error(str(ex))
sys.exit(1)
except Exception as ex:
@ -266,11 +282,11 @@ def init(verbose: int = 0, dry: bool = False) -> None:
cfg: config.MaybeConfig = config.parse(ctx)
if cfg:
logger.error(f"Configuration already initialized in {ctx.config_filepath}")
logger.error(f"Configuration already initialized in {ctx.config_rel_path}")
sys.exit(1)
if dry:
click.echo(f"Exiting because of '--dry'. Would have written to {ctx.config_filepath}:")
click.echo(f"Exiting because of '--dry'. Would have written to {ctx.config_rel_path}:")
cfg_text: str = config.default_config(ctx)
click.echo("\n " + "\n ".join(cfg_text.splitlines()))
sys.exit(0)
@ -362,7 +378,7 @@ def bump(
old_version = cfg.current_version
new_version = _incr(
old_version,
pattern=cfg.version_pattern,
raw_pattern=cfg.version_pattern,
release=release,
major=major,
minor=minor,
@ -387,20 +403,7 @@ def bump(
logger.info(f"New Version: {new_version}")
if dry or verbose >= 2:
try:
if cfg.is_new_pattern:
diff = v2cli.get_diff(cfg, new_version)
else:
diff = v1cli.get_diff(cfg, new_version)
_print_diff(diff)
except v1rewrite.NoPatternMatch as ex:
logger.error(str(ex))
sys.exit(1)
except Exception as ex:
# pylint:disable=broad-except; Mostly we expect IOError here, but
# could be other things and there's no option to recover anyway.
logger.error(str(ex))
sys.exit(1)
_print_diff(cfg, new_version)
if dry:
return
@ -408,8 +411,8 @@ def bump(
commit_message_kwargs = {
'new_version' : new_version,
'old_version' : old_version,
'new_version_pep440': v1version.to_pep440(new_version),
'old_version_pep440': v1version.to_pep440(old_version),
'new_version_pep440': version.to_pep440(new_version),
'old_version_pep440': version.to_pep440(old_version),
}
commit_message = cfg.commit_message.format(**commit_message_kwargs)

View file

@ -14,13 +14,22 @@ import configparser
import toml
import pathlib2 as pl
import pycalver.version as v1version
import pycalver2.version as v2version
from . import version
from . import v1version
from . import v2version
from . import v1patterns
from . import v2patterns
from .patterns import Pattern
logger = logging.getLogger("pycalver.config")
Patterns = typ.List[str]
PatternsByGlob = typ.Dict[str, Patterns]
RawPatterns = typ.List[str]
RawPatternsByFile = typ.Dict[str, RawPatterns]
FileRawPatternsItem = typ.Tuple[str, RawPatterns]
PatternsByFile = typ.Dict[str, typ.List[Pattern]]
FilePatternsItem = typ.Tuple[str, typ.List[Pattern]]
SUPPORTED_CONFIGS = ["setup.cfg", "pyproject.toml", "pycalver.toml"]
@ -32,6 +41,7 @@ class ProjectContext(typ.NamedTuple):
path : pl.Path
config_filepath: pl.Path
config_rel_path: str
config_format : str
vcs_type : typ.Optional[str]
@ -60,6 +70,12 @@ def init_project_ctx(project_path: typ.Union[str, pl.Path, None] = ".") -> Proje
config_filepath = path / "pycalver.toml"
config_format = 'toml'
if config_filepath.is_absolute():
config_rel_path = str(config_filepath.relative_to(path.absolute()))
else:
config_rel_path = str(config_filepath)
config_filepath = pl.Path.cwd() / config_filepath
vcs_type: typ.Optional[str]
if (path / ".git").exists():
@ -69,10 +85,11 @@ def init_project_ctx(project_path: typ.Union[str, pl.Path, None] = ".") -> Proje
else:
vcs_type = None
return ProjectContext(path, config_filepath, config_format, vcs_type)
return ProjectContext(path, config_filepath, config_rel_path, config_format, vcs_type)
RawConfig = typ.Dict[str, typ.Any]
MaybeRawConfig = typ.Optional[RawConfig]
class Config(typ.NamedTuple):
@ -88,56 +105,46 @@ class Config(typ.NamedTuple):
push : bool
is_new_pattern: bool
file_patterns: PatternsByGlob
file_patterns: PatternsByFile
MaybeConfig = typ.Optional[Config]
def _debug_str(cfg: Config) -> str:
cfg_str_parts = [
"Config Parsed: Config(",
f"current_version='{cfg.current_version}'",
f"version_pattern='{cfg.version_pattern}'",
f"pep440_version='{cfg.pep440_version}'",
f"commit_message='{cfg.commit_message}'",
f"commit={cfg.commit}",
f"tag={cfg.tag}",
f"push={cfg.push}",
f"is_new_pattern={cfg.is_new_pattern}",
"file_patterns={",
f"\n current_version='{cfg.current_version}',",
f"\n version_pattern='{cfg.version_pattern}',",
f"\n pep440_version='{cfg.pep440_version}',",
f"\n commit_message='{cfg.commit_message}',",
f"\n commit={cfg.commit},",
f"\n tag={cfg.tag},",
f"\n push={cfg.push},",
f"\n is_new_pattern={cfg.is_new_pattern},",
"\n file_patterns={",
]
for filepath, patterns in cfg.file_patterns.items():
for pattern in patterns:
cfg_str_parts.append(f"\n '{filepath}': '{pattern}'")
cfg_str_parts.append(f"\n '{filepath}': '{pattern.raw_pattern}',")
cfg_str_parts += ["\n})"]
return ", ".join(cfg_str_parts)
cfg_str_parts += ["\n }\n)"]
return "".join(cfg_str_parts)
MaybeConfig = typ.Optional[Config]
MaybeRawConfig = typ.Optional[RawConfig]
def _parse_cfg_file_patterns(
cfg_parser: configparser.RawConfigParser,
) -> typ.Iterable[FileRawPatternsItem]:
if not cfg_parser.has_section("pycalver:file_patterns"):
return
FilePatterns = typ.Dict[str, typ.List[str]]
def _parse_cfg_file_patterns(cfg_parser: configparser.RawConfigParser) -> FilePatterns:
file_patterns: FilePatterns = {}
file_pattern_items: typ.List[typ.Tuple[str, str]]
if cfg_parser.has_section("pycalver:file_patterns"):
file_pattern_items = cfg_parser.items("pycalver:file_patterns")
else:
file_pattern_items = []
file_pattern_items: typ.List[typ.Tuple[str, str]] = cfg_parser.items("pycalver:file_patterns")
for filepath, patterns_str in file_pattern_items:
patterns: typ.List[str] = []
for line in patterns_str.splitlines():
pattern = line.strip()
if pattern:
patterns.append(pattern)
file_patterns[filepath] = patterns
return file_patterns
maybe_patterns = (line.strip() for line in patterns_str.splitlines())
patterns = [p for p in maybe_patterns if p]
yield filepath, patterns
class _ConfigParser(configparser.RawConfigParser):
@ -178,7 +185,7 @@ def _parse_cfg(cfg_buffer: typ.IO[str]) -> RawConfig:
val = val.lower() in ("yes", "true", "1", "on")
raw_cfg[option] = val
raw_cfg['file_patterns'] = _parse_cfg_file_patterns(cfg_parser)
raw_cfg['file_patterns'] = dict(_parse_cfg_file_patterns(cfg_parser))
return raw_cfg
@ -193,64 +200,66 @@ def _parse_toml(cfg_buffer: typ.IO[str]) -> RawConfig:
return raw_cfg
def _normalize_file_patterns(raw_cfg: RawConfig) -> FilePatterns:
"""Create consistent representation of file_patterns.
def _iter_glob_expanded_file_patterns(
raw_patterns_by_file: RawPatternsByFile,
) -> typ.Iterable[FileRawPatternsItem]:
for filepath_glob, raw_patterns in raw_patterns_by_file.items():
filepaths = glob.glob(filepath_glob)
if filepaths:
for filepath in filepaths:
yield filepath, raw_patterns
else:
logger.warning(f"Invalid config, no such file: {filepath_glob}")
# fallback to treating it as a simple path
yield filepath_glob, raw_patterns
def _compile_v1_file_patterns(raw_cfg: RawConfig) -> typ.Iterable[FilePatternsItem]:
"""Create inernal/compiled representation of the file_patterns config field.
The result the same, regardless of the config format.
"""
version_str : str = raw_cfg['current_version']
version_pattern: str = raw_cfg['version_pattern']
pep440_version : str = v1version.to_pep440(version_str)
# current_version: str = raw_cfg['current_version']
# current_pep440_version = version.pep440_version(current_version)
file_patterns: FilePatterns
if 'file_patterns' in raw_cfg:
file_patterns = raw_cfg['file_patterns']
else:
file_patterns = {}
version_pattern : str = raw_cfg['version_pattern']
raw_patterns_by_file: RawPatternsByFile = raw_cfg['file_patterns']
for filepath_glob, patterns in list(file_patterns.items()):
filepaths = glob.glob(filepath_glob)
if not filepaths:
logger.warning(f"Invalid config, no such file: {filepath_glob}")
# fallback to treating it as a simple path
filepaths = [filepath_glob]
for filepath, raw_patterns in _iter_glob_expanded_file_patterns(raw_patterns_by_file):
compiled_patterns = [
v1patterns.compile_pattern(version_pattern, raw_pattern) for raw_pattern in raw_patterns
]
yield filepath, compiled_patterns
normalized_patterns: typ.List[str] = []
for pattern in patterns:
normalized_pattern = pattern.replace("{version}", version_pattern)
if version_pattern == "{pycalver}":
normalized_pattern = normalized_pattern.replace(
"{pep440_version}", "{pep440_pycalver}"
)
elif version_pattern == "{semver}":
normalized_pattern = normalized_pattern.replace("{pep440_version}", "{semver}")
elif "{pep440_version}" in pattern:
logger.warning(f"Invalid config, cannot match '{pattern}' for '{filepath_glob}'.")
logger.warning(f"No mapping of '{version_pattern}' to '{pep440_version}'")
normalized_patterns.append(normalized_pattern)
for filepath in filepaths:
file_patterns[filepath] = normalized_patterns
def _compile_v2_file_patterns(raw_cfg: RawConfig) -> typ.Iterable[FilePatternsItem]:
"""Create inernal/compiled representation of the file_patterns config field.
return file_patterns
The result the same, regardless of the config format.
"""
version_pattern : str = raw_cfg['version_pattern']
raw_patterns_by_file: RawPatternsByFile = raw_cfg['file_patterns']
for filepath, raw_patterns in _iter_glob_expanded_file_patterns(raw_patterns_by_file):
compiled_patterns = [
v2patterns.compile_pattern(version_pattern, raw_pattern) for raw_pattern in raw_patterns
]
yield filepath, compiled_patterns
def _parse_config(raw_cfg: RawConfig) -> Config:
"""Parse configuration which was loaded from an .ini/.cfg or .toml file."""
if 'current_version' not in raw_cfg:
raise ValueError("Missing 'pycalver.current_version'")
current_version: str = raw_cfg['current_version']
current_version = raw_cfg['current_version'] = current_version.strip("'\" ")
version_str: str = raw_cfg['current_version']
version_str = raw_cfg['current_version'] = version_str.strip("'\" ")
version_pattern: str = raw_cfg.get('version_pattern', "{pycalver}")
version_pattern: str = raw_cfg['version_pattern']
version_pattern = raw_cfg['version_pattern'] = version_pattern.strip("'\" ")
commit_message: str = raw_cfg.get('commit_message', DEFAULT_COMMIT_MESSAGE)
commit_message = raw_cfg['commit_message'] = commit_message.strip("'\" ")
is_new_pattern = not ("{" in version_pattern or "}" in version_pattern)
is_new_pattern = "{" not in version_pattern and "}" not in version_pattern
# TODO (mb 2020-09-18): Validate Pattern
# detect YY with WW or UU -> suggest GG with VV
@ -260,11 +269,11 @@ def _parse_config(raw_cfg: RawConfig) -> Config:
# NOTE (mb 2019-01-05): Provoke ValueError if version_pattern
# and current_version are not compatible.
if is_new_pattern:
v2version.parse_version_info(version_str, version_pattern)
v2version.parse_version_info(current_version, version_pattern)
else:
v1version.parse_version_info(version_str, version_pattern)
v1version.parse_version_info(current_version, version_pattern)
pep440_version = v1version.to_pep440(version_str)
pep440_version = version.to_pep440(current_version)
commit = raw_cfg['commit']
tag = raw_cfg['tag']
@ -281,10 +290,13 @@ def _parse_config(raw_cfg: RawConfig) -> Config:
if push and not commit:
raise ValueError("pycalver.commit = true required if pycalver.push = true")
file_patterns = _normalize_file_patterns(raw_cfg)
if is_new_pattern:
file_patterns = dict(_compile_v2_file_patterns(raw_cfg))
else:
file_patterns = dict(_compile_v1_file_patterns(raw_cfg))
cfg = Config(
current_version=version_str,
current_version=current_version,
version_pattern=version_pattern,
pep440_version=pep440_version,
commit_message=commit_message,
@ -298,11 +310,18 @@ def _parse_config(raw_cfg: RawConfig) -> Config:
return cfg
def _parse_current_version_default_pattern(cfg: Config, raw_cfg_text: str) -> str:
def _parse_current_version_default_pattern(ctx: ProjectContext, raw_cfg: RawConfig) -> str:
fobj: typ.IO[str]
with ctx.config_filepath.open(mode="rt", encoding="utf-8") as fobj:
raw_cfg_text = fobj.read()
is_pycalver_section = False
for line in raw_cfg_text.splitlines():
if is_pycalver_section and line.startswith("current_version"):
return line.replace(cfg.current_version, cfg.version_pattern)
current_version: str = raw_cfg['current_version']
version_pattern: str = raw_cfg['version_pattern']
return line.replace(current_version, version_pattern)
if line.strip() == "[pycalver]":
is_pycalver_section = True
@ -312,44 +331,56 @@ def _parse_current_version_default_pattern(cfg: Config, raw_cfg_text: str) -> st
raise ValueError("Could not parse pycalver.current_version")
def parse(ctx: ProjectContext) -> MaybeConfig:
"""Parse config file if available."""
if not ctx.config_filepath.exists():
logger.warning(f"File not found: {ctx.config_filepath}")
return None
fobj: typ.IO[str]
cfg_path: str
if ctx.config_filepath.is_absolute():
cfg_path = str(ctx.config_filepath.relative_to(ctx.path.absolute()))
else:
cfg_path = str(ctx.config_filepath)
raw_cfg: RawConfig
try:
def _parse_raw_config(ctx: ProjectContext) -> RawConfig:
with ctx.config_filepath.open(mode="rt", encoding="utf-8") as fobj:
if ctx.config_format == 'toml':
raw_cfg = _parse_toml(fobj)
elif ctx.config_format == 'cfg':
raw_cfg = _parse_cfg(fobj)
else:
err_msg = "Invalid config_format='{ctx.config_format}'"
err_msg = (
f"Invalid config_format='{ctx.config_format}'."
"Supported formats are 'setup.cfg' and 'pyproject.toml'"
)
raise RuntimeError(err_msg)
cfg: Config = _parse_config(raw_cfg)
if 'current_version' in raw_cfg:
if not isinstance(raw_cfg['current_version'], str):
err = f"Invalid type for pycalver.current_version = {raw_cfg['current_version']}"
raise TypeError(err)
else:
raise ValueError("Missing 'pycalver.current_version'")
if cfg_path not in cfg.file_patterns:
fobj.seek(0)
raw_cfg_text = fobj.read()
cfg.file_patterns[cfg_path] = [
_parse_current_version_default_pattern(cfg, raw_cfg_text)
]
if 'version_pattern' in raw_cfg:
if not isinstance(raw_cfg['version_pattern'], str):
err = f"Invalid type for pycalver.version_pattern = {raw_cfg['version_pattern']}"
raise TypeError(err)
else:
raw_cfg['version_pattern'] = "{pycalver}"
return cfg
except ValueError as ex:
logger.warning(f"Couldn't parse {cfg_path}: {str(ex)}")
if 'file_patterns' not in raw_cfg:
raw_cfg['file_patterns'] = {}
if ctx.config_rel_path not in raw_cfg['file_patterns']:
# NOTE (mb 2020-09-19): By default we always add
# a pattern for the config section itself.
raw_version_pattern = _parse_current_version_default_pattern(ctx, raw_cfg)
raw_cfg['file_patterns'][ctx.config_rel_path] = [raw_version_pattern]
return raw_cfg
def parse(ctx: ProjectContext) -> MaybeConfig:
"""Parse config file if available."""
if not ctx.config_filepath.exists():
logger.warning(f"File not found: {ctx.config_rel_path}")
return None
try:
raw_cfg = _parse_raw_config(ctx)
return _parse_config(raw_cfg)
except (TypeError, ValueError) as ex:
logger.warning(f"Couldn't parse {ctx.config_rel_path}: {str(ex)}")
return None
@ -445,11 +476,11 @@ DEFAULT_TOML_README_MD_STR = """
def _initial_version() -> str:
return dt.datetime.now().strftime("v%Y%m.0001-alpha")
return dt.datetime.now().strftime("v%Y%m.1001-alpha")
def _initial_version_pep440() -> str:
return dt.datetime.now().strftime("%Y%m.1a0")
return dt.datetime.now().strftime("%Y%m.1001a0")
def default_config(ctx: ProjectContext) -> str:
@ -506,4 +537,4 @@ def write_content(ctx: ProjectContext) -> None:
with ctx.config_filepath.open(mode="at", encoding="utf-8") as fobj:
fobj.write(cfg_content)
print(f"Updated {ctx.config_filepath}")
print(f"Updated {ctx.config_rel_path}")

View file

@ -7,7 +7,7 @@
import typing as typ
import pycalver.patterns as v1patterns
from .patterns import Pattern
class PatternMatch(typ.NamedTuple):
@ -15,7 +15,7 @@ class PatternMatch(typ.NamedTuple):
lineno : int # zero based
line : str
pattern: v1patterns.Pattern
pattern: Pattern
span : typ.Tuple[int, int]
match : str
@ -23,25 +23,26 @@ class PatternMatch(typ.NamedTuple):
PatternMatches = typ.Iterable[PatternMatch]
def _iter_for_pattern(lines: typ.List[str], pattern: v1patterns.Pattern) -> PatternMatches:
def _iter_for_pattern(lines: typ.List[str], pattern: Pattern) -> PatternMatches:
for lineno, line in enumerate(lines):
match = pattern.regexp.search(line)
if match:
yield PatternMatch(lineno, line, pattern, match.span(), match.group(0))
def iter_matches(lines: typ.List[str], patterns: typ.List[v1patterns.Pattern]) -> PatternMatches:
def iter_matches(lines: typ.List[str], patterns: typ.List[Pattern]) -> PatternMatches:
"""Iterate over all matches of any pattern on any line.
>>> import pycalver.patterns as v1patterns
>>> from . import v1patterns
>>> lines = ["__version__ = 'v201712.0002-alpha'"]
>>> patterns = ["{pycalver}", "{pep440_pycalver}"]
>>> patterns = [v1patterns.compile_pattern(p) for p in patterns]
>>> version_pattern = "{pycalver}"
>>> raw_patterns = ["{pycalver}", "{pep440_pycalver}"]
>>> patterns = [v1patterns.compile_pattern(version_pattern, p) for p in raw_patterns]
>>> matches = list(iter_matches(lines, patterns))
>>> assert matches[0] == PatternMatch(
... lineno = 0,
... line = "__version__ = 'v201712.0002-alpha'",
... pattern= v1patterns.compile_pattern("{pycalver}"),
... pattern= v1patterns.compile_pattern(version_pattern),
... span = (15, 33),
... match = "v201712.0002-alpha",
... )

View file

@ -1,62 +1,14 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Compose Regular Expressions from Patterns.
>>> version_info = PYCALVER_RE.match("v201712.0123-alpha").groupdict()
>>> assert version_info == {
... "pycalver" : "v201712.0123-alpha",
... "vYYYYMM" : "v201712",
... "year" : "2017",
... "month" : "12",
... "build" : ".0123",
... "build_no" : "0123",
... "release" : "-alpha",
... "release_tag" : "alpha",
... }
>>>
>>> version_info = PYCALVER_RE.match("v201712.0033").groupdict()
>>> assert version_info == {
... "pycalver" : "v201712.0033",
... "vYYYYMM" : "v201712",
... "year" : "2017",
... "month" : "12",
... "build" : ".0033",
... "build_no" : "0033",
... "release" : None,
... "release_tag": None,
... }
"""
import re
import typing as typ
# https://regex101.com/r/fnj60p/10
PYCALVER_PATTERN = r"""
\b
(?P<pycalver>
(?P<vYYYYMM>
v # "v" version prefix
(?P<year>\d{4})
(?P<month>\d{2})
)
(?P<build>
\. # "." build nr prefix
(?P<build_no>\d{4,})
)
(?P<release>
\- # "-" release prefix
(?P<release_tag>alpha|beta|dev|rc|post)
)?
)(?:\s|$)
"""
PYCALVER_RE: typ.Pattern[str] = re.compile(PYCALVER_PATTERN, flags=re.VERBOSE)
class Pattern(typ.NamedTuple):
version_pattern: str # "{pycalver}", "{year}.{month}", "vYYYY0M.BUILD"
raw_pattern : str # '__version__ = "{version}"', "Copyright (c) YYYY"
regexp : typ.Pattern[str]
PATTERN_ESCAPES = [
RE_PATTERN_ESCAPES = [
("\u005c", "\u005c\u005c"),
("-" , "\u005c-"),
("." , "\u005c."),
@ -70,158 +22,3 @@ PATTERN_ESCAPES = [
("(" , "\u005c("),
(")" , "\u005c)"),
]
COMPOSITE_PART_PATTERNS = {
'pep440_pycalver': r"{year}{month}\.{BID}(?:{pep440_tag})?",
'pycalver' : r"v{year}{month}\.{bid}(?:-{tag})?",
'calver' : r"v{year}{month}",
'semver' : r"{MAJOR}\.{MINOR}\.{PATCH}",
'release_tag' : r"{tag}",
'build' : r"\.{bid}",
'release' : r"(?:-{tag})?",
# depricated
'pep440_version': r"{year}{month}\.{BID}(?:{pep440_tag})?",
}
PART_PATTERNS = {
'year' : r"\d{4}",
'month' : r"(?:0[0-9]|1[0-2])",
'month_short': r"(?:1[0-2]|[1-9])",
'build_no' : r"\d{4,}",
'pep440_tag' : r"(?:a|b|dev|rc|post)?\d*",
'tag' : r"(?:alpha|beta|dev|rc|post|final)",
'yy' : r"\d{2}",
'yyyy' : r"\d{4}",
'quarter' : r"[1-4]",
'iso_week' : r"(?:[0-4]\d|5[0-3])",
'us_week' : r"(?:[0-4]\d|5[0-3])",
'dom' : r"(0[1-9]|[1-2][0-9]|3[0-1])",
'dom_short' : r"([1-9]|[1-2][0-9]|3[0-1])",
'doy' : r"(?:[0-2]\d\d|3[0-5][0-9]|36[0-6])",
'doy_short' : r"(?:[0-2]\d\d|3[0-5][0-9]|36[0-6])",
'MAJOR' : r"\d+",
'MINOR' : r"\d+",
'MM' : r"\d{2,}",
'MMM' : r"\d{3,}",
'MMMM' : r"\d{4,}",
'MMMMM' : r"\d{5,}",
'PATCH' : r"\d+",
'PP' : r"\d{2,}",
'PPP' : r"\d{3,}",
'PPPP' : r"\d{4,}",
'PPPPP' : r"\d{5,}",
'bid' : r"\d{4,}",
'BID' : r"[1-9]\d*",
'BB' : r"[1-9]\d{1,}",
'BBB' : r"[1-9]\d{2,}",
'BBBB' : r"[1-9]\d{3,}",
'BBBBB' : r"[1-9]\d{4,}",
'BBBBBB' : r"[1-9]\d{5,}",
'BBBBBBB' : r"[1-9]\d{6,}",
}
PATTERN_PART_FIELDS = {
'year' : 'year',
'month' : 'month',
'month_short': 'month',
'pep440_tag' : 'tag',
'tag' : 'tag',
'yy' : 'year',
'yyyy' : 'year',
'quarter' : 'quarter',
'iso_week' : 'iso_week',
'us_week' : 'us_week',
'dom' : 'dom',
'doy' : 'doy',
'dom_short' : 'dom',
'doy_short' : 'doy',
'MAJOR' : 'major',
'MINOR' : 'minor',
'MM' : 'minor',
'MMM' : 'minor',
'MMMM' : 'minor',
'MMMMM' : 'minor',
'PP' : 'patch',
'PPP' : 'patch',
'PPPP' : 'patch',
'PPPPP' : 'patch',
'PATCH' : 'patch',
'build_no' : 'bid',
'bid' : 'bid',
'BID' : 'bid',
'BB' : 'bid',
'BBB' : 'bid',
'BBBB' : 'bid',
'BBBBB' : 'bid',
'BBBBBB' : 'bid',
'BBBBBBB' : 'bid',
}
FULL_PART_FORMATS = {
'pep440_pycalver': "{year}{month:02}.{BID}{pep440_tag}",
'pycalver' : "v{year}{month:02}.{bid}{release}",
'calver' : "v{year}{month:02}",
'semver' : "{MAJOR}.{MINOR}.{PATCH}",
'release_tag' : "{tag}",
'build' : ".{bid}",
# NOTE (mb 2019-01-04): since release is optional, it
# is treates specially in version.format
# 'release' : "-{tag}",
'month' : "{month:02}",
'month_short': "{month}",
'build_no' : "{bid}",
'iso_week' : "{iso_week:02}",
'us_week' : "{us_week:02}",
'dom' : "{dom:02}",
'doy' : "{doy:03}",
'dom_short' : "{dom}",
'doy_short' : "{doy}",
# depricated
'pep440_version': "{year}{month:02}.{BID}{pep440_tag}",
'version' : "v{year}{month:02}.{bid}{release}",
}
class Pattern(typ.NamedTuple):
raw : str # "{pycalver}", "{year}.{month}", "YYYY0M.BUILD"
regexp: typ.Pattern[str]
Patterns = typ.List[typ.Pattern[str]]
def _replace_pattern_parts(pattern: str) -> str:
# The pattern is escaped, so that everything besides the format
# string variables is treated literally.
for part_name, part_pattern in PART_PATTERNS.items():
named_part_pattern = f"(?P<{part_name}>{part_pattern})"
placeholder = "\u005c{" + part_name + "\u005c}"
pattern = pattern.replace(placeholder, named_part_pattern)
return pattern
def compile_pattern_str(pattern: str) -> str:
for char, escaped in PATTERN_ESCAPES:
pattern = pattern.replace(char, escaped)
return _replace_pattern_parts(pattern)
def compile_pattern(pattern: str) -> Pattern:
pattern_str = compile_pattern_str(pattern)
pattern_re = re.compile(pattern_str)
return Pattern(pattern, pattern_re)
def _init_composite_patterns() -> None:
for part_name, part_pattern in COMPOSITE_PART_PATTERNS.items():
part_pattern = part_pattern.replace("{", "\u005c{").replace("}", "\u005c}")
pattern_str = _replace_pattern_parts(part_pattern)
PART_PATTERNS[part_name] = pattern_str
_init_composite_patterns()

View file

@ -1,24 +1,22 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Rewrite files, updating occurences of version strings."""
import io
import glob
import typing as typ
import difflib
import logging
import pathlib2 as pl
import pycalver.version as v1version
import pycalver.patterns as v1patterns
from pycalver import parse
from pycalver import config
from . import config
from .patterns import Pattern
logger = logging.getLogger("pycalver.rewrite")
class NoPatternMatch(Exception):
"""Pattern not found in content.
logger.error is used to show error info about the patterns so
that users can debug what is wrong with them. The class
itself doesn't capture that info. This approach is used so
that all patter issues can be shown, rather than bubbling
all the way up the stack on the very first pattern with no
matches.
"""
def detect_line_sep(content: str) -> str:
@ -41,18 +39,6 @@ def detect_line_sep(content: str) -> str:
return "\n"
class NoPatternMatch(Exception):
"""Pattern not found in content.
logger.error is used to show error info about the patterns so
that users can debug what is wrong with them. The class
itself doesn't capture that info. This approach is used so
that all patter issues can be shown, rather than bubbling
all the way up the stack on the very first pattern with no
matches.
"""
class RewrittenFileData(typ.NamedTuple):
"""Container for line-wise content of rewritten files."""
@ -62,117 +48,19 @@ class RewrittenFileData(typ.NamedTuple):
new_lines: typ.List[str]
def iter_file_paths(
file_patterns: config.PatternsByGlob,
) -> typ.Iterable[typ.Tuple[pl.Path, config.Patterns]]:
for globstr, pattern_strs in file_patterns.items():
file_paths = glob.glob(globstr)
if not any(file_paths):
errmsg = f"No files found for path/glob '{globstr}'"
raise IOError(errmsg)
for file_path_str in file_paths:
file_path = pl.Path(file_path_str)
yield (file_path, pattern_strs)
PathPatternsItem = typ.Tuple[pl.Path, typ.List[Pattern]]
def rewrite_lines(
pattern_strs: typ.List[str],
new_vinfo : v1version.VersionInfo,
old_lines : typ.List[str],
) -> typ.List[str]:
"""Replace occurances of pattern_strs in old_lines with new_vinfo.
>>> new_vinfo = v1version.parse_version_info("v201811.0123-beta")
>>> pattern_strs = ['__version__ = "{pycalver}"']
>>> rewrite_lines(pattern_strs, new_vinfo, ['__version__ = "v201809.0002-beta"'])
['__version__ = "v201811.0123-beta"']
>>> pattern_strs = ['__version__ = "{pep440_version}"']
>>> rewrite_lines(pattern_strs, new_vinfo, ['__version__ = "201809.2b0"'])
['__version__ = "201811.123b0"']
"""
new_lines = old_lines[:]
found_patterns = set()
patterns = [v1patterns.compile_pattern(p) for p in pattern_strs]
matches = parse.iter_matches(old_lines, patterns)
for match in matches:
found_patterns.add(match.pattern.raw)
replacement = v1version.format_version(new_vinfo, match.pattern.raw)
span_l, span_r = match.span
new_line = match.line[:span_l] + replacement + match.line[span_r:]
new_lines[match.lineno] = new_line
non_matched_patterns = set(pattern_strs) - found_patterns
if non_matched_patterns:
for non_matched_pattern in non_matched_patterns:
logger.error(f"No match for pattern '{non_matched_pattern}'")
compiled_pattern_str = v1patterns.compile_pattern_str(non_matched_pattern)
logger.error(f"Pattern compiles to regex '{compiled_pattern_str}'")
raise NoPatternMatch("Invalid pattern(s)")
def iter_path_patterns_items(
file_patterns: config.PatternsByFile,
) -> typ.Iterable[PathPatternsItem]:
for filepath_str, patterns in file_patterns.items():
filepath_obj = pl.Path(filepath_str)
if filepath_obj.exists():
yield (filepath_obj, patterns)
else:
return new_lines
def rfd_from_content(
pattern_strs: typ.List[str],
new_vinfo : v1version.VersionInfo,
content : str,
) -> RewrittenFileData:
r"""Rewrite pattern occurrences with version string.
>>> new_vinfo = v1version.parse_version_info("v201809.0123")
>>> pattern_strs = ['__version__ = "{pycalver}"']
>>> content = '__version__ = "v201809.0001-alpha"'
>>> rfd = rfd_from_content(pattern_strs, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v201809.0123"']
>>>
>>> new_vinfo = v1version.parse_version_info("v1.2.3", "v{semver}")
>>> pattern_strs = ['__version__ = "v{semver}"']
>>> content = '__version__ = "v1.2.2"'
>>> rfd = rfd_from_content(pattern_strs, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v1.2.3"']
"""
line_sep = detect_line_sep(content)
old_lines = content.split(line_sep)
new_lines = rewrite_lines(pattern_strs, new_vinfo, old_lines)
return RewrittenFileData("<path>", line_sep, old_lines, new_lines)
def iter_rewritten(
file_patterns: config.PatternsByGlob,
new_vinfo : v1version.VersionInfo,
) -> typ.Iterable[RewrittenFileData]:
r'''Iterate over files with version string replaced.
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{pycalver}"']}
>>> new_vinfo = v1version.parse_version_info("v201809.0123")
>>> rewritten_datas = iter_rewritten(file_patterns, new_vinfo)
>>> rfd = list(rewritten_datas)[0]
>>> expected = [
... '# This file is part of the pycalver project',
... '# https://github.com/mbarkhau/pycalver',
... '#',
... '# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License',
... '# SPDX-License-Identifier: MIT',
... '"""PyCalVer: CalVer for Python Packages."""',
... '',
... '__version__ = "v201809.0123"',
... '',
... ]
>>> assert rfd.new_lines[:len(expected)] == expected
'''
fobj: typ.IO[str]
for file_path, pattern_strs in iter_file_paths(file_patterns):
with file_path.open(mode="rt", encoding="utf-8") as fobj:
content = fobj.read()
rfd = rfd_from_content(pattern_strs, new_vinfo, content)
yield rfd._replace(path=str(file_path))
errmsg = f"File does not exist: '{filepath_str}'"
raise IOError(errmsg)
def diff_lines(rfd: RewrittenFileData) -> typ.List[str]:
@ -188,57 +76,10 @@ def diff_lines(rfd: RewrittenFileData) -> typ.List[str]:
['--- <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
a=rfd.old_lines,
b=rfd.new_lines,
lineterm="",
fromfile=rfd.path,
tofile=rfd.path,
)
return list(lines)
def diff(new_vinfo: v1version.VersionInfo, file_patterns: config.PatternsByGlob) -> str:
r"""Generate diffs of rewritten files.
>>> new_vinfo = v1version.parse_version_info("v201809.0123")
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{pycalver}"']}
>>> diff_str = diff(new_vinfo, file_patterns)
>>> lines = diff_str.split("\n")
>>> lines[:2]
['--- src/pycalver/__init__.py', '+++ src/pycalver/__init__.py']
>>> assert lines[6].startswith('-__version__ = "v2')
>>> assert not lines[6].startswith('-__version__ = "v201809.0123"')
>>> lines[7]
'+__version__ = "v201809.0123"'
"""
full_diff = ""
fobj: typ.IO[str]
for file_path, pattern_strs in sorted(iter_file_paths(file_patterns)):
with file_path.open(mode="rt", encoding="utf-8") as fobj:
content = fobj.read()
try:
rfd = rfd_from_content(pattern_strs, new_vinfo, content)
except NoPatternMatch:
# pylint:disable=raise-missing-from ; we support py2, so not an option
errmsg = f"No patterns matched for '{file_path}'"
raise NoPatternMatch(errmsg)
rfd = rfd._replace(path=str(file_path))
lines = diff_lines(rfd)
if len(lines) == 0:
errmsg = f"No patterns matched for '{file_path}'"
raise NoPatternMatch(errmsg)
full_diff += "\n".join(lines) + "\n"
full_diff = full_diff.rstrip("\n")
return full_diff
def rewrite(file_patterns: config.PatternsByGlob, new_vinfo: v1version.VersionInfo) -> None:
"""Rewrite project files, updating each with the new version."""
fobj: typ.IO[str]
for file_data in iter_rewritten(file_patterns, new_vinfo):
new_content = file_data.line_sep.join(file_data.new_lines)
with io.open(file_data.path, mode="wt", encoding="utf-8") as fobj:
fobj.write(new_content)

View file

@ -12,11 +12,12 @@ Provided subcommands: show, test, init, bump
import typing as typ
import logging
import pycalver.rewrite as v1rewrite
import pycalver.version as v1version
from pycalver import config
from . import config
from . import version
from . import v1rewrite
from . import v1version
logger = logging.getLogger("pycalver.cli")
logger = logging.getLogger("pycalver.v1cli")
def update_cfg_from_vcs(cfg: config.Config, all_tags: typ.List[str]) -> config.Config:
@ -28,7 +29,7 @@ def update_cfg_from_vcs(cfg: config.Config, all_tags: typ.List[str]) -> config.C
version_tags.sort(reverse=True)
logger.debug(f"found {len(version_tags)} tags: {version_tags[:2]}")
latest_version_tag = version_tags[0]
latest_version_pep440 = v1version.to_pep440(latest_version_tag)
latest_version_pep440 = version.to_pep440(latest_version_tag)
if latest_version_tag <= cfg.current_version:
return cfg
@ -40,14 +41,15 @@ def update_cfg_from_vcs(cfg: config.Config, all_tags: typ.List[str]) -> config.C
)
def rewrite(
def rewrite_files(
cfg : config.Config,
new_version: str,
) -> None:
new_vinfo = v1version.parse_version_info(new_version, cfg.version_pattern)
v1rewrite.rewrite(cfg.file_patterns, new_vinfo)
v1rewrite.rewrite_files(cfg.file_patterns, new_vinfo)
def get_diff(cfg: config.Config, new_version: str) -> str:
new_vinfo = v1version.parse_version_info(new_version, cfg.version_pattern)
return v1rewrite.diff(new_vinfo, cfg.file_patterns)
old_vinfo = v1version.parse_version_info(cfg.current_version, cfg.version_pattern)
new_vinfo = v1version.parse_version_info(new_version , cfg.version_pattern)
return v1rewrite.diff(old_vinfo, new_vinfo, cfg.file_patterns)

220
src/pycalver/v1patterns.py Normal file
View file

@ -0,0 +1,220 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Compose Regular Expressions from Patterns.
>>> version_info = PYCALVER_RE.match("v201712.0123-alpha").groupdict()
>>> assert version_info == {
... "pycalver" : "v201712.0123-alpha",
... "vYYYYMM" : "v201712",
... "year" : "2017",
... "month" : "12",
... "build" : ".0123",
... "build_no" : "0123",
... "release" : "-alpha",
... "release_tag" : "alpha",
... }
>>>
>>> version_info = PYCALVER_RE.match("v201712.0033").groupdict()
>>> assert version_info == {
... "pycalver" : "v201712.0033",
... "vYYYYMM" : "v201712",
... "year" : "2017",
... "month" : "12",
... "build" : ".0033",
... "build_no" : "0033",
... "release" : None,
... "release_tag": None,
... }
"""
import re
import typing as typ
import logging
from .patterns import RE_PATTERN_ESCAPES
from .patterns import Pattern
logger = logging.getLogger("pycalver.v1patterns")
# https://regex101.com/r/fnj60p/10
PYCALVER_PATTERN = r"""
\b
(?P<pycalver>
(?P<vYYYYMM>
v # "v" version prefix
(?P<year>\d{4})
(?P<month>\d{2})
)
(?P<build>
\. # "." build nr prefix
(?P<build_no>\d{4,})
)
(?P<release>
\- # "-" release prefix
(?P<release_tag>alpha|beta|dev|rc|post)
)?
)(?:\s|$)
"""
PYCALVER_RE: typ.Pattern[str] = re.compile(PYCALVER_PATTERN, flags=re.VERBOSE)
COMPOSITE_PART_PATTERNS = {
'pep440_pycalver': r"{year}{month}\.{BID}(?:{pep440_tag})?",
'pycalver' : r"v{year}{month}\.{bid}(?:-{tag})?",
'calver' : r"v{year}{month}",
'semver' : r"{MAJOR}\.{MINOR}\.{PATCH}",
'release_tag' : r"{tag}",
'build' : r"\.{bid}",
'release' : r"(?:-{tag})?",
# depricated
'pep440_version': r"{year}{month}\.{BID}(?:{pep440_tag})?",
}
PART_PATTERNS = {
'year' : r"\d{4}",
'month' : r"(?:0[0-9]|1[0-2])",
'month_short': r"(?:1[0-2]|[1-9])",
'build_no' : r"\d{4,}",
'pep440_tag' : r"(?:a|b|dev|rc|post)?\d*",
'tag' : r"(?:alpha|beta|dev|rc|post|final)",
'yy' : r"\d{2}",
'yyyy' : r"\d{4}",
'quarter' : r"[1-4]",
'iso_week' : r"(?:[0-4]\d|5[0-3])",
'us_week' : r"(?:[0-4]\d|5[0-3])",
'dom' : r"(0[1-9]|[1-2][0-9]|3[0-1])",
'dom_short' : r"([1-9]|[1-2][0-9]|3[0-1])",
'doy' : r"(?:[0-2]\d\d|3[0-5][0-9]|36[0-6])",
'doy_short' : r"(?:[0-2]\d\d|3[0-5][0-9]|36[0-6])",
'MAJOR' : r"\d+",
'MINOR' : r"\d+",
'MM' : r"\d{2,}",
'MMM' : r"\d{3,}",
'MMMM' : r"\d{4,}",
'MMMMM' : r"\d{5,}",
'PATCH' : r"\d+",
'PP' : r"\d{2,}",
'PPP' : r"\d{3,}",
'PPPP' : r"\d{4,}",
'PPPPP' : r"\d{5,}",
'bid' : r"\d{4,}",
'BID' : r"[1-9]\d*",
'BB' : r"[1-9]\d{1,}",
'BBB' : r"[1-9]\d{2,}",
'BBBB' : r"[1-9]\d{3,}",
'BBBBB' : r"[1-9]\d{4,}",
'BBBBBB' : r"[1-9]\d{5,}",
'BBBBBBB' : r"[1-9]\d{6,}",
}
PATTERN_PART_FIELDS = {
'year' : 'year',
'month' : 'month',
'month_short': 'month',
'pep440_tag' : 'tag',
'tag' : 'tag',
'yy' : 'year',
'yyyy' : 'year',
'quarter' : 'quarter',
'iso_week' : 'iso_week',
'us_week' : 'us_week',
'dom' : 'dom',
'doy' : 'doy',
'dom_short' : 'dom',
'doy_short' : 'doy',
'MAJOR' : 'major',
'MINOR' : 'minor',
'MM' : 'minor',
'MMM' : 'minor',
'MMMM' : 'minor',
'MMMMM' : 'minor',
'PP' : 'patch',
'PPP' : 'patch',
'PPPP' : 'patch',
'PPPPP' : 'patch',
'PATCH' : 'patch',
'build_no' : 'bid',
'bid' : 'bid',
'BID' : 'bid',
'BB' : 'bid',
'BBB' : 'bid',
'BBBB' : 'bid',
'BBBBB' : 'bid',
'BBBBBB' : 'bid',
'BBBBBBB' : 'bid',
}
FULL_PART_FORMATS = {
'pep440_pycalver': "{year}{month:02}.{BID}{pep440_tag}",
'pycalver' : "v{year}{month:02}.{bid}{release}",
'calver' : "v{year}{month:02}",
'semver' : "{MAJOR}.{MINOR}.{PATCH}",
'release_tag' : "{tag}",
'build' : ".{bid}",
# NOTE (mb 2019-01-04): since release is optional, it
# is treates specially in version.format
# 'release' : "-{tag}",
'month' : "{month:02}",
'month_short': "{month}",
'build_no' : "{bid}",
'iso_week' : "{iso_week:02}",
'us_week' : "{us_week:02}",
'dom' : "{dom:02}",
'doy' : "{doy:03}",
'dom_short' : "{dom}",
'doy_short' : "{doy}",
# depricated
'pep440_version': "{year}{month:02}.{BID}{pep440_tag}",
'version' : "v{year}{month:02}.{bid}{release}",
}
def _replace_pattern_parts(pattern: str) -> str:
# The pattern is escaped, so that everything besides the format
# string variables is treated literally.
for part_name, part_pattern in PART_PATTERNS.items():
named_part_pattern = f"(?P<{part_name}>{part_pattern})"
placeholder = "\u005c{" + part_name + "\u005c}"
pattern = pattern.replace(placeholder, named_part_pattern)
return pattern
def _init_composite_patterns() -> None:
for part_name, part_pattern in COMPOSITE_PART_PATTERNS.items():
part_pattern = part_pattern.replace("{", "\u005c{").replace("}", "\u005c}")
pattern_str = _replace_pattern_parts(part_pattern)
PART_PATTERNS[part_name] = pattern_str
_init_composite_patterns()
def _compile_pattern_re(version_pattern: str, raw_pattern: str) -> typ.Pattern[str]:
normalized_pattern = raw_pattern.replace(r"{version}", version_pattern)
if version_pattern == r"{pycalver}":
normalized_pattern = normalized_pattern.replace(r"{pep440_version}", r"{pep440_pycalver}")
elif version_pattern == r"{semver}":
normalized_pattern = normalized_pattern.replace(r"{pep440_version}", r"{semver}")
elif r"{pep440_version}" in raw_pattern:
logger.warning(f"No mapping of '{version_pattern}' to '{{pep440_version}}'")
escaped_pattern = normalized_pattern
for char, escaped in RE_PATTERN_ESCAPES:
escaped_pattern = escaped_pattern.replace(char, escaped)
# TODO (mb 2020-09-19): replace {version} etc with version_pattern
pattern_str = _replace_pattern_parts(escaped_pattern)
return re.compile(pattern_str)
def compile_pattern(version_pattern: str, raw_pattern: typ.Optional[str] = None) -> Pattern:
_raw_pattern = version_pattern if raw_pattern is None else raw_pattern
regexp = _compile_pattern_re(version_pattern, _raw_pattern)
return Pattern(version_pattern, _raw_pattern, regexp)

193
src/pycalver/v1rewrite.py Normal file
View file

@ -0,0 +1,193 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Rewrite files, updating occurences of version strings."""
import io
import typing as typ
import logging
from . import parse
from . import config
from . import rewrite
from . import version
from . import v1version
from .patterns import Pattern
logger = logging.getLogger("pycalver.v1rewrite")
def rewrite_lines(
patterns : typ.List[Pattern],
new_vinfo: version.V1VersionInfo,
old_lines: typ.List[str],
) -> typ.List[str]:
"""Replace occurances of patterns in old_lines with new_vinfo.
>>> from .v1patterns import compile_pattern
>>> version_pattern = "{pycalver}"
>>> new_vinfo = v1version.parse_version_info("v201811.0123-beta", version_pattern)
>>> patterns = [compile_pattern(version_pattern, '__version__ = "{pycalver}"')]
>>> rewrite_lines(patterns, new_vinfo, ['__version__ = "v201809.0002-beta"'])
['__version__ = "v201811.0123-beta"']
>>> patterns = [compile_pattern(version_pattern, '__version__ = "{pep440_version}"')]
>>> rewrite_lines(patterns, new_vinfo, ['__version__ = "201809.2b0"'])
['__version__ = "201811.123b0"']
"""
new_lines = old_lines[:]
found_patterns = set()
for match in parse.iter_matches(old_lines, patterns):
found_patterns.add(match.pattern.raw_pattern)
replacement = v1version.format_version(new_vinfo, match.pattern.raw_pattern)
span_l, span_r = match.span
new_line = match.line[:span_l] + replacement + match.line[span_r:]
new_lines[match.lineno] = new_line
non_matched_patterns = set(patterns) - found_patterns
if non_matched_patterns:
for nmp in non_matched_patterns:
logger.error(f"No match for pattern '{nmp.raw_pattern}'")
logger.error(f"Pattern compiles to regex '{nmp.regexp.pattern}'")
raise rewrite.NoPatternMatch("Invalid pattern(s)")
else:
return new_lines
def rfd_from_content(
patterns : typ.List[Pattern],
new_vinfo: version.V1VersionInfo,
content : str,
path : str = "<path>",
) -> rewrite.RewrittenFileData:
r"""Rewrite pattern occurrences with version string.
>>> from .v1patterns import compile_pattern
>>> patterns = [compile_pattern("{pycalver}", '__version__ = "{pycalver}"']
>>> new_vinfo = v1version.parse_version_info("v201809.0123")
>>> content = '__version__ = "v201809.0001-alpha"'
>>> rfd = rfd_from_content(patterns, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v201809.0123"']
>>> patterns = [compile_pattern('{semver}', '__version__ = "v{semver}"')]
>>> new_vinfo = v1version.parse_version_info("v1.2.3", "v{semver}")
>>> content = '__version__ = "v1.2.2"'
>>> rfd = rfd_from_content(patterns, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v1.2.3"']
"""
line_sep = rewrite.detect_line_sep(content)
old_lines = content.split(line_sep)
new_lines = rewrite_lines(patterns, new_vinfo, old_lines)
return rewrite.RewrittenFileData(path, line_sep, old_lines, new_lines)
def iter_rewritten(
file_patterns: config.PatternsByFile,
new_vinfo : version.V1VersionInfo,
) -> typ.Iterable[rewrite.RewrittenFileData]:
r'''Iterate over files with version string replaced.
>>> version_pattern = "{pycalver}"
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{pycalver}"']}
>>> new_vinfo = v1version.parse_version_info("v201809.0123")
>>> rewritten_datas = iter_rewritten(version_pattern, file_patterns, new_vinfo)
>>> rfd = list(rewritten_datas)[0]
>>> expected = [
... '# This file is part of the pycalver project',
... '# https://github.com/mbarkhau/pycalver',
... '#',
... '# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License',
... '# SPDX-License-Identifier: MIT',
... '"""PyCalVer: CalVer for Python Packages."""',
... '',
... '__version__ = "v201809.0123"',
... '',
... ]
>>> assert rfd.new_lines == expected
'''
fobj: typ.IO[str]
for file_path, pattern_strs in rewrite.iter_path_patterns_items(file_patterns):
with file_path.open(mode="rt", encoding="utf-8") as fobj:
content = fobj.read()
rfd = rfd_from_content(pattern_strs, new_vinfo, content)
yield rfd._replace(path=str(file_path))
def diff(
old_vinfo : version.V1VersionInfo,
new_vinfo : version.V1VersionInfo,
file_patterns: config.PatternsByFile,
) -> str:
r"""Generate diffs of rewritten files.
>>> old_vinfo = v1version.parse_version_info("v201809.0123")
>>> new_vinfo = v1version.parse_version_info("v201810.1124")
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{pycalver}"']}
>>> diff_str = diff(old_vinfo, new_vinfo, file_patterns)
>>> lines = diff_str.split("\n")
>>> lines[:2]
['--- src/pycalver/__init__.py', '+++ src/pycalver/__init__.py']
>>> assert lines[6].startswith('-__version__ = "v2')
>>> assert not lines[6].startswith('-__version__ = "v201809.0123"')
>>> lines[7]
'+__version__ = "v201809.0123"'
>>> file_patterns = {"LICENSE": ['Copyright (c) 2018-{year}']}
>>> diff_str = diff(old_vinfo, new_vinfo, file_patterns)
>>> assert not diff_str
"""
full_diff = ""
fobj: typ.IO[str]
for file_path, patterns in sorted(rewrite.iter_path_patterns_items(file_patterns)):
with file_path.open(mode="rt", encoding="utf-8") as fobj:
content = fobj.read()
has_updated_version = False
for pattern in patterns:
old_str = v1version.format_version(old_vinfo, pattern.raw_pattern)
new_str = v1version.format_version(new_vinfo, pattern.raw_pattern)
if old_str != new_str:
has_updated_version = True
try:
rfd = rfd_from_content(patterns, new_vinfo, content)
except rewrite.NoPatternMatch:
# pylint:disable=raise-missing-from ; we support py2, so not an option
errmsg = f"No patterns matched for '{file_path}'"
raise rewrite.NoPatternMatch(errmsg)
rfd = rfd._replace(path=str(file_path))
lines = rewrite.diff_lines(rfd)
if len(lines) == 0 and has_updated_version:
errmsg = f"No patterns matched for '{file_path}'"
raise rewrite.NoPatternMatch(errmsg)
full_diff += "\n".join(lines) + "\n"
full_diff = full_diff.rstrip("\n")
return full_diff
def rewrite_files(
file_patterns: config.PatternsByFile,
new_vinfo : version.V1VersionInfo,
) -> None:
"""Rewrite project files, updating each with the new version."""
fobj: typ.IO[str]
for file_data in iter_rewritten(file_patterns, new_vinfo):
new_content = file_data.line_sep.join(file_data.new_lines)
with io.open(file_data.path, mode="wt", encoding="utf-8") as fobj:
fobj.write(new_content)

407
src/pycalver/v1version.py Normal file
View file

@ -0,0 +1,407 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Functions related to version string manipulation."""
import typing as typ
import logging
import datetime as dt
from . import version
from . import v1patterns
logger = logging.getLogger("pycalver.v1version")
CalInfo = typ.Union[version.V1CalendarInfo, version.V1VersionInfo]
def _is_later_than(old: CalInfo, new: CalInfo) -> bool:
"""Is old > new based on non None fields."""
for field in version.V1CalendarInfo._fields:
aval = getattr(old, field)
bval = getattr(new, field)
if not (aval is None or bval is None):
if aval > bval:
return True
return False
def _ver_to_cal_info(vnfo: version.V1VersionInfo) -> version.V1CalendarInfo:
return version.V1CalendarInfo(
vnfo.year,
vnfo.quarter,
vnfo.month,
vnfo.dom,
vnfo.doy,
vnfo.iso_week,
vnfo.us_week,
)
def cal_info(date: dt.date = None) -> version.V1CalendarInfo:
"""Generate calendar components for current date.
>>> from datetime import date
>>> c = cal_info(date(2019, 1, 5))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 1, 1, 5, 5, 0, 0)
>>> c = cal_info(date(2019, 1, 6))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 1, 1, 6, 6, 0, 1)
>>> c = cal_info(date(2019, 1, 7))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 1, 1, 7, 7, 1, 1)
>>> c = cal_info(date(2019, 4, 7))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 2, 4, 7, 97, 13, 14)
"""
if date is None:
date = version.TODAY
kwargs = {
'year' : date.year,
'quarter' : version.quarter_from_month(date.month),
'month' : date.month,
'dom' : date.day,
'doy' : int(date.strftime("%j"), base=10),
'iso_week': int(date.strftime("%W"), base=10),
'us_week' : int(date.strftime("%U"), base=10),
}
return version.V1CalendarInfo(**kwargs)
FieldKey = str
MatchGroupKey = str
MatchGroupStr = str
PatternGroups = typ.Dict[MatchGroupKey, MatchGroupStr]
FieldValues = typ.Dict[FieldKey , MatchGroupStr]
def _parse_field_values(field_values: FieldValues) -> version.V1VersionInfo:
fvals = field_values
tag = fvals.get('tag')
if tag is None:
tag = "final"
tag = version.TAG_BY_PEP440_TAG.get(tag, tag)
assert tag is not None
bid = fvals['bid'] if 'bid' in fvals else "0001"
year = int(fvals['year']) if 'year' in fvals else None
doy = int(fvals['doy' ]) if 'doy' in fvals else None
month: typ.Optional[int]
dom : typ.Optional[int]
if year and doy:
date = version.date_from_doy(year, doy)
month = date.month
dom = date.day
else:
month = int(fvals['month']) if 'month' in fvals else None
dom = int(fvals['dom' ]) if 'dom' in fvals else None
iso_week: typ.Optional[int]
us_week : typ.Optional[int]
if year and month and dom:
date = dt.date(year, month, dom)
doy = int(date.strftime("%j"), base=10)
iso_week = int(date.strftime("%W"), base=10)
us_week = int(date.strftime("%U"), base=10)
else:
iso_week = None
us_week = None
quarter = int(fvals['quarter']) if 'quarter' in fvals else None
if quarter is None and month:
quarter = version.quarter_from_month(month)
major = int(fvals['major']) if 'major' in fvals else 0
minor = int(fvals['minor']) if 'minor' in fvals else 0
patch = int(fvals['patch']) if 'patch' in fvals else 0
return version.V1VersionInfo(
year=year,
quarter=quarter,
month=month,
dom=dom,
doy=doy,
iso_week=iso_week,
us_week=us_week,
major=major,
minor=minor,
patch=patch,
bid=bid,
tag=tag,
)
def _is_calver(cinfo: CalInfo) -> bool:
"""Check pattern for any calendar based parts.
>>> _is_calver(cal_info())
True
>>> vnfo = _parse_version_info({'year': "2018", 'month': "11", 'bid': "0018"})
>>> _is_calver(vnfo)
True
>>> vnfo = _parse_version_info({'MAJOR': "1", 'MINOR': "023", 'PATCH': "45"})
>>> _is_calver(vnfo)
False
"""
for field in version.V1CalendarInfo._fields:
maybe_val: typ.Any = getattr(cinfo, field, None)
if isinstance(maybe_val, int):
return True
return False
VersionInfoKW = typ.Dict[str, typ.Union[str, int, None]]
def _parse_pattern_groups(pattern_groups: PatternGroups) -> FieldValues:
for part_name in pattern_groups.keys():
is_valid_part_name = (
part_name in v1patterns.COMPOSITE_PART_PATTERNS
or part_name in v1patterns.PATTERN_PART_FIELDS
)
if not is_valid_part_name:
err_msg = f"Invalid part '{part_name}'"
raise version.PatternError(err_msg)
field_value_items = [
(field_name, pattern_groups[part_name])
for part_name, field_name in v1patterns.PATTERN_PART_FIELDS.items()
if part_name in pattern_groups.keys()
]
all_fields = [field_name for field_name, _ in field_value_items]
unique_fields = set(all_fields)
duplicate_fields = [f for f in unique_fields if all_fields.count(f) > 1]
if any(duplicate_fields):
err_msg = f"Multiple parts for same field {duplicate_fields}."
raise version.PatternError(err_msg)
else:
return dict(field_value_items)
def _parse_version_info(pattern_groups: PatternGroups) -> version.V1VersionInfo:
"""Parse normalized V1VersionInfo from groups of a matched pattern.
>>> vnfo = _parse_version_info({'year': "2018", 'month': "11", 'bid': "0099"})
>>> (vnfo.year, vnfo.month, vnfo.quarter, vnfo.bid, vnfo.tag)
(2018, 11, 4, '0099', 'final')
>>> vnfo = _parse_version_info({'year': "2018", 'doy': "11", 'bid': "099", 'tag': "b"})
>>> (vnfo.year, vnfo.month, vnfo.dom, vnfo.bid, vnfo.tag)
(2018, 1, 11, '099', 'beta')
>>> vnfo = _parse_version_info({'MAJOR': "1", 'MINOR': "23", 'PATCH': "45"})
>>> (vnfo.major, vnfo.minor, vnfo.patch)
(1, 23, 45)
>>> vnfo = _parse_version_info({'MAJOR': "1", 'MMM': "023", 'PPPP': "0045"})
>>> (vnfo.major, vnfo.minor, vnfo.patch)
(1, 23, 45)
"""
field_values = _parse_pattern_groups(pattern_groups)
return _parse_field_values(field_values)
def parse_version_info(version_str: str, raw_pattern: str = "{pycalver}") -> version.V1VersionInfo:
"""Parse normalized V1VersionInfo.
>>> vnfo = parse_version_info("v201712.0033-beta", raw_pattern="{pycalver}")
>>> assert vnfo == _parse_version_info({'year': 2017, 'month': 12, 'bid': "0033", 'tag': "beta"})
>>> vnfo = parse_version_info("1.23.456", raw_pattern="{semver}")
>>> assert vnfo == _parse_version_info({'MAJOR': "1", 'MINOR': "23", 'PATCH': "456"})
"""
pattern = v1patterns.compile_pattern(raw_pattern)
match = pattern.regexp.match(version_str)
if match is None:
err_msg = (
f"Invalid version string '{version_str}' "
f"for pattern '{raw_pattern}'/'{pattern.regexp}'"
)
raise version.PatternError(err_msg)
else:
return _parse_version_info(match.groupdict())
def is_valid(version_str: str, raw_pattern: str = "{pycalver}") -> bool:
"""Check if a version matches a pattern.
>>> is_valid("v201712.0033-beta", raw_pattern="{pycalver}")
True
>>> is_valid("v201712.0033-beta", raw_pattern="{semver}")
False
>>> is_valid("1.2.3", raw_pattern="{semver}")
True
>>> is_valid("v201712.0033-beta", raw_pattern="{semver}")
False
"""
try:
parse_version_info(version_str, raw_pattern)
return True
except version.PatternError:
return False
ID_FIELDS_BY_PART = {
'MAJOR' : 'major',
'MINOR' : 'minor',
'MM' : 'minor',
'MMM' : 'minor',
'MMMM' : 'minor',
'MMMMM' : 'minor',
'MMMMMM' : 'minor',
'MMMMMMM': 'minor',
'PATCH' : 'patch',
'PP' : 'patch',
'PPP' : 'patch',
'PPPP' : 'patch',
'PPPPP' : 'patch',
'PPPPPP' : 'patch',
'PPPPPPP': 'patch',
'BID' : 'bid',
'BB' : 'bid',
'BBB' : 'bid',
'BBBB' : 'bid',
'BBBBB' : 'bid',
'BBBBBB' : 'bid',
'BBBBBBB': 'bid',
}
def format_version(vinfo: version.V1VersionInfo, raw_pattern: str) -> str:
"""Generate version string.
>>> import datetime as dt
>>> vinfo = parse_version_info("v201712.0033-beta", raw_pattern="{pycalver}")
>>> vinfo_a = vinfo._replace(**cal_info(date=dt.date(2017, 1, 1))._asdict())
>>> vinfo_b = vinfo._replace(**cal_info(date=dt.date(2017, 12, 31))._asdict())
>>> vinfo_c = vinfo_b._replace(major=1, minor=2, patch=34, tag='final')
>>> format_version(vinfo_a, raw_pattern="v{yy}.{BID}{release}")
'v17.33-beta'
>>> format_version(vinfo_a, raw_pattern="{pep440_version}")
'201701.33b0'
>>> format_version(vinfo_a, raw_pattern="{pycalver}")
'v201701.0033-beta'
>>> format_version(vinfo_b, raw_pattern="{pycalver}")
'v201712.0033-beta'
>>> format_version(vinfo_a, raw_pattern="v{year}w{iso_week}.{BID}{release}")
'v2017w00.33-beta'
>>> format_version(vinfo_b, raw_pattern="v{year}w{iso_week}.{BID}{release}")
'v2017w52.33-beta'
>>> format_version(vinfo_a, raw_pattern="v{year}d{doy}.{bid}{release}")
'v2017d001.0033-beta'
>>> format_version(vinfo_b, raw_pattern="v{year}d{doy}.{bid}{release}")
'v2017d365.0033-beta'
>>> format_version(vinfo_c, raw_pattern="v{year}w{iso_week}.{BID}-{tag}")
'v2017w52.33-final'
>>> format_version(vinfo_c, raw_pattern="v{year}w{iso_week}.{BID}{release}")
'v2017w52.33'
>>> format_version(vinfo_c, raw_pattern="v{MAJOR}.{MINOR}.{PATCH}")
'v1.2.34'
>>> format_version(vinfo_c, raw_pattern="v{MAJOR}.{MM}.{PPP}")
'v1.02.034'
"""
full_pattern = raw_pattern
for part_name, full_part_format in v1patterns.FULL_PART_FORMATS.items():
full_pattern = full_pattern.replace("{" + part_name + "}", full_part_format)
kwargs: typ.Dict[str, typ.Union[str, int, None]] = vinfo._asdict()
tag = vinfo.tag
if tag == 'final':
kwargs['release' ] = ""
kwargs['pep440_tag'] = ""
else:
kwargs['release' ] = "-" + tag
kwargs['pep440_tag'] = version.PEP440_TAG_BY_TAG[tag] + "0"
kwargs['release_tag'] = tag
year = vinfo.year
if year:
kwargs['yy' ] = str(year)[-2:]
kwargs['yyyy'] = year
kwargs['BID'] = int(vinfo.bid, 10)
for part_name, field in ID_FIELDS_BY_PART.items():
val = kwargs[field]
if part_name.lower() == field.lower():
if isinstance(val, str):
kwargs[part_name] = int(val, base=10)
else:
kwargs[part_name] = val
else:
assert len(set(part_name)) == 1
padded_len = len(part_name)
kwargs[part_name] = str(val).zfill(padded_len)
return full_pattern.format(**kwargs)
def incr(
old_version: str,
raw_pattern: str = "{pycalver}",
*,
release : typ.Optional[str] = None,
major : bool = False,
minor : bool = False,
patch : bool = False,
pin_date: bool = False,
) -> typ.Optional[str]:
"""Increment version string.
'old_version' is assumed to be a string that matches 'pattern'
"""
try:
old_vinfo = parse_version_info(old_version, raw_pattern)
except version.PatternError as ex:
logger.error(str(ex))
return None
cur_cinfo = _ver_to_cal_info(old_vinfo) if pin_date else cal_info()
if _is_later_than(old_vinfo, cur_cinfo):
cur_vinfo = old_vinfo._replace(**cur_cinfo._asdict())
else:
logger.warning(f"Version appears to be from the future '{old_version}'")
cur_vinfo = old_vinfo
cur_vinfo = version.incr_non_cal_parts(
cur_vinfo,
release,
major,
minor,
patch,
)
new_version = format_version(cur_vinfo, raw_pattern)
if new_version == old_version:
logger.error("Invalid arguments or pattern, version did not change.")
return None
else:
return new_version

View file

@ -12,12 +12,12 @@ Provided subcommands: show, test, init, bump
import typing as typ
import logging
import pycalver.version as v1version
import pycalver2.rewrite as v2rewrite
import pycalver2.version as v2version
from pycalver import config
from . import config
from . import version
from . import v2rewrite
from . import v2version
logger = logging.getLogger("pycalver2.cli")
logger = logging.getLogger("pycalver.v2cli")
def update_cfg_from_vcs(cfg: config.Config, all_tags: typ.List[str]) -> config.Config:
@ -29,7 +29,7 @@ def update_cfg_from_vcs(cfg: config.Config, all_tags: typ.List[str]) -> config.C
version_tags.sort(reverse=True)
logger.debug(f"found {len(version_tags)} tags: {version_tags[:2]}")
latest_version_tag = version_tags[0]
latest_version_pep440 = v1version.to_pep440(latest_version_tag)
latest_version_pep440 = version.to_pep440(latest_version_tag)
if latest_version_tag <= cfg.current_version:
return cfg
@ -41,14 +41,15 @@ def update_cfg_from_vcs(cfg: config.Config, all_tags: typ.List[str]) -> config.C
)
def rewrite(
def rewrite_files(
cfg : config.Config,
new_version: str,
) -> None:
new_vinfo = v2version.parse_version_info(new_version, cfg.version_pattern)
v2rewrite.rewrite(cfg.file_patterns, new_vinfo)
v2rewrite.rewrite_files(cfg.file_patterns, new_vinfo)
def get_diff(cfg: config.Config, new_version: str) -> str:
new_vinfo = v2version.parse_version_info(new_version, cfg.version_pattern)
return v2rewrite.diff(new_vinfo, cfg.file_patterns)
old_vinfo = v2version.parse_version_info(cfg.current_version, cfg.version_pattern)
new_vinfo = v2version.parse_version_info(new_version , cfg.version_pattern)
return v2rewrite.diff(old_vinfo, new_vinfo, cfg.file_patterns)

View file

@ -33,30 +33,28 @@
import re
import typing as typ
import logging
import pycalver.patterns as v1patterns
from .patterns import RE_PATTERN_ESCAPES
from .patterns import Pattern
PATTERN_ESCAPES = [
("\u005c", "\u005c\u005c"),
("-" , "\u005c-"),
("." , "\u005c."),
("+" , "\u005c+"),
("*" , "\u005c*"),
("?" , "\u005c?"),
("{" , "\u005c{"),
("}" , "\u005c}"),
# ("[" , "\u005c["), # [braces] are used for optional parts
# ("]" , "\u005c]"),
("(", "\u005c("),
(")", "\u005c)"),
]
logger = logging.getLogger("pycalver.v2patterns")
# NOTE (mb 2020-09-17): For patterns with different options '(AAA|BB|C)', the
# patterns with more digits should be first/left of those with fewer digits:
#
# good: (?:1[0-2]|[1-9])
# bad: (?:[1-9]|1[0-2])
#
# This ensures that the longest match is done for a pattern.
#
# This implies that patterns for smaller numbers sometimes must be right of
# those for larger numbers. To be consistent we use this ordering not
# sometimes but always (even though in theory it wouldn't matter):
#
# good: (?:3[0-1]|[1-2][0-9]|[1-9])
# bad: (?:[1-2][0-9]|3[0-1]|[1-9])
# NOTE (mb 2020-09-17): For patterns with different options, the longer
# patterns should be first/left (e.g. for 'MM', `1[0-2]` before `[1-9]`).
# This ensures that the longest match is done rather than the shortest.
# To have a consistent ordering, we always put the pattern that matches
# the larger number first (even if the patterns would otherwise be the
# same size).
PART_PATTERNS = {
# Based on calver.org
@ -239,14 +237,28 @@ def _replace_pattern_parts(pattern: str) -> str:
return result_pattern
def compile_pattern_str(pattern: str) -> str:
for char, escaped in PATTERN_ESCAPES:
pattern = pattern.replace(char, escaped)
def _compile_pattern_re(version_pattern: str, raw_pattern: str) -> typ.Pattern[str]:
escaped_pattern = raw_pattern
for char, escaped in RE_PATTERN_ESCAPES:
# [] braces are used for optional parts, such as [-TAG]/[-beta]
is_semantic_char = char in "[]"
if not is_semantic_char:
# escape it so it is a literal in the re pattern
escaped_pattern = escaped_pattern.replace(char, escaped)
return _replace_pattern_parts(pattern)
escaped_pattern = raw_pattern.replace("[", "\u005c[").replace("]", "\u005c]")
normalized_pattern = escaped_pattern.replace("{version}", version_pattern)
print(">>>>", (raw_pattern ,))
print("....", (escaped_pattern ,))
print("....", (normalized_pattern,))
print("<<<<", (normalized_pattern,))
# TODO (mb 2020-09-19): replace {version} etc with version_pattern
pattern_str = _replace_pattern_parts(escaped_pattern)
return re.compile(pattern_str)
def compile_pattern(pattern: str) -> v1patterns.Pattern:
pattern_str = compile_pattern_str(pattern)
pattern_re = re.compile(pattern_str)
return v1patterns.Pattern(pattern, pattern_re)
def compile_pattern(version_pattern: str, raw_pattern: typ.Optional[str] = None) -> Pattern:
_raw_pattern = version_pattern if raw_pattern is None else raw_pattern
regexp = _compile_pattern_re(version_pattern, _raw_pattern)
return Pattern(version_pattern, _raw_pattern, regexp)

198
src/pycalver/v2rewrite.py Normal file
View file

@ -0,0 +1,198 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Rewrite files, updating occurences of version strings."""
import io
import typing as typ
import logging
from . import parse
from . import config
from . import rewrite
from . import version
from . import v2version
from .patterns import Pattern
logger = logging.getLogger("pycalver.v2rewrite")
def rewrite_lines(
patterns : typ.List[Pattern],
new_vinfo: version.V2VersionInfo,
old_lines: typ.List[str],
) -> typ.List[str]:
"""Replace occurances of patterns in old_lines with new_vinfo.
>>> from .v2patterns import compile_pattern
>>> version_pattern = "vYYYY0M.BUILD[-TAG]"
>>> new_vinfo = v2version.parse_version_info("v201811.0123-beta", version_pattern)
>>> patterns = [compile_pattern(version_pattern, '__version__ = "{version}"')]
>>> rewrite_lines(patterns, new_vinfo, ['__version__ = "v201809.0002-alpha" '])
['__version__ = "v201811.0123-beta" ']
>>> rewrite_lines(patterns, new_vinfo, ['__version__ = "v201809.0002-alpha" # comment'])
['__version__ = "v201811.0123-beta" # comment']
>>> patterns = [compile_pattern(version_pattern, '__version__ = "{pep440_version}"')]
>>> old_lines = ['__version__ = "201809.2a0"']
>>> rewrite_lines(patterns, new_vinfo, old_lines)
['__version__ = "201811.123b0"']
"""
new_lines = old_lines[:]
found_patterns = set()
for match in parse.iter_matches(old_lines, patterns):
found_patterns.add(match.pattern.raw_pattern)
replacement = v2version.format_version(new_vinfo, match.pattern.raw_pattern)
span_l, span_r = match.span
new_line = match.line[:span_l] + replacement + match.line[span_r:]
new_lines[match.lineno] = new_line
non_matched_patterns = set(patterns) - found_patterns
if non_matched_patterns:
for nmp in non_matched_patterns:
logger.error(f"No match for pattern '{nmp.raw_pattern}'")
logger.error(f"Pattern compiles to regex '{nmp.regexp.pattern}'")
raise rewrite.NoPatternMatch("Invalid pattern(s)")
else:
return new_lines
def rfd_from_content(
patterns : typ.List[Pattern],
new_vinfo: version.V2VersionInfo,
content : str,
path : str = "<path>",
) -> rewrite.RewrittenFileData:
r"""Rewrite pattern occurrences with version string.
>>> version_pattern = "vYYYY0M.BUILD[-TAG]"
>>> new_vinfo = v2version.parse_version_info("v201809.0123", version_pattern)
>>> raw_patterns = ['__version__ = "vYYYY0M.BUILD[-TAG]"']
>>> patterns =
>>> content = '__version__ = "v201809.0001-alpha"'
>>> rfd = rfd_from_content(patterns, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v201809.0123"']
>>> version_pattern = "vMAJOR.MINOR.PATCH"
>>> new_vinfo = v2version.parse_version_info("v1.2.3", version_pattern)
>>> raw_patterns = ['__version__ = "vMAJOR.MINOR.PATCH"']
>>> patterns =
>>> content = '__version__ = "v1.2.2"'
>>> rfd = rfd_from_content(patterns, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v1.2.3"']
"""
line_sep = rewrite.detect_line_sep(content)
old_lines = content.split(line_sep)
new_lines = rewrite_lines(patterns, new_vinfo, old_lines)
return rewrite.RewrittenFileData(path, line_sep, old_lines, new_lines)
def iter_rewritten(
file_patterns: config.PatternsByFile,
new_vinfo : version.V2VersionInfo,
) -> typ.Iterable[rewrite.RewrittenFileData]:
r'''Iterate over files with version string replaced.
>>> version_pattern = "vYYYY0M.BUILD[-TAG]"
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "vYYYY0M.BUILD[-TAG]"']}
>>> new_vinfo = v2version.parse_version_info("v201809.0123", version_pattern)
>>> rewritten_datas = iter_rewritten(file_patterns, new_vinfo)
>>> rfd = list(rewritten_datas)[0]
>>> expected = [
... '# This file is part of the pycalver project',
... '# https://github.com/mbarkhau/pycalver',
... '#',
... '# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License',
... '# SPDX-License-Identifier: MIT',
... '"""PyCalVer: CalVer for Python Packages."""',
... '',
... '__version__ = "v201809.0123"',
... '',
... ]
>>> assert rfd.new_lines == expected
'''
fobj: typ.IO[str]
for file_path, patterns in rewrite.iter_path_patterns_items(file_patterns):
with file_path.open(mode="rt", encoding="utf-8") as fobj:
content = fobj.read()
rfd = rfd_from_content(patterns, new_vinfo, content)
yield rfd._replace(path=str(file_path))
def diff(
old_vinfo : version.V2VersionInfo,
new_vinfo : version.V2VersionInfo,
file_patterns: config.PatternsByFile,
) -> str:
r"""Generate diffs of rewritten files.
>>> old_vinfo = v2version.parse_version_info("v201809.0123", version_pattern)
>>> new_vinfo = v2version.parse_version_info("v201810.1124", version_pattern)
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "vYYYY0M.BUILD[-TAG]"']}
>>> diff_str = diff(old_vinfo, new_vinfo, file_patterns)
>>> lines = diff_str.split("\n")
>>> lines[:2]
['--- src/pycalver/__init__.py', '+++ src/pycalver/__init__.py']
>>> assert lines[6].startswith('-__version__ = "v2')
>>> assert not lines[6].startswith('-__version__ = "v201810.1124"')
>>> lines[7]
'+__version__ = "v201810.1124"'
>>> file_patterns = {"LICENSE": ['Copyright (c) 2018-YYYY']}
>>> diff_str = diff(old_vinfo, new_vinfo, file_patterns)
>>> assert not diff_str
"""
full_diff = ""
fobj: typ.IO[str]
for file_path, patterns in sorted(rewrite.iter_path_patterns_items(file_patterns)):
with file_path.open(mode="rt", encoding="utf-8") as fobj:
content = fobj.read()
patterns_with_change = 0
for pattern in patterns:
old_str = v2version.format_version(old_vinfo, pattern.raw_pattern)
new_str = v2version.format_version(new_vinfo, pattern.raw_pattern)
if old_str != new_str:
patterns_with_change += 1
try:
rfd = rfd_from_content(patterns, new_vinfo, content)
except rewrite.NoPatternMatch:
# pylint:disable=raise-missing-from ; we support py2, so not an option
errmsg = f"No patterns matched for '{file_path}'"
raise rewrite.NoPatternMatch(errmsg)
rfd = rfd._replace(path=str(file_path))
lines = rewrite.diff_lines(rfd)
if len(lines) == 0 and patterns_with_change > 0:
errmsg = f"No patterns matched for '{file_path}'"
raise rewrite.NoPatternMatch(errmsg)
full_diff += "\n".join(lines) + "\n"
full_diff = full_diff.rstrip("\n")
return full_diff
def rewrite_files(
file_patterns: config.PatternsByFile,
new_vinfo : version.V2VersionInfo,
) -> None:
"""Rewrite project files, updating each with the new version."""
fobj: typ.IO[str]
for file_data in iter_rewritten(file_patterns, new_vinfo):
new_content = file_data.line_sep.join(file_data.new_lines)
with io.open(file_data.path, mode="wt", encoding="utf-8") as fobj:
fobj.write(new_content)

View file

@ -9,102 +9,28 @@ import typing as typ
import logging
import datetime as dt
import lexid
from . import version
from . import v2patterns
import pycalver2.patterns as v2patterns
# import pycalver.version as v1version
# import pycalver.patterns as v1patterns
logger = logging.getLogger("pycalver.version")
logger = logging.getLogger("pycalver.v2version")
# The test suite may replace this.
TODAY = dt.datetime.utcnow().date()
CalInfo = typ.Union[version.V2CalendarInfo, version.V2VersionInfo]
ZERO_VALUES = {
'MAJOR': "0",
'MINOR': "0",
'PATCH': "0",
'TAG' : "final",
'PYTAG': "",
'NUM' : "0",
}
def _is_later_than(old: CalInfo, new: CalInfo) -> bool:
"""Is old > new based on non None fields."""
for field in version.V1CalendarInfo._fields:
aval = getattr(old, field)
bval = getattr(new, field)
if not (aval is None or bval is None):
if aval > bval:
return True
return False
TAG_BY_PEP440_TAG = {
'a' : 'alpha',
'b' : 'beta',
"" : 'final',
'rc' : 'rc',
'dev' : 'dev',
'post': 'post',
}
PEP440_TAG_BY_TAG = {
'alpha': "a",
'beta' : "b",
'final': "",
'pre' : "rc",
'rc' : "rc",
'dev' : "dev",
'post' : "post",
}
assert set(TAG_BY_PEP440_TAG.keys()) == set(PEP440_TAG_BY_TAG.values())
assert set(TAG_BY_PEP440_TAG.values()) < set(PEP440_TAG_BY_TAG.keys())
# PEP440_TAGS_REVERSE = {
# "a" : 'alpha',
# "b" : 'beta',
# "rc" : 'rc',
# "dev" : 'dev',
# "post": 'post',
# }
MaybeInt = typ.Optional[int]
class CalendarInfo(typ.NamedTuple):
"""Container for calendar components of version strings."""
year_y : MaybeInt
year_g : MaybeInt
quarter: MaybeInt
month : MaybeInt
dom : MaybeInt
doy : MaybeInt
week_w : MaybeInt
week_u : MaybeInt
week_v : MaybeInt
class VersionInfo(typ.NamedTuple):
"""Container for parsed version string."""
year_y : MaybeInt
year_g : MaybeInt
quarter: MaybeInt
month : MaybeInt
dom : MaybeInt
doy : MaybeInt
week_w : MaybeInt
week_u : MaybeInt
week_v : MaybeInt
major : int
minor : int
patch : int
num : int
bid : str
tag : str
pytag : str
def _ver_to_cal_info(vinfo: VersionInfo) -> CalendarInfo:
return CalendarInfo(
def _ver_to_cal_info(vinfo: version.V2VersionInfo) -> version.V2CalendarInfo:
return version.V2CalendarInfo(
vinfo.year_y,
vinfo.year_g,
vinfo.quarter,
@ -117,32 +43,7 @@ def _ver_to_cal_info(vinfo: VersionInfo) -> CalendarInfo:
)
def _date_from_doy(year: int, doy: int) -> dt.date:
"""Parse date from year and day of year (1 indexed).
>>> cases = [
... (2016, 1), (2016, 31), (2016, 31 + 1), (2016, 31 + 29), (2016, 31 + 30),
... (2017, 1), (2017, 31), (2017, 31 + 1), (2017, 31 + 28), (2017, 31 + 29),
... ]
>>> dates = [_date_from_doy(year, month) for year, month in cases]
>>> assert [(d.month, d.day) for d in dates] == [
... (1, 1), (1, 31), (2, 1), (2, 29), (3, 1),
... (1, 1), (1, 31), (2, 1), (2, 28), (3, 1),
... ]
"""
return dt.date(year, 1, 1) + dt.timedelta(days=doy - 1)
def _quarter_from_month(month: int) -> int:
"""Calculate quarter (1 indexed) from month (1 indexed).
>>> [_quarter_from_month(month) for month in range(1, 13)]
[1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4]
"""
return ((month - 1) // 3) + 1
def cal_info(date: dt.date = None) -> CalendarInfo:
def cal_info(date: dt.date = None) -> version.V2CalendarInfo:
"""Generate calendar components for current date.
>>> import datetime as dt
@ -164,12 +65,12 @@ def cal_info(date: dt.date = None) -> CalendarInfo:
(2019, 2, 4, 7, 97, 13, 14, 14)
"""
if date is None:
date = TODAY
date = version.TODAY
kwargs = {
'year_y' : date.year,
'year_g' : int(date.strftime("%G"), base=10),
'quarter': _quarter_from_month(date.month),
'quarter': version.quarter_from_month(date.month),
'month' : date.month,
'dom' : date.day,
'doy' : int(date.strftime("%j"), base=10),
@ -178,10 +79,12 @@ def cal_info(date: dt.date = None) -> CalendarInfo:
'week_v' : int(date.strftime("%V"), base=10),
}
return CalendarInfo(**kwargs)
return version.V2CalendarInfo(**kwargs)
VALID_FIELD_KEYS = set(VersionInfo._fields) | {'version'}
VALID_FIELD_KEYS = set(version.V2VersionInfo._fields) | {'version'}
MaybeInt = typ.Optional[int]
FieldKey = str
MatchGroupKey = str
@ -190,44 +93,46 @@ MatchGroupStr = str
PatternGroups = typ.Dict[FieldKey, MatchGroupStr]
FieldValues = typ.Dict[FieldKey, MatchGroupStr]
VersionInfoKW = typ.Dict[str, typ.Union[str, int, None]]
def _parse_version_info(field_values: FieldValues) -> VersionInfo:
"""Parse normalized VersionInfo from groups of a matched pattern.
>>> vnfo = _parse_version_info({'year_y': "2018", 'month': "11", 'bid': "0099"})
>>> (vnfo.year_y, vnfo.month, vnfo.quarter, vnfo.bid, vnfo.tag)
def _parse_version_info(field_values: FieldValues) -> version.V2VersionInfo:
"""Parse normalized V2VersionInfo from groups of a matched pattern.
>>> vinfo = _parse_version_info({'year_y': "2018", 'month': "11", 'bid': "0099"})
>>> (vinfo.year_y, vinfo.month, vinfo.quarter, vinfo.bid, vinfo.tag)
(2018, 11, 4, '0099', 'final')
>>> vnfo = _parse_version_info({'year_y': "2018", 'doy': "11", 'bid': "099", 'tag': "beta"})
>>> (vnfo.year_y, vnfo.month, vnfo.dom, vnfo.doy, vnfo.bid, vnfo.tag)
>>> vinfo = _parse_version_info({'year_y': "2018", 'doy': "11", 'bid': "099", 'tag': "beta"})
>>> (vinfo.year_y, vinfo.month, vinfo.dom, vinfo.doy, vinfo.bid, vinfo.tag)
(2018, 1, 11, 11, '099', 'beta')
>>> vnfo = _parse_version_info({'year_y': "2018", 'month': "6", 'dom': "15"})
>>> (vnfo.year_y, vnfo.month, vnfo.dom, vnfo.doy)
>>> vinfo = _parse_version_info({'year_y': "2018", 'month': "6", 'dom': "15"})
>>> (vinfo.year_y, vinfo.month, vinfo.dom, vinfo.doy)
(2018, 6, 15, 166)
>>> vnfo = _parse_version_info({'major': "1", 'minor': "23", 'patch': "45"})
>>> (vnfo.major, vnfo.minor, vnfo.patch)
>>> vinfo = _parse_version_info({'major': "1", 'minor': "23", 'patch': "45"})
>>> (vinfo.major, vinfo.minor, vinfo.patch)
(1, 23, 45)
>>> vnfo = _parse_version_info({'major': "1", 'minor': "023", 'patch': "0045"})
>>> (vnfo.major, vnfo.minor, vnfo.patch)
>>> vinfo = _parse_version_info({'major': "1", 'minor': "023", 'patch': "0045"})
>>> (vinfo.major, vinfo.minor, vinfo.patch)
(1, 23, 45)
>>> vnfo = _parse_version_info({'year_y': "2021", 'week_w': "02"})
>>> (vnfo.year_y, vnfo.week_w)
>>> vinfo = _parse_version_info({'year_y': "2021", 'week_w': "02"})
>>> (vinfo.year_y, vinfo.week_w)
(2021, 2)
>>> vnfo = _parse_version_info({'year_y': "2021", 'week_u': "02"})
>>> (vnfo.year_y, vnfo.week_u)
>>> vinfo = _parse_version_info({'year_y': "2021", 'week_u': "02"})
>>> (vinfo.year_y, vinfo.week_u)
(2021, 2)
>>> vnfo = _parse_version_info({'year_g': "2021", 'week_v': "02"})
>>> (vnfo.year_g, vnfo.week_v)
>>> vinfo = _parse_version_info({'year_g': "2021", 'week_v': "02"})
>>> (vinfo.year_g, vinfo.week_v)
(2021, 2)
>>> vnfo = _parse_version_info({'year_y': "2021", 'month': "01", 'dom': "03"})
>>> (vnfo.year_y, vnfo.month, vnfo.dom, vnfo.tag)
>>> vinfo = _parse_version_info({'year_y': "2021", 'month': "01", 'dom': "03"})
>>> (vinfo.year_y, vinfo.month, vinfo.dom, vinfo.tag)
(2021, 1, 3, 'final')
>>> (vnfo.year_y, vnfo.week_w, vnfo.year_y, vnfo.week_u,vnfo.year_g, vnfo.week_v)
>>> (vinfo.year_y, vinfo.week_w, vinfo.year_y, vinfo.week_u,vinfo.year_g, vinfo.week_v)
(2021, 0, 2021, 1, 2020, 53)
"""
for key in field_values:
@ -238,9 +143,9 @@ def _parse_version_info(field_values: FieldValues) -> VersionInfo:
pytag = fvals.get('pytag') or ""
if tag and not pytag:
pytag = PEP440_TAG_BY_TAG[tag]
pytag = version.PEP440_TAG_BY_TAG[tag]
elif pytag and not tag:
tag = TAG_BY_PEP440_TAG[pytag]
tag = version.TAG_BY_PEP440_TAG[pytag]
date: typ.Optional[dt.date] = None
@ -256,7 +161,7 @@ def _parse_version_info(field_values: FieldValues) -> VersionInfo:
week_v: MaybeInt = int(fvals['week_v']) if 'week_v' in fvals else None
if year_y and doy:
date = _date_from_doy(year_y, doy)
date = version.date_from_doy(year_y, doy)
month = date.month
dom = date.day
else:
@ -279,7 +184,7 @@ def _parse_version_info(field_values: FieldValues) -> VersionInfo:
quarter = int(fvals['quarter']) if 'quarter' in fvals else None
if quarter is None and month:
quarter = _quarter_from_month(month)
quarter = version.quarter_from_month(month)
# NOTE (mb 2020-09-18): If a part is optional, fvals[<field>] may be None
major = int(fvals.get('major') or 0)
@ -288,7 +193,7 @@ def _parse_version_info(field_values: FieldValues) -> VersionInfo:
num = int(fvals.get('num' ) or 0)
bid = fvals['bid'] if 'bid' in fvals else "1000"
vnfo = VersionInfo(
vinfo = version.V2VersionInfo(
year_y=year_y,
year_g=year_g,
quarter=quarter,
@ -306,74 +211,69 @@ def _parse_version_info(field_values: FieldValues) -> VersionInfo:
tag=tag,
pytag=pytag,
)
return vnfo
return vinfo
VersionInfoKW = typ.Dict[str, typ.Union[str, int, None]]
def parse_version_info(
version_str: str, raw_pattern: str = "vYYYY0M.BUILD[-TAG[NUM]]"
) -> version.V2VersionInfo:
"""Parse normalized V2VersionInfo.
class PatternError(Exception):
pass
def parse_version_info(version_str: str, pattern: str = "vYYYY0M.BUILD[-TAG[NUM]]") -> VersionInfo:
"""Parse normalized VersionInfo.
>>> vnfo = parse_version_info("v201712.0033-beta0", pattern="vYYYY0M.BUILD[-TAG[NUM]]")
>>> vinfo = parse_version_info("v201712.0033-beta0", raw_pattern="vYYYY0M.BUILD[-TAG[NUM]]")
>>> fvals = {'year_y': 2017, 'month': 12, 'bid': "0033", 'tag': "beta", 'num': 0}
>>> assert vnfo == _parse_version_info(fvals)
>>> assert vinfo == _parse_version_info(fvals)
>>> vnfo = parse_version_info("v201712.0033-beta", pattern="vYYYY0M.BUILD[-TAG[NUM]]")
>>> vinfo = parse_version_info("v201712.0033-beta", raw_pattern="vYYYY0M.BUILD[-TAG[NUM]]")
>>> fvals = {'year_y': 2017, 'month': 12, 'bid': "0033", 'tag': "beta"}
>>> assert vnfo == _parse_version_info(fvals)
>>> assert vinfo == _parse_version_info(fvals)
>>> vnfo = parse_version_info("v201712.0033", pattern="vYYYY0M.BUILD[-TAG[NUM]]")
>>> vinfo = parse_version_info("v201712.0033", raw_pattern="vYYYY0M.BUILD[-TAG[NUM]]")
>>> fvals = {'year_y': 2017, 'month': 12, 'bid': "0033"}
>>> assert vnfo == _parse_version_info(fvals)
>>> assert vinfo == _parse_version_info(fvals)
>>> vnfo = parse_version_info("1.23.456", pattern="MAJOR.MINOR.PATCH")
>>> vinfo = parse_version_info("1.23.456", raw_pattern="MAJOR.MINOR.PATCH")
>>> fvals = {'major': "1", 'minor': "23", 'patch': "456"}
>>> assert vnfo == _parse_version_info(fvals)
>>> assert vinfo == _parse_version_info(fvals)
"""
pattern_tup = v2patterns.compile_pattern(pattern)
match = pattern_tup.regexp.match(version_str)
pattern = v2patterns.compile_pattern(raw_pattern)
match = pattern.regexp.match(version_str)
if match is None:
err_msg = (
f"Invalid version string '{version_str}' "
f"for pattern '{pattern}'/'{pattern_tup.regexp.pattern}'"
f"for pattern '{raw_pattern}'/'{pattern.regexp.pattern}'"
)
raise PatternError(err_msg)
raise version.PatternError(err_msg)
else:
field_values = match.groupdict()
return _parse_version_info(field_values)
def is_valid(version_str: str, pattern: str = "vYYYY0M.BUILD[-TAG]") -> bool:
def is_valid(version_str: str, raw_pattern: str = "vYYYY0M.BUILD[-TAG]") -> bool:
"""Check if a version matches a pattern.
>>> is_valid("v201712.0033-beta", pattern="vYYYY0M.BUILD[-TAG]")
>>> is_valid("v201712.0033-beta", raw_pattern="vYYYY0M.BUILD[-TAG]")
True
>>> is_valid("v201712.0033-beta", pattern="MAJOR.MINOR.PATCH")
>>> is_valid("v201712.0033-beta", raw_pattern="MAJOR.MINOR.PATCH")
False
>>> is_valid("1.2.3", pattern="MAJOR.MINOR.PATCH")
>>> is_valid("1.2.3", raw_pattern="MAJOR.MINOR.PATCH")
True
>>> is_valid("v201712.0033-beta", pattern="MAJOR.MINOR.PATCH")
>>> is_valid("v201712.0033-beta", raw_pattern="MAJOR.MINOR.PATCH")
False
"""
try:
parse_version_info(version_str, pattern)
parse_version_info(version_str, raw_pattern)
return True
except PatternError:
except version.PatternError:
return False
TemplateKwargs = typ.Dict[str, typ.Union[str, int, None]]
def _format_part_values(vinfo: VersionInfo) -> typ.Dict[str, str]:
"""Generate kwargs for template from minimal VersionInfo.
def _format_part_values(vinfo: version.V2VersionInfo) -> typ.Dict[str, str]:
"""Generate kwargs for template from minimal V2VersionInfo.
The VersionInfo Tuple only has the minimal representation
The V2VersionInfo Tuple only has the minimal representation
of a parsed version, not the values suitable for formatting.
It may for example have month=9, but not the formatted
representation '09' for '0M'.
@ -402,18 +302,20 @@ def _format_part_values(vinfo: VersionInfo) -> typ.Dict[str, str]:
return kwargs
def _make_segments(pattern: str) -> typ.List[str]:
def _make_segments(raw_pattern: str) -> typ.List[str]:
pattern_segs_l: typ.List[str] = []
pattern_segs_r: typ.List[str] = []
pattern_rest = pattern
pattern_rest = raw_pattern
while "[" in pattern_rest and "]" in pattern_rest:
try:
seg_l , pattern_rest = pattern_rest.split("[", 1)
pattern_rest, seg_r = pattern_rest.rsplit("]", 1)
except ValueError as val_err:
if "values to unpack" in str(val_err):
pat_err = PatternError(f"Unbalanced braces [] in '{pattern}'")
err = f"Unbalanced braces [] in '{raw_pattern}'"
pat_err = version.PatternError(err)
pat_err.__cause__ = val_err
raise pat_err
else:
@ -444,7 +346,7 @@ def _clear_zero_segments(
def _format_segments(
vinfo : VersionInfo,
vinfo : version.V2VersionInfo,
pattern_segs: typ.List[str],
) -> typ.List[str]:
kwargs = _format_part_values(vinfo)
@ -470,12 +372,12 @@ def _format_segments(
for part, part_value in part_values:
if part in seg_l:
seg_l = seg_l.replace(part, part_value)
if not (is_optional and str(part_value) == ZERO_VALUES.get(part)):
if not (is_optional and str(part_value) == version.ZERO_VALUES.get(part)):
is_zero_segment[idx_l] = False
if part in seg_r:
seg_r = seg_r.replace(part, part_value)
if not (is_optional and str(part_value) == ZERO_VALUES[part]):
if not (is_optional and str(part_value) == version.ZERO_VALUES[part]):
is_zero_segment[idx_r] = False
formatted_segs_l.append(seg_l)
@ -489,7 +391,7 @@ def _format_segments(
return _clear_zero_segments(formatted_segs, is_zero_segment)
def format_version(vinfo: VersionInfo, pattern: str) -> str:
def format_version(vinfo: version.V2VersionInfo, raw_pattern: str) -> str:
"""Generate version string.
>>> import datetime as dt
@ -575,7 +477,7 @@ def format_version(vinfo: VersionInfo, pattern: str) -> str:
>>> format_version(vinfo_d, pattern='__version__ = "vMAJOR[.MINOR[.PATCH[-TAG[NUM]]]]"')
'__version__ = "v1.0.0-rc2"'
"""
pattern_segs = _make_segments(pattern)
pattern_segs = _make_segments(raw_pattern)
formatted_segs = _format_segments(vinfo, pattern_segs)
return "".join(formatted_segs)
@ -583,9 +485,9 @@ def format_version(vinfo: VersionInfo, pattern: str) -> str:
def incr(
old_version: str,
pattern : str = "vYYYY0M.BUILD[-TAG]",
raw_pattern: str = "vYYYY0M.BUILD[-TAG]",
*,
release : str = None,
release : typ.Optional[str] = None,
major : bool = False,
minor : bool = False,
patch : bool = False,
@ -593,44 +495,30 @@ def incr(
) -> typ.Optional[str]:
"""Increment version string.
'old_version' is assumed to be a string that matches 'pattern'
'old_version' is assumed to be a string that matches 'raw_pattern'
"""
try:
old_vinfo = parse_version_info(old_version, pattern)
except PatternError as ex:
old_vinfo = parse_version_info(old_version, raw_pattern)
except version.PatternError as ex:
logger.error(str(ex))
return None
cur_vinfo = old_vinfo
cur_cinfo = _ver_to_cal_info(old_vinfo) if pin_date else cal_info()
cur_cal_nfo = _ver_to_cal_info(old_vinfo) if pin_date else cal_info()
old_date = (old_vinfo.year_y or 0 , old_vinfo.month or 0 , old_vinfo.dom or 0)
cur_date = (cur_cal_nfo.year_y or 0, cur_cal_nfo.month or 0, cur_cal_nfo.dom or 0)
if old_date <= cur_date:
cur_vinfo = cur_vinfo._replace(**cur_cal_nfo._asdict())
else:
if _is_later_than(old_vinfo, cur_cinfo):
logger.warning(f"Version appears to be from the future '{old_version}'")
cur_vinfo = old_vinfo
else:
cur_vinfo = old_vinfo._replace(**cur_cinfo._asdict())
_bid = cur_vinfo.bid
if int(_bid) < 1000:
# prevent truncation of leading zeros
_bid = str(int(_bid) + 1000)
cur_vinfo = cur_vinfo._replace(bid=lexid.incr(_bid))
if major:
cur_vinfo = cur_vinfo._replace(major=cur_vinfo.major + 1, minor=0, patch=0)
if minor:
cur_vinfo = cur_vinfo._replace(minor=cur_vinfo.minor + 1, patch=0)
if patch:
cur_vinfo = cur_vinfo._replace(patch=cur_vinfo.patch + 1)
if release:
cur_vinfo = cur_vinfo._replace(tag=release)
new_version = format_version(cur_vinfo, pattern)
cur_vinfo = version.incr_non_cal_parts(
cur_vinfo,
release,
major,
minor,
patch,
)
new_version = format_version(cur_vinfo, raw_pattern)
if new_version == old_version:
logger.error("Invalid arguments or pattern, version did not change.")
return None

View file

@ -1,30 +1,13 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Functions related to version string manipulation."""
import typing as typ
import logging
import datetime as dt
import lexid
import pkg_resources
import pycalver.patterns as v1patterns
logger = logging.getLogger("pycalver.version")
# The test suite may replace this.
TODAY = dt.datetime.utcnow().date()
MaybeInt = typ.Optional[int]
class CalendarInfo(typ.NamedTuple):
class V1CalendarInfo(typ.NamedTuple):
"""Container for calendar components of version strings."""
year : MaybeInt
@ -36,7 +19,7 @@ class CalendarInfo(typ.NamedTuple):
us_week : MaybeInt
class VersionInfo(typ.NamedTuple):
class V1VersionInfo(typ.NamedTuple):
"""Container for parsed version string."""
year : MaybeInt
@ -53,26 +36,94 @@ class VersionInfo(typ.NamedTuple):
tag : str
def _ver_to_cal_info(vinfo: VersionInfo) -> CalendarInfo:
return CalendarInfo(
vinfo.year,
vinfo.quarter,
vinfo.month,
vinfo.dom,
vinfo.doy,
vinfo.iso_week,
vinfo.us_week,
)
class V2CalendarInfo(typ.NamedTuple):
"""Container for calendar components of version strings."""
year_y : MaybeInt
year_g : MaybeInt
quarter: MaybeInt
month : MaybeInt
dom : MaybeInt
doy : MaybeInt
week_w : MaybeInt
week_u : MaybeInt
week_v : MaybeInt
def _date_from_doy(year: int, doy: int) -> dt.date:
class V2VersionInfo(typ.NamedTuple):
"""Container for parsed version string."""
year_y : MaybeInt
year_g : MaybeInt
quarter: MaybeInt
month : MaybeInt
dom : MaybeInt
doy : MaybeInt
week_w : MaybeInt
week_u : MaybeInt
week_v : MaybeInt
major : int
minor : int
patch : int
num : int
bid : str
tag : str
pytag : str
VersionInfoType = typ.TypeVar('VersionInfoType', V1VersionInfo, V2VersionInfo)
# The test suite may replace this.
TODAY = dt.datetime.utcnow().date()
TAG_BY_PEP440_TAG = {
'a' : 'alpha',
'b' : 'beta',
"" : 'final',
'rc' : 'rc',
'dev' : 'dev',
'post': 'post',
}
PEP440_TAG_BY_TAG = {
'alpha': "a",
'beta' : "b",
'final': "",
'pre' : "rc",
'rc' : "rc",
'dev' : "dev",
'post' : "post",
}
assert set(TAG_BY_PEP440_TAG.keys()) == set(PEP440_TAG_BY_TAG.values())
assert set(TAG_BY_PEP440_TAG.values()) < set(PEP440_TAG_BY_TAG.keys())
ZERO_VALUES = {
'MAJOR': "0",
'MINOR': "0",
'PATCH': "0",
'TAG' : "final",
'PYTAG': "",
'NUM' : "0",
}
class PatternError(Exception):
pass
def date_from_doy(year: int, doy: int) -> dt.date:
"""Parse date from year and day of year (1 indexed).
>>> cases = [
... (2016, 1), (2016, 31), (2016, 31 + 1), (2016, 31 + 29), (2016, 31 + 30),
... (2017, 1), (2017, 31), (2017, 31 + 1), (2017, 31 + 28), (2017, 31 + 29),
... ]
>>> dates = [_date_from_doy(year, month) for year, month in cases]
>>> dates = [date_from_doy(year, month) for year, month in cases]
>>> assert [(d.month, d.day) for d in dates] == [
... (1, 1), (1, 31), (2, 1), (2, 29), (3, 1),
... (1, 1), (1, 31), (2, 1), (2, 28), (3, 1),
@ -81,407 +132,15 @@ def _date_from_doy(year: int, doy: int) -> dt.date:
return dt.date(year, 1, 1) + dt.timedelta(days=doy - 1)
def _quarter_from_month(month: int) -> int:
def quarter_from_month(month: int) -> int:
"""Calculate quarter (1 indexed) from month (1 indexed).
>>> [_quarter_from_month(month) for month in range(1, 13)]
>>> [quarter_from_month(month) for month in range(1, 13)]
[1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4]
"""
return ((month - 1) // 3) + 1
def cal_info(date: dt.date = None) -> CalendarInfo:
"""Generate calendar components for current date.
>>> from datetime import date
>>> c = cal_info(date(2019, 1, 5))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 1, 1, 5, 5, 0, 0)
>>> c = cal_info(date(2019, 1, 6))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 1, 1, 6, 6, 0, 1)
>>> c = cal_info(date(2019, 1, 7))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 1, 1, 7, 7, 1, 1)
>>> c = cal_info(date(2019, 4, 7))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 2, 4, 7, 97, 13, 14)
"""
if date is None:
date = TODAY
kwargs = {
'year' : date.year,
'quarter' : _quarter_from_month(date.month),
'month' : date.month,
'dom' : date.day,
'doy' : int(date.strftime("%j"), base=10),
'iso_week': int(date.strftime("%W"), base=10),
'us_week' : int(date.strftime("%U"), base=10),
}
return CalendarInfo(**kwargs)
FieldKey = str
MatchGroupKey = str
MatchGroupStr = str
PatternGroups = typ.Dict[MatchGroupKey, MatchGroupStr]
FieldValues = typ.Dict[FieldKey , MatchGroupStr]
def _parse_field_values(field_values: FieldValues) -> VersionInfo:
fvals = field_values
tag = fvals.get('tag')
if tag is None:
tag = "final"
tag = TAG_ALIASES.get(tag, tag)
assert tag is not None
bid = fvals['bid'] if 'bid' in fvals else "0001"
year = int(fvals['year']) if 'year' in fvals else None
doy = int(fvals['doy' ]) if 'doy' in fvals else None
month: typ.Optional[int]
dom : typ.Optional[int]
if year and doy:
date = _date_from_doy(year, doy)
month = date.month
dom = date.day
else:
month = int(fvals['month']) if 'month' in fvals else None
dom = int(fvals['dom' ]) if 'dom' in fvals else None
iso_week: typ.Optional[int]
us_week : typ.Optional[int]
if year and month and dom:
date = dt.date(year, month, dom)
doy = int(date.strftime("%j"), base=10)
iso_week = int(date.strftime("%W"), base=10)
us_week = int(date.strftime("%U"), base=10)
else:
iso_week = None
us_week = None
quarter = int(fvals['quarter']) if 'quarter' in fvals else None
if quarter is None and month:
quarter = _quarter_from_month(month)
major = int(fvals['major']) if 'major' in fvals else 0
minor = int(fvals['minor']) if 'minor' in fvals else 0
patch = int(fvals['patch']) if 'patch' in fvals else 0
return VersionInfo(
year=year,
quarter=quarter,
month=month,
dom=dom,
doy=doy,
iso_week=iso_week,
us_week=us_week,
major=major,
minor=minor,
patch=patch,
bid=bid,
tag=tag,
)
def _is_calver(nfo: typ.Union[CalendarInfo, VersionInfo]) -> bool:
"""Check pattern for any calendar based parts.
>>> _is_calver(cal_info())
True
>>> vnfo = _parse_version_info({'year': "2018", 'month': "11", 'bid': "0018"})
>>> _is_calver(vnfo)
True
>>> vnfo = _parse_version_info({'MAJOR': "1", 'MINOR': "023", 'PATCH': "45"})
>>> _is_calver(vnfo)
False
"""
for field in CalendarInfo._fields:
maybe_val: typ.Any = getattr(nfo, field, None)
if isinstance(maybe_val, int):
return True
return False
TAG_ALIASES: typ.Dict[str, str] = {'a': "alpha", 'b': "beta", 'pre': "rc"}
PEP440_TAGS: typ.Dict[str, str] = {
'alpha': "a",
'beta' : "b",
'final': "",
'rc' : "rc",
'dev' : "dev",
'post' : "post",
}
VersionInfoKW = typ.Dict[str, typ.Union[str, int, None]]
class PatternError(Exception):
pass
def _parse_pattern_groups(pattern_groups: PatternGroups) -> FieldValues:
for part_name in pattern_groups.keys():
is_valid_part_name = (
part_name in v1patterns.COMPOSITE_PART_PATTERNS
or part_name in v1patterns.PATTERN_PART_FIELDS
)
if not is_valid_part_name:
err_msg = f"Invalid part '{part_name}'"
raise PatternError(err_msg)
field_value_items = [
(field_name, pattern_groups[part_name])
for part_name, field_name in v1patterns.PATTERN_PART_FIELDS.items()
if part_name in pattern_groups.keys()
]
all_fields = [field_name for field_name, _ in field_value_items]
unique_fields = set(all_fields)
duplicate_fields = [f for f in unique_fields if all_fields.count(f) > 1]
if any(duplicate_fields):
err_msg = f"Multiple parts for same field {duplicate_fields}."
raise PatternError(err_msg)
else:
return dict(field_value_items)
def _parse_version_info(pattern_groups: PatternGroups) -> VersionInfo:
"""Parse normalized VersionInfo from groups of a matched pattern.
>>> vnfo = _parse_version_info({'year': "2018", 'month': "11", 'bid': "0099"})
>>> (vnfo.year, vnfo.month, vnfo.quarter, vnfo.bid, vnfo.tag)
(2018, 11, 4, '0099', 'final')
>>> vnfo = _parse_version_info({'year': "2018", 'doy': "11", 'bid': "099", 'tag': "b"})
>>> (vnfo.year, vnfo.month, vnfo.dom, vnfo.bid, vnfo.tag)
(2018, 1, 11, '099', 'beta')
>>> vnfo = _parse_version_info({'MAJOR': "1", 'MINOR': "23", 'PATCH': "45"})
>>> (vnfo.major, vnfo.minor, vnfo.patch)
(1, 23, 45)
>>> vnfo = _parse_version_info({'MAJOR': "1", 'MMM': "023", 'PPPP': "0045"})
>>> (vnfo.major, vnfo.minor, vnfo.patch)
(1, 23, 45)
"""
field_values = _parse_pattern_groups(pattern_groups)
return _parse_field_values(field_values)
def parse_version_info(version_str: str, pattern: str = "{pycalver}") -> VersionInfo:
"""Parse normalized VersionInfo.
>>> vnfo = parse_version_info("v201712.0033-beta", pattern="{pycalver}")
>>> assert vnfo == _parse_version_info({'year': 2017, 'month': 12, 'bid': "0033", 'tag': "beta"})
>>> vnfo = parse_version_info("1.23.456", pattern="{semver}")
>>> assert vnfo == _parse_version_info({'MAJOR': "1", 'MINOR': "23", 'PATCH': "456"})
"""
pattern_tup = v1patterns.compile_pattern(pattern)
match = pattern_tup.regexp.match(version_str)
if match is None:
err_msg = (
f"Invalid version string '{version_str}' "
f"for pattern '{pattern}'/'{pattern_tup.regexp.pattern}'"
)
raise PatternError(err_msg)
else:
return _parse_version_info(match.groupdict())
def is_valid(version_str: str, pattern: str = "{pycalver}") -> bool:
"""Check if a version matches a pattern.
>>> is_valid("v201712.0033-beta", pattern="{pycalver}")
True
>>> is_valid("v201712.0033-beta", pattern="{semver}")
False
>>> is_valid("1.2.3", pattern="{semver}")
True
>>> is_valid("v201712.0033-beta", pattern="{semver}")
False
"""
try:
parse_version_info(version_str, pattern)
return True
except PatternError:
return False
ID_FIELDS_BY_PART = {
'MAJOR' : 'major',
'MINOR' : 'minor',
'MM' : 'minor',
'MMM' : 'minor',
'MMMM' : 'minor',
'MMMMM' : 'minor',
'MMMMMM' : 'minor',
'MMMMMMM': 'minor',
'PATCH' : 'patch',
'PP' : 'patch',
'PPP' : 'patch',
'PPPP' : 'patch',
'PPPPP' : 'patch',
'PPPPPP' : 'patch',
'PPPPPPP': 'patch',
'BID' : 'bid',
'BB' : 'bid',
'BBB' : 'bid',
'BBBB' : 'bid',
'BBBBB' : 'bid',
'BBBBBB' : 'bid',
'BBBBBBB': 'bid',
}
def format_version(vinfo: VersionInfo, pattern: str) -> str:
"""Generate version string.
>>> import datetime as dt
>>> vinfo = parse_version_info("v201712.0033-beta", pattern="{pycalver}")
>>> vinfo_a = vinfo._replace(**cal_info(date=dt.date(2017, 1, 1))._asdict())
>>> vinfo_b = vinfo._replace(**cal_info(date=dt.date(2017, 12, 31))._asdict())
>>> vinfo_c = vinfo_b._replace(major=1, minor=2, patch=34, tag='final')
>>> format_version(vinfo_a, pattern="v{yy}.{BID}{release}")
'v17.33-beta'
>>> format_version(vinfo_a, pattern="{pep440_version}")
'201701.33b0'
>>> format_version(vinfo_a, pattern="{pycalver}")
'v201701.0033-beta'
>>> format_version(vinfo_b, pattern="{pycalver}")
'v201712.0033-beta'
>>> format_version(vinfo_a, pattern="v{year}w{iso_week}.{BID}{release}")
'v2017w00.33-beta'
>>> format_version(vinfo_b, pattern="v{year}w{iso_week}.{BID}{release}")
'v2017w52.33-beta'
>>> format_version(vinfo_a, pattern="v{year}d{doy}.{bid}{release}")
'v2017d001.0033-beta'
>>> format_version(vinfo_b, pattern="v{year}d{doy}.{bid}{release}")
'v2017d365.0033-beta'
>>> format_version(vinfo_c, pattern="v{year}w{iso_week}.{BID}-{tag}")
'v2017w52.33-final'
>>> format_version(vinfo_c, pattern="v{year}w{iso_week}.{BID}{release}")
'v2017w52.33'
>>> format_version(vinfo_c, pattern="v{MAJOR}.{MINOR}.{PATCH}")
'v1.2.34'
>>> format_version(vinfo_c, pattern="v{MAJOR}.{MM}.{PPP}")
'v1.02.034'
"""
full_pattern = pattern
for part_name, full_part_format in v1patterns.FULL_PART_FORMATS.items():
full_pattern = full_pattern.replace("{" + part_name + "}", full_part_format)
kwargs: typ.Dict[str, typ.Union[str, int, None]] = vinfo._asdict()
tag = vinfo.tag
if tag == 'final':
kwargs['release' ] = ""
kwargs['pep440_tag'] = ""
else:
kwargs['release' ] = "-" + tag
kwargs['pep440_tag'] = PEP440_TAGS[tag] + "0"
kwargs['release_tag'] = tag
year = vinfo.year
if year:
kwargs['yy' ] = str(year)[-2:]
kwargs['yyyy'] = year
kwargs['BID'] = int(vinfo.bid, 10)
for part_name, field in ID_FIELDS_BY_PART.items():
val = kwargs[field]
if part_name.lower() == field.lower():
if isinstance(val, str):
kwargs[part_name] = int(val, base=10)
else:
kwargs[part_name] = val
else:
assert len(set(part_name)) == 1
padded_len = len(part_name)
kwargs[part_name] = str(val).zfill(padded_len)
return full_pattern.format(**kwargs)
def incr(
old_version: str,
pattern : str = "{pycalver}",
*,
release : str = None,
major : bool = False,
minor : bool = False,
patch : bool = False,
pin_date: bool = False,
) -> typ.Optional[str]:
"""Increment version string.
'old_version' is assumed to be a string that matches 'pattern'
"""
try:
old_vinfo = parse_version_info(old_version, pattern)
except PatternError as ex:
logger.error(str(ex))
return None
cur_vinfo = old_vinfo
cur_cal_nfo = _ver_to_cal_info(old_vinfo) if pin_date else cal_info()
old_date = (old_vinfo.year or 0 , old_vinfo.month or 0 , old_vinfo.dom or 0)
cur_date = (cur_cal_nfo.year or 0, cur_cal_nfo.month or 0, cur_cal_nfo.dom or 0)
if old_date <= cur_date:
cur_vinfo = cur_vinfo._replace(**cur_cal_nfo._asdict())
else:
logger.warning(f"Version appears to be from the future '{old_version}'")
cur_vinfo = cur_vinfo._replace(bid=lexid.next_id(cur_vinfo.bid))
if major:
cur_vinfo = cur_vinfo._replace(major=cur_vinfo.major + 1, minor=0, patch=0)
if minor:
cur_vinfo = cur_vinfo._replace(minor=cur_vinfo.minor + 1, patch=0)
if patch:
cur_vinfo = cur_vinfo._replace(patch=cur_vinfo.patch + 1)
if release:
cur_vinfo = cur_vinfo._replace(tag=release)
new_version = format_version(cur_vinfo, pattern)
if new_version == old_version:
logger.error("Invalid arguments or pattern, version did not change.")
return None
else:
return new_version
def to_pep440(version: str) -> str:
"""Derive pep440 compliant version string from PyCalVer version string.
@ -489,3 +148,28 @@ def to_pep440(version: str) -> str:
'201811.7b0'
"""
return str(pkg_resources.parse_version(version))
def incr_non_cal_parts(
vinfo : VersionInfoType,
release: typ.Optional[str],
major : bool,
minor : bool,
patch : bool,
) -> VersionInfoType:
_bid = vinfo.bid
if int(_bid) < 1000:
# prevent truncation of leading zeros
_bid = str(int(_bid) + 1000)
vinfo = vinfo._replace(bid=lexid.next_id(_bid))
if release:
vinfo = vinfo._replace(tag=release)
if major:
vinfo = vinfo._replace(major=vinfo.major + 1, minor=0, patch=0)
if minor:
vinfo = vinfo._replace(minor=vinfo.minor + 1, patch=0)
if patch:
vinfo = vinfo._replace(patch=vinfo.patch + 1)
return vinfo

View file

@ -1,8 +0,0 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""PyCalVer: CalVer for Python Packages."""
__version__ = "v202007.1036"

View file

@ -1,178 +0,0 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Rewrite files, updating occurences of version strings."""
import io
import typing as typ
import logging
import pycalver.rewrite as v1rewrite
import pycalver2.version as v2version
import pycalver2.patterns as v2patterns
from pycalver import parse
from pycalver import config
logger = logging.getLogger("pycalver2.rewrite")
def rewrite_lines(
pattern_strs: typ.List[str],
new_vinfo : v2version.VersionInfo,
old_lines : typ.List[str],
) -> typ.List[str]:
"""Replace occurances of pattern_strs in old_lines with new_vinfo.
>>> new_vinfo = v2version.parse_version_info("v201811.0123-beta")
>>> pattern_strs = ['__version__ = "vYYYY0M.BUILD[-TAG]"']
>>> old_lines = ['__version__ = "v201809.0002-alpha" ']
>>> rewrite_lines(pattern_strs, new_vinfo, old_lines)
['__version__ = "v201811.0123-beta" ']
>>> old_lines = ['__version__ = "v201809.0002-alpha" # comment']
>>> rewrite_lines(pattern_strs, new_vinfo, old_lines)
['__version__ = "v201811.0123-beta" # comment']
>>> pattern_strs = ['__version__ = "YYYY0M.BLD[PYTAGNUM]"']
>>> old_lines = ['__version__ = "201809.2a0"']
>>> rewrite_lines(pattern_strs, new_vinfo, old_lines)
['__version__ = "201811.123b0"']
"""
new_lines = old_lines[:]
found_patterns = set()
patterns = [v2patterns.compile_pattern(p) for p in pattern_strs]
matches = parse.iter_matches(old_lines, patterns)
for match in matches:
found_patterns.add(match.pattern.raw)
replacement = v2version.format_version(new_vinfo, match.pattern.raw)
span_l, span_r = match.span
new_line = match.line[:span_l] + replacement + match.line[span_r:]
new_lines[match.lineno] = new_line
non_matched_patterns = set(pattern_strs) - found_patterns
if non_matched_patterns:
for non_matched_pattern in non_matched_patterns:
logger.error(f"No match for pattern '{non_matched_pattern}'")
compiled_pattern_str = v2patterns.compile_pattern_str(non_matched_pattern)
logger.error(f"Pattern compiles to regex '{compiled_pattern_str}'")
raise v1rewrite.NoPatternMatch("Invalid pattern(s)")
else:
return new_lines
def rfd_from_content(
pattern_strs: typ.List[str],
new_vinfo : v2version.VersionInfo,
content : str,
) -> v1rewrite.RewrittenFileData:
r"""Rewrite pattern occurrences with version string.
>>> new_vinfo = v2version.parse_version_info("v201809.0123")
>>> pattern_strs = ['__version__ = "vYYYY0M.BUILD[-TAG]"']
>>> content = '__version__ = "v201809.0001-alpha"'
>>> rfd = rfd_from_content(pattern_strs, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v201809.0123"']
>>>
>>> new_vinfo = v2version.parse_version_info("v1.2.3", "vMAJOR.MINOR.PATCH")
>>> pattern_strs = ['__version__ = "vMAJOR.MINOR.PATCH"']
>>> content = '__version__ = "v1.2.2"'
>>> rfd = rfd_from_content(pattern_strs, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v1.2.3"']
"""
line_sep = v1rewrite.detect_line_sep(content)
old_lines = content.split(line_sep)
new_lines = rewrite_lines(pattern_strs, new_vinfo, old_lines)
return v1rewrite.RewrittenFileData("<path>", line_sep, old_lines, new_lines)
def iter_rewritten(
file_patterns: config.PatternsByGlob,
new_vinfo : v2version.VersionInfo,
) -> typ.Iterable[v1rewrite.RewrittenFileData]:
r'''Iterate over files with version string replaced.
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "vYYYY0M.BUILD[-TAG]"']}
>>> new_vinfo = v2version.parse_version_info("v201809.0123")
>>> rewritten_datas = iter_rewritten(file_patterns, new_vinfo)
>>> rfd = list(rewritten_datas)[0]
>>> assert rfd.new_lines == [
... '# This file is part of the pycalver project',
... '# https://github.com/mbarkhau/pycalver',
... '#',
... '# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License',
... '# SPDX-License-Identifier: MIT',
... '"""PyCalVer: CalVer for Python Packages."""',
... '',
... '__version__ = "v201809.0123"',
... '',
... ]
>>>
'''
fobj: typ.IO[str]
for file_path, pattern_strs in v1rewrite.iter_file_paths(file_patterns):
with file_path.open(mode="rt", encoding="utf-8") as fobj:
content = fobj.read()
rfd = rfd_from_content(pattern_strs, new_vinfo, content)
yield rfd._replace(path=str(file_path))
def diff(
new_vinfo : v2version.VersionInfo,
file_patterns: config.PatternsByGlob,
) -> str:
r"""Generate diffs of rewritten files.
>>> new_vinfo = v2version.parse_version_info("v201809.0123")
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "vYYYY0M.BUILD[-TAG]"']}
>>> diff_str = diff(new_vinfo, file_patterns)
>>> lines = diff_str.split("\n")
>>> lines[:2]
['--- src/pycalver/__init__.py', '+++ src/pycalver/__init__.py']
>>> assert lines[6].startswith('-__version__ = "v2')
>>> assert not lines[6].startswith('-__version__ = "v201809.0123"')
>>> lines[7]
'+__version__ = "v201809.0123"'
"""
full_diff = ""
fobj: typ.IO[str]
for file_path, pattern_strs in sorted(v1rewrite.iter_file_paths(file_patterns)):
with file_path.open(mode="rt", encoding="utf-8") as fobj:
content = fobj.read()
try:
rfd = rfd_from_content(pattern_strs, new_vinfo, content)
except v1rewrite.NoPatternMatch:
# pylint:disable=raise-missing-from ; we support py2, so not an option
errmsg = f"No patterns matched for '{file_path}'"
raise v1rewrite.NoPatternMatch(errmsg)
rfd = rfd._replace(path=str(file_path))
lines = v1rewrite.diff_lines(rfd)
if len(lines) == 0:
errmsg = f"No patterns matched for '{file_path}'"
raise v1rewrite.NoPatternMatch(errmsg)
full_diff += "\n".join(lines) + "\n"
full_diff = full_diff.rstrip("\n")
return full_diff
def rewrite(file_patterns: config.PatternsByGlob, new_vinfo: v2version.VersionInfo) -> None:
"""Rewrite project files, updating each with the new version."""
fobj: typ.IO[str]
for file_data in iter_rewritten(file_patterns, new_vinfo):
new_content = file_data.line_sep.join(file_data.new_lines)
with io.open(file_data.path, mode="wt", encoding="utf-8") as fobj:
fobj.write(new_content)

View file

@ -13,8 +13,8 @@ import pytest
import pathlib2 as pl
from click.testing import CliRunner
import pycalver.config as config
import pycalver.patterns as v1patterns
from pycalver import config
from pycalver import v1patterns
from pycalver.__main__ import cli
# pylint:disable=redefined-outer-name ; pytest fixtures

View file

@ -4,8 +4,8 @@ from __future__ import print_function
from __future__ import absolute_import
from __future__ import unicode_literals
import pycalver.patterns as v1patterns
from pycalver import parse
from pycalver import v1patterns
SETUP_PY_FIXTURE = """
# setup.py

View file

@ -8,8 +8,8 @@ import re
import pytest
import pycalver.patterns as v1patterns
import pycalver2.patterns as v2patterns
from pycalver import v1patterns
from pycalver import v2patterns
# TODO (mb 2020-09-06): test for v2patterns

View file

@ -8,10 +8,11 @@ import copy
from test import util
from pycalver import config
from pycalver import rewrite as v1rewrite
from pycalver import version as v1version
from pycalver2 import rewrite as v2rewrite
from pycalver2 import version as v2version
from pycalver import rewrite
from pycalver import v1rewrite
from pycalver import v1version
from pycalver import v2rewrite
from pycalver import v2version
# pylint:disable=protected-access ; allowed for test code
@ -54,7 +55,7 @@ def test_iter_file_paths():
cfg = config.parse(ctx)
assert cfg
_paths_and_patterns = v1rewrite.iter_file_paths(cfg.file_patterns)
_paths_and_patterns = rewrite.iter_path_patterns_items(cfg.file_patterns)
file_paths = {str(file_path) for file_path, patterns in _paths_and_patterns}
assert file_paths == {"pycalver.toml", "README.md"}
@ -66,7 +67,7 @@ def test_iter_file_globs():
cfg = config.parse(ctx)
assert cfg
_paths_and_patterns = v1rewrite.iter_file_paths(cfg.file_patterns)
_paths_and_patterns = rewrite.iter_path_patterns_items(cfg.file_patterns)
file_paths = {str(file_path) for file_path, patterns in _paths_and_patterns}
assert file_paths == {
@ -86,7 +87,7 @@ def test_error_bad_path():
(project.dir / "setup.py").unlink()
try:
list(v1rewrite.iter_file_paths(cfg.file_patterns))
list(rewrite.iter_path_patterns_items(cfg.file_patterns))
assert False, "expected IOError"
except IOError as ex:
assert "setup.py" in str(ex)
@ -102,10 +103,11 @@ def test_error_bad_pattern():
patterns["setup.py"] = patterns["setup.py"][0] + "invalid"
try:
old_vinfo = v1version.parse_version_info("v201808.0233")
new_vinfo = v1version.parse_version_info("v201809.1234")
list(v1rewrite.diff(new_vinfo, patterns))
assert False, "expected v1rewrite.NoPatternMatch"
except v1rewrite.NoPatternMatch as ex:
list(v1rewrite.diff(old_vinfo, new_vinfo, patterns))
assert False, "expected rewrite.NoPatternMatch"
except rewrite.NoPatternMatch as ex:
assert "setup.py" in str(ex)

View file

@ -9,9 +9,10 @@ import datetime as dt
import pytest
import pycalver.version as v1version
import pycalver2.version as v2version
import pycalver.patterns as v1patterns
from pycalver import version
from pycalver import v1version
from pycalver import v2version
from pycalver import v1patterns
# import pycalver2.patterns as v2patterns
@ -51,7 +52,7 @@ def test_bump_random(monkeypatch):
cur_date = dt.date(2016, 1, 1) + dt.timedelta(days=random.randint(1, 2000))
cur_version = cur_date.strftime("v%Y%m") + ".0001-dev"
monkeypatch.setattr(v1version, 'TODAY', cur_date)
monkeypatch.setattr(version, 'TODAY', cur_date)
for _ in range(1000):
cur_date += dt.timedelta(days=int((1 + random.random()) ** 10))
@ -120,7 +121,7 @@ def test_parse_error_empty():
try:
v1version.parse_version_info("")
assert False
except v1version.PatternError as err:
except version.PatternError as err:
assert "Invalid version string" in str(err)
@ -128,7 +129,7 @@ def test_parse_error_noprefix():
try:
v1version.parse_version_info("201809.0002")
assert False
except v1version.PatternError as err:
except version.PatternError as err:
assert "Invalid version string" in str(err)
@ -136,7 +137,7 @@ def test_parse_error_nopadding():
try:
v1version.parse_version_info("v201809.2b0")
assert False
except v1version.PatternError as err:
except version.PatternError as err:
assert "Invalid version string" in str(err)
@ -151,7 +152,7 @@ def test_part_field_mapping_v1():
assert not any(b_extra_names), sorted(b_extra_names)
a_fields = set(v1patterns.PATTERN_PART_FIELDS.values())
b_fields = set(v1version.VersionInfo._fields)
b_fields = set(version.V1VersionInfo._fields)
a_extra_fields = a_fields - b_fields
b_extra_fields = b_fields - a_fields
@ -200,7 +201,7 @@ def test_v1_parse_versions(pattern_str, line, expected_vinfo):
# def test_v2_parse_versions(pattern_str, line, expected_vinfo):
def test_v2_parse_versions():
_vnfo = v2version.parse_version_info("v201712.0033", pattern="vYYYY0M.BUILD[-TAG[NUM]]")
_vnfo = v2version.parse_version_info("v201712.0033", raw_pattern="vYYYY0M.BUILD[-TAG[NUM]]")
fvals = {'year_y': 2017, 'month': 12, 'bid': "0033"}
assert _vnfo == v2version._parse_version_info(fvals)
@ -217,24 +218,24 @@ def test_make_segments():
def test_v2_format_version():
pattern = "vYYYY0M.BUILD[-TAG[NUM]]"
version_pattern = "vYYYY0M.BUILD[-TAG[NUM]]"
in_version = "v200701.0033-beta"
vinfo = v2version.parse_version_info(in_version, pattern=pattern)
out_version = v2version.format_version(vinfo, pattern=pattern)
vinfo = v2version.parse_version_info(in_version, raw_pattern=version_pattern)
out_version = v2version.format_version(vinfo, raw_pattern=version_pattern)
assert in_version == out_version
result = v2version.format_version(vinfo, pattern="v0Y.BUILD[-TAG]")
result = v2version.format_version(vinfo, raw_pattern="v0Y.BUILD[-TAG]")
assert result == "v07.0033-beta"
result = v2version.format_version(vinfo, pattern="vYY.BLD[-TAG]")
result = v2version.format_version(vinfo, raw_pattern="vYY.BLD[-TAG]")
assert result == "v7.33-beta"
result = v2version.format_version(vinfo, pattern="vYY.BLD-TAG")
result = v2version.format_version(vinfo, raw_pattern="vYY.BLD-TAG")
assert result == "v7.33-beta"
result = v2version.format_version(vinfo, pattern='__version__ = "YYYY.BUILD[-TAG]"')
result = v2version.format_version(vinfo, raw_pattern='__version__ = "YYYY.BUILD[-TAG]"')
assert result == '__version__ = "2007.0033-beta"'
result = v2version.format_version(vinfo, pattern='__version__ = "YYYY.BLD"')
result = v2version.format_version(vinfo, raw_pattern='__version__ = "YYYY.BLD"')
assert result == '__version__ = "2007.33"'