Browse Source

move Changelog logic to "shut" package

shut
Niklas Rosenstein 1 year ago
parent
commit
b5235e9e60
No known key found for this signature in database GPG Key ID: 6D269B33D25F6C6
16 changed files with 653 additions and 429 deletions
  1. + 4
    - 0
      .changelog/_unreleased.yml
  2. + 6
    - 0
      .pylintrc
  3. + 0
    - 109
      src/shore/__main__.py
  4. + 0
    - 313
      src/shore/util/changelog.py
  5. + 62
    - 0
      src/shut/changelog/__init__.py
  6. + 137
    - 0
      src/shut/changelog/manager.py
  7. + 134
    - 0
      src/shut/changelog/render.py
  8. + 44
    - 0
      src/shut/changelog/v1.py
  9. + 70
    - 0
      src/shut/changelog/v2.py
  10. + 43
    - 0
      src/shut/changelog/v3.py
  11. + 1
    - 0
      src/shut/commands/__init__.py
  12. + 148
    - 0
      src/shut/commands/changelog/__init__.py
  13. + 0
    - 1
      src/shut/commands/mono/status.py
  14. + 1
    - 4
      src/shut/commands/pkg/bootstrap.py
  15. + 3
    - 1
      src/shut/commands/pkg/sanity.py
  16. + 0
    - 1
      src/shut/commands/pkg/status.py

+ 4
- 0
.changelog/_unreleased.yml

@ -4,3 +4,7 @@ changes:
component: changelog
description: add Changelog V3 format
fixes: []
- type: change
component: changelog
description: Moved entire Changelog logic to `shut.changelog` and `shut.commands.changelog`
fixes: []

+ 6
- 0
.pylintrc

@ -0,0 +1,6 @@
[MASTER]
jobs = 0
[MESSAGES CONTROL]
disable=all
enable=E,W0611,W0612

+ 0
- 109
src/shore/__main__.py

@ -34,7 +34,6 @@ from shore.core.plugins import (
from shore.mapper import mapper
from shore.model import Monorepo, ObjectCache, Package, VersionSelector
from shore.plugins.core import get_monorepo_interdependency_version_refs
from shore.util.changelog import ChangelogV3, ChangelogManager, render_changelogs
from shore.util.classifiers import get_classifiers
from shore.util.license import get_license_metadata, wrap_license_text
from shore.util.resources import walk_package_resources
@ -773,114 +772,6 @@ def publish(**args):
exit(status)
@cli.command()
@click.argument('version', type=parse_version, required=False)
@click.option('--reformat', is_flag=True, help='reformat the changelog')
@click.option('--add', metavar='type', help='create a new changelog entry')
@click.option('--for', metavar='component', help='components for the new changelog entry (default: general)', default='general')
@click.option('--fixes', metavar='issue,…', help='issues that this changelog entry fixes')
@click.option('-m', '--message', metavar='text', help='changelog entry description')
@click.option('-e', '--edit', is_flag=True, help='edit the changelog entry or file')
@click.option('--markdown', is_flag=True, help='render the changelog as markdown')
@click.option('-a', '--all', is_flag=True, help='show the changelog for all versions')
@click.option('-s', '--stage', is_flag=True, help='stage the created/updated changelog file with git')
@click.option('-c', '--commit', is_flag=True, help='commit the created/updated changelog file with git, together with other currently staged files')
def changelog(**args):
"""
Show changelogs or create new entries.
"""
if (args['version'] or args['reformat']) and args['add']:
logger.error('unsupported combination of arguments')
sys.exit(1)
subject = _load_subject(allow_none=True)
if subject:
manager = ChangelogManager(subject.changelog_directory, mapper)
else:
manager = ChangelogManager(Package.changelog_directory.default, mapper)
def _split(s: Optional[str]) -> List[str]:
return list(filter(bool, map(str.strip, (s or '').split(','))))
if args['add']:
if not args['for']:
args['for'] = 'general'
try:
type_ = ChangelogV3.Type[args['add']]
except KeyError:
logger.error('invalid changelog type: %r', args['add'])
sys.exit(1)
entry = ChangelogV3.Entry(
type_,
args['for'],
args['message'] or '',
_split(args['fixes']))
# Allow the user to edit the entry if no description is provided or the
# -e,--edit option was set.
if not entry.description or args['edit']:
serialized = yaml.safe_dump(mapper.serialize(entry, ChangelogV3.Entry), sort_keys=False)
entry = mapper.deserialize(yaml.safe_load(_edit_text(serialized)), ChangelogV3.Entry)
# Validate the entry contents (need a description and at least one type and component).
if not entry.description or not entry.component:
logger.error('changelog entries need a component and description')
sys.exit(1)
created = not manager.unreleased.exists()
manager.unreleased.add_entry(entry)
manager.unreleased.save(create_directory=True)
message = ('Created' if created else 'Updated') + ' "{}"'.format(manager.unreleased.filename)
print(colored(message, 'cyan'))
if args['stage'] or args['commit']:
_git.add([manager.unreleased.filename])
if args['commit']:
commit_message = entry.description
if isinstance(subject, Package) and subject.monorepo:
commit_message = '{}({}): '.format(entry.type_.name, subject.name) + commit_message
else:
commit_message = '{}: '.format(entry.type_.name) + commit_message
_git.commit(commit_message)
sys.exit(0)
if args['edit']:
if not manager.unreleased.exists():
logger.error('no staged changelog')
sys.exit(1)
sys.exit(_editor_open(manager.unreleased.filename))
changelogs = []
if args['version'] or not args['all']:
if args['all']:
sys.exit('error: incompatible arguments: <version> and -a,--all')
changelog = manager.version(args['version']) if args['version'] else manager.unreleased
# Load the changelog for the specified version or the current staged entries.
if not changelog.exists():
print('No changelog for {}.'.format(colored(str(args['version'] or 'unreleased'), 'yellow')))
sys.exit(0)
changelogs.append(changelog)
else:
changelogs = list(manager.all())
if args['reformat']:
for changelog in changelogs:
changelog.save()
sys.exit(0)
if args['markdown']:
changelog_format = 'markdown'
else:
changelog_format = 'terminal'
render_changelogs(sys.stdout, changelog_format, changelogs)
_entry_point = lambda: sys.exit(cli())
if __name__ == '__main__':

+ 0
- 313
src/shore/util/changelog.py

@ -1,313 +0,0 @@
# -*- coding: utf8 -*-
# Copyright (c) 2019 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.
from nr.databind.core import Collection, Field, FieldName, ObjectMapper, Struct
from nr.stream import Stream
from shore.util.version import Version
from termcolor import colored
from typing import Iterable, List, Optional, TextIO
import datetime
import enum
import os
import re
import shutil
import textwrap
import yaml
## changelog v1
class ChangelogV1Entry(Struct):
types = Field([str])
issues = Field([(str, int)], default=list)
components = Field([str])
description = Field(str)
def to_v3(self) -> 'ChangelogV3Entry':
try:
type_ = ChangelogV3Type[self.types[0].strip().lower()]
except KeyError:
type_ = ChangelogV3Type.change
return ChangelogV3Entry(
type_,
self.components[0],
self.description,
list(map(str, self.issues)))
class ChangelogV1(Collection, list):
item_type = ChangelogV1Entry
def to_v3(self) -> 'ChangelogV3':
return ChangelogV3(None, [x.to_v3() for x in self])
## changelog v2
class ChangelogV2Type(enum.Enum):
fix = 0
improvement = 1
change = 3
refactor = 4
feature = 5
docs = 6
tests = 7
class ChangelogV2Entry(Struct):
type_ = Field(ChangelogV2Type, FieldName('type'))
component = Field(str)
description = Field(str)
fixes = Field([str])
class ChangelogV2(Collection, list):
item_type = ChangelogV2Entry
def to_v3(self) -> 'ChangelogV3':
return ChangelogV3(None, list(self))
## changelog v3
ChangelogV3Type = ChangelogV2Type
ChangelogV3Entry = ChangelogV2Entry
class ChangelogV3(Struct):
release_date = Field(datetime.date, default=None)
changes = Field([ChangelogV3Entry])
Type = ChangelogV3Type
Entry = ChangelogV3Entry
## public API
class Changelog:
"""
Represents a changelog on disk.
"""
#: A mapping for the changelog renderers that are available. The default
#: renderer implementations are "terminal" and "markdown".
RENDERERS = {}
def __init__(self, filename: str, version: Optional[Version], mapper: ObjectMapper) -> None:
self.filename = filename
self.version = version
self.mapper = mapper
self.data = ChangelogV3(changes=[])
@property
def entries(self):
return self.data.changes
def exists(self) -> bool:
" Returns #True if the changelog file exists. "
return os.path.isfile(self.filename)
def load(self) -> None:
" Loads the data from the file of this changelog. "
with open(self.filename) as fp:
raw_data = yaml.safe_load(fp)
datatype = (ChangelogV1, ChangelogV2, ChangelogV3)
data = self.mapper.deserialize(raw_data, datatype, filename=self.filename)
if isinstance(data, (ChangelogV1, ChangelogV2)):
data = data.to_v3()
self.data = data
def save(self, create_directory: bool = False) -> None:
" Saves the changelog. It will always save the changelog in the newest supported format. "
if create_directory:
os.makedirs(os.path.dirname(self.filename), exist_ok=True)
data = self.mapper.serialize(self.data, ChangelogV3)
with open(self.filename, 'w') as fp:
yaml.safe_dump(data, fp, sort_keys=False)
def set_release_date(self, date: datetime.date) -> None:
self.data.release_date = date
def add_entry(self, entry: ChangelogV2Entry) -> None:
self.data.changes.append(entry)
class ChangelogManager:
TYPES = frozenset(['fix', 'improvement', 'docs', 'change', 'refactor', 'feature', 'enhancement'])
def __init__(self, directory: str, mapper: ObjectMapper) -> None:
self.directory = directory
self.mapper = mapper
self._cache = {}
def _get(self, name: str, version: Optional[str]) -> Changelog:
key = (name, str(version))
if key in self._cache:
return self._cache[key]
changelog = Changelog(os.path.join(self.directory, name), version, self.mapper)
if os.path.isfile(changelog.filename):
changelog.load()
self._cache[key] = changelog
return changelog
@property
def unreleased(self) -> Changelog:
return self._get('_unreleased.yml', None)
def version(self, version: Version) -> Changelog:
return self._get(str(version) + '.yml', version)
def release(self, version: Version) -> Changelog:
"""
Renames the unreleased changelog to the file name for the specified *version*.
"""
unreleased = self.unreleased
unreleased.data.release_date = datetime.date.today()
unreleased.save()
os.rename(unreleased.filename, self.version(version).filename)
self._cache.clear()
return self.version(version)
def all(self) -> Iterable[Changelog]:
"""
Yields all changelogs.
"""
for name in os.listdir(self.directory):
if not name.endswith('.yml'):
continue
if name == '_unreleased.yml':
yield self.unreleased
else:
version = Version(name[:-4])
yield self.version(version)
## changelog renderers
def _group_entries_by_component(entries):
key = lambda x: x.component
return list(Stream.sortby(entries, key).groupby(key, collect=list))
def render_changelogs_for_terminal(fp: TextIO, changelogs: List[Changelog]) -> None:
"""
Renders a #Changelog for the terminal to *fp*.
"""
def _md_term_stylize(text: str) -> str:
def _code(m):
return colored(m.group(1), 'cyan')
def _issue_ref(m):
return colored(m.group(0), 'yellow', attrs=['bold'])
text = re.sub(r'`([^`]+)`', _code, text)
text = re.sub(r'#\d+', _issue_ref, text)
return text
def _fmt_issue(i):
if str(i).isdigit():
return '#' + str(i)
return i
def _fmt_issues(entry):
if not entry.fixes:
return None
return '(' + ', '.join(colored(_fmt_issue(i), 'yellow', attrs=['underline']) for i in entry.fixes) + ')'
def _fmt_types(entry):
return colored(entry.type_.name, attrs=['bold'])
if hasattr(shutil, 'get_terminal_size'):
width = shutil.get_terminal_size((80, 23))[0]
else:
width = 80
# Explode entries by component.
for changelog in changelogs:
fp.write(colored(changelog.version or 'Unreleased', 'blue', attrs=['bold', 'underline']))
fp.write(' ({})\n'.format(changelog.data.release_date or 'no release date'))
for component, entries in _group_entries_by_component(changelog.entries):
maxw = max(map(lambda x: len(x.type_.name), entries))
fp.write(' ' + colored(component or 'No Component', 'red', attrs=['bold', 'underline']) + '\n')
for entry in entries:
lines = textwrap.wrap(entry.description, width - (maxw + 6))
suffix_fmt = ' '.join(filter(bool, (_fmt_issues(entry),)))
lines[-1] += ' ' + suffix_fmt
delta = maxw - len(entry.type_.name)
fp.write(' {} {}\n'.format(colored((_fmt_types(entry) + ':') + ' ' * delta, attrs=['bold']), _md_term_stylize(lines[0])))
for line in lines[1:]:
fp.write(' {}{}\n'.format(' ' * (maxw+2), _md_term_stylize(line)))
fp.write('\n')
def render_changelogs_as_markdown(fp: TextIO, changelogs: List[Changelog]) -> None:
def _fmt_issue(i):
if str(i).isdigit():
return '#' + str(i)
return i
def _fmt_issues(entry):
if not entry.fixes:
return None
return '(' + ', '.join(_fmt_issue(i) for i in entry.fixes) + ')'
for changelog in changelogs:
fp.write('## {}'.format(changelog.version or 'Unreleased'))
fp.write(' ({})\n\n'.format(changelog.data.release_date or 'no release date'))
for component, entries in _group_entries_by_component(changelog.entries):
fp.write('* __{}__\n'.format(component))
for entry in entries:
description ='**' + entry.type_.name + '**: ' + entry.description
if entry.fixes:
description += ' ' + _fmt_issues(entry)
lines = textwrap.wrap(description, 80)
fp.write(' * {}\n'.format(lines[0]))
for line in lines[1:]:
fp.write(' {}\n'.format(line))
fp.write('\n')
def render_changelogs(fp: TextIO, format: str, changelogs: List[Changelog]) -> None:
changelogs = list(changelogs)
unreleased = next((x for x in changelogs if not x.version), None)
if unreleased:
changelogs.remove(unreleased)
changelogs.sort(key=lambda x: x.version, reverse=True)
if unreleased:
changelogs.insert(0, unreleased)
Changelog.RENDERERS[format](fp, changelogs)
Changelog.RENDERERS.update({
'terminal': render_changelogs_for_terminal,
'markdown': render_changelogs_as_markdown,
})

+ 62
- 0
src/shut/changelog/__init__.py

@ -0,0 +1,62 @@
# -*- 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.
"""
This package provides the programmatic interface to read, process and write YAML
changelog files.
One changelog file is created per version. The name of the file is the version that
the changelog is for. There may be an "_unreleased.yml" changelog file at any point
in time that contains the logs for the next to-be-released version.
The changelog can be used to determine if the next version of a package requires a
major or minor bump, or if only the patch or build number needs to be incremented.
This is achieved by assigning a keyword to every changelog, and that keyword indicates
the type of change and whether it is breaking an API.
"""
from typing import Any, Type, TypeVar
T = TypeVar('T')
class _ChangelogBase:
"""
Base class for #nr.databind.core.Struct subclasses that represent the deserialized
form of a changelog in a specific version. A newer version should reference the
predecessor in the #Supersedes class-level attribute and implement the #adapt()
method in order to automatically support migrating to the next version.
"""
Supersedes: Type[T] = None
@classmethod
def migrate(cls, older_changelog: Any) -> '_ChangelogBase':
if not cls.Supersedes:
raise TypeError('reached {}, unsure how to migrate from {!r}'.format(
cls.__module__, type(older_changelog).__name__))
if not isinstance(older_changelog, cls.Supersedes):
older_changelog = cls.Supersedes.migrate(older_changelog)
return cls.adapt(older_changelog)
@classmethod
def adapt(cls, older_changelog: T) -> '_ChangelogBase':
raise NotImplementedError

+ 137
- 0
src/shut/changelog/manager.py

@ -0,0 +1,137 @@
# -*- 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.
from shore.util.version import Version
from . import v1, v2, v3
from nr.databind.core import ObjectMapper, SkipDefaults
from nr.databind.json import JsonModule
from typing import Iterable, Optional
import datetime
import os
import yaml
mapper = ObjectMapper(JsonModule())
supported_changelog_types = (
v3.Changelog,
v2.Changelog,
v1.Changelog,
)
class Changelog:
"""
Represents a changelog on disk.
"""
def __init__(self, filename: str, version: Optional[Version]) -> None:
self.filename = filename
self.version = version
self.data = v3.Changelog(changes=[])
@property
def entries(self):
return self.data.changes
def exists(self) -> bool:
" Returns #True if the changelog file exists. "
return os.path.isfile(self.filename)
def load(self) -> None:
" Loads the data from the file of this changelog. "
with open(self.filename) as fp:
raw_data = yaml.safe_load(fp)
data = mapper.deserialize(raw_data, supported_changelog_types, filename=self.filename)
if not isinstance(data, v3.Changelog):
data = v3.Changelog.migrate(data)
self.data = data
def save(self, create_directory: bool = False) -> None:
" Saves the changelog. It will always save the changelog in the newest supported format. "
if create_directory:
os.makedirs(os.path.dirname(self.filename), exist_ok=True)
data = mapper.serialize(self.data, v3.Changelog)
with open(self.filename, 'w') as fp:
yaml.safe_dump(data, fp, sort_keys=False)
def set_release_date(self, date: datetime.date) -> None:
self.data.release_date = date
def add_entry(self, entry) -> None:
assert isinstance(entry, v3.Changelog.Entry), type(entry)
self.data.changes.append(entry)
class ChangelogManager:
def __init__(self, directory: str) -> None:
self.directory = directory
self._cache = {}
def _get(self, name: str, version: Optional[str]) -> Changelog:
key = (name, str(version))
if key in self._cache:
return self._cache[key]
changelog = Changelog(os.path.join(self.directory, name), version)
if os.path.isfile(changelog.filename):
changelog.load()
self._cache[key] = changelog
return changelog
@property
def unreleased(self) -> Changelog:
return self._get('_unreleased.yml', None)
def version(self, version: Version) -> Changelog:
return self._get(str(version) + '.yml', version)
def release(self, version: Version) -> Changelog:
"""
Renames the unreleased changelog to the file name for the specified *version*.
"""
unreleased = self.unreleased
unreleased.data.release_date = datetime.date.today()
unreleased.save()
os.rename(unreleased.filename, self.version(version).filename)
self._cache.clear()
return self.version(version)
def all(self) -> Iterable[Changelog]:
"""
Yields all changelogs.
"""
for name in os.listdir(self.directory):
if not name.endswith('.yml'):
continue
if name == '_unreleased.yml':
yield self.unreleased
else:
version = Version(name[:-4])
yield self.version(version)

+ 134
- 0
src/shut/changelog/render.py

@ -0,0 +1,134 @@
# -*- 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.
"""
Provides functions to render a list of changelogs. Always only supports the newest
changelog version.
"""
from .manager import Changelog
from nr.stream import Stream
from termcolor import colored
from typing import List, TextIO
import re
import shutil
import textwrap
def _group_entries_by_component(entries):
key = lambda x: x.component
return list(Stream.sortby(entries, key).groupby(key, collect=list))
def _terminal(fp: TextIO, changelogs: List[Changelog]) -> None:
"""
Renders a #Changelog for the terminal to *fp*.
"""
def _md_term_stylize(text: str) -> str:
def _code(m):
return colored(m.group(1), 'cyan')
def _issue_ref(m):
return colored(m.group(0), 'yellow', attrs=['bold'])
text = re.sub(r'`([^`]+)`', _code, text)
text = re.sub(r'#\d+', _issue_ref, text)
return text
def _fmt_issue(i):
if str(i).isdigit():
return '#' + str(i)
return i
def _fmt_issues(entry):
if not entry.fixes:
return None
return '(' + ', '.join(colored(_fmt_issue(i), 'yellow', attrs=['underline']) for i in entry.fixes) + ')'
def _fmt_types(entry):
return colored(entry.type_.name, attrs=['bold'])
if hasattr(shutil, 'get_terminal_size'):
width = shutil.get_terminal_size((80, 23))[0]
else:
width = 80
# Explode entries by component.
for changelog in changelogs:
fp.write(colored(changelog.version or 'Unreleased', 'blue', attrs=['bold', 'underline']))
fp.write(' ({})\n'.format(changelog.data.release_date or 'no release date'))
for component, entries in _group_entries_by_component(changelog.entries):
maxw = max(map(lambda x: len(x.type_.name), entries))
fp.write(' ' + colored(component or 'No Component', 'red', attrs=['bold', 'underline']) + '\n')
for entry in entries:
lines = textwrap.wrap(entry.description, width - (maxw + 6))
suffix_fmt = ' '.join(filter(bool, (_fmt_issues(entry),)))
lines[-1] += ' ' + suffix_fmt
delta = maxw - len(entry.type_.name)
fp.write(' {} {}\n'.format(colored((_fmt_types(entry) + ':') + ' ' * delta, attrs=['bold']), _md_term_stylize(lines[0])))
for line in lines[1:]:
fp.write(' {}{}\n'.format(' ' * (maxw+2), _md_term_stylize(line)))
fp.write('\n')
def _markdown(fp: TextIO, changelogs: List[Changelog]) -> None:
def _fmt_issue(i):
if str(i).isdigit():
return '#' + str(i)
return i
def _fmt_issues(entry):
if not entry.fixes:
return None
return '(' + ', '.join(_fmt_issue(i) for i in entry.fixes) + ')'
for changelog in changelogs:
fp.write('## {}'.format(changelog.version or 'Unreleased'))
fp.write(' ({})\n\n'.format(changelog.data.release_date or 'no release date'))
for component, entries in _group_entries_by_component(changelog.entries):
fp.write('* __{}__\n'.format(component))
for entry in entries:
description ='**' + entry.type_.name + '**: ' + entry.description
if entry.fixes:
description += ' ' + _fmt_issues(entry)
lines = textwrap.wrap(description, 80)
fp.write(' * {}\n'.format(lines[0]))
for line in lines[1:]:
fp.write(' {}\n'.format(line))
fp.write('\n')
renderers = {
'terminal': _terminal,
'markdown': _markdown,
}
def render(fp: TextIO, format: str, changelogs: List[Changelog]) -> None:
changelogs = list(changelogs)
unreleased = next((x for x in changelogs if not x.version), None)
if unreleased:
changelogs.remove(unreleased)
changelogs.sort(key=lambda x: x.version, reverse=True)
if unreleased:
changelogs.insert(0, unreleased)
renderers[format](fp, changelogs)

+ 44
- 0
src/shut/changelog/v1.py

@ -0,0 +1,44 @@
# -*- 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.
"""
The V1 of changelogs.
"""
from . import _ChangelogBase
from nr.databind.core import Collection, Field, Struct
class Entry(Struct):
"""
Represents a changelog entry in the V1 changelog format.
"""
types = Field([str])
issues = Field([(str, int)], default=list)
components = Field([str])
description = Field(str)
class Changelog(_ChangelogBase, Collection, list):
Supersedes = None # _ChangelogBase
item_type = Entry # Collection
Entry = Entry

+ 70
- 0
src/shut/changelog/v2.py

@ -0,0 +1,70 @@
# -*- 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.
"""
The V3 of changelogs.
"""
from . import _ChangelogBase, v1
from nr.databind.core import Collection, Field, FieldName, Struct
import enum
class Type(enum.Enum):
fix = 0
improvement = 1
change = 3
refactor = 4
feature = 5
docs = 6
tests = 7
class Entry(Struct):
Type = Type
type_ = Field(Type, FieldName('type'))
component = Field(str)
description = Field(str)
fixes = Field([str])
@classmethod
def from_v1(cls, v1_entry: v1.Entry) -> 'Entry':
try:
type_ = Type[v1_entry.types[0].strip().lower()]
except KeyError:
type_ = Type.change
return cls(
type_=type_,
component=v1_entry.components[0],
description=v1_entry.description,
fixes=list(map(str, v1_entry.issues)),
)
class Changelog(_ChangelogBase, Collection, list):
Supersedes = v1.Changelog # _ChangelogBase
item_type = Entry # Collection
Entry = Entry
@classmethod
def adapt(cls, v1_changelog: v1.Changelog) -> 'Changelog':
return cls(map(Entry.from_v1, v1_changelog))

+ 43
- 0
src/shut/changelog/v3.py

@ -0,0 +1,43 @@
# -*- 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.
"""
The V2 of changelogs.
"""
from . import _ChangelogBase, v2
from nr.databind.core import Field, Struct
import datetime
class Changelog(_ChangelogBase, Struct):
Supersedes = v2.Changelog # _ChangelogBase
Entry = v2.Entry
release_date = Field(datetime.date, default=None)
changes = Field([v2.Entry])
@classmethod
def adapt(cls, v2_changelog: v2.Changelog) -> 'Changelog':
return cls(
release_date=None,
changes=list(v2_changelog),
)

+ 1
- 0
src/shut/commands/__init__.py

@ -70,6 +70,7 @@ def shut(cwd, verbose, quiet):
)
from . import changelog
from . import classifiers
from . import license
from . import mono

+ 148
- 0
src/shut/commands/changelog/__init__.py

@ -0,0 +1,148 @@
# -*- 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.
from shore.__main__ import _edit_text, _editor_open, _load_subject
from shore.model import Package
from shore.util.version import parse_version
from nr.utils.git import Git
from .. import shut, commons
from shut.changelog import v3
from shut.changelog.manager import mapper, ChangelogManager
from shut.changelog.render import render as render_changelogs
from termcolor import colored
from typing import List, Optional
import click
import logging
import sys
import yaml
_git = Git()
logger = logging.getLogger(__name__)
@shut.command()
@click.argument('version', type=parse_version, required=False)
@click.option('--reformat', is_flag=True, help='reformat the changelog')
@click.option('--add', metavar='type', help='create a new changelog entry')
@click.option('--for', metavar='component', help='components for the new changelog entry (default: general)', default='general')
@click.option('--fixes', metavar='issue,…', help='issues that this changelog entry fixes')
@click.option('-m', '--message', metavar='text', help='changelog entry description')
@click.option('-e', '--edit', is_flag=True, help='edit the changelog entry or file')
@click.option('--markdown', is_flag=True, help='render the changelog as markdown')
@click.option('-a', '--all', is_flag=True, help='show the changelog for all versions')
@click.option('-s', '--stage', is_flag=True, help='stage the created/updated changelog file with git')
@click.option('-c', '--commit', is_flag=True, help='commit the created/updated changelog file with git, together with other currently staged files')
def changelog(**args):
"""
Show changelogs or create new entries.
"""
if (args['version'] or args['reformat']) and args['add']:
logger.error('unsupported combination of arguments')
sys.exit(1)
subject = _load_subject(allow_none=True)
if subject:
manager = ChangelogManager(subject.changelog_directory)
else:
manager = ChangelogManager(Package.changelog_directory.default)
def _split(s: Optional[str]) -> List[str]:
return list(filter(bool, map(str.strip, (s or '').split(','))))
if args['add']:
if not args['for']:
args['for'] = 'general'
try:
type_ = v3.Changelog.Entry.Type[args['add']]
except KeyError:
logger.error('invalid changelog type: %r', args['add'])
sys.exit(1)
entry = v3.Changelog.Entry(
type_,
args['for'],
args['message'] or '',
_split(args['fixes']))
# Allow the user to edit the entry if no description is provided or the
# -e,--edit option was set.
if not entry.description or args['edit']:
serialized = yaml.safe_dump(mapper.serialize(entry, v3.Changelog.Entry), sort_keys=False)
entry = mapper.deserialize(yaml.safe_load(_edit_text(serialized)), v3.Changelog.Entry)
# Validate the entry contents (need a description and at least one type and component).
if not entry.description or not entry.component:
logger.error('changelog entries need a component and description')
sys.exit(1)
created = not manager.unreleased.exists()
manager.unreleased.add_entry(entry)
manager.unreleased.save(create_directory=True)
message = ('Created' if created else 'Updated') + ' "{}"'.format(manager.unreleased.filename)
print(colored(message, 'cyan'))
if args['stage'] or args['commit']:
_git.add([manager.unreleased.filename])
if args['commit']:
commit_message = entry.description
if isinstance(subject, Package) and subject.monorepo:
commit_message = '{}({}): '.format(entry.type_.name, subject.name) + commit_message
else:
commit_message = '{}: '.format(entry.type_.name) + commit_message
_git.commit(commit_message)
sys.exit(0)
if args['edit']:
if not manager.unreleased.exists():
logger.error('no staged changelog')
sys.exit(1)
sys.exit(_editor_open(manager.unreleased.filename))
changelogs = []
if args['version'] or not args['all']:
if args['all']:
sys.exit('error: incompatible arguments: <version> and -a,--all')
changelog = manager.version(args['version']) if args['version'] else manager.unreleased
# Load the changelog for the specified version or the current staged entries.
if not changelog.exists():
print('No changelog for {}.'.format(colored(str(args['version'] or 'unreleased'), 'yellow')))
sys.exit(0)
changelogs.append(changelog)
else:
changelogs = list(manager.all())
if args['reformat']:
for changelog in changelogs:
changelog.save()
sys.exit(0)
if args['markdown']:
changelog_format = 'markdown'
else:
changelog_format = 'terminal'
render_changelogs(sys.stdout, changelog_format, changelogs)

+ 0
- 1
src/shut/commands/mono/status.py

@ -21,7 +21,6 @@
from . import mono, load_monorepo_manifest
from ..commons.status import print_status
import click
@mono.command(help="""

+ 1
- 4
src/shut/commands/pkg/bootstrap.py

@ -22,17 +22,14 @@
from shut.utils.io.virtual import VirtualFiles
from . import pkg
from shore.core.plugins import FileToRender, write_to_disk
from shore.model import Author, Package, RootRequirements, VersionSelector
from shore.util.resources import walk_package_resources
from shore.util.license import get_license_metadata, wrap_license_text
from termcolor import colored
from typing import Iterable, Optional
from typing import Optional
import click
import datetime
import jinja2
import os
import pkg_resources
import subprocess
INIT_TEMPLATE = '''

+ 3
- 1
src/shut/commands/pkg/sanity.py

@ -19,9 +19,11 @@
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from shore.model import Monorepo
from shore.core.plugins import CheckResult
from . import pkg, load_package_manifest
from nr.stream import Stream
from shore.core.plugins import CheckResult
from termcolor import colored
import click
import logging

+ 0
- 1
src/shut/commands/pkg/status.py

@ -21,7 +21,6 @@
from . import pkg, load_package_manifest
from ..commons.status import print_status
import click
@pkg.command(help="""

Loading…
Cancel
Save