semversioner 3.0.0__py3-none-any.whl

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,14 @@
1
+ from semversioner.__version__ import __version__
2
+ from semversioner.core import Semversioner
3
+ from semversioner.models import (
4
+ Changeset,
5
+ MissingChangesetError,
6
+ Release,
7
+ ReleaseStatus,
8
+ ReleaseType,
9
+ SemversionerError,
10
+ )
11
+ from semversioner.storage import (
12
+ SemversionerFileSystemStorage,
13
+ SemversionerStorage,
14
+ )
@@ -0,0 +1,9 @@
1
+ from .cli import cli
2
+
3
+
4
+ def main() -> None:
5
+ cli()
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
@@ -0,0 +1 @@
1
+ __version__ = "3.0.0"
semversioner/cli.py ADDED
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Semversioner allows you to manage semantic versioning properly and simplifies changelog generation.
4
+
5
+ This project was inspired by the way AWS manages their versioning for AWS-CLI: https://github.com/aws/aws-cli/
6
+
7
+ At any given time, the ``.semversioner/`` directory looks like:
8
+ .semversioner
9
+ |
10
+ └── next-release
11
+ ├── minor-20181227010225.json
12
+ └── major-20181228010225.json
13
+ ├── 1.1.0.json
14
+ ├── 1.1.1.json
15
+ ├── 1.1.2.json
16
+
17
+ This script takes everything in ``next-release`` and aggregates them all together in a single JSON file for that release (e.g ``1.12.0.json``). This
18
+ JSON file is a list of all the individual JSON files from ``next-release``.
19
+
20
+ This is done to simplify changelog generation.
21
+
22
+ Usage
23
+ =====
24
+ ::
25
+ $ semversioner add-change --type major --description "This description will appear in the change log"
26
+ $ semversioner release
27
+ $ semversioner changelog > CHANGELOG.md
28
+ $ semversioner next-version
29
+ """
30
+
31
+ import logging
32
+ import sys
33
+ from pathlib import Path
34
+ from typing import TextIO
35
+
36
+ import click
37
+
38
+ from semversioner import __version__
39
+ from semversioner.core import Semversioner
40
+ from semversioner.models import MissingChangesetError, Release, ReleaseStatus
41
+
42
+
43
+ # Configure the logger for semversioner
44
+ class ClickHandler(logging.Handler):
45
+ def emit(self, record: logging.LogRecord) -> None:
46
+ click.echo(self.format(record))
47
+
48
+
49
+ logger = logging.getLogger("semversioner")
50
+ logger.setLevel(logging.INFO)
51
+ handler = ClickHandler()
52
+ handler.setFormatter(logging.Formatter("%(message)s"))
53
+ logger.addHandler(handler)
54
+ logger.propagate = False
55
+
56
+ ROOTDIR = Path.cwd()
57
+
58
+
59
+ def parse_key_value_pair(
60
+ _ctx: click.core.Context | None, _param: click.core.Parameter | None, value: list[str]
61
+ ) -> dict[str, str] | None:
62
+ """
63
+ Parses a list of strings into a dictionary where each string is a key-value pair.
64
+ """
65
+ if not value:
66
+ return None
67
+ dict_value = {}
68
+ for item in value:
69
+ key, val = item.split("=", 1)
70
+ dict_value[key] = val
71
+ return dict_value
72
+
73
+
74
+ @click.group()
75
+ @click.option(
76
+ "--path", default=str(ROOTDIR), help="Base path. Default to current directory.", type=click.Path(exists=True)
77
+ )
78
+ @click.version_option(version=__version__)
79
+ @click.pass_context
80
+ def cli(ctx: click.Context, path: str) -> None:
81
+ ctx.obj = {"releaser": Semversioner(path=path)}
82
+
83
+
84
+ @cli.command("release", help="Release a new version.")
85
+ @click.pass_context
86
+ def cli_release(ctx: click.Context) -> None:
87
+ releaser: Semversioner = ctx.obj["releaser"]
88
+ if releaser.is_deprecated():
89
+ click.secho("WARN", bg="yellow", fg="black", nl=False)
90
+ click.secho(" deprecated ", fg="magenta", nl=False)
91
+ click.echo(
92
+ "Semversioner now uses '.semversioner' directory instead of '.changes'. Please, rename it to remove this message."
93
+ )
94
+ try:
95
+ result: Release = releaser.release()
96
+ click.echo(message="Successfully created new release: " + result.version)
97
+ except MissingChangesetError:
98
+ click.secho("Error: No changes to release. Skipping release process.", fg="red")
99
+
100
+
101
+ @cli.command("changelog", help="Print the changelog.")
102
+ @click.option("--version", default=None, help="Filter the changelog by version.")
103
+ @click.option("--template", default=None, help="Path to a custom changelog template.", type=click.File("r"))
104
+ @click.pass_context
105
+ def cli_changelog(ctx: click.Context, version: str | None, template: TextIO | None) -> None:
106
+ releaser: Semversioner = ctx.obj["releaser"]
107
+ if template:
108
+ changelog = releaser.generate_changelog(version=version, template=template.read())
109
+ else:
110
+ changelog = releaser.generate_changelog(version=version)
111
+ click.echo(message=changelog, nl=False)
112
+
113
+
114
+ @cli.command("add-change", help="Create a new changeset file.")
115
+ @click.pass_context
116
+ @click.option("--type", "-t", "change_type", type=click.Choice(["major", "minor", "patch"]), required=True)
117
+ @click.option("--description", "-d", required=True)
118
+ @click.option("--attributes", multiple=True, callback=parse_key_value_pair, help="Attributes in key=value format.")
119
+ def cli_add_change(ctx: click.Context, change_type: str, description: str, attributes: dict | None) -> None:
120
+ releaser: Semversioner = ctx.obj["releaser"]
121
+ path: str = releaser.add_change(change_type, description, attributes)
122
+ click.echo(message="Successfully created file " + path)
123
+
124
+
125
+ @cli.command("current-version", help="Show the current version.")
126
+ @click.pass_context
127
+ def cli_current_version(ctx: click.Context) -> None:
128
+ releaser: Semversioner = ctx.obj["releaser"]
129
+ version = releaser.get_last_version()
130
+ click.echo(message=version)
131
+
132
+
133
+ @cli.command("next-version", help="Show computed next version.")
134
+ @click.pass_context
135
+ def cli_next_version(ctx: click.Context) -> None:
136
+ releaser: Semversioner = ctx.obj["releaser"]
137
+ version = releaser.get_next_version()
138
+ if version is None:
139
+ click.secho(message="Error: No changes found. No next version available.", fg="red")
140
+ sys.exit(-1)
141
+
142
+ click.echo(message=version, nl=False)
143
+
144
+
145
+ @cli.command("status", help="Show the status of the working directory.")
146
+ @click.pass_context
147
+ def status(ctx: click.Context) -> None:
148
+ releaser: Semversioner = ctx.obj["releaser"]
149
+ status_info: ReleaseStatus = releaser.get_status()
150
+ click.echo(message=f"Version: {status_info.version}")
151
+ if len(status_info.unreleased_changes) > 0:
152
+ click.echo(message=f"Next version: {status_info.next_version}")
153
+ click.echo(message="Unreleased changes:")
154
+ for change in status_info.unreleased_changes:
155
+ click.secho(message=f"\t{change.type}:\t{change.description}", fg="red")
156
+ click.echo(message='(use "semversioner release" to release the next version)')
157
+ else:
158
+ click.echo(message='No changes to release (use "semversioner add-change")')
159
+
160
+
161
+ @cli.command("check", help="Verifies changeset files exist.")
162
+ @click.pass_context
163
+ def cli_check(ctx: click.Context) -> None:
164
+ releaser: Semversioner = ctx.obj["releaser"]
165
+ if not releaser.check():
166
+ click.secho("Error: No changes to release.", fg="red")
167
+ sys.exit(-1)
168
+ else:
169
+ click.echo("OK")
semversioner/core.py ADDED
@@ -0,0 +1,180 @@
1
+ import logging
2
+ from datetime import datetime, timezone
3
+ from pathlib import Path
4
+
5
+ from jinja2 import Template
6
+
7
+ from semversioner.models import (
8
+ Changeset,
9
+ MissingChangesetError,
10
+ Release,
11
+ ReleaseStatus,
12
+ ReleaseType,
13
+ SemversionerError,
14
+ )
15
+ from semversioner.storage import SemversionerFileSystemStorage
16
+
17
+ logger = logging.getLogger("semversioner")
18
+
19
+ ROOTDIR = Path.cwd()
20
+ INITIAL_VERSION = "0.0.0"
21
+ # To add support for dates you can use: {{ ' (' + release.created_at.strftime("%m-%d-%Y") + ')' if release.created_at }}
22
+ DEFAULT_TEMPLATE = """# Changelog
23
+ Note: version releases in the 0.x.y range may introduce breaking changes.
24
+ {% for release in releases %}
25
+
26
+ ## {{ release.version }}
27
+
28
+ {% for change in release.changes %}
29
+ - {{ change.type }}: {{ change.description }}
30
+ {% endfor %}
31
+ {% endfor %}
32
+ """
33
+
34
+
35
+ class Semversioner:
36
+ def __init__(self, path: str | Path = ROOTDIR):
37
+ self.fs = SemversionerFileSystemStorage(path=str(path))
38
+
39
+ def is_deprecated(self) -> bool:
40
+ return self.fs.is_deprecated()
41
+
42
+ def add_change(self, change_type: str, description: str, attributes: dict[str, str] | None = None) -> str:
43
+ """
44
+ Create a new changeset file.
45
+
46
+ The method creates a new json file in the ``.semversioner/next-release/`` directory
47
+ with the type and description provided.
48
+
49
+ Parameters
50
+ -------
51
+ change_type (str): Change type. Allowed values: major, minor, patch.
52
+ description (str): Change description.
53
+ attributes (dict): Change attributes (Optional).
54
+
55
+ Returns
56
+ -------
57
+ path : str
58
+ Absolute path of the file generated.
59
+ """
60
+
61
+ return self.fs.create_changeset(Changeset(type=change_type, description=description, attributes=attributes))
62
+
63
+ def generate_changelog(self, version: str | None = None, template: str = DEFAULT_TEMPLATE) -> str:
64
+ """
65
+ Generates the changelog.
66
+
67
+ The method generates the changelog based on the template file defined
68
+ in ``DEFAULT_TEMPLATE``.
69
+
70
+ Returns
71
+ -------
72
+ str
73
+ Changelog string.
74
+ """
75
+ releases: list[Release] = self.fs.list_versions()
76
+
77
+ if version is not None:
78
+ releases = [x for x in releases if x.version == version]
79
+
80
+ current_version = self.get_last_version()
81
+ return Template(template, trim_blocks=True).render(
82
+ releases=releases,
83
+ current_version=current_version,
84
+ )
85
+
86
+ def release(self) -> Release:
87
+ """
88
+ Performs the release.
89
+
90
+ The method performs the release by taking everything in ``next-release`` folder
91
+ and aggregating all together in a single JSON file for that release (e.g ``1.12.0.json``).
92
+ The JSON file generated is a list of all the individual JSON files from ``next-release``.
93
+ After aggregating the files, it removes the ``next-release`` directory.
94
+
95
+ Returns
96
+ -------
97
+ previous_version : str
98
+ Previous version.
99
+ new_version : str
100
+ New version.
101
+
102
+ Raises
103
+ -------
104
+ MissingChangesetError: SemversionerError
105
+ """
106
+
107
+ if not self.check():
108
+ raise MissingChangesetError()
109
+
110
+ current_version_number = self.get_last_version()
111
+ next_version_number = self.get_next_version()
112
+ changes: list[Changeset] = self.fs.list_changesets()
113
+
114
+ if next_version_number is None:
115
+ raise SemversionerError("Can't calculate next version number.")
116
+
117
+ logger.info(f"Releasing version: {current_version_number} -> {next_version_number}")
118
+
119
+ release = Release(version=next_version_number, changes=changes, created_at=datetime.now(timezone.utc))
120
+
121
+ self.fs.create_version(release)
122
+ self.fs.remove_all_changesets()
123
+
124
+ return release
125
+
126
+ def get_last_version(self) -> str:
127
+ """
128
+ Gets the current version. Returns '0.0.0' if there are not versions created.
129
+
130
+ """
131
+ return self.fs.get_last_version() or INITIAL_VERSION
132
+
133
+ def get_next_version(self) -> str | None:
134
+ """
135
+ Gets the next version. Returns None is there are not changeset files created.
136
+ """
137
+ changes = self.fs.list_changesets()
138
+ current_version_number = self.get_last_version()
139
+
140
+ if len(changes) == 0:
141
+ return None
142
+
143
+ release_type: str = sorted(x.type for x in changes)[0]
144
+ next_version: str = self._get_next_version_from_type(current_version_number, release_type)
145
+ return next_version
146
+
147
+ def get_status(self) -> ReleaseStatus:
148
+ """
149
+ Displays the status of the working directory.
150
+ """
151
+ version = self.get_last_version()
152
+ changes = self.fs.list_changesets()
153
+ next_version = self.get_next_version()
154
+
155
+ return ReleaseStatus(version=version, next_version=next_version, unreleased_changes=changes)
156
+
157
+ def check(self) -> bool:
158
+ """
159
+ Check if changeset files are present.
160
+ This is useful to enforce that changeset files are present before merging PRs to the target branch.
161
+ """
162
+ changes: list[Changeset] = self.fs.list_changesets()
163
+ return len(changes) > 0
164
+
165
+ def _get_next_version_from_type(self, current_version: str, release_type: str) -> str:
166
+ """
167
+ Returns a string like '1.0.0'.
168
+ """
169
+ # Convert to a list of ints: [1, 0, 0].
170
+ version_parts = [int(i) for i in current_version.split(".")]
171
+ if release_type == ReleaseType.PATCH.value:
172
+ version_parts[2] += 1
173
+ elif release_type == ReleaseType.MINOR.value:
174
+ version_parts[1] += 1
175
+ version_parts[2] = 0
176
+ elif release_type == ReleaseType.MAJOR.value:
177
+ version_parts[0] += 1
178
+ version_parts[1] = 0
179
+ version_parts[2] = 0
180
+ return ".".join(str(i) for i in version_parts)
semversioner/models.py ADDED
@@ -0,0 +1,62 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from enum import Enum
4
+
5
+
6
+ class SemversionerError(Exception):
7
+ """
8
+ Base Exception
9
+ """
10
+
11
+ pass
12
+
13
+
14
+ class MissingChangesetError(SemversionerError):
15
+ """
16
+ Missing changeset files
17
+ """
18
+
19
+ pass
20
+
21
+
22
+ class ReleaseType(Enum):
23
+ """
24
+ Represents the type of release.
25
+ """
26
+
27
+ MAJOR = "major"
28
+ MINOR = "minor"
29
+ PATCH = "patch"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Changeset:
34
+ """
35
+ Represents a change in the version.
36
+ """
37
+
38
+ type: str
39
+ description: str
40
+ attributes: dict[str, str] | None = None
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Release:
45
+ """
46
+ Represents a release.
47
+ """
48
+
49
+ version: str
50
+ changes: list[Changeset]
51
+ created_at: datetime | None = None
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class ReleaseStatus:
56
+ """
57
+ Represents the status of the release in a particular point of time.
58
+ """
59
+
60
+ version: str
61
+ next_version: str | None
62
+ unreleased_changes: list[Changeset]
@@ -0,0 +1,224 @@
1
+ import dataclasses
2
+ import json
3
+ import logging
4
+ from abc import ABCMeta, abstractmethod
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ from packaging.version import parse
9
+
10
+ from semversioner.models import Changeset, Release
11
+
12
+ logger = logging.getLogger("semversioner")
13
+
14
+
15
+ class SemversionerStorage(metaclass=ABCMeta):
16
+ """
17
+ Abstract base class that defines the interface for a storage class in a semversioner system.
18
+ The storage class is responsible for creating, listing, and managing changesets and versions.
19
+ """
20
+
21
+ @abstractmethod
22
+ def is_deprecated(self) -> bool:
23
+ """
24
+ Determines if the storage is deprecated.
25
+ """
26
+ pass
27
+
28
+ @abstractmethod
29
+ def create_changeset(self, change: Changeset) -> str:
30
+ """
31
+ Creates a changeset in the storage.
32
+ """
33
+ pass
34
+
35
+ @abstractmethod
36
+ def remove_all_changesets(self) -> None:
37
+ """
38
+ Removes all changesets from the storage.
39
+ """
40
+ pass
41
+
42
+ @abstractmethod
43
+ def list_changesets(self) -> list[Changeset]:
44
+ """
45
+ Retrieves a list of all changesets in the storage.
46
+ """
47
+ pass
48
+
49
+ @abstractmethod
50
+ def create_version(self, release: Release) -> None:
51
+ """
52
+ Creates a new version in the storage.
53
+ """
54
+ pass
55
+
56
+ @abstractmethod
57
+ def list_versions(self) -> list[Release]:
58
+ """
59
+ Lists all versions in the storage.
60
+ """
61
+ pass
62
+
63
+ @abstractmethod
64
+ def get_last_version(self) -> str | None:
65
+ """
66
+ Retrieves the latest version from the storage. Returns None if no versions exist.
67
+ """
68
+ pass
69
+
70
+
71
+ class EnhancedJSONEncoder(json.JSONEncoder):
72
+ """
73
+ This class extends the built-in json.JSONEncoder class to provide a custom encoding for dataclasses.
74
+ By default, the json.JSONEncoder class doesn't know how to encode dataclasses, so we define a custom encoding here.
75
+ """
76
+
77
+ def default(self, o):
78
+ if dataclasses.is_dataclass(o):
79
+ return dataclasses.asdict(o, dict_factory=lambda x: {k: v for (k, v) in x if v is not None})
80
+ return super().default(o)
81
+
82
+
83
+ class ReleaseJsonMapper:
84
+ """
85
+ Provides functionality to convert a Release object to JSON and vice versa.
86
+ """
87
+
88
+ @staticmethod
89
+ def to_json(release: Release) -> str:
90
+ """
91
+ Converts a Release object to a JSON-formatted string.
92
+ """
93
+ data = {
94
+ "version": release.version,
95
+ "created_at": release.created_at.isoformat(timespec="seconds") if release.created_at else None,
96
+ "changes": release.changes,
97
+ }
98
+
99
+ return json.dumps(data, cls=EnhancedJSONEncoder, indent=2, sort_keys=True)
100
+
101
+ @staticmethod
102
+ def from_json(data: dict, release_identifier: str) -> Release:
103
+ """
104
+ Creates a Release object from a JSON-formatted string.
105
+ """
106
+ created_at: datetime | None = None
107
+
108
+ if "created_at" in data: # New format
109
+ created_at = datetime.fromisoformat(data["created_at"])
110
+ version = data["version"]
111
+ changes = sorted(data["changes"], key=lambda k: k["type"] + k["description"])
112
+ else:
113
+ changes = sorted(data, key=lambda k: k["type"] + k["description"])
114
+ version = release_identifier
115
+
116
+ return Release(version=version, changes=changes, created_at=created_at)
117
+
118
+
119
+ class SemversionerFileSystemStorage(SemversionerStorage):
120
+ def __init__(self, path: str):
121
+ base_path = Path(path)
122
+ semversioner_path_legacy = base_path / ".changes"
123
+ semversioner_path_new = base_path / ".semversioner"
124
+ semversioner_path = semversioner_path_new
125
+ deprecated = False
126
+
127
+ if semversioner_path_legacy.is_dir() and not semversioner_path_new.is_dir():
128
+ deprecated = True
129
+ semversioner_path = semversioner_path_legacy
130
+ if not semversioner_path.is_dir():
131
+ semversioner_path.mkdir(parents=True)
132
+
133
+ next_release_path = semversioner_path / "next-release"
134
+ if not next_release_path.is_dir():
135
+ next_release_path.mkdir(parents=True)
136
+
137
+ self.path = base_path
138
+ self.semversioner_path = semversioner_path
139
+ self.next_release_path = next_release_path
140
+ self.deprecated = deprecated
141
+
142
+ def is_deprecated(self) -> bool:
143
+ return self.deprecated
144
+
145
+ def create_changeset(self, change: Changeset) -> str:
146
+ """
147
+ Create a new changeset file.
148
+
149
+ The method creates a new json file in the ``.semversioner/next-release/`` directory
150
+ with the type and description provided.
151
+
152
+ Parameters
153
+ -------
154
+ change (Changeset): Changeset.
155
+
156
+ Returns
157
+ -------
158
+ path : str
159
+ Absolute path of the file generated.
160
+ """
161
+
162
+ # Retry loop with atomic file creation to prevent race conditions
163
+ while True:
164
+ filename = f"{change.type}-{datetime.now(timezone.utc):%Y%m%d%H%M%S%f}.json"
165
+ full_path = self.next_release_path / filename
166
+
167
+ try:
168
+ # Use 'x' mode for exclusive creation - fails if file already exists
169
+ with full_path.open("x") as f:
170
+ f.write(json.dumps(change, cls=EnhancedJSONEncoder, indent=2) + "\n")
171
+ return str(full_path)
172
+ except FileExistsError:
173
+ # File already exists, retry with a new timestamp
174
+ continue
175
+
176
+ def remove_all_changesets(self) -> None:
177
+ logger.info(f"Removing changeset files in '{self.next_release_path}' directory.")
178
+
179
+ # Remove all json files in next_release_path
180
+ for file_path in self.next_release_path.iterdir():
181
+ if file_path.suffix == ".json":
182
+ file_path.unlink()
183
+ # Remove next_release_path if the directory is empty
184
+ if not any(self.next_release_path.iterdir()):
185
+ logger.info(f"Removing '{self.next_release_path}' directory.")
186
+ self.next_release_path.rmdir()
187
+
188
+ def list_changesets(self) -> list[Changeset]:
189
+ changes: list[Changeset] = []
190
+ next_release_dir = self.next_release_path
191
+ if not next_release_dir.is_dir():
192
+ return changes
193
+ for file_path in next_release_dir.iterdir():
194
+ if file_path.suffix == ".json":
195
+ with file_path.open() as f:
196
+ change_data = json.load(f)
197
+ changes.append(Changeset(**change_data))
198
+ return sorted(changes, key=lambda k: k.type + k.description)
199
+
200
+ def create_version(self, release: Release) -> None:
201
+ version: str = release.version
202
+ release_json_file = self.semversioner_path / f"{version}.json"
203
+ with release_json_file.open("w") as f:
204
+ f.write(ReleaseJsonMapper.to_json(release))
205
+ logger.info(f"Generated '{release_json_file}' file.")
206
+
207
+ def list_versions(self) -> list[Release]:
208
+ releases: list[Release] = []
209
+ for release_identifier in self._list_release_numbers():
210
+ release_json_file = self.semversioner_path / f"{release_identifier}.json"
211
+ with release_json_file.open() as f:
212
+ data = json.load(f)
213
+ releases.append(ReleaseJsonMapper.from_json(data, release_identifier))
214
+ return releases
215
+
216
+ def get_last_version(self) -> str | None:
217
+ releases = self._list_release_numbers()
218
+ if len(releases) > 0:
219
+ return releases[0]
220
+ return None
221
+
222
+ def _list_release_numbers(self) -> list[str]:
223
+ files = [f.name for f in self.semversioner_path.iterdir() if f.is_file()]
224
+ return sorted((x[: -len(".json")] for x in files if x.endswith(".json")), key=lambda x: parse(x), reverse=True)
@@ -0,0 +1,252 @@
1
+ Metadata-Version: 2.4
2
+ Name: semversioner
3
+ Version: 3.0.0
4
+ Summary: Manage properly semver in your repository
5
+ Author-email: Raul Gomis <raulgomis@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2020-2026 Raul Gomis
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/raulgomis/semversioner
29
+ Classifier: Development Status :: 5 - Production/Stable
30
+ Classifier: Environment :: Console
31
+ Classifier: Intended Audience :: Developers
32
+ Classifier: Intended Audience :: End Users/Desktop
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.10
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Programming Language :: Python :: 3.13
40
+ Classifier: Programming Language :: Python :: 3.14
41
+ Classifier: Operating System :: OS Independent
42
+ Requires-Python: >=3.10
43
+ Description-Content-Type: text/markdown
44
+ License-File: LICENSE
45
+ Requires-Dist: click>=8.0.0
46
+ Requires-Dist: jinja2>=3.0.0
47
+ Requires-Dist: packaging>=21.0
48
+ Provides-Extra: dev
49
+ Requires-Dist: pytest>=9.0.0; extra == "dev"
50
+ Requires-Dist: pytest-cov>=6.0.0; extra == "dev"
51
+ Requires-Dist: importlib_resources>=6.0.0; extra == "dev"
52
+ Requires-Dist: ruff>=0.9.0; extra == "dev"
53
+ Dynamic: license-file
54
+
55
+ # Semversioner
56
+
57
+ The easiest way to manage [semantic versioning](https://semver.org/) in your project and generate `CHANGELOG.md` files automatically.
58
+
59
+ Semversioner provides the tooling to automate the semver release process for libraries, docker images, microservices, and more.
60
+
61
+ This project was inspired by the way AWS manages their versioning for [AWS-cli](https://github.com/aws/aws-cli/).
62
+
63
+ [![PyPI Version](https://img.shields.io/pypi/v/semversioner.svg)](https://pypi.org/project/semversioner/)
64
+
65
+ ## Semantic Versioning
66
+
67
+ The [semantic versioning](https://semver.org/) spec involves several possible variations, but to simplify, in _Semversioner_ we are using the three-part version number:
68
+
69
+ `<major>.<minor>.<patch>`
70
+
71
+ Constructed with the following guidelines:
72
+
73
+ - Breaking backward compatibility or major features bumps the **major** (and resets the minor and patch).
74
+ - New additions without breaking backward compatibility bumps the **minor** (and resets the patch).
75
+ - Bug fixes and misc changes bumps the **patch**.
76
+
77
+ An example would be `1.0.0`.
78
+
79
+ ## How it works
80
+
81
+ At any given time, the `.semversioner/` directory looks like:
82
+
83
+ ```text
84
+ .semversioner
85
+ ├── next-release
86
+ │ ├── minor-20181227010225.json
87
+ │ └── major-20181228010225.json
88
+ ├── 1.1.0.json
89
+ ├── 1.1.1.json
90
+ └── 1.1.2.json
91
+ ```
92
+
93
+ The release process takes everything in the `next-release` directory and aggregates them all together in a single JSON file for that release (e.g., `1.12.0.json`). This JSON file is a list of all the individual JSON files from `next-release`.
94
+
95
+ ## Install
96
+
97
+ ```shell
98
+ pip install semversioner
99
+ ```
100
+
101
+ ## Usage
102
+
103
+ You can use the `--help` option on any command to see the available options:
104
+
105
+ ```shell
106
+ semversioner --help
107
+ ```
108
+
109
+ ### Adding changesets
110
+
111
+ In your local environment, you can use the CLI to create the different changeset files that will be committed with your code. This ensures that every pull request or commit contains its own self-contained change description and version bump intention.
112
+
113
+ ```shell
114
+ semversioner add-change --type patch --description "Fix security vulnerability with authentication."
115
+ ```
116
+
117
+ Allowed `--type` values are: `major`, `minor`, `patch`.
118
+
119
+ You can also add custom attributes to the changeset file that will be available later in your release template. Use the `--attributes` flag in `key=value` format (you can pass it multiple times):
120
+
121
+ ```shell
122
+ semversioner add-change --type patch \
123
+ --description "My custom changelog message with attributes." \
124
+ --attributes pr_id=322 \
125
+ --attributes issue_id=123
126
+ ```
127
+
128
+ ### Checking working directory status
129
+
130
+ You can check the status of your working directory to see the current version, the computed next version, and any unreleased changes:
131
+
132
+ ```shell
133
+ semversioner status
134
+ ```
135
+
136
+ Example output:
137
+
138
+ ```text
139
+ Version: 1.0.0
140
+ Next version: 1.1.0
141
+ Unreleased changes:
142
+ minor: Added new authentication feature
143
+ (use "semversioner release" to release the next version)
144
+ ```
145
+
146
+ ### Enforcing changesets in CI/CD (Check)
147
+
148
+ In your CI/CD pipeline, it's often useful to enforce that a PR includes a changeset before merging. You can use the `check` command to verify that there are unreleased changes in the `.semversioner/next-release/` directory.
149
+
150
+ ```shell
151
+ semversioner check
152
+ ```
153
+
154
+ If no changes are found, the command exits with a non-zero status code (`-1`) and prints an error message.
155
+
156
+ ### Releasing a new version
157
+
158
+ When you are ready to create a release (usually in your CI/CD tool on the main branch), you run the `release` command. This automatically computes the new version number based on the unreleased changes, generates a new version JSON file, and clears the `next-release` directory.
159
+
160
+ ```shell
161
+ semversioner release
162
+ ```
163
+
164
+ ### Generating the Changelog
165
+
166
+ As part of your release workflow, you can generate the changelog file with all aggregated changes.
167
+
168
+ ```shell
169
+ semversioner changelog > CHANGELOG.md
170
+ ```
171
+
172
+ #### Customizing the changelog template
173
+
174
+ You can customize the changelog by creating a template and passing it as a parameter to the command. For example:
175
+
176
+ ```shell
177
+ semversioner changelog --template .semversioner/config/template.j2
178
+ ```
179
+
180
+ The template uses [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/), a templating language for Python. A basic example:
181
+
182
+ ```jinja2
183
+ # Changelog
184
+ {% for release in releases %}
185
+
186
+ ## {{ release.version }}
187
+
188
+ {% for change in release.changes %}
189
+ - {{ change.type }}: {{ change.description }}
190
+ {% endfor %}
191
+ {% endfor %}
192
+ ```
193
+
194
+ If you included custom attributes (e.g., `pr_id`, `issue_id`) using the `add-change` command, you can reference them in your template. You also have access to `current_version`:
195
+
196
+ ```jinja2
197
+ # Changelog
198
+ Note: version releases in the 0.x.y range may introduce breaking changes.
199
+
200
+ # Current version: {{ current_version }}
201
+
202
+ {% for release in releases %}
203
+
204
+ ## {{ release.version }}{{ ' (' + release.created_at.strftime('%Y-%m-%d') + ')' if release.created_at }}
205
+
206
+ {% for change in release.changes %}
207
+ - {{ change.type }}: {{ change.description }}{{ ' (#' + change.attributes.pr_id + ')' if change.attributes }}{{ ' (J' + change.attributes.issue_id + ')' if change.attributes }}
208
+ {% endfor %}
209
+ {% endfor %}
210
+ ```
211
+
212
+ #### Filtering the changelog
213
+
214
+ You can filter the changelog by only showing changes for a specific version:
215
+
216
+ ```shell
217
+ semversioner changelog --version "1.0.0"
218
+ ```
219
+
220
+ Alternatively, you can filter changes for the last released version:
221
+
222
+ ```shell
223
+ semversioner changelog --version $(semversioner current-version)
224
+ ```
225
+
226
+ ### Getting the current version
227
+
228
+ You can retrieve the currently released version of your project:
229
+
230
+ ```shell
231
+ semversioner current-version
232
+ ```
233
+
234
+ ### Getting the next version
235
+
236
+ As part of the CI/CD workflow, sometimes you want to release dev, rc, or other pre-release packages. For this purpose, the `next-version` command can be issued to compute the upcoming version based on the current changeset. This will not modify any files on disk.
237
+
238
+ ```shell
239
+ semversioner next-version
240
+ ```
241
+
242
+ ## Global Options
243
+
244
+ - `--path`: Specify a custom base path for your project. Defaults to the current directory. Example: `semversioner --path /path/to/project release`
245
+
246
+ ## License
247
+
248
+ Copyright (c) 2026 Raul Gomis.
249
+ MIT licensed, see [LICENSE](LICENSE) file.
250
+
251
+ ---
252
+ Made with ♥ by [Raul Gomis](https://twitter.com/rgomis).
@@ -0,0 +1,13 @@
1
+ semversioner/__init__.py,sha256=MMhdivi9feYBsQhzsKb8e-LyJ6hmXXTqWMC5Ncblds4,339
2
+ semversioner/__main__.py,sha256=Z9ms2FZUlo8L-vveo4ivpepVJsbjEPBSjDAZF7y11vA,93
3
+ semversioner/__version__.py,sha256=EPmgXOdWKks5S__ZMH7Nu6xpAeVrZpfxaFy4pykuyeI,22
4
+ semversioner/cli.py,sha256=mbxTygEQ2i8EvVUnfu-TUxyIsX_tvPjSUDA1aKEkOco,6224
5
+ semversioner/core.py,sha256=zB9YkgANXFD2vwNLvwwr5VOdBbBCklAhigBuu9rxLNA,5898
6
+ semversioner/models.py,sha256=7esoT9X46Do74SP7UQMejeZ2DtjCJSoAFNoRXtDDTVU,984
7
+ semversioner/storage.py,sha256=tDtbsw0SU84bitHdp9jIs7YGV6eB0GYZGj3fPnnx1IA,7748
8
+ semversioner-3.0.0.dist-info/licenses/LICENSE,sha256=BWexocEqoNJxx_9oeWLgRy7Ybp05IUh1tx-jMdnwzCM,1072
9
+ semversioner-3.0.0.dist-info/METADATA,sha256=-9VcyrTF78clapserjoKQZjZCgyhdXxooSJkNo0kUss,8769
10
+ semversioner-3.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ semversioner-3.0.0.dist-info/entry_points.txt,sha256=DiOn5HsCdcEO7eORmsm9pEd9yRTixG1u_Fv5kBOM8dM,60
12
+ semversioner-3.0.0.dist-info/top_level.txt,sha256=cL85ZNjxcHLYEQV02DtdKwtxzy-L-blj3LZ2y5q8fFI,13
13
+ semversioner-3.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ semversioner = semversioner.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020-2026 Raul Gomis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ semversioner