tag-sync 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.
File without changes
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: tag-sync
3
+ Version: 0.1.0
4
+ Summary: A CLI tool for syncing git tags with project versions
5
+ Author: Tucker Beck
6
+ Author-email: Tucker Beck <tucker.beck@gmail.com>
7
+ License-File: LICENSE.md
8
+ Requires-Dist: bidict>=0.23.1
9
+ Requires-Dist: gitpython>=3.1.46
10
+ Requires-Dist: loguru>=0.7
11
+ Requires-Dist: py-buzz>=4.0
12
+ Requires-Dist: pydantic>=2.12.5
13
+ Requires-Dist: snick>=2.0
14
+ Requires-Dist: typerdrive>=0.9
15
+ Requires-Python: >=3.13
16
+ Project-URL: homepage, https://github.com/dusktreader/tag-sync
17
+ Project-URL: source, https://github.com/dusktreader/tag-sync
18
+ Project-URL: changelog, https://github.com/dusktreader/tag-sync/blob/main/CHANGELOG.md
19
+ Description-Content-Type: text/markdown
20
+
21
+ [![Latest Version](https://img.shields.io/pypi/v/tag-sync?label=pypi-version&logo=python&style=plastic)](https://pypi.org/project/tag-sync/)
22
+ [![Python Versions](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fdusktreader%2Ftag-sync%2Fmain%2Fpyproject.toml&style=plastic&logo=python&label=python-versions)](https://www.python.org/)
23
+ [![Documentation Status](https://github.com/dusktreader/tag-sync/actions/workflows/docs.yml/badge.svg)](https://dusktreader.github.io/tag-sync/)
24
+
25
+ # tag-sync
26
+
27
+ ![tag-sync icon](docs/source/images/tag-sync-icon.png)
28
+
29
+ _A CLI tool for syncing git tags with project versions._
30
+
31
+
32
+ ## Super-quick Start
33
+
34
+ Requires: Python 3.13+
35
+
36
+ Install through pip:
37
+
38
+ ```bash
39
+ pip install tag-sync
40
+ ```
41
+
42
+ Publish a tag for the current package version:
43
+
44
+ ```bash
45
+ tag-sync publish
46
+ ```
47
+
48
+
49
+ ## Documentation
50
+
51
+ The complete documentation can be found at the
52
+ [tag-sync home page](https://dusktreader.github.io/tag-sync).
@@ -0,0 +1,32 @@
1
+ [![Latest Version](https://img.shields.io/pypi/v/tag-sync?label=pypi-version&logo=python&style=plastic)](https://pypi.org/project/tag-sync/)
2
+ [![Python Versions](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fdusktreader%2Ftag-sync%2Fmain%2Fpyproject.toml&style=plastic&logo=python&label=python-versions)](https://www.python.org/)
3
+ [![Documentation Status](https://github.com/dusktreader/tag-sync/actions/workflows/docs.yml/badge.svg)](https://dusktreader.github.io/tag-sync/)
4
+
5
+ # tag-sync
6
+
7
+ ![tag-sync icon](docs/source/images/tag-sync-icon.png)
8
+
9
+ _A CLI tool for syncing git tags with project versions._
10
+
11
+
12
+ ## Super-quick Start
13
+
14
+ Requires: Python 3.13+
15
+
16
+ Install through pip:
17
+
18
+ ```bash
19
+ pip install tag-sync
20
+ ```
21
+
22
+ Publish a tag for the current package version:
23
+
24
+ ```bash
25
+ tag-sync publish
26
+ ```
27
+
28
+
29
+ ## Documentation
30
+
31
+ The complete documentation can be found at the
32
+ [tag-sync home page](https://dusktreader.github.io/tag-sync).
@@ -0,0 +1,87 @@
1
+ [project]
2
+ name = "tag-sync"
3
+ version = "0.1.0"
4
+ description = "A CLI tool for syncing git tags with project versions"
5
+ authors = [
6
+ {name = "Tucker Beck", email = "tucker.beck@gmail.com"},
7
+ ]
8
+ readme = "README.md"
9
+ license-files = ["LICENSE.md"]
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "bidict>=0.23.1",
13
+ "gitpython>=3.1.46",
14
+ "loguru>=0.7",
15
+ "py-buzz>=4.0",
16
+ "pydantic>=2.12.5",
17
+ "snick>=2.0",
18
+ "typerdrive>=0.9",
19
+ ]
20
+
21
+ [project.urls]
22
+ homepage = "https://github.com/dusktreader/tag-sync"
23
+ source = "https://github.com/dusktreader/tag-sync"
24
+ changelog = "https://github.com/dusktreader/tag-sync/blob/main/CHANGELOG.md"
25
+
26
+ [project.scripts]
27
+ tag-sync = "tag_sync.cli.main:cli"
28
+
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "debugpy~=1.8",
33
+ "ipython~=8.18",
34
+ "zensical>=0.0.31",
35
+ "pyclean~=3.1",
36
+ "pygments~=2.19",
37
+ "pytest~=8.3",
38
+ "pytest-bdd>=7.0.0",
39
+ "pytest-cov~=6.0",
40
+ "pytest-mock~=3.14",
41
+ "pytest-pretty~=1.2",
42
+ "pytest-random-order~=1.1",
43
+ "ruff~=0.11",
44
+ "ty~=0.0",
45
+ "typos~=1.31",
46
+ ]
47
+
48
+
49
+ [tool.uv]
50
+ package = true
51
+
52
+
53
+ [tool.pytest.ini_options]
54
+ addopts = [
55
+ "--random-order",
56
+ "--cov=src/tag_sync",
57
+ "--cov-report=term-missing",
58
+ "--cov-fail-under=85",
59
+ "--cov-report=xml:.coverage.xml",
60
+ "--junitxml=.junit.xml",
61
+ "--override-ini=junit_family=legacy",
62
+ ]
63
+ testpaths = ["tests"]
64
+ pythonpath = ["src"]
65
+ python_files = ["test_*.py", "*_steps.py"]
66
+ markers = [
67
+ "integration: Integration tests that exercise real git repos and packager files",
68
+ "unit: Fast unit tests with all external dependencies mocked",
69
+ ]
70
+
71
+
72
+ [tool.ruff]
73
+ line-length = 120
74
+ src = ["src/tag_sync", "tests"]
75
+
76
+
77
+ [tool.ruff.format]
78
+ docstring-code-format = true
79
+
80
+
81
+ [tool.typos.default]
82
+ extend-ignore-identifiers-re = []
83
+
84
+
85
+ [build-system]
86
+ requires = ["uv-build>=0.1.0"]
87
+ build-backend = "uv_build"
@@ -0,0 +1,5 @@
1
+ from tag_sync.version import get_version
2
+
3
+ __version__ = get_version()
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,3 @@
1
+ from tag_sync.cli.main import cli
2
+
3
+ __all__ = ["cli"]
@@ -0,0 +1,167 @@
1
+ from pathlib import Path
2
+ from typing import Annotated
3
+
4
+ import typer
5
+ from loguru import logger
6
+ from typerdrive import add_logs_subcommand, handle_errors, terminal_message
7
+
8
+ from tag_sync.exceptions import TagAlreadyPublishedError
9
+ from tag_sync.packager import PACKAGERS, resolve_packager
10
+ from tag_sync.pattern import Pattern
11
+ from tag_sync.tagger import Tagger
12
+
13
+ cli = typer.Typer(
14
+ name="tag-sync",
15
+ help="Sync git tags with project versions.",
16
+ no_args_is_help=True,
17
+ )
18
+
19
+ add_logs_subcommand(cli)
20
+
21
+ DryRunOption = Annotated[bool, typer.Option("--dry-run", help="Show what would happen without making any changes")]
22
+ PackagerOption = Annotated[
23
+ str | None,
24
+ typer.Option(
25
+ "--packager",
26
+ help=f"Packager to use for reading the project version. One of: {', '.join(PACKAGERS)}. Auto-detected when omitted.",
27
+ ),
28
+ ]
29
+ DirectoryOption = Annotated[
30
+ Path,
31
+ typer.Option(
32
+ "--directory",
33
+ "-d",
34
+ help="Directory containing the project. Defaults to the current working directory.",
35
+ ),
36
+ ]
37
+
38
+
39
+ @cli.command()
40
+ @handle_errors("verify failed")
41
+ def verify(
42
+ packager_name: PackagerOption = None,
43
+ directory: DirectoryOption = Path("."),
44
+ ) -> None:
45
+ """
46
+ Verify whether the current package version has a published git tag.
47
+
48
+ Uses the tagger's default pattern to convert the package's SemVer to a
49
+ canonical tag string, then checks whether that tag exists on origin.
50
+
51
+ The packager is auto-detected from the project directory when --packager
52
+ is not supplied.
53
+ """
54
+ packager = resolve_packager(packager_name, directory)
55
+ package_version = packager.package_version
56
+ default_pattern = Pattern(Tagger.DEFAULT_PATTERN_TEMPLATE)
57
+ tagger = Tagger(default_pattern.format(package_version))
58
+ version_string = tagger.pattern.format(tagger.version)
59
+ if tagger.is_published():
60
+ suffix = "is already published."
61
+ else:
62
+ suffix = "has not been published yet."
63
+ terminal_message(f"Version [cyan]{version_string}[/cyan] {suffix}", subject="verify")
64
+
65
+
66
+ @cli.command()
67
+ @handle_errors("check failed")
68
+ def check(
69
+ tag_version_string: Annotated[
70
+ str, typer.Argument(help="Tag version string to validate against the package version")
71
+ ],
72
+ packager_name: PackagerOption = None,
73
+ directory: DirectoryOption = Path("."),
74
+ ) -> None:
75
+ """
76
+ Validate that a tag version matches the current package version.
77
+
78
+ The packager is auto-detected from the project directory when --packager
79
+ is not supplied.
80
+ """
81
+ logger.debug(f"Checking tag version: {tag_version_string}")
82
+ packager = resolve_packager(packager_name, directory)
83
+ tagger = Tagger(tag_version_string)
84
+ tagger.check(packager)
85
+ version_string = tagger.pattern.format(tagger.version)
86
+ terminal_message(f"Version [cyan]{version_string}[/cyan] matches the package version.", subject="check")
87
+
88
+
89
+ @cli.command()
90
+ @handle_errors("publish failed")
91
+ def publish(
92
+ tag_version_string: Annotated[
93
+ str | None, typer.Argument(help="Tag version string to publish. Derived from the package version when omitted.")
94
+ ] = None,
95
+ packager_name: PackagerOption = None,
96
+ directory: DirectoryOption = Path("."),
97
+ replace: Annotated[bool, typer.Option(help="Replace existing tag if it exists")] = False,
98
+ dry_run: DryRunOption = False,
99
+ ) -> None:
100
+ """
101
+ Validate the project version, create a git tag, and push it to origin.
102
+
103
+ When the tag version string is supplied it is validated against the current
104
+ package version. When omitted it is derived from the package version using
105
+ the default tag pattern, skipping the version-match check.
106
+
107
+ If the tag is already published on origin the command fails unless
108
+ --replace is given, in which case the existing tag is deleted locally and
109
+ on origin before the new one is created.
110
+
111
+ The packager is auto-detected from the project directory when --packager
112
+ is not supplied.
113
+ """
114
+ packager = resolve_packager(packager_name, directory)
115
+ explicit = tag_version_string is not None
116
+ if tag_version_string is None:
117
+ default_pattern = Pattern(Tagger.DEFAULT_PATTERN_TEMPLATE)
118
+ tag_version_string = default_pattern.format(packager.package_version)
119
+ tag: str = tag_version_string
120
+ tagger = Tagger(tag)
121
+ if explicit:
122
+ tagger.check(packager)
123
+ try:
124
+ tagger.require_unpublished()
125
+ except TagAlreadyPublishedError:
126
+ if not replace:
127
+ raise
128
+ if not typer.confirm(f"Tag {tag} is already on origin. Replace it?"):
129
+ raise typer.Abort()
130
+ logger.debug(f"Replacing tag: {tag}")
131
+ tagger.delete_local_tag(dry_run=dry_run)
132
+ tagger.delete_remote_tag(dry_run=dry_run)
133
+ logger.debug(f"Publishing tag: {tag}")
134
+ tagger.make_tag(dry_run=dry_run)
135
+ tagger.push_tag(dry_run=dry_run)
136
+ version_string = tagger.pattern.format(tagger.version)
137
+ terminal_message(
138
+ f"Tag [cyan]{version_string}[/cyan] published successfully.",
139
+ subject="publish",
140
+ )
141
+
142
+
143
+ @cli.command()
144
+ @handle_errors("nuke failed")
145
+ def nuke(
146
+ tag_version_string: Annotated[str, typer.Argument(help="Tag version string to remove locally and on origin")],
147
+ force: Annotated[bool | None, typer.Option(help="Don't prompt to confirm deletion")] = None,
148
+ dry_run: DryRunOption = False,
149
+ ) -> None:
150
+ """
151
+ Remove a tag from both the local git repository and origin.
152
+ """
153
+ if force is None:
154
+ force = typer.confirm(
155
+ f"Are you sure you want to nuke tag {tag_version_string}? This will delete it locally and on origin."
156
+ )
157
+ if not force:
158
+ raise typer.Abort()
159
+ tagger = Tagger(tag_version_string)
160
+ logger.debug(f"Nuking tag: {tag_version_string}")
161
+ tagger.delete_local_tag(dry_run=dry_run)
162
+ tagger.delete_remote_tag(dry_run=dry_run)
163
+ version_string = tagger.pattern.format(tagger.version)
164
+ terminal_message(
165
+ f"Tag [cyan]{version_string}[/cyan] removed locally and from origin.",
166
+ subject="nuke",
167
+ )
@@ -0,0 +1,7 @@
1
+ from typing import Literal
2
+
3
+ from bidict import bidict
4
+
5
+
6
+ PRETYPE_CANONICAL = Literal["alpha", "beta", "rc", "dev"]
7
+ type PretypeMap = bidict[str, PRETYPE_CANONICAL]
@@ -0,0 +1,25 @@
1
+ from typerdrive import TyperdriveError
2
+
3
+
4
+ class TagSyncError(TyperdriveError):
5
+ """Base exception for all tag-sync errors."""
6
+
7
+
8
+ class InvalidPatternError(TagSyncError):
9
+ """Raised when a Pattern regex is missing required named groups."""
10
+
11
+
12
+ class VersionParseError(TagSyncError):
13
+ """Raised when a version string cannot be parsed."""
14
+
15
+
16
+ class VersionMismatchError(TagSyncError):
17
+ """Raised when a tag version does not match the package version."""
18
+
19
+
20
+ class GitError(TagSyncError):
21
+ """Raised when a git operation fails."""
22
+
23
+
24
+ class TagAlreadyPublishedError(TagSyncError):
25
+ """Raised when a tag is already published on origin and replacement was not requested."""
@@ -0,0 +1,123 @@
1
+ import json
2
+ import tomllib
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+ from typing import Protocol, override
6
+
7
+ from tag_sync.exceptions import TagSyncError, VersionParseError
8
+ from tag_sync.pattern import Pattern
9
+ from tag_sync.semver import SemVer
10
+
11
+
12
+ class Packager(ABC):
13
+ pattern: Pattern
14
+
15
+ def parse(self, version_string: str) -> SemVer:
16
+ return self.pattern.parse(version_string)
17
+
18
+ @abstractmethod
19
+ def extract_version_string(self) -> str: ...
20
+
21
+ def format(self, version: SemVer) -> str:
22
+ return self.pattern.format(version)
23
+
24
+ @property
25
+ def package_version(self) -> SemVer:
26
+ with VersionParseError.handle_errors(f"Couldn't extract package version using `{self.__class__.__name__}`"):
27
+ return self.parse(self.extract_version_string())
28
+
29
+
30
+ class UvPackager(Packager):
31
+ def __init__(self, path: Path = Path(".")):
32
+ self.path = path
33
+ self.pattern = Pattern(
34
+ "<major>.<minor>.<patch><pre_type:a|b|rc|dev><pre_id>",
35
+ pretype_map={"a": "alpha", "b": "beta"},
36
+ )
37
+
38
+ @override
39
+ def extract_version_string(self) -> str:
40
+ pyproject_path = self.path / "pyproject.toml"
41
+ data = tomllib.loads(pyproject_path.read_text())
42
+ return data["project"]["version"]
43
+
44
+
45
+ class NpmPackager(Packager):
46
+ def __init__(self, path: Path = Path(".")):
47
+ self.path = path
48
+ self.pattern = Pattern("<major>.<minor>.<patch>-<pre_type:alpha|beta|rc|dev>.<pre_id>")
49
+
50
+ @override
51
+ def extract_version_string(self) -> str:
52
+ package_json_path = self.path / "package.json"
53
+ data = json.loads(package_json_path.read_text())
54
+ return data["version"]
55
+
56
+
57
+ class _PackagerFactory(Protocol):
58
+ def __call__(self, path: Path = ...) -> Packager: ...
59
+
60
+
61
+ PACKAGERS: dict[str, _PackagerFactory] = {
62
+ "npm": NpmPackager,
63
+ "uv": UvPackager,
64
+ }
65
+
66
+ _MANIFEST_PACKAGER: dict[str, str] = {
67
+ "pyproject.toml": "uv",
68
+ "package.json": "npm",
69
+ }
70
+
71
+
72
+ def resolve_packager(packager_name: str | None, path: Path = Path(".")) -> Packager:
73
+ """
74
+ Return an instantiated `Packager` for the given directory.
75
+
76
+ When `packager_name` is `None` the packager is auto-detected from the
77
+ manifest files present in `path`. When a name is provided it is looked up
78
+ in the `PACKAGERS` registry and instantiated with `path`.
79
+
80
+ Args:
81
+ packager_name: Explicit packager key (e.g. `"uv"`, `"npm"`), or `None`
82
+ to trigger auto-detection.
83
+ path: Project directory to pass to the packager.
84
+
85
+ Returns:
86
+ An instantiated `Packager`.
87
+ """
88
+ if packager_name is None:
89
+ return detect_packager(path)
90
+ return PACKAGERS[packager_name](path)
91
+
92
+
93
+ def detect_packager(path: Path = Path(".")) -> Packager:
94
+ """
95
+ Auto-detect the packager from manifest files in `path`.
96
+
97
+ Looks for `pyproject.toml` (uv) and `package.json` (npm). Raises
98
+ `TagSyncError` when zero or more than one manifest is found — the caller
99
+ should use `--packager` to be explicit in that case.
100
+
101
+ Args:
102
+ path: Directory to inspect. Defaults to the current working directory.
103
+
104
+ Returns:
105
+ An instantiated `Packager` for the detected manifest.
106
+
107
+ Raises:
108
+ TagSyncError: When no manifest or multiple manifests are found.
109
+ """
110
+ found = [name for name in _MANIFEST_PACKAGER if (path / name).exists()]
111
+ if len(found) == 0:
112
+ raise TagSyncError(
113
+ f"No supported manifest file found in {path}. "
114
+ f"Expected one of: {', '.join(_MANIFEST_PACKAGER)}. "
115
+ "Use --packager to specify the packager explicitly."
116
+ )
117
+ if len(found) > 1:
118
+ raise TagSyncError(
119
+ f"Multiple manifest files found in {path}: {', '.join(found)}. "
120
+ "Use --packager to specify the packager explicitly."
121
+ )
122
+ packager_name = _MANIFEST_PACKAGER[found[0]]
123
+ return PACKAGERS[packager_name](path)
@@ -0,0 +1,118 @@
1
+ import re
2
+ from typing import cast
3
+
4
+ from bidict import bidict
5
+ import snick
6
+
7
+ from tag_sync.constants import PRETYPE_CANONICAL, PretypeMap
8
+ from tag_sync.exceptions import InvalidPatternError, VersionParseError
9
+ from tag_sync.semver import SemVer
10
+
11
+
12
+ # Parses: [prefix]<major>.<minor>.<patch>[<pre_leader>]<pre_type:t1|t2|...>[<pre_sep>]<pre_id>
13
+ # Examples:
14
+ # v<major>.<minor>.<patch>-<pre_type:alpha|beta|rc|dev>.<pre_id> -> v1.2.3, v1.2.3-alpha.1, etc
15
+ # <major>.<minor>.<patch><pre_type:a|b|rc|dev><pre_id> -> 1.2.3, 1.2.3a1, etc
16
+ PATTERN_TEMPLATE = re.compile(
17
+ r"^(?P<prefix>.*?)"
18
+ r"<major>\.<minor>\.<patch>"
19
+ r"(?P<pre_leader>.*?)<pre_type:(?P<pre_types>[^>]+)>(?P<pre_sep>.*?)<pre_id>"
20
+ r"$"
21
+ )
22
+
23
+
24
+ class Pattern:
25
+ template: str
26
+
27
+ def __init__(self, template: str, pretype_map: "dict[str, PRETYPE_CANONICAL] | None" = None) -> None:
28
+ match = InvalidPatternError.enforce_defined(
29
+ PATTERN_TEMPLATE.match(template),
30
+ snick.dedent(
31
+ f"""
32
+ Pattern template is not valid: {template!r}
33
+ Expected format: [prefix]<major>.<minor>.<patch>[<pre_leader>]<pre_type:t1|t2|...>[<pre_sep>]<pre_id>
34
+ """
35
+ ),
36
+ )
37
+ self.template = template
38
+ base: PretypeMap = bidict(pretype_map) if pretype_map is not None else bidict()
39
+ # Ensure every canonical pre-type that isn't already a value in the
40
+ # supplied map gets an identity entry (raw == canonical).
41
+ canonical_types: tuple[PRETYPE_CANONICAL, ...] = ("alpha", "beta", "rc", "dev")
42
+ supplemental = {c: c for c in canonical_types if c not in base.inverse}
43
+ self.pretype_map: PretypeMap = bidict({**base, **supplemental})
44
+
45
+ prefix = match.group("prefix") or ""
46
+ pre_leader = match.group("pre_leader") or ""
47
+ pre_sep = match.group("pre_sep") or ""
48
+ self._pre_types = match.group("pre_types").split("|")
49
+
50
+ self.core_format_string = f"{prefix}{{major}}.{{minor}}.{{patch}}"
51
+ self.pre_format_string = f"{pre_leader}{{pre_type}}{pre_sep}{{pre_id}}"
52
+
53
+ pre_type_alt = "|".join(self._pre_types)
54
+ core = rf"^{prefix}(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"
55
+ pre = rf"(?:{re.escape(pre_leader)}(?P<pre_type>{pre_type_alt}){re.escape(pre_sep)}(?P<pre_id>\d+))?"
56
+ self.regex = core + pre + r"$"
57
+
58
+ def parse(self, version_string: str) -> SemVer:
59
+ """
60
+ Parse a version string against this pattern and return a SemVer.
61
+
62
+ Raises:
63
+ VersionParseError: If the string does not match the pattern.
64
+ """
65
+ message: str = snick.dedent(
66
+ f"""
67
+ Invalid version string: {version_string}
68
+
69
+ Please use format {self.template}.
70
+
71
+ Examples:
72
+ """
73
+ )
74
+ message += "\n".join(f"- {ex}" for ex in self.examples)
75
+ match = VersionParseError.enforce_defined(
76
+ re.match(self.regex, version_string),
77
+ message,
78
+ )
79
+ with VersionParseError.handle_errors(f"Couldn't parse version from {version_string}"):
80
+ version = SemVer(
81
+ major=int(match.group("major")),
82
+ minor=int(match.group("minor")),
83
+ patch=int(match.group("patch")),
84
+ )
85
+ if match.group("pre_type"):
86
+ raw = match.group("pre_type")
87
+ version.pre_type = cast(PRETYPE_CANONICAL, self.pretype_map.get(raw, raw))
88
+ version.pre_id = int(match.group("pre_id"))
89
+ return version
90
+
91
+ def format(self, version: SemVer) -> str:
92
+ """
93
+ Produce a version string from the given SemVer using this Pattern.
94
+
95
+ Canonical pre_type values (e.g. 'alpha') are mapped back to their
96
+ pattern-native form (e.g. 'a') via the inverse of pretype_map before
97
+ substitution.
98
+ """
99
+ result = self.core_format_string.format(major=version.major, minor=version.minor, patch=version.patch)
100
+ if version.pre_type is not None and version.pre_id is not None:
101
+ raw_pre_type = self.pretype_map.inverse.get(version.pre_type, version.pre_type)
102
+ result += self.pre_format_string.format(pre_type=raw_pre_type, pre_id=version.pre_id)
103
+ return result
104
+
105
+ @property
106
+ def examples(self) -> list[str]:
107
+ """
108
+ Return four representative example version strings: two release
109
+ versions and two pre-release versions with fixed values.
110
+ """
111
+ pt0 = self._pre_types[0]
112
+ pt1 = self._pre_types[min(1, len(self._pre_types) - 1)]
113
+ return [
114
+ self.format(SemVer(major=1, minor=0, patch=0)),
115
+ self.format(SemVer(major=1, minor=2, patch=3)),
116
+ self.format(SemVer(major=2, minor=0, patch=0, pre_type=self.pretype_map[pt0], pre_id=1)),
117
+ self.format(SemVer(major=1, minor=2, patch=3, pre_type=self.pretype_map[pt1], pre_id=2)),
118
+ ]
File without changes
@@ -0,0 +1,26 @@
1
+ from dataclasses import dataclass
2
+
3
+ from tag_sync.constants import PRETYPE_CANONICAL
4
+
5
+
6
+ @dataclass
7
+ class SemVer:
8
+ major: int
9
+ minor: int
10
+ patch: int
11
+ pre_type: PRETYPE_CANONICAL | None = None
12
+ pre_id: int | None = None
13
+
14
+ def __eq__(self, other: object) -> bool:
15
+ if not isinstance(other, SemVer):
16
+ return NotImplemented
17
+ return (self.major, self.minor, self.patch, self.pre_type, self.pre_id) == (
18
+ other.major,
19
+ other.minor,
20
+ other.patch,
21
+ other.pre_type,
22
+ other.pre_id,
23
+ )
24
+
25
+ def __hash__(self) -> int:
26
+ return hash((self.major, self.minor, self.patch, self.pre_type, self.pre_id))
@@ -0,0 +1,112 @@
1
+ import snick
2
+ from git import Repo
3
+
4
+ from tag_sync.constants import PRETYPE_CANONICAL
5
+ from tag_sync.exceptions import GitError, TagAlreadyPublishedError, VersionMismatchError
6
+ from tag_sync.packager import Packager
7
+ from tag_sync.pattern import Pattern
8
+ from tag_sync.semver import SemVer
9
+
10
+
11
+ class Tagger:
12
+ pattern: Pattern
13
+ version: SemVer
14
+
15
+ DEFAULT_PATTERN_TEMPLATE = "v<major>.<minor>.<patch>-<pre_type:alpha|beta|rc|dev>.<pre_id>"
16
+
17
+ def __init__(
18
+ self,
19
+ version_string: str,
20
+ pattern_template: str | None = None,
21
+ pretype_map: "dict[str, PRETYPE_CANONICAL] | None" = None,
22
+ ):
23
+ self.pattern = Pattern(
24
+ pattern_template if pattern_template is not None else self.DEFAULT_PATTERN_TEMPLATE,
25
+ pretype_map=pretype_map,
26
+ )
27
+ self.version = self.parse(version_string)
28
+
29
+ def parse(self, version_string: str) -> SemVer:
30
+ return self.pattern.parse(version_string)
31
+
32
+ def check(self, packager: Packager) -> None:
33
+ """
34
+ Verify that the version from the packager matches this tag version.
35
+
36
+ Raises:
37
+ VersionMismatchError: If the versions do not match.
38
+ """
39
+ package_version = packager.package_version
40
+ VersionMismatchError.require_condition(
41
+ self.version == package_version,
42
+ snick.dedent(
43
+ f"""
44
+ Package version doesn't match tag version.
45
+
46
+ Tag version: {self.pattern.format(self.version)}
47
+ Package version: {packager.pattern.format(package_version)}
48
+
49
+ Please update the package version.
50
+ """
51
+ ),
52
+ )
53
+
54
+ def is_published(self) -> bool:
55
+ """Check whether this tag exists on the remote."""
56
+ tag = self.pattern.format(self.version)
57
+ repo = Repo(search_parent_directories=True)
58
+ refs = repo.git.ls_remote("--tags", "origin", tag)
59
+ return bool(refs.strip())
60
+
61
+ def require_unpublished(self) -> None:
62
+ """
63
+ Raise `TagAlreadyPublishedError` if this tag is already on origin.
64
+
65
+ Raises:
66
+ TagAlreadyPublishedError: If the tag is already published.
67
+ """
68
+ tag = self.pattern.format(self.version)
69
+ TagAlreadyPublishedError.require_condition(
70
+ not self.is_published(),
71
+ f"Tag {tag} is already published on origin. Use --replace to overwrite it.",
72
+ )
73
+
74
+ def make_tag(self, dry_run: bool = False) -> None:
75
+ """Create this tag in the local git repository."""
76
+ tag = self.pattern.format(self.version)
77
+ if dry_run:
78
+ print(f"[dry-run] Would create tag: {tag}")
79
+ return
80
+ repo = Repo(search_parent_directories=True)
81
+ with GitError.handle_errors(f"Failed to create tag {tag}"):
82
+ repo.create_tag(tag)
83
+
84
+ def push_tag(self, dry_run: bool = False) -> None:
85
+ """Push this tag to the remote."""
86
+ tag = self.pattern.format(self.version)
87
+ if dry_run:
88
+ print(f"[dry-run] Would push tag: {tag}")
89
+ return
90
+ repo = Repo(search_parent_directories=True)
91
+ with GitError.handle_errors(f"Failed to push tag {tag}"):
92
+ repo.remotes["origin"].push(tag)
93
+
94
+ def delete_local_tag(self, dry_run: bool = False) -> None:
95
+ """Delete this tag from the local git repository."""
96
+ tag = self.pattern.format(self.version)
97
+ if dry_run:
98
+ print(f"[dry-run] Would delete local tag: {tag}")
99
+ return
100
+ repo = Repo(search_parent_directories=True)
101
+ with GitError.handle_errors(f"Failed to delete local tag {tag}"):
102
+ repo.delete_tag(repo.tag(f"refs/tags/{tag}"))
103
+
104
+ def delete_remote_tag(self, dry_run: bool = False) -> None:
105
+ """Delete this tag from the remote."""
106
+ tag = self.pattern.format(self.version)
107
+ if dry_run:
108
+ print(f"[dry-run] Would delete remote tag: {tag}")
109
+ return
110
+ repo = Repo(search_parent_directories=True)
111
+ with GitError.handle_errors(f"Failed to delete remote tag {tag}"):
112
+ repo.remotes["origin"].push(refspec=f":refs/tags/{tag}")
@@ -0,0 +1,24 @@
1
+ import tomllib
2
+ from importlib import metadata
3
+
4
+
5
+ def get_version_from_metadata() -> str:
6
+ return metadata.version(__package__ or __name__)
7
+
8
+
9
+ def get_version_from_pyproject() -> str:
10
+ with open("pyproject.toml", "rb") as file:
11
+ return tomllib.load(file)["project"]["version"]
12
+
13
+
14
+ def get_version() -> str:
15
+ try:
16
+ return get_version_from_metadata()
17
+ except metadata.PackageNotFoundError:
18
+ try:
19
+ return get_version_from_pyproject()
20
+ except (FileNotFoundError, KeyError):
21
+ return "unknown"
22
+
23
+
24
+ __version__ = get_version()