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.
- semversioner/__init__.py +14 -0
- semversioner/__main__.py +9 -0
- semversioner/__version__.py +1 -0
- semversioner/cli.py +169 -0
- semversioner/core.py +180 -0
- semversioner/models.py +62 -0
- semversioner/storage.py +224 -0
- semversioner-3.0.0.dist-info/METADATA +252 -0
- semversioner-3.0.0.dist-info/RECORD +13 -0
- semversioner-3.0.0.dist-info/WHEEL +5 -0
- semversioner-3.0.0.dist-info/entry_points.txt +2 -0
- semversioner-3.0.0.dist-info/licenses/LICENSE +21 -0
- semversioner-3.0.0.dist-info/top_level.txt +1 -0
semversioner/__init__.py
ADDED
|
@@ -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
|
+
)
|
semversioner/__main__.py
ADDED
|
@@ -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]
|
semversioner/storage.py
ADDED
|
@@ -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
|
+
[](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,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
|