changelet 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Ross McFarland & the octoDNS Maintainers
4
+ Copyright (c) 2017 GitHub, Inc.
5
+
6
+ Permission is hereby granted, free of charge, to any person
7
+ obtaining a copy of this software and associated documentation
8
+ files (the "Software"), to deal in the Software without
9
+ restriction, including without limitation the rights to use,
10
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the
12
+ Software is furnished to do so, subject to the following
13
+ conditions:
14
+
15
+ The above copyright notice and this permission notice shall be
16
+ included in all copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
20
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
22
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
23
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
24
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
25
+ OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: changelet
3
+ Version: 0.1.0
4
+ Summary: ChangeLog and Release Management Tooling
5
+ Home-page: https://github.com/octodns/changelet
6
+ Author: Ross McFarland
7
+ Author-email: rwmcfa1@gmail.com
8
+ License: MIT
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: PyYaml
13
+ Requires-Dist: semver>=3.0.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest; extra == "dev"
16
+ Requires-Dist: pytest-cov; extra == "dev"
17
+ Requires-Dist: pytest-network; extra == "dev"
18
+ Requires-Dist: black<25.0.0,>=24.3.0; extra == "dev"
19
+ Requires-Dist: build>=0.7.0; extra == "dev"
20
+ Requires-Dist: isort>=5.11.5; extra == "dev"
21
+ Requires-Dist: pyflakes>=2.2.0; extra == "dev"
22
+ Requires-Dist: readme_renderer[md]>=26.0; extra == "dev"
23
+ Requires-Dist: twine>=3.4.2; extra == "dev"
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest; extra == "test"
26
+ Requires-Dist: pytest-cov; extra == "test"
27
+ Requires-Dist: pytest-network; extra == "test"
28
+ Dynamic: author
29
+ Dynamic: author-email
30
+ Dynamic: description
31
+ Dynamic: description-content-type
32
+ Dynamic: home-page
33
+ Dynamic: license
34
+ Dynamic: license-file
35
+ Dynamic: provides-extra
36
+ Dynamic: requires-dist
37
+ Dynamic: requires-python
38
+ Dynamic: summary
39
+
40
+ ## ChangeLog and Release Management Tooling
41
+
42
+ A simple standalone Python module for CHANGELOG and release management. Spun out of [octoDNS](https://github.com/octodns/octodns/).
43
+
44
+ ### Installation
45
+
46
+ #### Command line
47
+
48
+ ```console
49
+ pip install changelet
50
+ ```
51
+
52
+ ### Usage
53
+
54
+ For help with using the command
55
+
56
+ ```console
57
+ changelet --help
58
+ ```
59
+
60
+ In most cases the only command you're likely to encounter is `create`. It will be used to add a changelog entry to your branch/PR.
61
+
62
+ ```console
63
+ changelet create --type (TYPE) Short description of your change to be included in the changelog
64
+ ```
65
+
66
+ The options for type are
67
+ * `major` - rare for non-maintainers, a change that will break backwards compatibility and require users to take care when updating
68
+ * `minor` - adds new functionality that is either self contained or done in a completely backwards compatible manner
69
+ * `patch` - fixes an issue or bug with existing functionality
70
+ * `none` - change that should not be included in the CHANGELOG and will not directly impact users, e.g. documentation, README, tooling, ...
71
+
72
+ ### Using changlet
73
+
74
+ Currently the tooling has only been tested with repositories in the octoDNS org, but it should be usable elsewhere. You'll want to have a look at the following for how to incorporate it into your repo and workflows.
75
+
76
+ * [script/changelog](script/changelog)
77
+ * [.git_hooks_pre-commit](.git_hooks_pre-commit) (and [script/bootstrap](script/bootstrap) which installs it)
78
+ * [.github/workflows/changelog.yml](.github/workflows/changelog.yml)
79
+
80
+ ### Development
81
+
82
+ See the [/script/](/script/) directory for some tools to help with the development process. They generally follow the [Script to rule them all](https://github.com/github/scripts-to-rule-them-all) pattern. Most useful is `./script/bootstrap` which will create a venv and install both the runtime and development related requirements. It will also hook up a pre-commit hook that covers most of what's run by CI.
@@ -0,0 +1,43 @@
1
+ ## ChangeLog and Release Management Tooling
2
+
3
+ A simple standalone Python module for CHANGELOG and release management. Spun out of [octoDNS](https://github.com/octodns/octodns/).
4
+
5
+ ### Installation
6
+
7
+ #### Command line
8
+
9
+ ```console
10
+ pip install changelet
11
+ ```
12
+
13
+ ### Usage
14
+
15
+ For help with using the command
16
+
17
+ ```console
18
+ changelet --help
19
+ ```
20
+
21
+ In most cases the only command you're likely to encounter is `create`. It will be used to add a changelog entry to your branch/PR.
22
+
23
+ ```console
24
+ changelet create --type (TYPE) Short description of your change to be included in the changelog
25
+ ```
26
+
27
+ The options for type are
28
+ * `major` - rare for non-maintainers, a change that will break backwards compatibility and require users to take care when updating
29
+ * `minor` - adds new functionality that is either self contained or done in a completely backwards compatible manner
30
+ * `patch` - fixes an issue or bug with existing functionality
31
+ * `none` - change that should not be included in the CHANGELOG and will not directly impact users, e.g. documentation, README, tooling, ...
32
+
33
+ ### Using changlet
34
+
35
+ Currently the tooling has only been tested with repositories in the octoDNS org, but it should be usable elsewhere. You'll want to have a look at the following for how to incorporate it into your repo and workflows.
36
+
37
+ * [script/changelog](script/changelog)
38
+ * [.git_hooks_pre-commit](.git_hooks_pre-commit) (and [script/bootstrap](script/bootstrap) which installs it)
39
+ * [.github/workflows/changelog.yml](.github/workflows/changelog.yml)
40
+
41
+ ### Development
42
+
43
+ See the [/script/](/script/) directory for some tools to help with the development process. They generally follow the [Script to rule them all](https://github.com/github/scripts-to-rule-them-all) pattern. Most useful is `./script/bootstrap` which will create a venv and install both the runtime and development related requirements. It will also hook up a pre-commit hook that covers most of what's run by CI.
@@ -0,0 +1,5 @@
1
+ #
2
+ #
3
+ #
4
+
5
+ __version__ = __VERSION__ = '0.1.0'
@@ -0,0 +1,18 @@
1
+ #
2
+ #
3
+ #
4
+
5
+ from .bump import Bump
6
+ from .check import Check
7
+ from .create import Create
8
+
9
+ commands = {}
10
+
11
+
12
+ def register(klass):
13
+ commands[klass.name] = klass()
14
+
15
+
16
+ register(Bump)
17
+ register(Check)
18
+ register(Create)
@@ -0,0 +1,134 @@
1
+ #
2
+ #
3
+ #
4
+
5
+ from datetime import datetime
6
+ from importlib import import_module
7
+ from io import StringIO
8
+ from os.path import abspath, basename, join
9
+ from sys import exit, path
10
+
11
+ from semver import Version
12
+
13
+ from changelet.entry import Entry
14
+
15
+
16
+ def _get_current_version(module_name, directory='.'):
17
+ path.append(directory)
18
+ module = import_module(module_name)
19
+ return Version.parse(module.__version__)
20
+
21
+
22
+ def _get_new_version(current_version, entries):
23
+ try:
24
+ bump_type = entries[0].type
25
+ except IndexError:
26
+ return None
27
+ if bump_type == 'major':
28
+ return current_version.bump_major()
29
+ elif bump_type == 'minor':
30
+ return current_version.bump_minor()
31
+ elif bump_type == 'patch':
32
+ return current_version.bump_patch()
33
+ return None
34
+
35
+
36
+ def version(value):
37
+ return Version.parse(value)
38
+
39
+
40
+ class Bump:
41
+ name = 'bump'
42
+ description = (
43
+ 'Builds a changelog update and calculates a new version number.'
44
+ )
45
+
46
+ def configure(self, parser):
47
+ parser.add_argument(
48
+ '--version',
49
+ type=version,
50
+ required=False,
51
+ help='Use the supplied version number for the bump',
52
+ )
53
+ parser.add_argument(
54
+ '--make-changes',
55
+ action='store_true',
56
+ help='Write changelog update and bump version number',
57
+ )
58
+ parser.add_argument(
59
+ 'title', nargs='*', help='A short title/quip for the release title'
60
+ )
61
+
62
+ def exit(self, code):
63
+ exit(code)
64
+
65
+ def run(self, args, config, root='.'):
66
+ buf = StringIO()
67
+
68
+ module_name = basename(abspath(root)).replace('-', '_')
69
+
70
+ buf.write('## ')
71
+ current_version = _get_current_version(module_name)
72
+
73
+ entries = sorted(Entry.load_all(config), reverse=True)
74
+
75
+ new_version = (
76
+ args.version
77
+ if args.version
78
+ else _get_new_version(current_version, entries)
79
+ )
80
+ if not new_version:
81
+ print('No changelog entries found that would bump, nothing to do')
82
+ return self.exit(1)
83
+ buf.write(str(new_version))
84
+ buf.write(' - ')
85
+ buf.write(datetime.now().strftime('%Y-%m-%d'))
86
+ if args.title:
87
+ buf.write(' - ')
88
+ buf.write(' '.join(args.title))
89
+ buf.write('\n')
90
+
91
+ current_type = None
92
+ for entry in entries:
93
+ type = entry.type
94
+ if type == 'none':
95
+ # these aren't included in the listing
96
+ continue
97
+ if type != current_type:
98
+ buf.write('\n')
99
+ buf.write(type.capitalize())
100
+ buf.write(':\n')
101
+ current_type = type
102
+ buf.write(entry.markdown)
103
+
104
+ buf.write('\n')
105
+
106
+ buf.write('\n')
107
+
108
+ buf = buf.getvalue()
109
+ if not args.make_changes:
110
+ print(f'New version number {new_version}\n')
111
+ print(buf)
112
+ self.exit(0)
113
+ else:
114
+ changelog = join(root, 'CHANGELOG.md')
115
+ with open(changelog) as fh:
116
+ existing = fh.read()
117
+
118
+ with open(changelog, 'w') as fh:
119
+ fh.write(buf)
120
+ fh.write(existing)
121
+
122
+ init = join(root, module_name, '__init__.py')
123
+ with open(init) as fh:
124
+ existing = fh.read()
125
+
126
+ with open(init, 'w') as fh:
127
+ fh.write(
128
+ existing.replace(str(current_version), str(new_version))
129
+ )
130
+
131
+ for entry in entries:
132
+ entry.remove()
133
+
134
+ return new_version, buf
@@ -0,0 +1,30 @@
1
+ #
2
+ #
3
+ #
4
+
5
+ from sys import argv, exit, stderr
6
+
7
+
8
+ class Check:
9
+ name = 'check'
10
+ description = (
11
+ 'Checks to see if the current branch contains a changelog entry'
12
+ )
13
+
14
+ def configure(self, parser):
15
+ pass
16
+
17
+ def exit(self, code):
18
+ exit(code)
19
+
20
+ def run(self, args, config):
21
+ if config.provider.changelog_entries_in_branch(
22
+ root=config.root, directory=config.directory
23
+ ):
24
+ return self.exit(0)
25
+
26
+ print(
27
+ f'PR is missing required changelog file, run {argv[0]} create',
28
+ file=stderr,
29
+ )
30
+ self.exit(1)
@@ -0,0 +1,74 @@
1
+ #
2
+ #
3
+ #
4
+
5
+ from os.path import join
6
+ from uuid import uuid4
7
+
8
+ from changelet.entry import Entry
9
+
10
+
11
+ class Create:
12
+ name = 'create'
13
+ description = 'Creates a new changelog entry.'
14
+
15
+ def configure(self, parser):
16
+ parser.add_argument(
17
+ '-t',
18
+ '--type',
19
+ choices=('none', 'patch', 'minor', 'major'),
20
+ required=True,
21
+ help='''The scope of the change.
22
+
23
+ * patch - This is a bug fix
24
+ * minor - This adds new functionality or makes changes in a fully backwards
25
+ compatible way
26
+ * major - This includes substantial new functionality and/or changes that break
27
+ compatibility and may require careful migration
28
+ * none - This change does not need to be mentioned in the changelog
29
+
30
+ See https://semver.org/ for more info''',
31
+ )
32
+ parser.add_argument(
33
+ '-p',
34
+ '--pr',
35
+ type=int,
36
+ help='Manually override the PR number for the change, maintainer use only.',
37
+ )
38
+ parser.add_argument(
39
+ '-a',
40
+ '--add',
41
+ action='store_true',
42
+ default=False,
43
+ help='`git add` the newly created changelog entry',
44
+ )
45
+ parser.add_argument(
46
+ 'description',
47
+ metavar='change-description',
48
+ nargs='+',
49
+ help='''A short description of the changes in this PR, suitable as an entry in
50
+ CHANGELOG.md. Should be a single line. Can option include simple markdown formatting
51
+ and links.''',
52
+ )
53
+
54
+ def run(self, args, config):
55
+ filename = join(config.directory, f'{uuid4().hex}.md')
56
+ entry = Entry(
57
+ type=args.type,
58
+ description=' '.join(args.description),
59
+ pr=args.pr,
60
+ filename=filename,
61
+ )
62
+ entry.save()
63
+
64
+ if args.add:
65
+ config.provider.add_file(entry.filename)
66
+ print(
67
+ f'Created {entry.filename}, it has been staged and should be committed to your branch.'
68
+ )
69
+ else:
70
+ print(
71
+ f'Created {entry.filename}, it can be further edited and should be committed to your branch.'
72
+ )
73
+
74
+ return entry
@@ -0,0 +1,107 @@
1
+ #
2
+ #
3
+ #
4
+
5
+ from importlib import import_module
6
+ from os.path import isfile, join
7
+ from sys import version_info
8
+ from typing import TYPE_CHECKING
9
+
10
+ from yaml import safe_load as yaml_load
11
+
12
+ # https://pypi.org/project/tomli/#intro
13
+ # based on code in black.file
14
+ if version_info >= (3, 11): # pragma: no cover
15
+ try:
16
+ from tomllib import load as toml_load
17
+ except ImportError:
18
+ # Help users on older alphas
19
+ if not TYPE_CHECKING:
20
+ from tomli import load as toml_load
21
+ else: # pragma: no cover
22
+ from tomli import load as toml_load
23
+
24
+
25
+ class Config:
26
+ DEFAULT_ROOT = ''
27
+
28
+ @classmethod
29
+ def build(cls, **kwargs):
30
+ # create w/defaults
31
+ config = Config()
32
+
33
+ root = kwargs.get('root', cls.DEFAULT_ROOT)
34
+
35
+ # override w/toml, if applicable
36
+ pyproject_toml_filename = join(root, 'pyproject.toml')
37
+ if isfile(pyproject_toml_filename):
38
+ config.load_pyproject_toml(pyproject_toml_filename)
39
+
40
+ # explicit yaml file
41
+ yaml_filename = kwargs.get('config')
42
+ if not yaml_filename:
43
+ # default yaml file
44
+ yaml_filename = join(root, '.changelet.yaml')
45
+ # override w/yaml, if applicable
46
+ if isfile(yaml_filename):
47
+ config.load_yaml(yaml_filename)
48
+
49
+ # override root w/command line arg, if applicable
50
+ try:
51
+ config.root = kwargs['root']
52
+ except KeyError:
53
+ pass
54
+
55
+ # override directory w/command line arg, if applicable
56
+ try:
57
+ config.directory = kwargs['directory']
58
+ except KeyError:
59
+ pass
60
+
61
+ return config
62
+
63
+ def __init__(
64
+ self,
65
+ root=DEFAULT_ROOT,
66
+ directory='.changelog',
67
+ provider={'class': 'changelet.github.GitHubCli'},
68
+ ):
69
+ self.root = root
70
+ self.directory = directory
71
+
72
+ # will instantiate & configure
73
+ self.provider = provider
74
+
75
+ @property
76
+ def provider(self):
77
+ if self._provider_config is not None:
78
+ value = dict(self._provider_config)
79
+ klass = value.pop('class')
80
+ if isinstance(klass, str):
81
+ module, klass = klass.rsplit('.', 1)
82
+ module = import_module(module)
83
+ klass = getattr(module, klass)
84
+ self._provider = klass(**value)
85
+ self._provider_config = None
86
+ return self._provider
87
+
88
+ @provider.setter
89
+ def provider(self, value):
90
+ self._provider_config = value
91
+
92
+ def load_pyproject_toml(self, filename):
93
+ with open(filename, 'rb') as fh:
94
+ config = toml_load(fh).get('tool', {}).get('changelet')
95
+ if isinstance(config, dict):
96
+ for k, v in config.items():
97
+ setattr(self, k, v)
98
+
99
+ def load_yaml(self, filename=None):
100
+ with open(filename, 'rb') as fh:
101
+ config = yaml_load(fh)
102
+ if isinstance(config, dict):
103
+ for k, v in config.items():
104
+ setattr(self, k, v)
105
+
106
+ def __repr__(self):
107
+ return f'Config<root={self.root}, directory={self.directory}, provider={self.provider}>'
@@ -0,0 +1,120 @@
1
+ #
2
+ #
3
+ #
4
+
5
+ from datetime import datetime, timezone
6
+ from enum import Enum
7
+ from os import listdir, makedirs, remove
8
+ from os.path import dirname, isdir, join
9
+
10
+ from yaml import safe_load
11
+
12
+
13
+ class EntryType(Enum):
14
+ NONE = 'none'
15
+ PATCH = 'patch'
16
+ MINOR = 'minor'
17
+ MAJOR = 'major'
18
+
19
+
20
+ class Entry:
21
+ EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
22
+ ORDERING = {'major': 3, 'minor': 2, 'patch': 1, 'none': 0, '': 0}
23
+
24
+ @classmethod
25
+ def load(self, filename, config):
26
+ with open(filename, 'r') as fh:
27
+ pieces = fh.read().split('---\n')
28
+ data = safe_load(pieces[1])
29
+ description = pieces[2]
30
+ if description[-1] == '\n':
31
+ description = description[:-1]
32
+ if 'pr' in data:
33
+ pr = config.provider.pr_by_id(
34
+ root=config.root, directory=config.directory, id=data['pr']
35
+ )
36
+ else:
37
+ pr = config.provider.pr_by_filename(
38
+ root=config.root,
39
+ directory=config.directory,
40
+ filename=filename,
41
+ )
42
+ return Entry(
43
+ filename=filename,
44
+ type=data['type'],
45
+ description=description,
46
+ pr=pr,
47
+ )
48
+
49
+ @classmethod
50
+ def load_all(cls, config):
51
+ directory = config.directory
52
+ entries = []
53
+ if isdir(directory):
54
+ for filename in sorted(listdir(directory)):
55
+ if not filename.endswith('.md'):
56
+ continue
57
+ filename = join(directory, filename)
58
+ entries.append(Entry.load(filename, config))
59
+ return entries
60
+
61
+ def __init__(self, type, description, pr=None, filename=None):
62
+ self.type = type
63
+ self.description = description
64
+ self.pr = pr
65
+ self.filename = filename
66
+
67
+ @property
68
+ def _ordering(self):
69
+ return (
70
+ self.ORDERING[self.type],
71
+ self.pr.merged_at if self.pr else self.EPOCH,
72
+ )
73
+
74
+ def save(self, filename=None):
75
+ if filename is None:
76
+ filename = self.filename
77
+ directory = dirname(filename)
78
+ if not isdir(directory):
79
+ makedirs(directory)
80
+ with open(filename, 'w') as fh:
81
+ fh.write('---\ntype: ')
82
+ fh.write(self.type)
83
+ if self.pr:
84
+ fh.write('\npr: ')
85
+ fh.write(str(self.pr.id))
86
+ fh.write('\n---\n')
87
+ fh.write(self.description)
88
+ self.filename = filename
89
+
90
+ def remove(self):
91
+ if not self.filename:
92
+ return False
93
+ remove(self.filename)
94
+ return True
95
+
96
+ @property
97
+ def text(self):
98
+ if self.pr:
99
+ return f'* {self.description} - {self.pr.plain}'
100
+ return f'* {self.description}'
101
+
102
+ @property
103
+ def markdown(self):
104
+ if self.pr:
105
+ return f'* {self.description} - {self.pr.markdown}'
106
+ return f'* {self.description}'
107
+
108
+ def copy(self):
109
+ return Entry(
110
+ type=self.type,
111
+ description=self.description,
112
+ pr=self.pr,
113
+ filename=self.filename,
114
+ )
115
+
116
+ def __lt__(self, other):
117
+ return self._ordering < other._ordering
118
+
119
+ def __repr__(self):
120
+ return f'Entry<{self.type}, {self.description[:16]}, {self.filename}, {self.pr}>'