vcs-versioning 2.2.4__tar.gz → 2.3.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.
Files changed (103) hide show
  1. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/CHANGELOG.md +29 -0
  2. {vcs_versioning-2.2.4/src/vcs_versioning.egg-info → vcs_versioning-2.3.0}/PKG-INFO +1 -1
  3. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/pyproject.toml +3 -0
  4. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_backends/_git.py +102 -3
  5. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_backends/_hg.py +151 -33
  6. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_backends/_scm_workdir.py +64 -1
  7. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_config.py +3 -14
  8. vcs_versioning-2.3.0/src/vcs_versioning/_dynamic_metadata.py +49 -0
  9. vcs_versioning-2.3.0/src/vcs_versioning/_file_finders/_git.py +208 -0
  10. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_protocols.py +14 -6
  11. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_run_cmd.py +2 -0
  12. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_test_utils.py +1 -0
  13. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/test_api.py +14 -0
  14. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0/src/vcs_versioning.egg-info}/PKG-INFO +1 -1
  15. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning.egg-info/SOURCES.txt +4 -0
  16. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning.egg-info/entry_points.txt +3 -0
  17. vcs_versioning-2.3.0/testing_vcs/test_dynamic_metadata.py +49 -0
  18. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_git.py +90 -21
  19. vcs_versioning-2.3.0/testing_vcs/test_hg_tag_strict.py +206 -0
  20. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_tag_config.py +11 -2
  21. vcs_versioning-2.3.0/testing_vcs/test_tag_strict_diagnostics.py +293 -0
  22. vcs_versioning-2.2.4/src/vcs_versioning/_file_finders/_git.py +0 -122
  23. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/LICENSE.txt +0 -0
  24. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/MANIFEST.in +0 -0
  25. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/README.md +0 -0
  26. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/setup.cfg +0 -0
  27. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/setup.py +0 -0
  28. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/__init__.py +0 -0
  29. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/__main__.py +0 -0
  30. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_backends/__init__.py +0 -0
  31. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_backends/_discover_vcs.py +0 -0
  32. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_backends/_hg_git.py +0 -0
  33. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_backends/_jj.py +0 -0
  34. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_cli/__init__.py +0 -0
  35. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_cli/_args.py +0 -0
  36. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_cli/git_archival_full.txt +0 -0
  37. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_cli/git_archival_stable.txt +0 -0
  38. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_compat.py +0 -0
  39. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_discover.py +0 -0
  40. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_dump_version.py +0 -0
  41. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_entrypoints.py +0 -0
  42. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_environment.py +0 -0
  43. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_exceptions.py +0 -0
  44. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_fallback_workdir.py +0 -0
  45. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_fallbacks.py +0 -0
  46. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_file_finders/__init__.py +0 -0
  47. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_file_finders/_hg.py +0 -0
  48. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_file_finders/_jj.py +0 -0
  49. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_get_version_impl.py +0 -0
  50. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_integration.py +0 -0
  51. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_integrator_helpers.py +0 -0
  52. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_legacy_parse.py +0 -0
  53. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_log.py +0 -0
  54. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_modify_version.py +0 -0
  55. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_node_utils.py +0 -0
  56. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_overrides.py +0 -0
  57. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_paths.py +0 -0
  58. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_project_overrides.py +0 -0
  59. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_pyproject_reading.py +0 -0
  60. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_requirement_cls.py +0 -0
  61. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_scm_metadata.py +0 -0
  62. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_scm_version.py +0 -0
  63. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_toml.py +0 -0
  64. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_types.py +0 -0
  65. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_version_cls.py +0 -0
  66. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_version_fields.py +0 -0
  67. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_version_inference.py +0 -0
  68. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_version_schemes/__init__.py +0 -0
  69. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_version_schemes/_common.py +0 -0
  70. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_version_schemes/_standard.py +0 -0
  71. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_version_schemes/_towncrier.py +0 -0
  72. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/_worktree_discovery.py +0 -0
  73. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/overrides.py +0 -0
  74. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning/py.typed +0 -0
  75. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning.egg-info/dependency_links.txt +0 -0
  76. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning.egg-info/requires.txt +0 -0
  77. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/src/vcs_versioning.egg-info/top_level.txt +0 -0
  78. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/__init__.py +0 -0
  79. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/conftest.py +0 -0
  80. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_better_root_errors.py +0 -0
  81. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_chain_api.py +0 -0
  82. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_compat.py +0 -0
  83. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_config.py +0 -0
  84. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_expect_parse.py +0 -0
  85. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_file_finders.py +0 -0
  86. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_hg_git.py +0 -0
  87. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_integrator_helpers.py +0 -0
  88. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_internal_log_level.py +0 -0
  89. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_jj.py +0 -0
  90. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_legacy_parse.py +0 -0
  91. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_mercurial.py +0 -0
  92. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_overrides_api.py +0 -0
  93. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_overrides_env_reader.py +0 -0
  94. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_project_overrides.py +0 -0
  95. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_project_path.py +0 -0
  96. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_regressions.py +0 -0
  97. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_scm_metadata.py +0 -0
  98. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_version.py +0 -0
  99. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_version_scheme_towncrier.py +0 -0
  100. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_version_schemes.py +0 -0
  101. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_workdir_api.py +0 -0
  102. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/testing_vcs/test_workdir_discovery.py +0 -0
  103. {vcs_versioning-2.2.4 → vcs_versioning-2.3.0}/uv.lock +0 -0
@@ -2,6 +2,35 @@
2
2
 
3
3
  <!-- towncrier release notes start -->
4
4
 
5
+ ## 2.3.0 (2026-08-13)
6
+
7
+ ### Added
8
+
9
+ - Add a ``vcs_versioning.dynamic_metadata`` provider for the [dynamic-metadata](https://github.com/scikit-build/dynamic-metadata) system. ([#1465](https://github.com/pypa/setuptools-scm/issues/1465))
10
+
11
+
12
+ ### Fixed
13
+
14
+ - Make the `tag.strict` and `scm.git.describe_command` diagnostics actionable and non-conflicting.
15
+
16
+ The `tag.strict` future-default notice is now reported by the git backend rather than at configuration time, and only when the future default would actually select a different tag for the repository -- the message names both the current and the future version string. Projects the change cannot affect are silent, and setting an explicit `describe_command` no longer triggers it at all, so the two warnings can no longer contradict each other.
17
+
18
+ The `describe_command` notice is likewise limited to the case where it and an explicit `tag.strict` really disagree, and no longer claims that `tag.prefix` has no effect -- prefix stripping applies regardless of how the tag was selected.
19
+
20
+ Both are logged at warning level instead of raised as warnings, so `SETUPTOOLS_SCM_DEBUG=ERROR` silences them. ([#1429](https://github.com/pypa/setuptools-scm/issues/1429))
21
+ - Honour `export-ignore` on directories and submodules again in the git file finder.
22
+
23
+ The switch from `git archive` to `git ls-files --recurse-submodules` lost two parts of the archive semantics: `--recurse-submodules` listed every submodule regardless of `export-ignore`, and the `:(exclude,attr:export-ignore)` pathspec only matches files, so an `export-ignore` on a directory no longer excluded the files below it. Projects that kept vendored submodules in an `export-ignore`d directory suddenly shipped them in their sdists.
24
+
25
+ The finder now lists a repository without recursion, checks `export-ignore` for directories via `git check-attr` (which is what `git archive` effectively does when it skips a tree), and only then descends into the submodules that survived. Submodule contents are still listed - with their own `.gitattributes` applied - so `export-ignore` in the parent repository now controls exactly which submodules get packaged. Submodules that are not checked out are skipped instead of failing the listing. ([#1469](https://github.com/pypa/setuptools-scm/issues/1469))
26
+ - Honour `tag.strict` on Mercurial changesets that carry tags of their own, and report the coming strict default for Mercurial repositories.
27
+
28
+ `tag.strict` was only applied when looking for the latest tag, so a checked-out changeset tagged `event-2024` still produced version `2024` even with `tag.strict = true`, while git rejected the same tag. Strict matching now applies to the tags on the changeset too: a changeset carrying only event-style tags is treated as untagged and versioning continues from the last real version tag, matching `git describe --match`. When several tags sit on one changeset, the version-shaped one is now selected instead of whichever Mercurial happened to list first.
29
+
30
+ The `tag.strict` divergence diagnostic added in #1429 now covers Mercurial as well, naming the current and future version whenever the coming default would change them. Both backends share the message, and the git-only helpers moved to `_backends/_scm_workdir.py`.
31
+
32
+ Note that the Mercurial backend required a dot in version tags before setuptools-scm 9, so for Mercurial projects the coming strict default restores the historical behavior. ([#1495](https://github.com/pypa/setuptools-scm/issues/1495))
33
+
5
34
  ## 2.2.4 (2026-08-07)
6
35
 
7
36
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vcs-versioning
3
- Version: 2.2.4
3
+ Version: 2.3.0
4
4
  Summary: the blessed package to manage your versions by vcs metadata
5
5
  Author-email: Ronny Pfannschmidt <opensource@ronnypfannschmidt.de>
6
6
  License: MIT
@@ -91,6 +91,9 @@ node-and-timestamp = "vcs_versioning._version_schemes:get_local_node_and_timesta
91
91
  "semver-pep440-release-branch" = "vcs_versioning._version_schemes:release_branch_semver_version"
92
92
  "towncrier-fragments" = "vcs_versioning._version_schemes._towncrier:version_from_fragments"
93
93
 
94
+ [project.entry-points."dynamic_metadata.provider"]
95
+ "vcs_versioning" = "vcs_versioning._dynamic_metadata"
96
+
94
97
  [tool.setuptools.packages.find]
95
98
  where = ["src"]
96
99
  namespaces = false
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import dataclasses
4
+ import fnmatch
4
5
  import logging
5
6
  import os
6
7
  import re
@@ -23,10 +24,17 @@ from .._run_cmd import CompletedProcess as _CompletedProcess
23
24
  from .._run_cmd import require_command as _require_command
24
25
  from .._run_cmd import run as _run
25
26
  from .._scm_version import ScmVersion, meta, tag_to_version
26
- from ._scm_workdir import Workdir, get_latest_file_mtime
27
+ from ._scm_workdir import (
28
+ STRICT_DIAGNOSTIC,
29
+ Workdir,
30
+ config_location,
31
+ get_latest_file_mtime,
32
+ report_once,
33
+ version_outcome,
34
+ )
27
35
 
28
36
  if TYPE_CHECKING:
29
- from .._protocols import DescribeCapable
37
+ from .._protocols import DescribeCapable, GitQueryable
30
38
  from . import _hg_git as hg_git
31
39
  log = logging.getLogger(__name__)
32
40
 
@@ -74,12 +82,14 @@ def run_git(
74
82
  *,
75
83
  check: bool = False,
76
84
  timeout: int | None = None,
85
+ input: str | None = None,
77
86
  ) -> _CompletedProcess:
78
87
  return _run(
79
88
  ["git", "--git-dir", repo / ".git", *args],
80
89
  cwd=repo,
81
90
  check=check,
82
91
  timeout=timeout,
92
+ input=input,
83
93
  )
84
94
 
85
95
 
@@ -232,7 +242,10 @@ class GitWorkdir(Workdir):
232
242
  def default_describe(self) -> _CompletedProcess:
233
243
  match_glob = self.config.tag.describe_match_glob()
234
244
  cmd = make_describe_command(match_glob)
235
- return self.run_git(cmd[1:])
245
+ res = self.run_git(cmd[1:])
246
+ if self.config.tag.strict is None:
247
+ _warn_if_strict_would_differ(self, self.config, res)
248
+ return res
236
249
 
237
250
  def get_scm_version(self) -> ScmVersion | None:
238
251
  """Obtain version metadata from this git work directory."""
@@ -264,6 +277,90 @@ class GitWorkdir(Workdir):
264
277
  return res.returncode == 0
265
278
 
266
279
 
280
+ def _describe_tag(output: str) -> str | None:
281
+ """Extract just the tag name from a ``git describe --long`` output."""
282
+ if not output.strip():
283
+ return None
284
+ return _git_parse_describe(output.strip())[0]
285
+
286
+
287
+ def _strict_match_glob(prefix: str) -> str:
288
+ """The ``--match`` glob ``tag.strict = true`` would produce."""
289
+ return f"{prefix}*[0-9]*.*[0-9]*"
290
+
291
+
292
+ def _describe_outcome(output: str, config: Configuration) -> str:
293
+ """Render what a ``git describe`` *output* would yield as a version."""
294
+ if not output.strip():
295
+ return version_outcome(config, None)
296
+
297
+ tag, distance, node, dirty = _git_parse_describe(output.strip())
298
+ return version_outcome(config, tag, distance=distance, dirty=dirty, node=node)
299
+
300
+
301
+ def _warn_if_strict_would_differ(
302
+ wd: GitQueryable, config: Configuration, permissive: _CompletedProcess
303
+ ) -> None:
304
+ """Report the ``tag.strict`` future default only when it changes the answer.
305
+
306
+ ``tag.strict`` is unset, so the permissive glob was used. Stay silent
307
+ unless the strict glob would pick a different tag, so that projects the
308
+ future default cannot affect are never nagged.
309
+ """
310
+ permissive_tag = _describe_tag(permissive.stdout)
311
+ strict_glob = _strict_match_glob(config.tag.prefix)
312
+
313
+ if permissive_tag is None:
314
+ # nothing matched the wider glob, so nothing matches the narrower one
315
+ return
316
+ if fnmatch.fnmatchcase(permissive_tag, strict_glob):
317
+ # the strict glob matches a subset of the permissive one, so a
318
+ # permissive answer that is itself strict-matching is also the
319
+ # closest strict-matching tag -- no subprocess needed
320
+ return
321
+
322
+ strict = wd.run_git(make_describe_command(strict_glob)[1:])
323
+ report_once(
324
+ f"strict-divergence:{wd.path}:{permissive_tag}:{_describe_tag(strict.stdout)}",
325
+ STRICT_DIAGNOSTIC,
326
+ _describe_outcome(permissive.stdout, config),
327
+ _describe_outcome(strict.stdout, config),
328
+ config_location(config),
329
+ )
330
+
331
+
332
+ def _warn_if_describe_command_overrides_strict(
333
+ wd: GitQueryable, config: Configuration, describe_res: _CompletedProcess
334
+ ) -> None:
335
+ """Report that ``describe_command`` beat an explicit ``tag.strict``.
336
+
337
+ Only fires when the two actually disagree about which tag to use --
338
+ setting both is harmless as long as they pick the same tag. ``tag.prefix``
339
+ is deliberately not mentioned: it still strips the prefix before version
340
+ parsing, so combining it with ``describe_command`` is legitimate.
341
+ """
342
+ strict_glob = _strict_match_glob(config.tag.prefix)
343
+ strict = wd.run_git(make_describe_command(strict_glob)[1:])
344
+ strict_tag = _describe_tag(strict.stdout)
345
+ describe_tag = _describe_tag(describe_res.stdout)
346
+ if strict_tag == describe_tag:
347
+ return
348
+
349
+ report_once(
350
+ f"describe-overrides-strict:{wd.path}:{describe_tag}:{strict_tag}",
351
+ "scm.git.describe_command takes precedence over tag.strict, and they"
352
+ " disagree for this repository:\n"
353
+ " describe_command gives: %s\n"
354
+ " tag.strict = %s would give: %s\n"
355
+ "Drop tag.strict, or drop describe_command and let tag.prefix/tag.strict"
356
+ " build the match pattern, in %s.",
357
+ _describe_outcome(describe_res.stdout, config),
358
+ str(config.tag.strict).lower(),
359
+ _describe_outcome(strict.stdout, config),
360
+ config_location(config),
361
+ )
362
+
363
+
267
364
  def warn_on_shallow(wd: GitWorkdir) -> None:
268
365
  """experimental, may change at any time"""
269
366
  if wd.is_shallow() and not wd.head_is_exact_tag():
@@ -410,6 +507,8 @@ def version_from_describe(
410
507
  describe_res = wd.run_git(cmd_args[1:])
411
508
  else:
412
509
  describe_res = _run(cmd_args, wd.path, timeout=wd._subprocess_timeout)
510
+ if config.tag.strict is not None:
511
+ _warn_if_describe_command_overrides_strict(wd, config, describe_res)
413
512
  else:
414
513
  describe_res = wd.default_describe()
415
514
 
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import dataclasses
3
4
  import datetime
4
5
  import logging
5
6
  import os
@@ -15,12 +16,92 @@ from .._run_cmd import require_command as _require_command
15
16
  from .._run_cmd import run as _run
16
17
  from .._scm_version import ScmVersion, meta, tag_to_version
17
18
  from .._version_cls import Version
18
- from ._scm_workdir import Workdir, get_latest_file_mtime
19
+ from .._version_schemes import format_version
20
+ from ._scm_workdir import (
21
+ STRICT_DIAGNOSTIC,
22
+ Workdir,
23
+ config_location,
24
+ get_latest_file_mtime,
25
+ report_once,
26
+ )
19
27
 
20
28
  log = logging.getLogger(__name__)
21
29
 
22
30
  _HG_PSEUDO_TAGS = frozenset({"tip", "qbase", "qtip", "qparent"})
23
31
 
32
+ _KEEP: Any = object()
33
+ """Sentinel for "use the configured value" -- ``None`` is a real strictness."""
34
+
35
+
36
+ def hg_tag_pattern(config: Configuration, strict: bool | None = _KEEP) -> str:
37
+ """Build the Mercurial regex ``latesttag()`` is given from tag config.
38
+
39
+ *strict* overrides ``config.tag.strict``, which the diagnostics use to ask
40
+ what the other setting would have selected.
41
+ """
42
+ if strict is _KEEP:
43
+ strict = config.tag.strict
44
+ prefix = re.escape(config.tag.prefix) if config.tag.prefix else ""
45
+ if strict:
46
+ # Require at least one dot in the version part
47
+ return rf"{prefix}\d+\.\d+"
48
+ else:
49
+ return rf"{prefix}\d+"
50
+
51
+
52
+ def matches_tag_pattern(
53
+ tag: str, config: Configuration, strict: bool | None = _KEEP
54
+ ) -> bool:
55
+ """Whether *tag* satisfies the configured strictness.
56
+
57
+ The pattern is the same one ``latesttag()`` is given on the distance path,
58
+ so both paths agree about which tags count as version tags.
59
+ """
60
+ return re.search(hg_tag_pattern(config, strict), tag) is not None
61
+
62
+
63
+ def select_tag(
64
+ tags: list[str], config: Configuration, strict: bool | None = _KEEP
65
+ ) -> str | None:
66
+ """Pick the version tag to use from the tags on the current changeset.
67
+
68
+ Pre-filters with tag_regex so non-version tags are silently skipped
69
+ without emitting warnings from tag_to_version().
70
+ Strips tag.prefix before matching when configured.
71
+
72
+ Under ``tag.strict = true`` a tag that is not version-shaped is
73
+ rejected outright rather than falling back to a looser match, so a
74
+ changeset carrying only event-style tags falls through to the distance
75
+ path -- the same thing ``git describe --match`` does (#1495).
76
+ """
77
+ if strict is _KEEP:
78
+ strict = config.tag.strict
79
+ tag_prefix = config.tag.prefix
80
+ for tag_str in tags:
81
+ check_str = tag_str
82
+ if tag_prefix and tag_str.startswith(tag_prefix):
83
+ check_str = tag_str[len(tag_prefix) :]
84
+ if not config.tag.regex.match(check_str):
85
+ log.debug("skipping non-version tag %r", tag_str)
86
+ continue
87
+ # only narrow when strictness was asked for, so the permissive
88
+ # path keeps selecting exactly what it always has
89
+ if strict and not matches_tag_pattern(tag_str, config, strict):
90
+ log.debug("skipping tag %r: not version-shaped under tag.strict", tag_str)
91
+ continue
92
+ if tag_to_version(tag_str, config) is not None:
93
+ return tag_str
94
+ return None
95
+
96
+
97
+ def _rendered(version: ScmVersion | None, tag: str | None) -> str:
98
+ """Render one side of the ``tag.strict`` comparison for the diagnostic."""
99
+ if version is None:
100
+ return "no matching tag, falling back to the fallback version"
101
+ if tag is None:
102
+ return format_version(version)
103
+ return f"{format_version(version)} (from tag {tag!r})"
104
+
24
105
 
25
106
  def _get_hg_command() -> str:
26
107
  """Read the hg command from resolved runtime settings.
@@ -95,12 +176,71 @@ class HgWorkdir(Workdir):
95
176
  tags = self._parse_tags(tags_str)
96
177
 
97
178
  # Try to get version from current tags
179
+ result: ScmVersion | None
98
180
  tag_version = self._get_version_from_tags(tags, config)
99
181
  if tag_version:
100
- return meta(tag_version, dirty=dirty, branch=branch, config=config)
182
+ result = meta(tag_version, dirty=dirty, branch=branch, config=config)
183
+ else:
184
+ # Fall back to distance-based versioning
185
+ result = self._get_distance_based_version(
186
+ config, dirty, branch, node, node_date
187
+ )
188
+
189
+ if config.tag.strict is None:
190
+ self._report_strict_divergence(config, tags, result)
191
+ return result
101
192
 
102
- # Fall back to distance-based versioning
103
- return self._get_distance_based_version(config, dirty, branch, node, node_date)
193
+ def _report_strict_divergence(
194
+ self, config: Configuration, tags: list[str], result: ScmVersion | None
195
+ ) -> None:
196
+ """Report the coming ``tag.strict`` default when it changes the version.
197
+
198
+ Mirrors the git backend: stay silent unless strict matching would
199
+ select a different tag, so repositories the change cannot affect are
200
+ never nagged (#1495).
201
+ """
202
+ permissive_tag = select_tag(tags, config, strict=False)
203
+ strict_tag = select_tag(tags, config, strict=True)
204
+
205
+ if permissive_tag is not None and permissive_tag == strict_tag:
206
+ # the changeset is tagged with a version-shaped tag either way
207
+ return
208
+ if permissive_tag is None and strict_tag is None:
209
+ # neither takes the exact-tag path, so compare the latest tags
210
+ permissive_tag = self.get_latest_normalizable_tag(config, strict=False)
211
+ if permissive_tag is None:
212
+ return
213
+ if matches_tag_pattern(permissive_tag, config, strict=True):
214
+ # the permissive answer is itself version-shaped, and strict
215
+ # matches a subset, so both paths land on the same tag
216
+ return
217
+
218
+ # whichever side found no tag on this changeset falls through to the
219
+ # distance path, so name the tag it would count from there
220
+ if permissive_tag is None:
221
+ permissive_tag = self.get_latest_normalizable_tag(config, strict=False)
222
+ if strict_tag is None:
223
+ strict_tag = self.get_latest_normalizable_tag(config, strict=True)
224
+
225
+ strict_config = dataclasses.replace(
226
+ config, tag=dataclasses.replace(config.tag, strict=True)
227
+ )
228
+ strict_result = self.get_meta(strict_config)
229
+ if (
230
+ result is not None
231
+ and strict_result is not None
232
+ and format_version(result) == format_version(strict_result)
233
+ ):
234
+ # different tags, same resulting version -- nothing to act on
235
+ return
236
+
237
+ report_once(
238
+ f"strict-divergence:{self.path}:{permissive_tag}:{strict_tag}",
239
+ STRICT_DIAGNOSTIC,
240
+ _rendered(result, permissive_tag),
241
+ _rendered(strict_result, strict_tag),
242
+ config_location(config),
243
+ )
104
244
 
105
245
  def _get_node_info(self) -> tuple[str, str, str] | None:
106
246
  """Get node, tags, and date information from mercurial log."""
@@ -163,24 +303,11 @@ class HgWorkdir(Workdir):
163
303
  def _get_version_from_tags(
164
304
  self, tags: list[str], config: Configuration
165
305
  ) -> Version | None:
166
- """Try to get a version from the current tags.
167
-
168
- Pre-filters with tag_regex so non-version tags are silently skipped
169
- without emitting warnings from tag_to_version().
170
- Strips tag.prefix before matching when configured.
171
- """
172
- tag_prefix = config.tag.prefix
173
- for tag_str in tags:
174
- check_str = tag_str
175
- if tag_prefix and tag_str.startswith(tag_prefix):
176
- check_str = tag_str[len(tag_prefix) :]
177
- if not config.tag.regex.match(check_str):
178
- log.debug("skipping non-version tag %r", tag_str)
179
- continue
180
- version = tag_to_version(tag_str, config)
181
- if version is not None:
182
- return version
183
- return None
306
+ """Try to get a version from the current tags."""
307
+ tag_str = select_tag(tags, config)
308
+ if tag_str is None:
309
+ return None
310
+ return tag_to_version(tag_str, config)
184
311
 
185
312
  def _get_distance_based_version(
186
313
  self,
@@ -234,20 +361,11 @@ class HgWorkdir(Workdir):
234
361
  check=True,
235
362
  ).stdout
236
363
 
237
- def _hg_tag_pattern(self, config: Configuration) -> str:
238
- """Build a Mercurial regex pattern from tag configuration."""
239
- prefix = re.escape(config.tag.prefix) if config.tag.prefix else ""
240
- if config.tag.strict:
241
- # Require at least one dot in the version part
242
- return rf"{prefix}\d+\.\d+"
243
- else:
244
- return rf"{prefix}\d+"
245
-
246
364
  def get_latest_normalizable_tag(
247
- self, config: Configuration | None = None
365
+ self, config: Configuration | None = None, strict: bool | None = _KEEP
248
366
  ) -> str | None:
249
367
  if config is not None:
250
- pattern = self._hg_tag_pattern(config)
368
+ pattern = hg_tag_pattern(config, strict)
251
369
  else:
252
370
  pattern = r"\."
253
371
  result = self.hg_log(
@@ -1,11 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ import warnings
4
5
  from dataclasses import dataclass
5
6
  from dataclasses import field as dc_field
6
7
  from datetime import date, datetime, timezone
7
8
  from pathlib import Path
8
- from typing import TYPE_CHECKING, overload
9
+ from typing import TYPE_CHECKING, Any, overload
9
10
 
10
11
  from .._scm_version import ScmVersion
11
12
 
@@ -77,6 +78,68 @@ def get_latest_file_mtime(changed_files: list[str], base_path: Path) -> date | N
77
78
  return None
78
79
 
79
80
 
81
+ # ---------------------------------------------------------------------------
82
+ # Configuration diagnostics shared by the backends
83
+ #
84
+ # These report settings whose effect can only be judged once the SCM has been
85
+ # consulted -- most of all the coming ``tag.strict`` default, which is only
86
+ # worth mentioning when it changes the version at hand (#1429, #1495).
87
+ # ---------------------------------------------------------------------------
88
+
89
+ _diagnostics_reported: set[str] = set()
90
+
91
+
92
+ def report_once(key: str, message: str, *args: object) -> None:
93
+ """Log a config diagnostic at most once per process.
94
+
95
+ Unlike ``warnings.warn`` the logging module does not deduplicate, and a
96
+ build constructs the configuration more than once (metadata hook plus
97
+ build hook), so the guard is explicit.
98
+ """
99
+ if key in _diagnostics_reported:
100
+ return
101
+ _diagnostics_reported.add(key)
102
+ log.warning(message, *args)
103
+
104
+
105
+ def config_location(config: Configuration) -> str:
106
+ """Where the user should go to change the setting being reported."""
107
+ return str(config.relative_to or "your pyproject.toml")
108
+
109
+
110
+ def version_outcome(config: Configuration, tag: str | None, **meta_kw: Any) -> str:
111
+ """Render the version *tag* would produce, for a diagnostic message.
112
+
113
+ Shows the resulting version string rather than just the tag, since the
114
+ version is what the user actually cares about. Falls back to prose when
115
+ no tag matched or the tag does not parse as a version. Never raises and
116
+ never emits warnings of its own: this describes a path *not* taken.
117
+ """
118
+ if tag is None:
119
+ return "no matching tag, falling back to the fallback version"
120
+
121
+ from .._scm_version import meta
122
+ from .._version_schemes import format_version
123
+
124
+ try:
125
+ with warnings.catch_warnings():
126
+ warnings.simplefilter("ignore")
127
+ version = format_version(meta(tag, config=config, **meta_kw))
128
+ except (ValueError, TypeError):
129
+ return f"no usable version -- tag {tag!r} does not parse as a version"
130
+ return f"{version} (from tag {tag!r})"
131
+
132
+
133
+ STRICT_DIAGNOSTIC = (
134
+ "tag.strict is not set, and the future default changes this"
135
+ " repository's version:\n"
136
+ " now (tag.strict = false): %s\n"
137
+ " future default (tag.strict = true): %s\n"
138
+ "Set tag.strict explicitly in the [tool.setuptools_scm] table of %s."
139
+ )
140
+ """Message shared by the backends so their advice reads identically."""
141
+
142
+
80
143
  @dataclass()
81
144
  class ScmWorkdir:
82
145
  """Base class for VCS work directories.
@@ -343,20 +343,9 @@ class Configuration:
343
343
  self.tag, regex=_check_tag_regex(tag_regex)
344
344
  )
345
345
 
346
- # TODO(#1429): re-introduce these warnings with non-conflicting logic
347
- if self.tag.strict is None:
348
- log.debug(
349
- "tag.strict is not set — defaults to False (permissive tag matching)"
350
- )
351
-
352
- if (
353
- self.tag.prefix or self.tag.strict is not None
354
- ) and self.scm.git.describe_command is not None:
355
- log.debug(
356
- "Both tag.prefix/tag.strict and scm.git.describe_command are set. "
357
- "The explicit describe_command takes precedence; tag.prefix and "
358
- "tag.strict will have no effect on the git describe match pattern."
359
- )
346
+ # tag.strict and describe_command diagnostics deliberately do not live
347
+ # here: they are only actionable once the SCM has been consulted, so the
348
+ # git backend reports them when they actually change the version (#1429)
360
349
 
361
350
  self._resolved_paths = resolve_paths(
362
351
  relative_to=self.relative_to,
@@ -0,0 +1,49 @@
1
+ """scikit-build ``dynamic-metadata`` provider for vcs-versioning.
2
+
3
+ Use this module as a provider for `scikit-build/dynamic-metadata
4
+ <https://github.com/scikit-build/dynamic-metadata>`_ so any backend supporting
5
+ that can fill in a VCS-derived ``version``::
6
+
7
+ [[tool.dynamic-metadata]]
8
+ provider = "vcs_versioning"
9
+
10
+ Configuration is read from ``[tool.vcs-versioning]``; any keys in the
11
+ ``[[tool.dynamic-metadata]]`` table are passed through as overrides.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ import vcs_versioning
19
+ import vcs_versioning.overrides
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Mapping
23
+
24
+ __all__ = ["dynamic_metadata"]
25
+
26
+
27
+ def __dir__() -> list[str]:
28
+ return __all__
29
+
30
+
31
+ def dynamic_metadata(
32
+ settings: Mapping[str, Any],
33
+ project: Mapping[str, Any],
34
+ ) -> dict[str, Any]:
35
+ """Return the ``version`` field for a dynamic-metadata consumer."""
36
+
37
+ dist_name = project.get("name")
38
+ # dynamic-metadata runs hooks with cwd at the project root.
39
+ with vcs_versioning.overrides.GlobalOverrides.from_env(
40
+ "VCS_VERSIONING", dist_name=dist_name
41
+ ):
42
+ pyproject = vcs_versioning.PyProjectData.from_file("pyproject.toml")
43
+ version = vcs_versioning.infer_version_string(
44
+ dist_name=dist_name,
45
+ pyproject_data=pyproject,
46
+ overrides=dict(settings) or None,
47
+ force_write_version_files=True,
48
+ )
49
+ return {"version": version}