summaryrefslogtreecommitdiff
path: root/src/tox/config/sets.py
blob: f75aca3788de608e8061796c4025b28276ca7c49 (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
from __future__ import annotations

import sys
from abc import ABC, abstractmethod
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Iterator, Mapping, Sequence, TypeVar, cast

from .loader.convert import Factory
from .loader.section import Section
from .of_type import ConfigConstantDefinition, ConfigDefinition, ConfigDynamicDefinition, ConfigLoadArgs
from .set_env import SetEnv
from .types import EnvList

if TYPE_CHECKING:
    from tox.config.loader.api import Loader
    from tox.config.main import Config

V = TypeVar("V")


class ConfigSet(ABC):
    """A set of configuration that belong together (such as a tox environment settings, core tox settings)"""

    def __init__(self, conf: Config, section: Section, env_name: str | None):
        self._section = section
        self._env_name = env_name
        self._conf = conf
        self.loaders: list[Loader[Any]] = []  #: active configuration loaders, can alter to change configuration values
        self._defined: dict[str, ConfigDefinition[Any]] = {}
        self._keys: dict[str, None] = {}
        self._alias: dict[str, str] = {}
        self._final = False
        self.register_config()

    @abstractmethod
    def register_config(self) -> None:
        raise NotImplementedError

    def mark_finalized(self) -> None:
        self._final = True

    def add_config(
        self,
        keys: str | Sequence[str],
        of_type: type[V],
        default: Callable[[Config, str | None], V] | V,
        desc: str,
        post_process: Callable[[V], V] | None = None,
        factory: Factory[Any] = None,
    ) -> ConfigDynamicDefinition[V]:
        """
        Add configuration value.

        :param keys: the keys under what to register the config (first is primary key)
        :param of_type: the type of the config value
        :param default: the default value of the config value
        :param desc: a help message describing the configuration
        :param post_process: a callback to post-process the configuration value after it has been loaded
        :param factory: factory method used to build contained objects (if ``of_type`` is a container type it
          should perform the contained item creation, otherwise creates objects that match the type)
        :return: the new dynamic config definition
        """
        if self._final:
            raise RuntimeError("config set has been marked final and cannot be extended")
        keys_ = self._make_keys(keys)
        definition = ConfigDynamicDefinition(keys_, desc, of_type, default, post_process, factory)
        result = self._add_conf(keys_, definition)
        return cast(ConfigDynamicDefinition[V], result)

    def add_constant(self, keys: str | Sequence[str], desc: str, value: V) -> ConfigConstantDefinition[V]:
        """
        Add a constant value.

        :param keys: the keys under what to register the config (first is primary key)
        :param desc: a help message describing the configuration
        :param value: the config value to use
        :return: the new constant config value
        """
        if self._final:
            raise RuntimeError("config set has been marked final and cannot be extended")
        keys_ = self._make_keys(keys)
        definition = ConfigConstantDefinition(keys_, desc, value)
        result = self._add_conf(keys_, definition)
        return cast(ConfigConstantDefinition[V], result)

    @staticmethod
    def _make_keys(keys: str | Sequence[str]) -> Sequence[str]:
        return (keys,) if isinstance(keys, str) else keys

    def _add_conf(self, keys: Sequence[str], definition: ConfigDefinition[V]) -> ConfigDefinition[V]:
        key = keys[0]
        if key in self._defined:
            self._on_duplicate_conf(key, definition)
        else:
            self._keys[key] = None
            for item in keys:
                self._alias[item] = key
            for key in keys:
                self._defined[key] = definition
        return definition

    def _on_duplicate_conf(self, key: str, definition: ConfigDefinition[V]) -> None:
        earlier = self._defined[key]
        if definition != earlier:  # pragma: no branch
            raise ValueError(f"config {key} already defined")

    def __getitem__(self, item: str) -> Any:
        """
        Get the config value for a given key (will materialize in case of dynamic config).

        :param item: the config key
        :return: the configuration value
        """
        return self.load(item)

    def load(self, item: str, chain: list[str] | None = None) -> Any:
        """
        Get the config value for a given key (will materialize in case of dynamic config).

        :param item: the config key
        :param chain: a chain of configuration keys already loaded for this load operation (used to detect circles)
        :return: the configuration value
        """
        config_definition = self._defined[item]
        return config_definition.__call__(self._conf, self.loaders, ConfigLoadArgs(chain, self.name, self.env_name))

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}(loaders={self.loaders!r})"

    def __iter__(self) -> Iterator[str]:
        """:return: iterate through the defined config keys (primary keys used)"""
        return iter(self._keys.keys())

    def __contains__(self, item: str) -> bool:
        """
        Check if a configuration key is within the config set.

        :param item: the configuration value
        :return: a boolean indicating the truthiness of the statement
        """
        return item in self._alias

    def unused(self) -> list[str]:
        """:return: Return a list of keys present in the config source but not used"""
        found: set[str] = set()
        # keys within loaders (only if the loader is not a parent too)
        parents = {id(i.parent) for i in self.loaders if i.parent is not None}
        for loader in self.loaders:
            if id(loader) not in parents:
                found.update(loader.found_keys())
        found -= self._defined.keys()
        return sorted(found)

    def primary_key(self, key: str) -> str:
        """
        Get the primary key for a config key.

        :param key: the config key
        :return: the key that's considered the primary for the input key
        """
        return self._alias[key]

    @property
    def name(self) -> str:
        return self._section.name

    @property
    def env_name(self) -> str | None:
        return self._env_name


class CoreConfigSet(ConfigSet):
    """Configuration set for the core tox config"""

    def __init__(self, conf: Config, section: Section, root: Path, src_path: Path) -> None:
        self._root = root
        self._src_path = src_path
        super().__init__(conf, section=section, env_name=None)
        desc = "define environments to automatically run"
        self.add_config(keys=["env_list", "envlist"], of_type=EnvList, default=EnvList([]), desc=desc)

    def _default_work_dir(self, conf: Config, env_name: str | None) -> Path:  # noqa: U100
        return cast(Path, self["tox_root"] / ".tox")

    def _default_temp_dir(self, conf: Config, env_name: str | None) -> Path:  # noqa: U100
        return cast(Path, self["work_dir"] / ".tmp")

    def _work_dir_post_process(self, dir: Path) -> Path:
        return self._conf.work_dir if self._conf.options.work_dir else dir

    def register_config(self) -> None:
        self.add_constant(keys=["config_file_path"], desc="path to the configuration file", value=self._src_path)
        self.add_config(
            keys=["tox_root", "toxinidir"],
            of_type=Path,
            default=self._root,
            desc="the root directory (where the configuration file is found)",
        )

        self.add_config(
            keys=["work_dir", "toxworkdir"],
            of_type=Path,
            default=self._default_work_dir,
            post_process=self._work_dir_post_process,
            desc="working directory",
        )
        self.add_config(
            keys=["temp_dir"],
            of_type=Path,
            default=self._default_temp_dir,
            desc="a folder for temporary files (is not cleaned at start)",
        )
        self.add_constant("host_python", "the host python executable path", sys.executable)

    def _on_duplicate_conf(self, key: str, definition: ConfigDefinition[V]) -> None:  # noqa: U100
        pass  # core definitions may be defined multiple times as long as all their options match, first defined wins


class EnvConfigSet(ConfigSet):
    """Configuration set for a tox environment"""

    def __init__(self, conf: Config, section: Section, env_name: str) -> None:
        super().__init__(conf, section, env_name)
        self.default_set_env_loader: Callable[[], Mapping[str, str]] = lambda: {}

    def register_config(self) -> None:
        def set_env_post_process(values: SetEnv) -> SetEnv:
            values.update(self.default_set_env_loader(), override=False)
            values.update({"PYTHONIOENCODING": "utf-8"}, override=True)
            return values

        def set_env_factory(raw: object) -> SetEnv:
            if not isinstance(raw, str):
                raise TypeError(raw)
            return SetEnv(raw, self.name, self.env_name, root)

        root = self._conf.core["tox_root"]
        self.add_config(
            keys=["set_env", "setenv"],
            of_type=SetEnv,
            factory=set_env_factory,
            default=SetEnv("", self.name, self.env_name, root),
            desc="environment variables to set when running commands in the tox environment",
            post_process=set_env_post_process,
        )

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}(name={self._env_name!r}, loaders={self.loaders!r})"


__all__ = (
    "ConfigSet",
    "CoreConfigSet",
    "EnvConfigSet",
)