Browse Source

implement mono-repo inter-dependency bumps, streamline bump logic

shut-new-model
Niklas Rosenstein 9 months ago
parent
commit
6c992ab148
No known key found for this signature in database GPG Key ID: 6D269B33D25F6C6
8 changed files with 204 additions and 27 deletions
  1. + 1
    - 1
      src/shut/checks/monorepo.py
  2. + 37
    - 20
      src/shut/commands/commons/bump.py
  3. + 0
    - 1
      src/shut/commands/commons/checks.py
  4. + 3
    - 3
      src/shut/commands/commons/new.py
  5. + 41
    - 0
      src/shut/commands/mono/bump.py
  6. + 38
    - 2
      src/shut/model/monorepo.py
  7. + 31
    - 0
      src/shut/utils/test_text.py
  8. + 53
    - 0
      src/shut/utils/text.py

+ 1
- 1
src/shut/checks/monorepo.py

@ -35,7 +35,7 @@ class MonorepoChecker(Checker[MonorepoModel]):
if monorepo.release.single_version and project.packages:
for package in project.packages:
if package.data.version is not None and package.data.version != monorepo.version:
yield CheckResult(CheckStatus.ERROR, package.data.name)
yield CheckResult(CheckStatus.ERROR, f'{package.data.name} v{package.data.version}, expected v{monorepo.version}')
else:
yield SkipCheck()

+ 37
- 20
src/shut/commands/commons/bump.py

@ -27,12 +27,15 @@ from typing import Iterable, Generic, Optional, T, Type
import click
from databind.core import datamodel
from nr.stream import Stream
from nr.utils.git import Git
from termcolor import colored
from shut.commands import project
from shut.model import AbstractProjectModel, Project
from shut.model.version import bump_version, parse_version, Version
from shut.update import get_version_refs, VersionRef
from shut.utils.text import substitute_ranges
logger = logging.getLogger(__name__)
@ -73,6 +76,39 @@ class VersionBumpData(Generic[T], metaclass=abc.ABCMeta):
def get_snapshot_version(self) -> Version:
pass
def bump_to_version(self, target_version: Version) -> Iterable[str]:
"""
Called to bump to the specified *target_version*. The default implementation uses the
version refs provided by #get_version_refs() to bump.
"""
version_refs = list(get_version_refs(self.obj))
print()
print(f'bumping {len(version_refs)} version reference(s)')
for filename, refs in Stream.groupby(version_refs, lambda r: r.filename, collect=list):
with open(filename) as fp:
content = fp.read()
if len(refs) == 1:
ref = refs[0]
print(f' {colored(os.path.relpath(ref.filename), "cyan")}: {ref.value} → {target_version}')
else:
print(f' {colored(os.path.relpath(ref.filename), "cyan")}:')
for ref in refs:
print(f' {ref.value} → {target_version}')
content = substitute_ranges(
content,
((ref.start, ref.end, target_version) for ref in refs),
)
if not self.args.dry:
with open(filename, 'w') as fp:
fp.write(content)
return list(set(x.filename for x in version_refs))
@abc.abstractmethod
def update(self) -> None:
pass
@ -165,26 +201,7 @@ def do_bump(args: Args, data: VersionBumpData[AbstractProjectModel]) -> None:
logger.warning(f'new version "{new_version}" is equal to current version')
exit(0)
# The substitution logic below does not work if the same file is listed multiple
# times so let's check for now that every file is listed only once.
n_files = set(os.path.normpath(os.path.abspath(ref.filename))
for ref in version_refs)
assert len(n_files) == len(version_refs), "multiple version refs in one file is not currently supported."
# Bump version references.
print(f'bumping {len(version_refs)} version reference(s)')
for ref in version_refs:
print(f' {os.path.relpath(ref.filename)}: {ref.value} → {new_version}')
if not args.dry:
with open(ref.filename) as fp:
contents = fp.read()
contents = contents[:ref.start] + str(new_version) + contents[ref.end:]
with open(ref.filename, 'w') as fp:
fp.write(contents)
changed_files = [x.filename for x in version_refs]
# TODO(NiklasRosenstein): For single-versioned mono repositories, bump inter dependencies.
changed_files = list(data.bump_to_version(new_version))
# TODO(NiklasRosenstein): Release staged changelogs.

+ 0
- 1
src/shut/commands/commons/checks.py

@ -68,7 +68,6 @@ def print_checks_all(name: str, checks: List[Check], seconds: float):
print_checks(checks, prefix=' ')
print()
print('run', len(checks), 'checks for package', package_name, 'in {:.3f}s'.format(seconds))
print()
def get_checks_status(checks: List[Check], warnings_as_errors: bool = False) -> int:

+ 3
- 3
src/shut/commands/commons/new.py

@ -31,9 +31,9 @@ from shut.utils.io.virtual import VirtualFiles
from termcolor import colored
GITIGNORE_TEMPLATE = '''
/.venv*/
/dist
/build
.venv*/
dist/
build/
*.py[cod]
*.egg-info
*.egg

+ 41
- 0
src/shut/commands/mono/bump.py

@ -25,10 +25,14 @@ import sys
from typing import Iterable
import click
import nr.fs
from nr.stream import Stream
from termcolor import colored
from shut.commands.commons.bump import make_bump_command, VersionBumpData, VersionRef
from shut.model import MonorepoModel, Project
from shut.model.version import get_commit_distance_version, parse_version, Version
from shut.utils.text import substitute_ranges
from . import mono
from .checks import check_monorepo
from .update import update_monorepo
@ -44,6 +48,43 @@ class MonorepoBumpdata(VersionBumpData[MonorepoModel]):
def update(self) -> None:
update_monorepo(self.obj, dry=self.args.dry)
def bump_to_version(self, target_version: Version) -> Iterable[str]:
changed_files = list(super().bump_to_version(target_version))
if not self.obj.release.single_version:
return changed_files
inter_deps = list(self.obj.get_inter_dependencies())
if not inter_deps:
return changed_files
print()
print(f'bumping {len(inter_deps)} mono repository inter-dependency(-ies)')
for filename, refs in Stream.groupby(inter_deps, lambda d: d.filename, collect=list):
print(f' {colored(nr.fs.rel(filename), "cyan")}:')
with open(filename) as fp:
content = fp.read()
for ref in refs:
value = content[ref.version_start:ref.version_end]
print(f' {ref.package_name} {value} → ^{target_version}')
content = substitute_ranges(
content,
((ref.version_start, ref.version_end, f'^{target_version}') for ref in refs),
)
if not self.args.dry:
with open(filename, 'w') as fp:
fp.write(content)
changed_files.append(filename)
if self.args.tag and inter_deps and self.args.skip_update:
logger.warning('bump requires an update in order to automatically tag')
return changed_files
def get_snapshot_version(self) -> Version:
return get_commit_distance_version(
self.obj.directory,

+ 38
- 2
src/shut/model/monorepo.py

@ -19,14 +19,27 @@
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from typing import List, Optional
import re
from typing import List, Iterable, Optional
from databind.core import datamodel, field
from .author import Author
from .abstract import AbstractProjectModel
from .version import Version
from .requirements import VersionSelector
from .release import MonorepoReleaseConfiguration
@datamodel
class InterdependencyRef:
filename: str
package_name: str
version_selector: VersionSelector
version_start: int
version_end: int
@datamodel
class MonorepoModel(AbstractProjectModel):
name: str
@ -35,7 +48,30 @@ class MonorepoModel(AbstractProjectModel):
license: str = None
url: str = None
# Overrides
def get_inter_dependencies(self) -> Iterable[InterdependencyRef]:
"""
Returns a dictionary that maps the names of packages in the mono repository to a list
of their dependencies on other packages in the same repository. Note that it does so
by regex-matching in the package configuration file rather than reading the deserialized
package data in order to return start and end index data.
"""
regex = re.compile(r'^\s*- +([A-z0-9\.\-_]+) *([^\n:]+)?$', re.M)
packages = list(self.project.packages)
package_names = set(p.data.name for p in self.project.packages)
for package in self.project.packages:
with open(package.filename) as fp:
content = fp.read()
for match in regex.finditer(content):
package_name, version_selector = match.groups()
if package_name not in package_names:
continue
if version_selector:
version_selector = VersionSelector(version_selector)
yield InterdependencyRef(package.filename, package_name, version_selector, match.start(2), match.end(2))
# AbstractProjectModel Overrides
release: MonorepoReleaseConfiguration = field(default_factory=MonorepoReleaseConfiguration)

+ 31
- 0
src/shut/utils/test_text.py

@ -0,0 +1,31 @@
from pytest import raises
from .text import substitute_ranges
def test_substitute_ranges():
text = 'abcdefghijklmnopqrstuvwxyz'
assert substitute_ranges(text, [
(1, 4, 'SPAM'),
(10, 11, 'EGGS'),
]) == 'aSPAMefghijEGGSlmnopqrstuvwxyz'
assert substitute_ranges(text, [
(10, 11, 'EGGS'),
(1, 4, 'SPAM'),
]) == 'aSPAMefghijEGGSlmnopqrstuvwxyz'
with raises(ValueError) as excinfo:
substitute_ranges(text, [
(10, 11, 'EGGS'),
(1, 4, 'SPAM'),
], is_sorted=True)
assert str(excinfo.value) == 'invalid range at index 1: overlap with previous range'
with raises(ValueError) as excinfo:
substitute_ranges(text, [
(1, 4, 'SPAM'),
(3, 5, 'EGGS'),
])
assert str(excinfo.value) == 'invalid range at index 1: overlap with previous range'

+ 53
- 0
src/shut/utils/text.py

@ -0,0 +1,53 @@
# -*- coding: utf8 -*-
# Copyright (c) 2020 Niklas Rosenstein
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
import io
from typing import Iterable, Tuple
SubstRange = Tuple[int, int, str]
def substitute_ranges(text: str, ranges: Iterable[SubstRange], is_sorted: bool = False) -> str:
"""
Replaces parts of *text* using the specified *ranges* and returns the new text. Ranges
must not overlap. *is_sorted* can be set to `True` if the input *ranges* are already
sorted from lowest to highest starting index to optimize the function.
"""
if not is_sorted:
ranges = sorted(ranges, key=lambda x: x[0])
out = io.StringIO()
max_start_index = 0
max_end_index = 0
for index, (istart, iend, subst) in enumerate(ranges):
if iend < istart:
raise ValueError(f'invalid range at index {index}: (istart: {istart!r}, iend: {iend!r})')
if istart < max_end_index:
raise ValueError(f'invalid range at index {index}: overlap with previous range')
subst = str(subst)
out.write(text[max_end_index:istart])
out.write(subst)
max_start_index, max_end_index = istart, iend
out.write(text[max_end_index:])
return out.getvalue()

Loading…
Cancel
Save