setuptools-git-versioning 2.0.0__py3-none-any.whl → 3.0.0__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.
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from setuptools_git_versioning.git import (
6
+ count_since,
7
+ get_all_tags,
8
+ get_branch,
9
+ get_branches,
10
+ get_latest_file_commit,
11
+ get_sha,
12
+ get_tag,
13
+ get_tags,
14
+ is_dirty,
15
+ )
16
+ from setuptools_git_versioning.setup import get_version, infer_version
17
+ from setuptools_git_versioning.version import version_from_git
18
+
19
+ if TYPE_CHECKING:
20
+ from setuptools.dist import Distribution
21
+
22
+
23
+ def parse_config(dist: Distribution, attr: Any, value: Any) -> None:
24
+ "Dummy function used only to register in distutils"
25
+ return
26
+
27
+
28
+ __all__ = [
29
+ "count_since",
30
+ "get_all_tags",
31
+ "get_branch",
32
+ "get_branches",
33
+ "get_latest_file_commit",
34
+ "get_sha",
35
+ "get_tag",
36
+ "get_tags",
37
+ "get_version",
38
+ "infer_version",
39
+ "is_dirty",
40
+ "version_from_git",
41
+ ]
@@ -0,0 +1,3 @@
1
+ from setuptools_git_versioning.cli import main
2
+
3
+ main()
@@ -0,0 +1,36 @@
1
+ import argparse
2
+ import logging
3
+ import sys
4
+
5
+ from setuptools_git_versioning.log import DEBUG, LOG_FORMAT, VERBOSITY_LEVELS
6
+ from setuptools_git_versioning.setup import get_version
7
+
8
+
9
+ def get_parser() -> argparse.ArgumentParser:
10
+ parser = argparse.ArgumentParser(
11
+ prog="setuptools-git-versioning",
12
+ description="Get version based on git information and 'setup.py' or 'pyproject.toml' config",
13
+ )
14
+ parser.add_argument(
15
+ "root",
16
+ type=str,
17
+ default=None,
18
+ nargs="?",
19
+ help="Path to folder containing 'setup.py' or 'pyproject.toml' file. Default: current dir",
20
+ )
21
+ parser.add_argument(
22
+ "--verbose",
23
+ "-v",
24
+ action="count",
25
+ default=0,
26
+ help="Increase verbosity level. Can be used multiple times: -v for INFO messages, -vv for DEBUG",
27
+ )
28
+ return parser
29
+
30
+
31
+ def main():
32
+ parser = get_parser()
33
+ namespace = parser.parse_args()
34
+ log_level = VERBOSITY_LEVELS.get(namespace.verbose, DEBUG)
35
+ logging.basicConfig(level=log_level, format=LOG_FORMAT, stream=sys.stderr)
36
+ print(str(get_version(root=namespace.root))) # noqa: T201
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ DEFAULT_TEMPLATE = "{tag}"
4
+ DEFAULT_DEV_TEMPLATE = "{tag}.post{ccount}+git.{sha}"
5
+ DEFAULT_DIRTY_TEMPLATE = "{tag}.post{ccount}+git.{sha}.dirty"
6
+ DEFAULT_STARTING_VERSION = "0.0.1"
7
+ DEFAULT_SORT_BY = "creatordate"
8
+ DEFAULT_CONFIG = {
9
+ "template": DEFAULT_TEMPLATE,
10
+ "dev_template": DEFAULT_DEV_TEMPLATE,
11
+ "dirty_template": DEFAULT_DIRTY_TEMPLATE,
12
+ "starting_version": DEFAULT_STARTING_VERSION,
13
+ "version_callback": None,
14
+ "version_file": None,
15
+ "count_commits_from_version_file": False,
16
+ "tag_formatter": None,
17
+ "branch_formatter": None,
18
+ "tag_filter": None,
19
+ "sort_by": DEFAULT_SORT_BY,
20
+ }
21
+
22
+
23
+ def set_default_options(config: dict):
24
+ for key, value in DEFAULT_CONFIG.items():
25
+ config.setdefault(key, value)
26
+ return config
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import inspect
5
+ import logging
6
+ import os
7
+ import re
8
+ import sys
9
+ from typing import Any, Callable
10
+
11
+ from setuptools_git_versioning.log import DEBUG, INFO
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ def add_to_sys_path(root: str | os.PathLike | None) -> None:
17
+ project_root = os.fspath(root) if root else os.getcwd() # noqa: PTH109
18
+ if project_root not in sys.path:
19
+ log.log(DEBUG, "Adding '%s' folder to sys.path", project_root)
20
+ sys.path.insert(0, project_root)
21
+
22
+
23
+ def import_reference(
24
+ ref: str,
25
+ package_name: str | None = None,
26
+ root: str | os.PathLike | None = None,
27
+ ) -> Any:
28
+ if ":" not in ref:
29
+ msg = f"Wrong reference name: {ref}"
30
+ raise NameError(msg)
31
+
32
+ add_to_sys_path(root)
33
+
34
+ module_name, attr = ref.split(":")
35
+ log.log(DEBUG, "Executing 'from %s.%s import %s'", package_name or "", module_name, attr)
36
+ module = importlib.import_module(module_name, package_name)
37
+
38
+ return getattr(module, attr)
39
+
40
+
41
+ def load_callable(
42
+ inp: str,
43
+ package_name: str | None = None,
44
+ root: str | os.PathLike | None = None,
45
+ ) -> Callable:
46
+ ref = import_reference(inp, package_name, root=root)
47
+ if not callable(ref):
48
+ msg = f"{ref} of type {type(ref)} is not callable"
49
+ raise TypeError(msg)
50
+
51
+ return ref
52
+
53
+
54
+ def _callable_factory(
55
+ callable_name: str,
56
+ regexp_or_ref: str | Callable,
57
+ callable_factory: Callable[[str], Callable],
58
+ package_name: str | None = None,
59
+ root: str | os.PathLike | None = None,
60
+ ) -> Callable:
61
+ log.log(INFO, "Parsing %s %r of type %r", callable_name, regexp_or_ref, type(regexp_or_ref).__name__)
62
+
63
+ if callable(regexp_or_ref):
64
+ log.log(DEBUG, "Value is callable with signature %s", inspect.Signature.from_callable(regexp_or_ref))
65
+ return regexp_or_ref
66
+
67
+ try:
68
+ return load_callable(regexp_or_ref, package_name, root=root)
69
+ except (ImportError, NameError) as e:
70
+ log.log(DEBUG, "%s is not a valid function reference: %s", callable_name, e)
71
+
72
+ try:
73
+ return callable_factory(regexp_or_ref)
74
+ except re.error as e:
75
+ log.exception("%s is not valid regexp neither a valid function reference", callable_name)
76
+ msg = f"Cannot parse {callable_name}"
77
+ raise ValueError(msg) from e
78
+
79
+
80
+ def branch_formatter_factory(regexp: str) -> Callable[[str], str]:
81
+ pattern = re.compile(regexp)
82
+
83
+ def branch_formatter(branch: str) -> str:
84
+ match = pattern.match(branch)
85
+ if match:
86
+ return match.group("branch")
87
+
88
+ msg = f"Branch name {branch} does not match regexp '{regexp}'"
89
+ raise ValueError(msg)
90
+
91
+ return branch_formatter
92
+
93
+
94
+ def create_branch_formatter(
95
+ branch_formatter: Callable[[str], str] | str,
96
+ package_name: str | None = None,
97
+ root: str | os.PathLike | None = None,
98
+ ):
99
+ return _callable_factory(
100
+ callable_name="branch_formatter",
101
+ regexp_or_ref=branch_formatter,
102
+ callable_factory=branch_formatter_factory,
103
+ package_name=package_name,
104
+ root=root,
105
+ )
106
+
107
+
108
+ def tag_formatter_factory(regexp: str) -> Callable[[str], str]:
109
+ pattern = re.compile(regexp)
110
+
111
+ def tag_formatter(tag: str) -> str:
112
+ match = pattern.match(tag)
113
+ if match:
114
+ return match.group("tag")
115
+
116
+ msg = f"Tag name {tag} does not match regexp '{regexp}'"
117
+ raise ValueError(msg)
118
+
119
+ return tag_formatter
120
+
121
+
122
+ def create_tag_formatter(
123
+ tag_formatter: Callable[[str], str] | str,
124
+ package_name: str | None = None,
125
+ root: str | os.PathLike | None = None,
126
+ ):
127
+ return _callable_factory(
128
+ callable_name="tag_formatter",
129
+ regexp_or_ref=tag_formatter,
130
+ callable_factory=tag_formatter_factory,
131
+ package_name=package_name,
132
+ root=root,
133
+ )
134
+
135
+
136
+ def tag_filter_factory(regexp: str) -> Callable[[str], str | None]:
137
+ pattern = re.compile(regexp)
138
+
139
+ def tag_filter(tag: str) -> str | None:
140
+ match = pattern.match(tag)
141
+ if match:
142
+ log.info("Matched %s", tag)
143
+ return tag
144
+ return None
145
+
146
+ return tag_filter
147
+
148
+
149
+ def create_tag_filter(
150
+ tag_filter: Callable[[str], str | None] | str,
151
+ package_name: str | None = None,
152
+ root: str | os.PathLike | None = None,
153
+ ):
154
+ return _callable_factory(
155
+ callable_name="tag_filter",
156
+ regexp_or_ref=tag_filter,
157
+ callable_factory=tag_filter_factory,
158
+ package_name=package_name,
159
+ root=root,
160
+ )
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import subprocess # nosec
6
+ from contextlib import suppress
7
+ from pathlib import Path
8
+ from typing import Callable
9
+
10
+ from setuptools_git_versioning.defaults import DEFAULT_SORT_BY
11
+ from setuptools_git_versioning.log import DEBUG
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ def _exec(*cmd: str, root: str | os.PathLike | None = None) -> list[str]:
17
+ log.log(DEBUG, "Executing %r at '%s'", cmd, root or Path.cwd())
18
+ try:
19
+ stdout = subprocess.check_output(cmd, text=True, cwd=root) # noqa: S603
20
+ except subprocess.CalledProcessError as e:
21
+ stdout = e.output
22
+ lines = stdout.splitlines()
23
+ return [line.rstrip() for line in lines if line.rstrip()]
24
+
25
+
26
+ def get_branches(root: str | os.PathLike | None = None) -> list[str]:
27
+ """Return list of branch names in the git repository"""
28
+ branches = _exec("git", "branch", "-l", "--format", "%(refname:short)", root=root)
29
+ return branches or []
30
+
31
+
32
+ def get_branch(root: str | os.PathLike | None = None) -> str | None:
33
+ """Return branch name pointing to HEAD, or None"""
34
+ branches = _exec("git", "rev-parse", "--abbrev-ref", "HEAD", root=root)
35
+ return branches[0] if branches else None
36
+
37
+
38
+ def get_all_tags(sort_by: str = DEFAULT_SORT_BY, root: str | os.PathLike | None = None) -> list[str]:
39
+ """Return list of tags in the git repository"""
40
+ tags = _exec("git", "tag", f"--sort=-{sort_by}", root=root)
41
+ return tags or []
42
+
43
+
44
+ def get_tags(
45
+ sort_by: str = DEFAULT_SORT_BY,
46
+ filter_callback: Callable[[str], str | None] | None = None,
47
+ root: str | os.PathLike | None = None,
48
+ ) -> list[str]:
49
+ """Return list of tags merged into HEAD history tree"""
50
+ tags = _exec("git", "tag", f"--sort=-{sort_by}", "--merged", root=root)
51
+ if filter_callback:
52
+ # pull the tags that don't start with tag_prefix out of the list
53
+ return list(filter(filter_callback, tags))
54
+ return tags or []
55
+
56
+
57
+ def get_tag(*args, **kwargs) -> str | None:
58
+ """Return latest tag merged into HEAD history tree"""
59
+ tags = get_tags(*args, **kwargs)
60
+ return tags[0] if tags else None
61
+
62
+
63
+ def get_sha(name: str = "HEAD", root: str | os.PathLike | None = None) -> str | None:
64
+ """Get commit SHA-1 hash"""
65
+ sha = _exec("git", "rev-list", "-n", "1", name, root=root)
66
+ return sha[0] if sha else None
67
+
68
+
69
+ def get_latest_file_commit(path: str | os.PathLike, root: str | os.PathLike | None = None) -> str | None:
70
+ """Get SHA-1 hash of latest commit of the file in the repository"""
71
+ sha = _exec("git", "log", "-n", "1", "--pretty=format:%H", "--", os.fspath(path), root=root)
72
+ return sha[0] if sha else None
73
+
74
+
75
+ def is_dirty(root: str | os.PathLike | None = None) -> bool:
76
+ """Check index status, and return True if there are some uncommitted changes"""
77
+ res = _exec("git", "status", "--short", root=root)
78
+ return bool(res)
79
+
80
+
81
+ def count_since(name: str, root: str | os.PathLike | None = None) -> int | None:
82
+ """Get number of commits between HEAD and the commit, or None if they are not related"""
83
+ res = _exec("git", "rev-list", "--count", "HEAD", f"^{name}", root=root)
84
+ if res:
85
+ with suppress(ValueError, TypeError):
86
+ return int(res[0])
87
+ return None
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ # setuptools v60.2.0 changed default logging level to DEBUG: https://github.com/pypa/setuptools/pull/2974
6
+ # to avoid printing information messages to the same output as version number,
7
+ # use a custom levels below built-in DEBUG level (10)
8
+ INFO = 9
9
+ DEBUG = 8
10
+ logging.addLevelName(INFO, "INF0")
11
+ logging.addLevelName(DEBUG, "DE8UG")
12
+ VERBOSITY_LEVELS = {
13
+ 0: logging.WARNING,
14
+ 1: INFO,
15
+ 2: DEBUG,
16
+ }
17
+
18
+ LOG_FORMAT = "[%(asctime)s] %(levelname)+8s: %(message)s"
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import sys
6
+ import textwrap
7
+ from pathlib import Path
8
+ from pprint import pformat
9
+ from typing import TYPE_CHECKING
10
+
11
+ from setuptools_git_versioning.defaults import set_default_options
12
+ from setuptools_git_versioning.factories import add_to_sys_path
13
+ from setuptools_git_versioning.log import DEBUG, INFO
14
+ from setuptools_git_versioning.version import version_from_git
15
+
16
+ if TYPE_CHECKING:
17
+ # avoid importing 'packaging' because setuptools-git-versioning can be installed using sdist
18
+ # where 'packaging' is not installed yet
19
+ from packaging.version import Version
20
+ from setuptools.dist import Distribution
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+
25
+ def read_toml(name_or_path: str | os.PathLike = "pyproject.toml", root: str | os.PathLike | None = None) -> dict:
26
+ project_root = Path(root) if root else Path.cwd()
27
+ file_path = project_root.joinpath(name_or_path)
28
+ if not file_path.exists():
29
+ log.log(INFO, "'%s' does not exist", file_path)
30
+ return {}
31
+
32
+ if not file_path.is_file():
33
+ msg = f"'{file_path}' is not a file"
34
+ raise OSError(msg)
35
+
36
+ log.log(INFO, "Trying 'pyproject.toml' ...")
37
+ try:
38
+ # for Python 3.11+
39
+ import tomllib
40
+
41
+ with file_path.open("rb") as file:
42
+ parsed_file = tomllib.load(file)
43
+ except (ImportError, NameError):
44
+ import tomli
45
+
46
+ with file_path.open("rb") as file:
47
+ parsed_file = tomli.load(file)
48
+
49
+ result = parsed_file.get("tool", {}).get("setuptools-git-versioning", None)
50
+ if result:
51
+ log.log(DEBUG, "'tool.setuptools-git-versioning' section content:\n%s", pformat(result))
52
+ return result
53
+
54
+
55
+ def infer_version(dist: Distribution, root: str | os.PathLike | None = None) -> Version | None:
56
+ log.log(INFO, "Trying 'setup.py' ...")
57
+
58
+ # see above
59
+ import setuptools # isort: skip # noqa: F401
60
+
61
+ from distutils.errors import DistutilsOptionError, DistutilsSetupError
62
+
63
+ config = getattr(dist, "setuptools_git_versioning", None)
64
+ toml_config = read_toml(root=root)
65
+
66
+ if config is None:
67
+ config = toml_config
68
+ elif toml_config:
69
+ msg = (
70
+ "Both 'setup.py' and 'pyproject.toml' have 'setuptools_git_versioning' config section. "
71
+ "Please remove one of them"
72
+ )
73
+ raise DistutilsSetupError(msg)
74
+
75
+ if config is None:
76
+ # Nothing to do here
77
+ return None
78
+
79
+ if not isinstance(config, dict):
80
+ msg = f"Wrong config format. Expected dict, got: {config}"
81
+ raise DistutilsOptionError(msg)
82
+
83
+ if not config or not config.pop("enabled", True):
84
+ # Nothing to do here
85
+ return None
86
+
87
+ set_default_options(config)
88
+
89
+ version = version_from_git(dist.metadata.name, **config, root=root)
90
+ dist.metadata.version = str(version)
91
+ return version
92
+
93
+
94
+ def infer_setup_py(name_or_path: str = "setup.py", root: str | os.PathLike | None = None) -> Version | None:
95
+ project_root = Path(root) if root else Path.cwd()
96
+ setup_py_path = project_root.joinpath(name_or_path)
97
+ if not setup_py_path.exists():
98
+ log.log(INFO, "'%s' does not exist", setup_py_path)
99
+ return None
100
+
101
+ # because we use distutils in this file, we need to ensure that setuptools is
102
+ # imported first so that it can do monkey patching. this is not always already
103
+ # done for us, for example, when running this in a test or as a module
104
+ import setuptools # isort: skip # noqa: F401
105
+
106
+ from distutils.core import run_setup
107
+
108
+ # distutils does not change current directory, causing version of 'setuptools_git_versioning'
109
+ # is being get instead of target package.
110
+ # also some setup.py files can contain imports of other files from the package,
111
+ # and if they will be missing from sys.path, import will fail.
112
+ # emulating `python setup.py` call by modifying current dir and sys.path, but restore everything back after import
113
+ original_cwd = os.getcwd() # noqa: PTH109
114
+ original_sys_path = sys.path.copy()
115
+ original_sys_modules = sys.modules.copy()
116
+ try:
117
+ add_to_sys_path(project_root)
118
+ os.chdir(project_root)
119
+ dist = run_setup(os.fspath(setup_py_path), stop_after="init")
120
+ return infer_version(dist, root=root)
121
+ finally:
122
+ sys.path[:] = original_sys_path
123
+ sys.modules = original_sys_modules
124
+ os.chdir(original_cwd)
125
+
126
+
127
+ def get_version(config: dict | None = None, root: str | os.PathLike | None = None) -> Version:
128
+ if not config:
129
+ log.log(INFO, "No explicit config passed")
130
+ log.log(INFO, "Searching for config files in '%s' folder", root or Path.cwd())
131
+ result = infer_setup_py(root=root)
132
+ if result is not None:
133
+ return result
134
+
135
+ config = read_toml(root=root)
136
+
137
+ if not config or not config.pop("enabled", True):
138
+ raise RuntimeError(
139
+ textwrap.dedent(
140
+ f"""
141
+ 'setuptools-git-versioning' command can be used only
142
+ with 'pyproject.toml' or 'setup.py' file present in folder '{root or Path.cwd()}',
143
+ containing the 'enabled: True' setting
144
+ (see https://setuptools-git-versioning.readthedocs.io/en/stable/install.html)
145
+ """,
146
+ ),
147
+ )
148
+
149
+ set_default_options(config)
150
+ return version_from_git(**config, root=root)
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import re
6
+ from datetime import datetime, timezone
7
+ from pprint import pformat
8
+
9
+ from setuptools_git_versioning.log import DEBUG
10
+
11
+ ENV_VARS_REGEXP = re.compile(r"\{env:(?P<name>[^:}]+):?(?P<default>[^}]+\}*)?\}", re.IGNORECASE | re.UNICODE)
12
+ TIMESTAMP_REGEXP = re.compile(r"\{timestamp:?(?P<fmt>[^:}]+)?\}", re.IGNORECASE | re.UNICODE)
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+
17
+ def substitute_env_variables(template: str) -> str:
18
+ log.log(DEBUG, "Substitute environment variables in template %r", template)
19
+ for var, default_value in ENV_VARS_REGEXP.findall(template):
20
+ log.log(DEBUG, "Variable: %r", var)
21
+
22
+ default = default_value
23
+ if default_value.upper() == "IGNORE":
24
+ default = ""
25
+ elif not default_value:
26
+ default = "UNKNOWN"
27
+ log.log(DEBUG, "Default: %r", default)
28
+
29
+ value = os.environ.get(var, default)
30
+ log.log(DEBUG, "Value: %r", value)
31
+
32
+ template, _ = ENV_VARS_REGEXP.subn(value, template, count=1)
33
+
34
+ log.log(DEBUG, "Result: %r", template)
35
+ return template
36
+
37
+
38
+ def substitute_timestamp(template: str) -> str:
39
+ log.log(DEBUG, "Substitute timestamps in template %r", template)
40
+
41
+ now = datetime.now(tz=timezone.utc).astimezone()
42
+ for fmt in TIMESTAMP_REGEXP.findall(template):
43
+ format_string = fmt or "%s"
44
+ log.log(DEBUG, "Format: %r", format_string)
45
+
46
+ result = now.strftime(format_string)
47
+ log.log(DEBUG, "Value: %r", result)
48
+
49
+ template, _ = TIMESTAMP_REGEXP.subn(result, template, count=1)
50
+
51
+ log.log(DEBUG, "Result: %r", template)
52
+ return template
53
+
54
+
55
+ def resolve_substitutions(template: str, *args, **kwargs) -> str:
56
+ log.log(DEBUG, "Template: %r", template)
57
+ log.log(DEBUG, "Args:%s", pformat(args))
58
+
59
+ while True:
60
+ if "{env" in template:
61
+ new_template = substitute_env_variables(template)
62
+ if new_template == template:
63
+ break
64
+ template = new_template
65
+ else:
66
+ break
67
+
68
+ if "{timestamp" in template:
69
+ template = substitute_timestamp(template)
70
+
71
+ return template.format(*args, **kwargs)