python-semantic-release 10.4.1__py3-none-any.whl → 10.5.1__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.
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-semantic-release
3
- Version: 10.4.1
3
+ Version: 10.5.1
4
4
  Summary: Automatic Semantic Versioning for Python projects
5
- Author-email: Rolf Erik Lekang <me@rolflekang.com>
5
+ Author-email: Rolf Erik Lekang <me@rolflekang.com>, codejedi365 <codejedi365@gmail.com>
6
6
  License: MIT
7
7
  Project-URL: changelog, https://github.com/python-semantic-release/python-semantic-release/blob/master/CHANGELOG.md
8
8
  Project-URL: documentation, https://python-semantic-release.readthedocs.io
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
20
21
  Requires-Python: ~=3.8
21
22
  Description-Content-Type: text/x-rst
22
23
  License-File: LICENSE
@@ -26,7 +27,7 @@ Requires-Dist: gitpython~=3.0
26
27
  Requires-Dist: requests~=2.25
27
28
  Requires-Dist: jinja2~=3.1
28
29
  Requires-Dist: python-gitlab<7.0.0,>=4.0.0
29
- Requires-Dist: tomlkit~=0.11
30
+ Requires-Dist: tomlkit~=0.13.0
30
31
  Requires-Dist: dotty-dict~=1.3
31
32
  Requires-Dist: importlib-resources~=6.0
32
33
  Requires-Dist: pydantic~=2.0
@@ -35,11 +36,12 @@ Requires-Dist: shellingham~=1.5
35
36
  Requires-Dist: Deprecated~=1.2
36
37
  Provides-Extra: build
37
38
  Requires-Dist: build~=1.2; extra == "build"
39
+ Requires-Dist: tomlkit~=0.13.0; extra == "build"
38
40
  Provides-Extra: docs
39
- Requires-Dist: Sphinx~=6.0; extra == "docs"
40
- Requires-Dist: sphinxcontrib-apidoc==0.5.0; extra == "docs"
41
+ Requires-Dist: Sphinx~=7.4; extra == "docs"
42
+ Requires-Dist: sphinxcontrib-apidoc==0.6.0; extra == "docs"
41
43
  Requires-Dist: sphinx-autobuild==2024.2.4; extra == "docs"
42
- Requires-Dist: furo~=2024.1; extra == "docs"
44
+ Requires-Dist: furo~=2025.9; extra == "docs"
43
45
  Provides-Extra: test
44
46
  Requires-Dist: coverage[toml]~=7.0; extra == "test"
45
47
  Requires-Dist: filelock~=3.15; extra == "test"
@@ -48,9 +50,9 @@ Requires-Dist: freezegun~=1.5; extra == "test"
48
50
  Requires-Dist: pyyaml~=6.0; extra == "test"
49
51
  Requires-Dist: pytest~=8.3; extra == "test"
50
52
  Requires-Dist: pytest-clarity~=1.0; extra == "test"
51
- Requires-Dist: pytest-cov<7.0.0,>=5.0.0; extra == "test"
53
+ Requires-Dist: pytest-cov<8.0.0,>=5.0.0; extra == "test"
52
54
  Requires-Dist: pytest-env~=1.0; extra == "test"
53
- Requires-Dist: pytest-lazy-fixtures~=1.1.1; extra == "test"
55
+ Requires-Dist: pytest-lazy-fixtures~=1.4; extra == "test"
54
56
  Requires-Dist: pytest-mock~=3.0; extra == "test"
55
57
  Requires-Dist: pytest-order~=1.3; extra == "test"
56
58
  Requires-Dist: pytest-pretty~=1.2; extra == "test"
@@ -58,7 +60,7 @@ Requires-Dist: pytest-xdist~=3.0; extra == "test"
58
60
  Requires-Dist: responses~=0.25.0; extra == "test"
59
61
  Requires-Dist: requests-mock~=1.10; extra == "test"
60
62
  Provides-Extra: dev
61
- Requires-Dist: pre-commit~=3.5; extra == "dev"
63
+ Requires-Dist: pre-commit~=4.3; extra == "dev"
62
64
  Requires-Dist: tox~=4.11; extra == "dev"
63
65
  Requires-Dist: ruff==0.6.1; extra == "dev"
64
66
  Provides-Extra: mypy
@@ -1,31 +1,31 @@
1
- python_semantic_release-10.4.1.dist-info/licenses/LICENSE,sha256=NE85nszX252sdQdu0xgS9qwfYES0k8qS6gW3uO4jRGE,1083
1
+ python_semantic_release-10.5.1.dist-info/licenses/LICENSE,sha256=NE85nszX252sdQdu0xgS9qwfYES0k8qS6gW3uO4jRGE,1083
2
2
  semantic_release/__init__.py,sha256=tRJWhrn_dUt0QycXD2DoJSfEP5uwmxngH7jvbG2i-hA,1317
3
3
  semantic_release/__main__.py,sha256=pksxr6g1vkKq98Q1lShsxG8tk55IMiSMHzAHKyFU5x0,1704
4
4
  semantic_release/const.py,sha256=wInJR7vcOgT1ysm5VuJQ6lD_ZGYnCwRVKz7Uz3htQc4,861
5
5
  semantic_release/enums.py,sha256=vrEw1UNRcNrFjPqOFnuUzfeoqKj0ChixVVlyk5fqbng,1744
6
- semantic_release/errors.py,sha256=PY9rmviSFBZkqawW6VXbUfmF9C_RNOIObcmeGxLefMo,2904
7
- semantic_release/gitproject.py,sha256=UMvxcxqu9jRnZ3C5miNYYDh2i6hygLNEqo3-TuW4Pj4,9423
6
+ semantic_release/errors.py,sha256=FyocaqHbRhux-iNmCf9nI7awyUaGKjG9_5C_QDvhEas,3399
7
+ semantic_release/gitproject.py,sha256=qF4GVZh-aaS4bVV3OmFQLmXMAxHNFyZCwUaWPtUmZ1k,16546
8
8
  semantic_release/globals.py,sha256=IBhBbhZr2jx8dmpySnnu9m9jOGYu9Yu-vqHvAGQxgnw,464
9
9
  semantic_release/helpers.py,sha256=2uQXOiuiemiviVD52SH2FF6cn14p_gqvREeHvP7dexw,10012
10
10
  semantic_release/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  semantic_release/changelog/__init__.py,sha256=Bg6Xe5Vt32rWoMscW-hd4sUwiZqzWmsg4CD1EhMesMY,262
12
- semantic_release/changelog/context.py,sha256=WeLQ2BvYEWunIF8XEl6ldQE4IRg0d7r8mYSBi-TqK7o,5988
12
+ semantic_release/changelog/context.py,sha256=234LLcB_uuGaQ5N_zTbcQYF27ZwYIBmicIWkhFs7-aQ,5993
13
13
  semantic_release/changelog/release_history.py,sha256=kX6D9VReq85wnHz0D6JpmJJCfWA6axV6RToSM0O5laU,10602
14
14
  semantic_release/changelog/template.py,sha256=eqOjtVfBbksgTK4CAZOajfkaAcKueJ-FhFv99U3-J5E,5695
15
15
  semantic_release/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
16
  semantic_release/cli/changelog_writer.py,sha256=2jP5b0LK61Y2tb22GQSFFmwHwXd2WjbopgsMAmsQsfY,9284
17
17
  semantic_release/cli/cli_context.py,sha256=Nop71LdVCJOeSUHgTXunMyK3xAu_QKQC2cRp1QBVkX0,4134
18
- semantic_release/cli/config.py,sha256=dCQ1HqzBc_8RG_oTQtBMuCnM7zOEj9E1bDblxobg8os,33569
18
+ semantic_release/cli/config.py,sha256=zNXlJHZBnFk2GEemgCg2Yw42EWihtFMot1twI7bEN-g,33668
19
19
  semantic_release/cli/const.py,sha256=h7XE2D0D__TAZSrUUtVszwvzpkHTMOiQCf97XQNbEvA,163
20
20
  semantic_release/cli/github_actions_output.py,sha256=yC0nsMvEFGACjDwB8DdmGKwNGI8aIIhDxRHrmcS7tzA,5410
21
- semantic_release/cli/masking_filter.py,sha256=GsTyaoZbUVJLXVMqeXhCttXK84UnBQ8cNDSHxd52sOc,3218
21
+ semantic_release/cli/masking_filter.py,sha256=7t7XFL7Iy4QTvaYevZ-jnTkJBz15GoBw1vjW3hihe38,3159
22
22
  semantic_release/cli/util.py,sha256=4rf4xDHb7l-XpdcFNtslFz1xHslnBu6cTgWHXVnQXQs,3746
23
23
  semantic_release/cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
24
  semantic_release/cli/commands/changelog.py,sha256=wJfd4VVfrGnu2jnIpG25cdVcbXIX-ElKl3b2jdtPPa0,5391
25
25
  semantic_release/cli/commands/generate_config.py,sha256=2xZOu3NpyhBp0pWr7d8ugKl_kjqQgpSsSMHq5wHTfrE,1699
26
26
  semantic_release/cli/commands/main.py,sha256=u1zhkkvKCZ2TtUqjzvdFTe5UZsvfws_pjqqo6CY0bBo,4351
27
27
  semantic_release/cli/commands/publish.py,sha256=CE_LJTxFnc337MfpsfdJopi7QCwwE13GqGNQ-dNgWis,2871
28
- semantic_release/cli/commands/version.py,sha256=vlBNbQq0rW930UpI983-Rr-71fdK6qVPjMiRqMTBBUE,26616
28
+ semantic_release/cli/commands/version.py,sha256=BvSEDFX5p8Mzl8gSBV0O0WjQn3TpD7zeS7gBLnyIjWQ,29424
29
29
  semantic_release/commit_parser/__init__.py,sha256=uCC2YI-EDPUAa6TwWYJA8TS7baWE2bXrLEGcrhJKofI,1323
30
30
  semantic_release/commit_parser/_base.py,sha256=DLsHnbXG-39JkUbcnsBCSV2GmV35w1rasyoMhK8G0UE,3058
31
31
  semantic_release/commit_parser/angular.py,sha256=MY_fo9F4EZ-ac8wYzBR0uD94O5Li2D-8zEMR01wss4c,18534
@@ -70,15 +70,15 @@ semantic_release/hvcs/util.py,sha256=PUNV4yUlpzDtNCFmh2joaPdU4JyfUBnVp0zaQsT9EDQ
70
70
  semantic_release/version/__init__.py,sha256=CLhtGQry9dLIij5XyRa9ZevxU_1p8tjMTSQ-K_GMpWM,270
71
71
  semantic_release/version/algorithm.py,sha256=IxgYNF78W7qdMzdV4WsoljwfJgB-sn2XdfkevD0aZlo,16254
72
72
  semantic_release/version/declaration.py,sha256=eot_lUyFaEhzK4bPncfv9tahf51LdxZP6EaS54h3aAs,3635
73
- semantic_release/version/translator.py,sha256=LjmsMHWJJOG8ES6lRhjQsP2t8pmw4Ux1XVYzvROMR_M,3047
74
- semantic_release/version/version.py,sha256=3QlPKsrmNnFH71GlsYvpI-WwhpkJEs_JrDBbMaepwJY,14183
73
+ semantic_release/version/translator.py,sha256=iIfu3WreB9qqPHgqJLILbBluVQQNcpP0DEsnn_WzAaM,3689
74
+ semantic_release/version/version.py,sha256=Y3Qqqv7CypirikT7jNqqFMNAvR2UjV-VQx-c2G0mZc0,14519
75
75
  semantic_release/version/declarations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
76
76
  semantic_release/version/declarations/enum.py,sha256=3n5Py9DoFkmItIdsmtQrJgmAhepTv_1EnogzSdXu1Wg,244
77
77
  semantic_release/version/declarations/i_version_replacer.py,sha256=oP6BxJuxwI44roI6448tomShv1sMoy9ry8TlhhIQtfc,2416
78
- semantic_release/version/declarations/pattern.py,sha256=MpUmsHYGAVAuFSKSb29FLcWeUCEHG_TRyhMO-2DWAAs,8308
78
+ semantic_release/version/declarations/pattern.py,sha256=sKk0uQpJjWVZc8RJUjxQoEPUvFLxXNGGBow5h1IqCTM,8378
79
79
  semantic_release/version/declarations/toml.py,sha256=2K4DtX5Qq1iHT8cG8mISPTMmp50w6Av0KmLAKZPYqq8,4931
80
- python_semantic_release-10.4.1.dist-info/METADATA,sha256=_p8pbdBeY6Sz6ch5N7HguLDiBT2IzfNR5NflXHrTt38,3927
81
- python_semantic_release-10.4.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
82
- python_semantic_release-10.4.1.dist-info/entry_points.txt,sha256=kzkCyDJsMOwgpFwEWKE9wxN1tXaUP6g6GIO4xtc0QuE,162
83
- python_semantic_release-10.4.1.dist-info/top_level.txt,sha256=qYA24nyg3eP-ti5UW7Vuj2aXVmM0wqVHx4mREdRZNAA,17
84
- python_semantic_release-10.4.1.dist-info/RECORD,,
80
+ python_semantic_release-10.5.1.dist-info/METADATA,sha256=BMnYUZ_wcxmoCISQx404tvKP6cj-yrPjvXJjcN7RJ3s,4064
81
+ python_semantic_release-10.5.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
82
+ python_semantic_release-10.5.1.dist-info/entry_points.txt,sha256=kzkCyDJsMOwgpFwEWKE9wxN1tXaUP6g6GIO4xtc0QuE,162
83
+ python_semantic_release-10.5.1.dist-info/top_level.txt,sha256=qYA24nyg3eP-ti5UW7Vuj2aXVmM0wqVHx4mREdRZNAA,17
84
+ python_semantic_release-10.5.1.dist-info/RECORD,,
@@ -119,7 +119,7 @@ def read_file(filepath: str) -> str:
119
119
  return rfd.read()
120
120
 
121
121
  except FileNotFoundError as err:
122
- logging.warning(err)
122
+ logging.warning(str(err))
123
123
  return ""
124
124
 
125
125
 
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING
10
10
  import click
11
11
  import shellingham # type: ignore[import]
12
12
  from click_option_group import MutuallyExclusiveOptionGroup, optgroup
13
- from git import Repo
13
+ from git import GitCommandError, Repo
14
14
  from requests import HTTPError
15
15
 
16
16
  from semantic_release.changelog.release_history import ReleaseHistory
@@ -27,9 +27,14 @@ from semantic_release.const import DEFAULT_SHELL, DEFAULT_VERSION
27
27
  from semantic_release.enums import LevelBump
28
28
  from semantic_release.errors import (
29
29
  BuildDistributionsError,
30
+ DetachedHeadGitError,
30
31
  GitCommitEmptyIndexError,
32
+ GitFetchError,
31
33
  InternalError,
34
+ LocalGitError,
32
35
  UnexpectedResponse,
36
+ UnknownUpstreamBranchError,
37
+ UpstreamBranchChangedError,
33
38
  )
34
39
  from semantic_release.gitproject import GitProject
35
40
  from semantic_release.globals import logger
@@ -75,10 +80,13 @@ def is_forced_prerelease(
75
80
  )
76
81
 
77
82
 
78
- def last_released(repo_dir: Path, tag_format: str) -> tuple[Tag, Version] | None:
83
+ def last_released(
84
+ repo_dir: Path, tag_format: str, add_partial_tags: bool = False
85
+ ) -> tuple[Tag, Version] | None:
79
86
  with Repo(str(repo_dir)) as git_repo:
80
87
  ts_and_vs = tags_and_versions(
81
- git_repo.tags, VersionTranslator(tag_format=tag_format)
88
+ git_repo.tags,
89
+ VersionTranslator(tag_format=tag_format, add_partial_tags=add_partial_tags),
82
90
  )
83
91
 
84
92
  return ts_and_vs[0] if ts_and_vs else None
@@ -449,7 +457,11 @@ def version( # noqa: C901
449
457
  if print_last_released or print_last_released_tag:
450
458
  # TODO: get tag format a better way
451
459
  if not (
452
- last_release := last_released(config.repo_dir, tag_format=config.tag_format)
460
+ last_release := last_released(
461
+ config.repo_dir,
462
+ tag_format=config.tag_format,
463
+ add_partial_tags=config.add_partial_tags,
464
+ )
453
465
  ):
454
466
  logger.warning("No release tags found.")
455
467
  return
@@ -470,6 +482,7 @@ def version( # noqa: C901
470
482
  major_on_zero = runtime.major_on_zero
471
483
  no_verify = runtime.no_git_verify
472
484
  opts = runtime.global_cli_options
485
+ add_partial_tags = config.add_partial_tags
473
486
  gha_output = VersionGitHubActionsOutput(
474
487
  gh_client=hvcs_client if isinstance(hvcs_client, Github) else None,
475
488
  mode=(
@@ -491,6 +504,17 @@ def version( # noqa: C901
491
504
  logger.info("Forcing use of %s as the prerelease token", prerelease_token)
492
505
  translator.prerelease_token = prerelease_token
493
506
 
507
+ # Check if the repository is shallow and unshallow it if necessary
508
+ # This ensures we have the full history for commit analysis
509
+ project = GitProject(
510
+ directory=runtime.repo_dir,
511
+ commit_author=runtime.commit_author,
512
+ credential_masker=runtime.masker,
513
+ )
514
+ if project.is_shallow_clone():
515
+ logger.info("Repository is a shallow clone, converting to full clone...")
516
+ project.git_unshallow(noop=opts.noop)
517
+
494
518
  # Only push if we're committing changes
495
519
  if push_changes and not commit_changes and not create_tag:
496
520
  logger.info("changes will not be pushed because --no-commit disables pushing")
@@ -683,12 +707,6 @@ def version( # noqa: C901
683
707
  license_name="" if not isinstance(license_cfg, str) else license_cfg,
684
708
  )
685
709
 
686
- project = GitProject(
687
- directory=runtime.repo_dir,
688
- commit_author=runtime.commit_author,
689
- credential_masker=runtime.masker,
690
- )
691
-
692
710
  # Preparing for committing changes; we always stage files even if we're not committing them in order to support a two-stage commit
693
711
  project.git_add(paths=all_paths_to_add, noop=opts.noop)
694
712
  if commit_changes:
@@ -727,6 +745,33 @@ def version( # noqa: C901
727
745
  )
728
746
 
729
747
  if commit_changes:
748
+ # Verify that the upstream branch has not changed before pushing
749
+ # This prevents conflicts if another commit was pushed while we were preparing the release
750
+ # We check HEAD~1 because we just made a release commit
751
+ try:
752
+ project.verify_upstream_unchanged(
753
+ local_ref="HEAD~1",
754
+ upstream_ref=config.remote.name,
755
+ noop=opts.noop,
756
+ )
757
+ except UpstreamBranchChangedError as exc:
758
+ click.echo(str(exc), err=True)
759
+ click.echo(
760
+ "Upstream branch has changed. Please pull the latest changes and try again.",
761
+ err=True,
762
+ )
763
+ ctx.exit(1)
764
+ except (
765
+ DetachedHeadGitError,
766
+ GitCommandError,
767
+ UnknownUpstreamBranchError,
768
+ GitFetchError,
769
+ LocalGitError,
770
+ ) as exc:
771
+ click.echo(str(exc), err=True)
772
+ click.echo("Unable to verify upstream due to error!", err=True)
773
+ ctx.exit(1)
774
+
730
775
  # TODO: integrate into push branch
731
776
  with Repo(str(runtime.repo_dir)) as git_repo:
732
777
  active_branch = git_repo.active_branch.name
@@ -744,6 +789,27 @@ def version( # noqa: C901
744
789
  tag=new_version.as_tag(),
745
790
  noop=opts.noop,
746
791
  )
792
+ # Create or update partial tags for releases
793
+ if add_partial_tags and not prerelease:
794
+ partial_tags = [new_version.as_major_tag(), new_version.as_minor_tag()]
795
+ # If build metadata is set, also retag the version without the metadata
796
+ if build_metadata:
797
+ partial_tags.append(new_version.as_patch_tag())
798
+
799
+ for partial_tag in partial_tags:
800
+ project.git_tag(
801
+ tag_name=partial_tag,
802
+ message=f"{partial_tag} => {new_version.as_tag()}",
803
+ isotimestamp=commit_date.isoformat(),
804
+ noop=opts.noop,
805
+ force=True,
806
+ )
807
+ project.git_push_tag(
808
+ remote_url=remote_url,
809
+ tag=partial_tag,
810
+ noop=opts.noop,
811
+ force=True,
812
+ )
747
813
 
748
814
  # Update GitHub Actions output value now that release has occurred
749
815
  gha_output.released = True
@@ -366,6 +366,7 @@ class RawConfig(BaseModel):
366
366
  remote: RemoteConfig = RemoteConfig()
367
367
  no_git_verify: bool = False
368
368
  tag_format: str = "v{version}"
369
+ add_partial_tags: bool = False
369
370
  publish: PublishConfig = PublishConfig()
370
371
  version_toml: Optional[Tuple[str, ...]] = None
371
372
  version_variables: Optional[Tuple[str, ...]] = None
@@ -827,7 +828,9 @@ class RuntimeContext:
827
828
 
828
829
  # version_translator
829
830
  version_translator = VersionTranslator(
830
- tag_format=raw.tag_format, prerelease_token=branch_config.prerelease_token
831
+ tag_format=raw.tag_format,
832
+ prerelease_token=branch_config.prerelease_token,
833
+ add_partial_tags=raw.add_partial_tags,
831
834
  )
832
835
 
833
836
  build_cmd_env = {}
@@ -62,9 +62,7 @@ class MaskingFilter(LoggingFilter):
62
62
 
63
63
  def mask(self, msg: str) -> str:
64
64
  if not isinstance(msg, str):
65
- logger.debug( # type: ignore[unreachable]
66
- "cannot mask object of type %s", type(msg)
67
- )
65
+ logger.debug("cannot mask object of type %s", type(msg))
68
66
  return msg
69
67
  for mask, values in self._redact_patterns.items():
70
68
  repl_string = (
@@ -106,3 +106,19 @@ class GitTagError(SemanticReleaseBaseError):
106
106
 
107
107
  class GitPushError(SemanticReleaseBaseError):
108
108
  """Raised when there is a failure to push to the git remote."""
109
+
110
+
111
+ class GitFetchError(SemanticReleaseBaseError):
112
+ """Raised when there is a failure to fetch from the git remote."""
113
+
114
+
115
+ class LocalGitError(SemanticReleaseBaseError):
116
+ """Raised when there is a failure with local git operations."""
117
+
118
+
119
+ class UnknownUpstreamBranchError(SemanticReleaseBaseError):
120
+ """Raised when the upstream branch cannot be determined."""
121
+
122
+
123
+ class UpstreamBranchChangedError(SemanticReleaseBaseError):
124
+ """Raised when the upstream branch has changed before pushing."""
@@ -12,11 +12,16 @@ from git import GitCommandError, Repo
12
12
  from semantic_release.cli.masking_filter import MaskingFilter
13
13
  from semantic_release.cli.util import indented, noop_report
14
14
  from semantic_release.errors import (
15
+ DetachedHeadGitError,
15
16
  GitAddError,
16
17
  GitCommitEmptyIndexError,
17
18
  GitCommitError,
19
+ GitFetchError,
18
20
  GitPushError,
19
21
  GitTagError,
22
+ LocalGitError,
23
+ UnknownUpstreamBranchError,
24
+ UpstreamBranchChangedError,
20
25
  )
21
26
  from semantic_release.globals import logger
22
27
 
@@ -85,6 +90,42 @@ class GitProject:
85
90
  with Repo(str(self.project_root)) as repo:
86
91
  return repo.is_dirty()
87
92
 
93
+ def is_shallow_clone(self) -> bool:
94
+ """
95
+ Check if the repository is a shallow clone.
96
+
97
+ :return: True if the repository is a shallow clone, False otherwise
98
+ """
99
+ with Repo(str(self.project_root)) as repo:
100
+ shallow_file = Path(repo.git_dir, "shallow")
101
+ return shallow_file.exists()
102
+
103
+ def git_unshallow(self, noop: bool = False) -> None:
104
+ """
105
+ Convert a shallow clone to a full clone by fetching the full history.
106
+
107
+ :param noop: Whether or not to actually run the unshallow command
108
+ """
109
+ if noop:
110
+ noop_report("would have run:\n" " git fetch --unshallow")
111
+ return
112
+
113
+ with Repo(str(self.project_root)) as repo:
114
+ try:
115
+ self.logger.info("Converting shallow clone to full clone...")
116
+ repo.git.fetch("--unshallow")
117
+ self.logger.info("Repository unshallowed successfully")
118
+ except GitCommandError as err:
119
+ # If the repository is already a full clone, git fetch --unshallow will fail
120
+ # with "fatal: --unshallow on a complete repository does not make sense"
121
+ # We can safely ignore this error by checking the stderr message
122
+ stderr = str(err.stderr) if err.stderr else ""
123
+ if "does not make sense" in stderr or "complete repository" in stderr:
124
+ self.logger.debug("Repository is already a full clone")
125
+ else:
126
+ self.logger.exception(str(err))
127
+ raise
128
+
88
129
  def git_add(
89
130
  self,
90
131
  paths: Sequence[Path | str],
@@ -197,7 +238,12 @@ class GitProject:
197
238
  raise GitCommitError("Failed to commit changes") from err
198
239
 
199
240
  def git_tag(
200
- self, tag_name: str, message: str, isotimestamp: str, noop: bool = False
241
+ self,
242
+ tag_name: str,
243
+ message: str,
244
+ isotimestamp: str,
245
+ force: bool = False,
246
+ noop: bool = False,
201
247
  ) -> None:
202
248
  try:
203
249
  datetime.fromisoformat(isotimestamp)
@@ -207,21 +253,25 @@ class GitProject:
207
253
  if noop:
208
254
  command = str.join(
209
255
  " ",
210
- [
211
- f"GIT_COMMITTER_DATE={isotimestamp}",
212
- *(
213
- [
214
- f"GIT_AUTHOR_NAME={self._commit_author.name}",
215
- f"GIT_AUTHOR_EMAIL={self._commit_author.email}",
216
- f"GIT_COMMITTER_NAME={self._commit_author.name}",
217
- f"GIT_COMMITTER_EMAIL={self._commit_author.email}",
218
- ]
219
- if self._commit_author
220
- else [""]
221
- ),
222
- f"git tag -a {tag_name} -m '{message}'",
223
- ],
224
- )
256
+ filter(
257
+ None,
258
+ [
259
+ f"GIT_COMMITTER_DATE={isotimestamp}",
260
+ *(
261
+ [
262
+ f"GIT_AUTHOR_NAME={self._commit_author.name}",
263
+ f"GIT_AUTHOR_EMAIL={self._commit_author.email}",
264
+ f"GIT_COMMITTER_NAME={self._commit_author.name}",
265
+ f"GIT_COMMITTER_EMAIL={self._commit_author.email}",
266
+ ]
267
+ if self._commit_author
268
+ else [""]
269
+ ),
270
+ f"git tag -a {tag_name} -m '{message}'",
271
+ "--force" if force else "",
272
+ ],
273
+ ),
274
+ ).strip()
225
275
 
226
276
  noop_report(
227
277
  indented(
@@ -238,7 +288,7 @@ class GitProject:
238
288
  {"GIT_COMMITTER_DATE": isotimestamp},
239
289
  ):
240
290
  try:
241
- repo.git.tag("-a", tag_name, m=message)
291
+ repo.git.tag(tag_name, a=True, m=message, force=force)
242
292
  except GitCommandError as err:
243
293
  self.logger.exception(str(err))
244
294
  raise GitTagError(f"Failed to create tag ({tag_name})") from err
@@ -264,13 +314,15 @@ class GitProject:
264
314
  f"Failed to push branch ({branch}) to remote"
265
315
  ) from err
266
316
 
267
- def git_push_tag(self, remote_url: str, tag: str, noop: bool = False) -> None:
317
+ def git_push_tag(
318
+ self, remote_url: str, tag: str, noop: bool = False, force: bool = False
319
+ ) -> None:
268
320
  if noop:
269
321
  noop_report(
270
322
  indented(
271
323
  f"""\
272
324
  would have run:
273
- git push {self._cred_masker.mask(remote_url)} tag {tag}
325
+ git push {self._cred_masker.mask(remote_url)} tag {tag} {"--force" if force else ""}
274
326
  """ # noqa: E501
275
327
  )
276
328
  )
@@ -278,7 +330,122 @@ class GitProject:
278
330
 
279
331
  with Repo(str(self.project_root)) as repo:
280
332
  try:
281
- repo.git.push(remote_url, "tag", tag)
333
+ repo.git.push(remote_url, "tag", tag, force=force)
282
334
  except GitCommandError as err:
283
335
  self.logger.exception(str(err))
284
336
  raise GitPushError(f"Failed to push tag ({tag}) to remote") from err
337
+
338
+ def verify_upstream_unchanged( # noqa: C901
339
+ self, local_ref: str = "HEAD", upstream_ref: str = "origin", noop: bool = False
340
+ ) -> None:
341
+ """
342
+ Verify that the upstream branch has not changed since the given local reference.
343
+
344
+ :param local_ref: The local reference to compare against upstream (default: HEAD)
345
+ :param upstream_ref: The name of the upstream remote or specific remote branch (default: origin)
346
+ :param noop: Whether to skip the actual verification (for dry-run mode)
347
+
348
+ :raises UpstreamBranchChangedError: If the upstream branch has changed
349
+ """
350
+ if not local_ref.strip():
351
+ raise ValueError("Local reference cannot be empty")
352
+ if not upstream_ref.strip():
353
+ raise ValueError("Upstream reference cannot be empty")
354
+
355
+ if noop:
356
+ noop_report(
357
+ indented(
358
+ """\
359
+ would have verified that upstream branch has not changed
360
+ """
361
+ )
362
+ )
363
+ return
364
+
365
+ with Repo(str(self.project_root)) as repo:
366
+ # Get the current active branch
367
+ try:
368
+ active_branch = repo.active_branch
369
+ except TypeError:
370
+ # When in detached HEAD state, active_branch raises TypeError
371
+ err_msg = (
372
+ "Repository is in detached HEAD state, cannot verify upstream state"
373
+ )
374
+ raise DetachedHeadGitError(err_msg) from None
375
+
376
+ # Get the tracking branch (upstream branch)
377
+ if (tracking_branch := active_branch.tracking_branch()) is not None:
378
+ upstream_full_ref_name = tracking_branch.name
379
+ self.logger.info("Upstream branch name: %s", upstream_full_ref_name)
380
+ else:
381
+ # If no tracking branch is set, derive it
382
+ upstream_name = (
383
+ upstream_ref.strip()
384
+ if upstream_ref.find("/") == -1
385
+ else upstream_ref.strip().split("/", maxsplit=1)[0]
386
+ )
387
+
388
+ if not repo.remotes or upstream_name not in repo.remotes:
389
+ err_msg = "No remote found; cannot verify upstream state!"
390
+ raise UnknownUpstreamBranchError(err_msg)
391
+
392
+ upstream_full_ref_name = (
393
+ f"{upstream_name}/{active_branch.name}"
394
+ if upstream_ref.find("/") == -1
395
+ else upstream_ref.strip()
396
+ )
397
+
398
+ if upstream_full_ref_name not in repo.refs:
399
+ err_msg = f"No upstream branch found for '{active_branch.name}'; cannot verify upstream state!"
400
+ raise UnknownUpstreamBranchError(err_msg)
401
+
402
+ # Extract the remote name from the tracking branch
403
+ # tracking_branch.name is in the format "remote/branch"
404
+ remote_name, remote_branch_name = upstream_full_ref_name.split(
405
+ "/", maxsplit=1
406
+ )
407
+ remote_ref_obj = repo.remotes[remote_name]
408
+
409
+ # Fetch the latest changes from the remote
410
+ self.logger.info("Fetching latest changes from remote '%s'", remote_name)
411
+ try:
412
+ remote_ref_obj.fetch()
413
+ except GitCommandError as err:
414
+ self.logger.exception(str(err))
415
+ err_msg = f"Failed to fetch from remote '{remote_name}'"
416
+ raise GitFetchError(err_msg) from err
417
+
418
+ # Get the SHA of the upstream branch
419
+ try:
420
+ upstream_commit_ref = remote_ref_obj.refs[remote_branch_name].commit
421
+ upstream_sha = upstream_commit_ref.hexsha
422
+ except AttributeError as err:
423
+ self.logger.exception(str(err))
424
+ err_msg = f"Unable to determine upstream branch SHA for '{upstream_full_ref_name}'"
425
+ raise GitFetchError(err_msg) from err
426
+
427
+ # Get the SHA of the specified ref (default: HEAD)
428
+ try:
429
+ local_commit = repo.commit(repo.git.rev_parse(local_ref))
430
+ except GitCommandError as err:
431
+ self.logger.exception(str(err))
432
+ err_msg = f"Unable to determine the SHA for local ref '{local_ref}'"
433
+ raise LocalGitError(err_msg) from err
434
+
435
+ # Compare the two SHAs
436
+ if local_commit.hexsha != upstream_sha and not any(
437
+ commit.hexsha == upstream_sha for commit in local_commit.iter_parents()
438
+ ):
439
+ err_msg = str.join(
440
+ "\n",
441
+ (
442
+ f"[LOCAL SHA] {local_commit.hexsha} != {upstream_sha} [UPSTREAM SHA].",
443
+ f"Upstream branch '{upstream_full_ref_name}' has changed!",
444
+ ),
445
+ )
446
+ raise UpstreamBranchChangedError(err_msg)
447
+
448
+ self.logger.info(
449
+ "Verified upstream branch '%s' has not changed",
450
+ upstream_full_ref_name,
451
+ )
@@ -228,8 +228,8 @@ class PatternVersionDeclaration(IVersionReplacer):
228
228
  # Negative lookbehind to ensure we don't match part of a variable name
229
229
  f"""(?x)(?P<quote1>['"])?(?<![\\w.-]){regex_escape(variable)}(?P=quote1)?""",
230
230
  # Supports walrus, equals sign, double-equals, colon, or @ as assignment operator
231
- # ignoring whitespace separation
232
- r"\s*(:=|==|[:=@])\s*",
231
+ # ignoring whitespace separation. Also allows a space as the separator for c-macro style definitions.
232
+ r"\s*(:=|==|[:=@ ])\s*",
233
233
  # Supports optional matching quotations around a version pattern (tag or raw format)
234
234
  f"""(?P<quote2>['"])?{value_replace_pattern_str}(?P=quote2)?""",
235
235
  ],
@@ -1,12 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
- import re
3
+ from re import VERBOSE, compile as regexp, escape as regex_escape
4
+ from typing import TYPE_CHECKING
4
5
 
5
6
  from semantic_release.const import SEMVER_REGEX
6
7
  from semantic_release.globals import logger
7
8
  from semantic_release.helpers import check_tag_format
8
9
  from semantic_release.version.version import Version
9
10
 
11
+ if TYPE_CHECKING:
12
+ from re import Pattern
13
+
10
14
 
11
15
  class VersionTranslator:
12
16
  """
@@ -17,7 +21,7 @@ class VersionTranslator:
17
21
  _VERSION_REGEX = SEMVER_REGEX
18
22
 
19
23
  @classmethod
20
- def _invert_tag_format_to_re(cls, tag_format: str) -> re.Pattern[str]:
24
+ def _invert_tag_format_to_re(cls, tag_format: str) -> Pattern[str]:
21
25
  r"""
22
26
  Unpick the "tag_format" format string and create a regex which can be used to
23
27
  convert a tag to a version string.
@@ -31,9 +35,11 @@ class VersionTranslator:
31
35
  >>> assert m is not None
32
36
  >>> assert m.expand(r"\g<version>") == version
33
37
  """
34
- pat = re.compile(
35
- tag_format.replace(r"{version}", r"(?P<version>.*)"),
36
- flags=re.VERBOSE,
38
+ pat = regexp(
39
+ regex_escape(tag_format).replace(
40
+ regex_escape(r"{version}"), r"(?P<version>.+)"
41
+ ),
42
+ flags=VERBOSE,
37
43
  )
38
44
  logger.debug("inverted tag_format %r to %r", tag_format, pat.pattern)
39
45
  return pat
@@ -42,11 +48,19 @@ class VersionTranslator:
42
48
  self,
43
49
  tag_format: str = "v{version}",
44
50
  prerelease_token: str = "rc", # noqa: S107
51
+ add_partial_tags: bool = False,
45
52
  ) -> None:
46
53
  check_tag_format(tag_format)
47
54
  self.tag_format = tag_format
48
55
  self.prerelease_token = prerelease_token
56
+ self.add_partial_tags = add_partial_tags
49
57
  self.from_tag_re = self._invert_tag_format_to_re(self.tag_format)
58
+ self.partial_tag_re = regexp(
59
+ regex_escape(tag_format).replace(
60
+ regex_escape(r"{version}"), r"[0-9]+(\.(0|[1-9][0-9]*))?$"
61
+ ),
62
+ flags=VERBOSE,
63
+ )
50
64
 
51
65
  def from_string(self, version_str: str) -> Version:
52
66
  """
@@ -69,6 +83,10 @@ class VersionTranslator:
69
83
  tag_match = self.from_tag_re.match(tag)
70
84
  if not tag_match:
71
85
  return None
86
+ if self.add_partial_tags:
87
+ partial_tag_match = self.partial_tag_re.match(tag)
88
+ if partial_tag_match:
89
+ return None
72
90
  raw_version_str = tag_match.group("version")
73
91
  return self.from_string(raw_version_str)
74
92
 
@@ -203,6 +203,15 @@ class Version:
203
203
  def as_tag(self) -> str:
204
204
  return self.tag_format.format(version=str(self))
205
205
 
206
+ def as_major_tag(self) -> str:
207
+ return self.tag_format.format(version=f"{self.major}")
208
+
209
+ def as_minor_tag(self) -> str:
210
+ return self.tag_format.format(version=f"{self.major}.{self.minor}")
211
+
212
+ def as_patch_tag(self) -> str:
213
+ return self.tag_format.format(version=f"{self.major}.{self.minor}.{self.patch}")
214
+
206
215
  def as_semver_tag(self) -> str:
207
216
  return f"v{self!s}"
208
217