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.
- tag_sync-0.1.0/LICENSE.md +0 -0
- tag_sync-0.1.0/PKG-INFO +52 -0
- tag_sync-0.1.0/README.md +32 -0
- tag_sync-0.1.0/pyproject.toml +87 -0
- tag_sync-0.1.0/src/tag_sync/__init__.py +5 -0
- tag_sync-0.1.0/src/tag_sync/cli/__init__.py +3 -0
- tag_sync-0.1.0/src/tag_sync/cli/main.py +167 -0
- tag_sync-0.1.0/src/tag_sync/constants.py +7 -0
- tag_sync-0.1.0/src/tag_sync/exceptions.py +25 -0
- tag_sync-0.1.0/src/tag_sync/packager.py +123 -0
- tag_sync-0.1.0/src/tag_sync/pattern.py +118 -0
- tag_sync-0.1.0/src/tag_sync/py.typed +0 -0
- tag_sync-0.1.0/src/tag_sync/semver.py +26 -0
- tag_sync-0.1.0/src/tag_sync/tagger.py +112 -0
- tag_sync-0.1.0/src/tag_sync/version.py +24 -0
|
File without changes
|
tag_sync-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/tag-sync/)
|
|
22
|
+
[](https://www.python.org/)
|
|
23
|
+
[](https://dusktreader.github.io/tag-sync/)
|
|
24
|
+
|
|
25
|
+
# tag-sync
|
|
26
|
+
|
|
27
|
+

|
|
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).
|
tag_sync-0.1.0/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[](https://pypi.org/project/tag-sync/)
|
|
2
|
+
[](https://www.python.org/)
|
|
3
|
+
[](https://dusktreader.github.io/tag-sync/)
|
|
4
|
+
|
|
5
|
+
# tag-sync
|
|
6
|
+
|
|
7
|
+

|
|
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,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,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()
|