Skip to content

Commit 49c719a

Browse files
committed
Make version format check more strict
1 parent 144f3e5 commit 49c719a

File tree

5 files changed

+163
-82
lines changed

5 files changed

+163
-82
lines changed

CHANGELOG.rst

+16
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
Changelog
22
==========
33

4+
1.12
5+
----
6+
7+
.. changelog::
8+
:version: 1.12.0
9+
10+
.. change::
11+
:tags: core, breaking
12+
13+
Sanitize ``starting_version`` according :pep:`440`
14+
15+
.. change::
16+
:tags: core, breaking
17+
18+
Do not remove leading non-numeric symbols from version number (except ``v``)
19+
420
1.11
521
----
622

setuptools_git_versioning.py

+50-68
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,19 @@
1313
from datetime import datetime
1414
from pathlib import Path
1515
from pprint import pformat
16-
from typing import TYPE_CHECKING, Any, Callable
16+
from typing import Any, Callable
1717

1818
from deprecated import deprecated
19+
from packaging.version import Version
1920
from setuptools.dist import Distribution
2021

21-
if TYPE_CHECKING:
22-
from packaging.version import Version
23-
2422
DEFAULT_TEMPLATE = "{tag}"
2523
DEFAULT_DEV_TEMPLATE = "{tag}.post{ccount}+git.{sha}"
2624
DEFAULT_DIRTY_TEMPLATE = "{tag}.post{ccount}+git.{sha}.dirty"
2725
DEFAULT_STARTING_VERSION = "0.0.1"
2826
DEFAULT_SORT_BY = "creatordate"
2927
ENV_VARS_REGEXP = re.compile(r"\{env:(?P<name>[^:}]+):?(?P<default>[^}]+\}*)?\}", re.IGNORECASE | re.UNICODE)
3028
TIMESTAMP_REGEXP = re.compile(r"\{timestamp:?(?P<fmt>[^:}]+)?\}", re.IGNORECASE | re.UNICODE)
31-
LOCAL_REGEXP = re.compile(r"[^a-z\d.]+", re.IGNORECASE)
32-
VERSION_PREFIX_REGEXP = re.compile(r"^[^\d]+", re.IGNORECASE | re.UNICODE)
3329

3430
LOG_FORMAT = "[%(asctime)s] %(levelname)+8s: %(message)s"
3531
# setuptools v60.2.0 changed default logging level to DEBUG: https://github.com/pypa/setuptools/pull/2974
@@ -62,7 +58,7 @@
6258

6359

6460
def _exec(cmd: str, root: str | os.PathLike | None = None) -> list[str]:
65-
log.log(DEBUG, "Executing '%s' at '%s'", cmd, root or os.getcwd())
61+
log.log(DEBUG, "Executing %r at '%s'", cmd, root or os.getcwd())
6662
try:
6763
stdout = subprocess.check_output(cmd, shell=True, text=True, cwd=root) # nosec
6864
except subprocess.CalledProcessError as e:
@@ -262,44 +258,44 @@ def read_version_from_file(name_or_path: str | os.PathLike, root: str | os.PathL
262258

263259

264260
def substitute_env_variables(template: str) -> str:
265-
log.log(DEBUG, "Substitute environment variables in template '%s'", template)
261+
log.log(DEBUG, "Substitute environment variables in template %r", template)
266262
for var, default in ENV_VARS_REGEXP.findall(template):
267-
log.log(DEBUG, "Variable: '%s'", var)
263+
log.log(DEBUG, "Variable: %r", var)
268264

269265
if default.upper() == "IGNORE":
270266
default = ""
271267
elif not default:
272268
default = "UNKNOWN"
273-
log.log(DEBUG, "Default: '%s'", default)
269+
log.log(DEBUG, "Default: %r", default)
274270

275271
value = os.environ.get(var, default)
276-
log.log(DEBUG, "Value: '%s'", value)
272+
log.log(DEBUG, "Value: %r", value)
277273

278274
template, _ = ENV_VARS_REGEXP.subn(value, template, count=1)
279275

280-
log.log(DEBUG, "Result: '%s'", template)
276+
log.log(DEBUG, "Result: %r", template)
281277
return template
282278

283279

284280
def substitute_timestamp(template: str) -> str:
285-
log.log(DEBUG, "Substitute timestampts in template '%s'", template)
281+
log.log(DEBUG, "Substitute timestamps in template %r", template)
286282

287283
now = datetime.now()
288284
for fmt in TIMESTAMP_REGEXP.findall(template):
289285
format_string = fmt or "%s"
290-
log.log(DEBUG, "Format: '%s'", format_string)
286+
log.log(DEBUG, "Format: %r", format_string)
291287

292288
result = now.strftime(fmt or "%s")
293-
log.log(DEBUG, "Value: '%s'", result)
289+
log.log(DEBUG, "Value: %r", result)
294290

295291
template, _ = TIMESTAMP_REGEXP.subn(result, template, count=1)
296292

297-
log.log(DEBUG, "Result: '%s'", template)
293+
log.log(DEBUG, "Result: %r", template)
298294
return template
299295

300296

301297
def resolve_substitutions(template: str, *args, **kwargs) -> str:
302-
log.log(DEBUG, "Template: '%s'", template)
298+
log.log(DEBUG, "Template: %r", template)
303299
log.log(DEBUG, "Args:%s", pformat(args))
304300

305301
while True:
@@ -359,7 +355,7 @@ def load_tag_formatter(
359355
package_name: str | None = None,
360356
root: str | os.PathLike | None = None,
361357
) -> Callable:
362-
log.log(INFO, "Parsing tag_formatter '%s' of type '%s'", tag_formatter, type(tag_formatter).__name__)
358+
log.log(INFO, "Parsing tag_formatter %r of type %r", tag_formatter, type(tag_formatter).__name__)
363359

364360
if callable(tag_formatter):
365361
log.log(DEBUG, "Value is callable with signature %s", inspect.Signature.from_callable(tag_formatter))
@@ -383,16 +379,15 @@ def formatter(tag):
383379
return formatter
384380
except re.error as e:
385381
log.error("tag_formatter is not valid regexp: %s", e)
386-
387-
raise ValueError("Cannot parse tag_formatter")
382+
raise ValueError("Cannot parse tag_formatter") from e
388383

389384

390385
def load_branch_formatter(
391386
branch_formatter: str | Callable[[str], str],
392387
package_name: str | None = None,
393388
root: str | os.PathLike | None = None,
394389
) -> Callable:
395-
log.log(INFO, "Parsing branch_formatter '%s' of type '%s'", branch_formatter, type(branch_formatter).__name__)
390+
log.log(INFO, "Parsing branch_formatter %r of type %r", branch_formatter, type(branch_formatter).__name__)
396391

397392
if callable(branch_formatter):
398393
log.log(DEBUG, "Value is callable with signature %s", inspect.Signature.from_callable(branch_formatter))
@@ -416,8 +411,7 @@ def formatter(branch):
416411
return formatter
417412
except re.error as e:
418413
log.error("branch_formatter is not valid regexp: %s", e)
419-
420-
raise ValueError("Cannot parse branch_formatter")
414+
raise ValueError("Cannot parse branch_formatter") from e
421415

422416

423417
# TODO: return Version object instead of str
@@ -426,7 +420,7 @@ def get_version_from_callback(
426420
package_name: str | None = None,
427421
root: str | os.PathLike | None = None,
428422
) -> str:
429-
log.log(INFO, "Parsing version_callback %s of type %s", version_callback, type(version_callback))
423+
log.log(INFO, "Parsing version_callback %r of type %r", version_callback, type(version_callback).__name__)
430424

431425
if callable(version_callback):
432426
log.log(DEBUG, "Value is callable with signature %s", inspect.Signature.from_callable(version_callback))
@@ -447,10 +441,15 @@ def get_version_from_callback(
447441
except (ImportError, NameError) as e:
448442
log.warning("version_callback is not a valid reference: %s", e)
449443

450-
from packaging.version import Version
444+
return sanitize_version(result)
451445

452-
log.log(INFO, "Result %s", result)
453-
return Version(result).public
446+
447+
def sanitize_version(version: str) -> str:
448+
log.log(INFO, "Before sanitization %r", version)
449+
450+
result = str(Version(version))
451+
log.log(INFO, "Result %r", result)
452+
return result
454453

455454

456455
# TODO: return Version object instead of str
@@ -476,8 +475,8 @@ def version_from_git(
476475
for line in lines:
477476
if line.startswith("Version:"):
478477
result = line[8:].strip()
479-
log.log(INFO, "Return '%s'", result)
480-
return result
478+
log.log(INFO, "Return %r", result)
479+
return sanitize_version(result)
481480

482481
if version_callback is not None:
483482
if version_file is not None:
@@ -488,70 +487,68 @@ def version_from_git(
488487

489488
from_file = False
490489
log.log(INFO, "Getting latest tag")
491-
log.log(DEBUG, "Sorting tags by '%s'", sort_by)
490+
log.log(DEBUG, "Sorting tags by %r", sort_by)
492491
tag = get_tag(sort_by=sort_by, root=root)
493492

494493
if tag is None:
495494
log.log(INFO, "No tag, checking for 'version_file'")
496495
if version_file is None:
497-
log.log(INFO, "No 'version_file' set, return starting_version '%s'", starting_version)
498-
return starting_version
496+
log.log(INFO, "No 'version_file' set, return starting_version %r", starting_version)
497+
return sanitize_version(starting_version)
499498

500499
if not Path(version_file).exists():
501500
log.log(
502501
INFO,
503-
"version_file '%s' does not exist, return starting_version '%s'",
502+
"version_file '%s' does not exist, return starting_version %r",
504503
version_file,
505504
starting_version,
506505
)
507-
return starting_version
506+
return sanitize_version(starting_version)
508507

509508
log.log(INFO, "version_file '%s' does exist, reading its content", version_file)
510509
from_file = True
511510
tag = read_version_from_file(version_file, root=root)
512511

513512
if not tag:
514-
log.log(INFO, "File is empty, return starting_version '%s'", version_file, starting_version)
515-
return starting_version
513+
log.log(INFO, "File is empty, return starting_version %r", version_file, starting_version)
514+
return sanitize_version(starting_version)
516515

517-
log.log(DEBUG, "File content: '%s'", tag)
516+
log.log(DEBUG, "File content: %r", tag)
518517
if not count_commits_from_version_file:
519-
result = VERSION_PREFIX_REGEXP.sub("", tag) # for tag "v1.0.0" drop leading "v" symbol
520-
log.log(INFO, "Return '%s'", result)
521-
return result
518+
return sanitize_version(tag)
522519

523520
tag_sha = get_latest_file_commit(version_file, root=root)
524-
log.log(DEBUG, "File content: '%s'", tag)
521+
log.log(DEBUG, "File SHA-256: %r", tag_sha)
525522
else:
526-
log.log(INFO, "Latest tag: '%s'", tag)
523+
log.log(INFO, "Latest tag: %r", tag)
527524
tag_sha = get_sha(tag, root=root)
528-
log.log(INFO, "Tag SHA-256: '%s'", tag_sha)
525+
log.log(INFO, "Tag SHA-256: %r", tag_sha)
529526

530527
if tag_formatter is not None:
531528
tag_fmt = load_tag_formatter(tag_formatter, package_name, root=root)
532529
tag = tag_fmt(tag)
533-
log.log(DEBUG, "Tag after formatting: '%s'", tag)
530+
log.log(DEBUG, "Tag after formatting: %r", tag)
534531

535532
dirty = is_dirty(root=root)
536-
log.log(INFO, "Is dirty: %s", dirty)
533+
log.log(INFO, "Is dirty: %r", dirty)
537534

538535
head_sha = get_sha(root=root)
539-
log.log(INFO, "HEAD SHA-256: '%s'", head_sha)
536+
log.log(INFO, "HEAD SHA-256: %r", head_sha)
540537

541538
full_sha = head_sha if head_sha is not None else ""
542539
ccount = count_since(tag_sha, root=root) if tag_sha is not None else None
543-
log.log(INFO, "Commits count between HEAD and latest tag: %s", ccount)
540+
log.log(INFO, "Commits count between HEAD and latest tag: %r", ccount)
544541

545542
on_tag = head_sha is not None and head_sha == tag_sha and not from_file
546-
log.log(INFO, "HEAD is tagged: %s", on_tag)
543+
log.log(INFO, "HEAD is tagged: %r", on_tag)
547544

548545
branch = get_branch(root=root)
549-
log.log(INFO, "Current branch: '%s'", branch)
546+
log.log(INFO, "Current branch: %r", branch)
550547

551548
if branch_formatter is not None and branch is not None:
552549
branch_fmt = load_branch_formatter(branch_formatter, package_name, root=root)
553550
branch = branch_fmt(branch)
554-
log.log(INFO, "Branch after formatting: '%s'", branch)
551+
log.log(INFO, "Branch after formatting: %r", branch)
555552

556553
if dirty:
557554
log.log(INFO, "Using template from 'dirty_template' option")
@@ -564,26 +561,11 @@ def version_from_git(
564561
t = template
565562

566563
version = resolve_substitutions(t, sha=full_sha[:8], tag=tag, ccount=ccount, branch=branch, full_sha=full_sha)
567-
log.log(INFO, "Version number after resolving substitutions: '%s'", version)
568-
569-
# Ensure local version label only contains permitted characters
570-
public, sep, local = version.partition("+")
571-
local_sanitized = LOCAL_REGEXP.sub(".", local)
572-
if local_sanitized != local:
573-
log.log(INFO, "Local version part after sanitization: '%s'", local_sanitized)
574-
575-
public_sanitized = VERSION_PREFIX_REGEXP.sub("", public) # for version "v1.0.0" drop leading "v" symbol
576-
if public_sanitized != public:
577-
log.log(INFO, "Public version part after sanitization: '%s'", public_sanitized)
578-
579-
result = (public_sanitized + sep + local_sanitized) or "0.0.0"
580-
log.log(INFO, "Result: '%s'", result)
581-
return result
564+
log.log(INFO, "Version number after resolving substitutions: %r", version)
565+
return sanitize_version(version)
582566

583567

584568
def main(config: dict | None = None, root: str | os.PathLike | None = None) -> Version:
585-
from packaging.version import Version
586-
587569
if not config:
588570
log.log(INFO, "No explicit config passed")
589571
log.log(INFO, "Searching for config files in '%s' folder", root or os.getcwd())
@@ -640,7 +622,7 @@ def __main__():
640622
namespace = parser.parse_args()
641623
log_level = VERBOSITY_LEVELS.get(namespace.verbose, logging.DEBUG)
642624
logging.basicConfig(level=log_level, format=LOG_FORMAT, stream=sys.stderr)
643-
print(main(root=namespace.root).public)
625+
print(str(main(root=namespace.root)))
644626

645627

646628
if __name__ == "__main__":

tests/test_integration/test_tag.py

+42-7
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import subprocess
12
from datetime import datetime, timedelta
23
import pytest
34
import time
@@ -92,7 +93,7 @@ def test_tag_dirty(repo, create_config, add, template, subst):
9293

9394

9495
@pytest.mark.parametrize("starting_version, version", [(None, "0.0.1"), ("1.2.3", "1.2.3")])
95-
def test_tag_no_tag(repo, create_config, starting_version, version):
96+
def test_tag_missing(repo, create_config, starting_version, version):
9697
if starting_version:
9798
create_config(repo, {"starting_version": starting_version})
9899
else:
@@ -106,20 +107,54 @@ def test_tag_no_tag(repo, create_config, starting_version, version):
106107
[
107108
("1.0.0", "1.0.0"),
108109
("v1.2.3", "1.2.3"),
109-
("beta1.2.3", "1.2.3"),
110-
("alpha1.2.3", "1.2.3"),
110+
("1.2.3dev1", "1.2.3.dev1"),
111+
("1.2.3.dev1", "1.2.3.dev1"),
112+
("1.2.3-dev1", "1.2.3.dev1"),
113+
("1.2.3+local", "1.2.3+local"),
114+
("1.2.3+local-abc", "1.2.3+local.abc"),
115+
("1.2.3+local_abc", "1.2.3+local.abc"),
111116
],
112117
)
113-
def test_tag_drop_leading_non_numbers(repo, create_config, tag, version):
118+
def test_tag_sanitization(repo, create_config, tag, version):
114119
create_config(repo)
115-
116120
create_tag(repo, tag)
121+
117122
assert get_version(repo) == version
118123

119124

120-
def test_tag_missing(repo, create_config):
125+
@pytest.mark.parametrize(
126+
"tag",
127+
[
128+
"alpha1.0.0",
129+
"1.0.0abc",
130+
"1.0.0.abc",
131+
"1.0.0-abc",
132+
"1.0.0_abc",
133+
],
134+
)
135+
def test_tag_wrong_version_number(repo, tag, create_config):
121136
create_config(repo)
122-
assert get_version(repo) == "0.0.1"
137+
create_tag(repo, tag)
138+
139+
with pytest.raises(subprocess.CalledProcessError):
140+
get_version(repo)
141+
142+
143+
@pytest.mark.parametrize(
144+
"starting_version",
145+
[
146+
"alpha1.0.0",
147+
"1.0.0abc",
148+
"1.0.0.abc",
149+
"1.0.0-abc",
150+
"1.0.0_abc",
151+
],
152+
)
153+
def test_tag_wrong_starting_version(repo, create_config, starting_version):
154+
create_config(repo, {"starting_version": starting_version})
155+
156+
with pytest.raises(subprocess.CalledProcessError):
157+
get_version(repo)
123158

124159

125160
def test_tag_not_a_repo(repo_dir, create_config):

0 commit comments

Comments
 (0)