summaryrefslogtreecommitdiff
path: root/src/setuptools_scm/config.py
blob: fee652c4e8adaea4ee8b1f23e3d8b187c1adbbdb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
""" configuration """
from __future__ import annotations

import os
import re
import warnings
from typing import Any
from typing import Callable
from typing import cast
from typing import Pattern
from typing import Type
from typing import TYPE_CHECKING
from typing import Union

from ._version_cls import NonNormalizedVersion
from ._version_cls import Version
from .utils import trace


if TYPE_CHECKING:
    from . import _types as _t
    from setuptools_scm.version import ScmVersion

DEFAULT_TAG_REGEX = r"^(?:[\w-]+-)?(?P<version>[vV]?\d+(?:\.\d+){0,2}[^\+]*)(?:\+.*)?$"
DEFAULT_VERSION_SCHEME = "guess-next-dev"
DEFAULT_LOCAL_SCHEME = "node-and-date"
_ROOT = "root"


def _check_tag_regex(value: str | Pattern[str] | None) -> Pattern[str]:
    if not value:
        value = DEFAULT_TAG_REGEX
    regex = re.compile(value)

    group_names = regex.groupindex.keys()
    if regex.groups == 0 or (regex.groups > 1 and "version" not in group_names):
        warnings.warn(
            "Expected tag_regex to contain a single match group or a group named"
            " 'version' to identify the version part of any tag."
        )

    return regex


def _check_absolute_root(root: _t.PathT, relative_to: _t.PathT | None) -> str:
    trace("abs root", repr(locals()))
    if relative_to:
        if (
            os.path.isabs(root)
            and not os.path.commonpath([root, relative_to]) == relative_to
        ):
            warnings.warn(
                "absolute root path '%s' overrides relative_to '%s'"
                % (root, relative_to)
            )
        if os.path.isdir(relative_to):
            warnings.warn(
                "relative_to is expected to be a file,"
                " its the directory %r\n"
                "assuming the parent directory was passed" % (relative_to,)
            )
            trace("dir", relative_to)
            root = os.path.join(relative_to, root)
        else:
            trace("file", relative_to)
            root = os.path.join(os.path.dirname(relative_to), root)
    return os.path.abspath(root)


def _lazy_tomli_load(data: str) -> dict[str, Any]:
    from tomli import loads

    return loads(data)


_VersionT = Union[Version, NonNormalizedVersion]


def _validate_version_cls(
    version_cls: type[_VersionT] | str | None, normalize: bool
) -> type[_VersionT]:
    if not normalize:
        # `normalize = False` means `version_cls = NonNormalizedVersion`
        if version_cls is not None:
            raise ValueError(
                "Providing a custom `version_cls` is not permitted when "
                "`normalize=False`"
            )
        return NonNormalizedVersion
    else:
        # Use `version_cls` if provided, default to packaging or pkg_resources
        if version_cls is None:
            return Version
        elif isinstance(version_cls, str):
            try:
                # Not sure this will work in old python
                import importlib

                pkg, cls_name = version_cls.rsplit(".", 1)
                version_cls_host = importlib.import_module(pkg)
                return cast(Type[_VersionT], getattr(version_cls_host, cls_name))
            except:  # noqa
                raise ValueError(f"Unable to import version_cls='{version_cls}'")
        else:
            return version_cls


class Configuration:
    """Global configuration model"""

    parent: _t.PathT | None
    _root: str
    _relative_to: str | None
    version_cls: type[_VersionT]

    def __init__(
        self,
        relative_to: _t.PathT | None = None,
        root: _t.PathT = ".",
        version_scheme: (
            str | Callable[[ScmVersion], str | None]
        ) = DEFAULT_VERSION_SCHEME,
        local_scheme: (str | Callable[[ScmVersion], str | None]) = DEFAULT_LOCAL_SCHEME,
        write_to: _t.PathT | None = None,
        write_to_template: str | None = None,
        tag_regex: str | Pattern[str] = DEFAULT_TAG_REGEX,
        parentdir_prefix_version: str | None = None,
        fallback_version: str | None = None,
        fallback_root: _t.PathT = ".",
        parse: Any | None = None,
        git_describe_command: _t.CMD_TYPE | None = None,
        dist_name: str | None = None,
        version_cls: type[_VersionT] | type | str | None = None,
        normalize: bool = True,
        search_parent_directories: bool = False,
    ):
        # TODO:
        self._relative_to = None if relative_to is None else os.fspath(relative_to)
        self._root = "."

        self.root = os.fspath(root)
        self.version_scheme = version_scheme
        self.local_scheme = local_scheme
        self.write_to = write_to
        self.write_to_template = write_to_template
        self.parentdir_prefix_version = parentdir_prefix_version
        self.fallback_version = fallback_version
        self.fallback_root = fallback_root  # type: ignore
        self.parse = parse
        self.tag_regex = tag_regex  # type: ignore
        self.git_describe_command = git_describe_command
        self.dist_name = dist_name
        self.search_parent_directories = search_parent_directories
        self.parent = None

        self.version_cls = _validate_version_cls(version_cls, normalize)

    @property
    def fallback_root(self) -> str:
        return self._fallback_root

    @fallback_root.setter
    def fallback_root(self, value: _t.PathT) -> None:
        self._fallback_root = os.path.abspath(value)

    @property
    def absolute_root(self) -> str:
        return self._absolute_root

    @property
    def relative_to(self) -> str | None:
        return self._relative_to

    @relative_to.setter
    def relative_to(self, value: _t.PathT) -> None:
        self._absolute_root = _check_absolute_root(self._root, value)
        self._relative_to = os.fspath(value)
        trace("root", repr(self._absolute_root))
        trace("relative_to", repr(value))

    @property
    def root(self) -> str:
        return self._root

    @root.setter
    def root(self, value: _t.PathT) -> None:
        self._absolute_root = _check_absolute_root(value, self._relative_to)
        self._root = os.fspath(value)
        trace("root", repr(self._absolute_root))
        trace("relative_to", repr(self._relative_to))

    @property
    def tag_regex(self) -> Pattern[str]:
        return self._tag_regex

    @tag_regex.setter
    def tag_regex(self, value: str | Pattern[str]) -> None:
        self._tag_regex = _check_tag_regex(value)

    @classmethod
    def from_file(
        cls,
        name: str = "pyproject.toml",
        dist_name: str | None = None,
        _load_toml: Callable[[str], dict[str, Any]] = _lazy_tomli_load,
        **kwargs: Any,
    ) -> Configuration:
        """
        Read Configuration from pyproject.toml (or similar).
        Raises exceptions when file is not found or toml is
        not installed or the file has invalid format or does
        not contain the [tool.setuptools_scm] section.
        """

        with open(name, encoding="UTF-8") as strm:
            data = strm.read()

        defn = _load_toml(data)
        try:
            section = defn.get("tool", {})["setuptools_scm"]
        except LookupError as e:
            raise LookupError(
                f"{name} does not contain a tool.setuptools_scm section"
            ) from e

        project = defn.get("project", {})
        dist_name = cls._cleanup_from_file_args_data(
            project, dist_name, kwargs, section
        )
        return cls(dist_name=dist_name, relative_to=name, **section, **kwargs)

    @staticmethod
    def _cleanup_from_file_args_data(
        project: dict[str, Any],
        dist_name: str | None,
        kwargs: dict[str, Any],
        section: dict[str, Any],
    ) -> str | None:
        """drops problematic details and figures the distribution name"""
        if "dist_name" in section:
            if dist_name is None:
                dist_name = section.pop("dist_name")
            else:
                assert dist_name == section["dist_name"]
                del section["dist_name"]
        if dist_name is None:
            # minimal pep 621 support for figuring the pretend keys
            dist_name = project.get("name")
        if dist_name is None:
            dist_name = _read_dist_name_from_setup_cfg()
        if _ROOT in kwargs:
            if kwargs[_ROOT] is None:
                kwargs.pop(_ROOT, None)
            elif _ROOT in section:
                if section[_ROOT] != kwargs[_ROOT]:
                    warnings.warn(
                        f"root {section[_ROOT]} is overridden"
                        f" by the cli arg {kwargs[_ROOT]}"
                    )
                section.pop("root", None)
        return dist_name


def _read_dist_name_from_setup_cfg() -> str | None:

    # minimal effort to read dist_name off setup.cfg metadata
    import configparser

    parser = configparser.ConfigParser()
    parser.read(["setup.cfg"])
    dist_name = parser.get("metadata", "name", fallback=None)
    return dist_name