confkit 0.10.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.
- confkit/__init__.py +59 -0
- confkit/config.py +325 -0
- confkit/data_types.py +724 -0
- confkit/exceptions.py +8 -0
- confkit/ext/__init__.py +8 -0
- confkit/ext/pydantic.py +21 -0
- confkit/py.typed +0 -0
- confkit/sentinels.py +21 -0
- confkit-0.10.0.dist-info/METADATA +105 -0
- confkit-0.10.0.dist-info/RECORD +11 -0
- confkit-0.10.0.dist-info/WHEEL +4 -0
confkit/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Module that provides the main interface for the confkit package.
|
|
2
|
+
|
|
3
|
+
It includes the Config class and various data types used for configuration values.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .config import Config
|
|
7
|
+
from .data_types import (
|
|
8
|
+
BaseDataType,
|
|
9
|
+
Binary,
|
|
10
|
+
Boolean,
|
|
11
|
+
Date,
|
|
12
|
+
DateTime,
|
|
13
|
+
Dict,
|
|
14
|
+
Enum,
|
|
15
|
+
Float,
|
|
16
|
+
Hex,
|
|
17
|
+
Integer,
|
|
18
|
+
IntEnum,
|
|
19
|
+
IntFlag,
|
|
20
|
+
List,
|
|
21
|
+
NoneType,
|
|
22
|
+
Octal,
|
|
23
|
+
Optional,
|
|
24
|
+
Set,
|
|
25
|
+
StrEnum,
|
|
26
|
+
String,
|
|
27
|
+
Time,
|
|
28
|
+
TimeDelta,
|
|
29
|
+
Tuple,
|
|
30
|
+
)
|
|
31
|
+
from .exceptions import InvalidConverterError, InvalidDefaultError
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"BaseDataType",
|
|
35
|
+
"Binary",
|
|
36
|
+
"Boolean",
|
|
37
|
+
"Config",
|
|
38
|
+
"Date",
|
|
39
|
+
"DateTime",
|
|
40
|
+
"Dict",
|
|
41
|
+
"Enum",
|
|
42
|
+
"Float",
|
|
43
|
+
"Hex",
|
|
44
|
+
"IntEnum",
|
|
45
|
+
"IntFlag",
|
|
46
|
+
"Integer",
|
|
47
|
+
"InvalidConverterError",
|
|
48
|
+
"InvalidDefaultError",
|
|
49
|
+
"List",
|
|
50
|
+
"NoneType",
|
|
51
|
+
"Octal",
|
|
52
|
+
"Optional",
|
|
53
|
+
"Set",
|
|
54
|
+
"StrEnum",
|
|
55
|
+
"String",
|
|
56
|
+
"Time",
|
|
57
|
+
"TimeDelta",
|
|
58
|
+
"Tuple",
|
|
59
|
+
]
|
confkit/config.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Module for a config descriptor.
|
|
2
|
+
|
|
3
|
+
The Config descriptor is used to read and write config values from a ConfigParser object.
|
|
4
|
+
It is used to create a descriptor for config values, preserving type information.
|
|
5
|
+
It also provides a way to set default values and to set config values using decorators.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import warnings
|
|
10
|
+
from functools import wraps
|
|
11
|
+
from types import NoneType
|
|
12
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, ParamSpec, TypeVar, overload
|
|
13
|
+
|
|
14
|
+
from .data_types import BaseDataType, Optional
|
|
15
|
+
from .exceptions import InvalidConverterError, InvalidDefaultError
|
|
16
|
+
from .sentinels import UNSET
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from configparser import ConfigParser
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# Type variables for Python 3.10+ (pre-PEP 695) compatibility
|
|
24
|
+
VT = TypeVar("VT")
|
|
25
|
+
OVT = TypeVar("OVT") # Separate TypeVar for nested generic to avoid scope collision
|
|
26
|
+
F = TypeVar("F")
|
|
27
|
+
P = ParamSpec("P")
|
|
28
|
+
|
|
29
|
+
class Config(Generic[VT]):
|
|
30
|
+
"""A descriptor for config values, preserving type information.
|
|
31
|
+
|
|
32
|
+
the ValueType (VT) is the type you want the config value to be.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
validate_types: ClassVar[bool] = True # Validate that the converter returns the same type as the default value. (not strict)
|
|
36
|
+
write_on_edit: ClassVar[bool] = True # Write to the config file when updating a value.
|
|
37
|
+
optional: bool = False # if True, allows None as an extra type when validating types. (both instance and class variables.)
|
|
38
|
+
|
|
39
|
+
_parser: ConfigParser = UNSET
|
|
40
|
+
_file: Path = UNSET
|
|
41
|
+
_has_read_config: bool = False
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
# Overloads for type checkers to understand the different settings of the Config descriptors.
|
|
45
|
+
@overload # Custom data type, like Enum's or custom class.
|
|
46
|
+
def __init__(self, default: BaseDataType[VT]) -> None: ...
|
|
47
|
+
@overload
|
|
48
|
+
def __init__(self, default: VT) -> None: ...
|
|
49
|
+
# Specify the states of optional explicitly for type checkers.
|
|
50
|
+
@overload
|
|
51
|
+
def __init__(self: Config[VT], default: VT, *, optional: Literal[False]) -> None: ... # pyright: ignore[reportInvalidTypeVarUse]
|
|
52
|
+
@overload
|
|
53
|
+
def __init__(self: Config[VT], default: BaseDataType[VT], *, optional: Literal[False]) -> None: ... # pyright: ignore[reportInvalidTypeVarUse]
|
|
54
|
+
@overload
|
|
55
|
+
def __init__(self: Config[VT | None], default: VT, *, optional: Literal[True]) -> None: ... # pyright: ignore[reportInvalidTypeVarUse]
|
|
56
|
+
@overload
|
|
57
|
+
def __init__(self: Config[VT | None], default: BaseDataType[VT], *, optional: Literal[True]) -> None: ... # pyright: ignore[reportInvalidTypeVarUse]
|
|
58
|
+
|
|
59
|
+
# type Complains about the self and default overloads for None and str
|
|
60
|
+
# they are explicitly set for type checkers, the actual representation doesn't matter
|
|
61
|
+
# in runtime, as VT is allowed to be any type.
|
|
62
|
+
def __init__( # type: ignore[reportInconsistentOverload]
|
|
63
|
+
self,
|
|
64
|
+
default: VT | None | BaseDataType[VT] = UNSET,
|
|
65
|
+
*,
|
|
66
|
+
optional: bool = False,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Initialize the config descriptor with a default value.
|
|
69
|
+
|
|
70
|
+
Validate that parser and filepath are present.
|
|
71
|
+
"""
|
|
72
|
+
cls = self.__class__
|
|
73
|
+
self.optional = optional or cls.optional # Be truthy when either one is true.
|
|
74
|
+
|
|
75
|
+
if not self.optional and default is UNSET:
|
|
76
|
+
msg = "Default value cannot be None when optional is False."
|
|
77
|
+
raise InvalidDefaultError(msg)
|
|
78
|
+
|
|
79
|
+
self._initialize_data_type(default)
|
|
80
|
+
self._validate_init()
|
|
81
|
+
self._read_parser()
|
|
82
|
+
|
|
83
|
+
def __init_subclass__(cls) -> None:
|
|
84
|
+
"""Allow for multiple config files/parsers without conflicts."""
|
|
85
|
+
super().__init_subclass__()
|
|
86
|
+
|
|
87
|
+
parent = cls._find_parent()
|
|
88
|
+
|
|
89
|
+
cls.validate_types = parent.validate_types
|
|
90
|
+
cls.write_on_edit = parent.write_on_edit
|
|
91
|
+
cls._parser = parent._parser # noqa: SLF001
|
|
92
|
+
cls._file = parent._file # noqa: SLF001
|
|
93
|
+
cls._has_read_config = parent._has_read_config # noqa: SLF001
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def _find_parent(cls) -> type[Config[Any]]:
|
|
97
|
+
for base in cls.__bases__:
|
|
98
|
+
if issubclass(base, Config):
|
|
99
|
+
parent = base
|
|
100
|
+
break
|
|
101
|
+
else:
|
|
102
|
+
parent = Config
|
|
103
|
+
return parent
|
|
104
|
+
|
|
105
|
+
def _initialize_data_type(self, default: VT | None | BaseDataType[VT]) -> None:
|
|
106
|
+
"""Initialize the data type based on the default value."""
|
|
107
|
+
if not self.optional and default is not None:
|
|
108
|
+
self._data_type = BaseDataType[VT].cast(default)
|
|
109
|
+
else:
|
|
110
|
+
self._data_type = BaseDataType[VT].cast_optional(default)
|
|
111
|
+
|
|
112
|
+
def _read_parser(self) -> None:
|
|
113
|
+
"""Ensure the parser has read the file at initialization. Avoids rewriting the file when settings are already set."""
|
|
114
|
+
if not self._has_read_config:
|
|
115
|
+
self._parser.read(self._file)
|
|
116
|
+
self._has_read_config = True
|
|
117
|
+
|
|
118
|
+
def _validate_init(self) -> None:
|
|
119
|
+
"""Validate the config descriptor, ensuring it's properly set up."""
|
|
120
|
+
self.validate_file()
|
|
121
|
+
self.validate_parser()
|
|
122
|
+
|
|
123
|
+
def convert(self, value: str) -> VT:
|
|
124
|
+
"""Convert the value to the desired type using the given converter method."""
|
|
125
|
+
# Ignore the type error of VT, type checkers don't like None as an option
|
|
126
|
+
# We handle it using the `optional` flag, or using Optional DataType. so we can safely ignore it.
|
|
127
|
+
return self._data_type.convert(value) # type: ignore[reportReturnType]
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _warn_base_class_usage() -> None:
|
|
131
|
+
"""Warn users that setting parser/file on the base class can lead to unexpected behavior."""
|
|
132
|
+
warnings.warn("<Config> is the base class. Subclass <Config> to avoid unexpected behavior.", stacklevel=2)
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def set_parser(cls, parser: ConfigParser) -> None:
|
|
136
|
+
"""Set the parser for ALL descriptors."""
|
|
137
|
+
if cls is Config:
|
|
138
|
+
# Warn users that setting this value on the base class can lead to unexpected behavoir.
|
|
139
|
+
# Tell the user to subclass <Config> first.
|
|
140
|
+
cls._warn_base_class_usage()
|
|
141
|
+
cls._parser = parser
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def set_file(cls, file: Path) -> None:
|
|
145
|
+
"""Set the file for ALL descriptors."""
|
|
146
|
+
if cls is Config:
|
|
147
|
+
# Warn users that setting this value on the base class can lead to unexpected behavoir.
|
|
148
|
+
# Tell the user to subclass <Config> first.
|
|
149
|
+
cls._warn_base_class_usage()
|
|
150
|
+
cls._file = file
|
|
151
|
+
|
|
152
|
+
def validate_strict_type(self) -> None:
|
|
153
|
+
"""Validate the type of the converter matches the desired type."""
|
|
154
|
+
if self._data_type.convert is UNSET:
|
|
155
|
+
msg = "Converter is not set."
|
|
156
|
+
raise InvalidConverterError(msg)
|
|
157
|
+
|
|
158
|
+
cls = self.__class__
|
|
159
|
+
self.__config_value = cls._parser.get(self._section, self._setting)
|
|
160
|
+
self.__converted_value = self.convert(self.__config_value)
|
|
161
|
+
|
|
162
|
+
if not cls.validate_types:
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
self.__converted_type = type(self.__converted_value)
|
|
166
|
+
default_value_type = type(self._data_type.default)
|
|
167
|
+
|
|
168
|
+
is_optional = self.optional or isinstance(self._data_type, Optional)
|
|
169
|
+
if (is_optional) and self.__converted_type in (default_value_type, NoneType):
|
|
170
|
+
# Allow None or the same type as the default value to be returned by the converter when _optional is True.
|
|
171
|
+
return
|
|
172
|
+
if self.__converted_type is not default_value_type:
|
|
173
|
+
msg = f"Converter does not return the same type as the default value <{default_value_type}> got <{self.__converted_type}>." # noqa: E501
|
|
174
|
+
raise InvalidConverterError(msg)
|
|
175
|
+
|
|
176
|
+
# Set the data_type value. ensuring validation works as expected.
|
|
177
|
+
self._data_type.value = self.__converted_value
|
|
178
|
+
if not self._data_type.validate():
|
|
179
|
+
msg = f"Invalid value for {self._section}.{self._setting}: {self.__converted_value}"
|
|
180
|
+
raise InvalidConverterError(msg)
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def validate_file(cls) -> None:
|
|
184
|
+
"""Validate the config file."""
|
|
185
|
+
if cls._file is UNSET:
|
|
186
|
+
msg = f"Config file is not set. use {cls.__name__}.set_file() to set it."
|
|
187
|
+
raise ValueError(msg)
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def validate_parser(cls) -> None:
|
|
191
|
+
"""Validate the config parser."""
|
|
192
|
+
if cls._parser is UNSET:
|
|
193
|
+
msg = f"Config parser is not set. use {cls.__name__}.set_parser() to set it."
|
|
194
|
+
raise ValueError(msg)
|
|
195
|
+
|
|
196
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
197
|
+
"""Set the name of the attribute to the name of the descriptor."""
|
|
198
|
+
self.name = name
|
|
199
|
+
self._section = owner.__name__
|
|
200
|
+
self._setting = name
|
|
201
|
+
self._ensure_option()
|
|
202
|
+
cls = self.__class__
|
|
203
|
+
self._original_value = cls._parser.get(self._section, self._setting) or self._data_type.default
|
|
204
|
+
self.private = f"_{self._section}_{self._setting}_{self.name}"
|
|
205
|
+
|
|
206
|
+
def _ensure_section(self) -> None:
|
|
207
|
+
"""Ensure the section exists in the config file. Creates one if it doesn't exist."""
|
|
208
|
+
if not self._parser.has_section(self._section):
|
|
209
|
+
self._parser.add_section(self._section)
|
|
210
|
+
|
|
211
|
+
def _ensure_option(self) -> None:
|
|
212
|
+
"""Ensure the option exists in the config file. Creates one if it doesn't exist."""
|
|
213
|
+
self._ensure_section()
|
|
214
|
+
if not self._parser.has_option(self._section, self._setting):
|
|
215
|
+
cls = self.__class__
|
|
216
|
+
cls._set(self._section, self._setting, self._data_type)
|
|
217
|
+
|
|
218
|
+
def __get__(self, obj: object, obj_type: object) -> VT:
|
|
219
|
+
"""Get the value of the attribute."""
|
|
220
|
+
# obj_type is the class in which the variable is defined
|
|
221
|
+
# so it can be different than type of VT
|
|
222
|
+
# but we don't need obj or it's type to get the value from config in our case.
|
|
223
|
+
self.validate_strict_type()
|
|
224
|
+
return self.__converted_value
|
|
225
|
+
|
|
226
|
+
def __set__(self, obj: object, value: VT) -> None:
|
|
227
|
+
"""Set the value of the attribute."""
|
|
228
|
+
self._data_type.value = value
|
|
229
|
+
cls = self.__class__
|
|
230
|
+
cls._set(self._section, self._setting, self._data_type)
|
|
231
|
+
setattr(obj, self.private, value)
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _sanitize_str(value: str) -> str:
|
|
235
|
+
"""Escape the percent sign in the value."""
|
|
236
|
+
return value.replace("%", "%%")
|
|
237
|
+
|
|
238
|
+
@classmethod
|
|
239
|
+
def _set(cls, section: str, setting: str, value: VT | BaseDataType[VT] | BaseDataType[VT | None]) -> None:
|
|
240
|
+
"""Set a config value, and write it to the file."""
|
|
241
|
+
if not cls._parser.has_section(section):
|
|
242
|
+
cls._parser.add_section(section)
|
|
243
|
+
|
|
244
|
+
sanitized_str = cls._sanitize_str(str(value))
|
|
245
|
+
cls._parser.set(section, setting, sanitized_str)
|
|
246
|
+
|
|
247
|
+
if cls.write_on_edit:
|
|
248
|
+
cls.write()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@classmethod
|
|
252
|
+
def write(cls) -> None:
|
|
253
|
+
"""Write the config parser to the file."""
|
|
254
|
+
cls.validate_file()
|
|
255
|
+
with cls._file.open("w") as f:
|
|
256
|
+
cls._parser.write(f)
|
|
257
|
+
|
|
258
|
+
@classmethod
|
|
259
|
+
def set(cls, section: str, setting: str, value: VT): # noqa: ANN206
|
|
260
|
+
"""Set a config value using this descriptor."""
|
|
261
|
+
|
|
262
|
+
def wrapper(func: Callable[..., F]) -> Callable[..., F]:
|
|
263
|
+
@wraps(func)
|
|
264
|
+
def inner(*args: P.args, **kwargs: P.kwargs) -> F:
|
|
265
|
+
cls._set(section, setting, value)
|
|
266
|
+
return func(*args, **kwargs)
|
|
267
|
+
|
|
268
|
+
return inner
|
|
269
|
+
return wrapper
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@classmethod
|
|
273
|
+
def with_setting(cls, setting: Config[OVT]): # noqa: ANN206
|
|
274
|
+
"""Insert a config value into **kwargs to the wrapped method/function using this decorator."""
|
|
275
|
+
def wrapper(func: Callable[..., F]) -> Callable[..., F]:
|
|
276
|
+
@wraps(func)
|
|
277
|
+
def inner(*args: P.args, **kwargs: P.kwargs) -> F:
|
|
278
|
+
kwargs[setting.name] = setting.convert(cls._parser.get(setting._section, setting._setting))
|
|
279
|
+
return func(*args, **kwargs)
|
|
280
|
+
|
|
281
|
+
return inner
|
|
282
|
+
return wrapper
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@classmethod
|
|
286
|
+
def with_kwarg(cls, section: str, setting: str, name: str | None = None, default: VT = UNSET): # noqa: ANN206
|
|
287
|
+
"""Insert a config value into **kwargs to the wrapped method/function using this descriptor.
|
|
288
|
+
|
|
289
|
+
Use kwarg.get(`name`) to get the value.
|
|
290
|
+
`name` is the name the kwarg gets if passed, if None, it will be the same as `setting`.
|
|
291
|
+
Section parameter is just for finding the config value.
|
|
292
|
+
"""
|
|
293
|
+
if name is None:
|
|
294
|
+
name = setting
|
|
295
|
+
if default is UNSET and not cls._parser.has_option(section, setting):
|
|
296
|
+
msg = f"Config value {section=} {setting=} is not set. and no default value is given."
|
|
297
|
+
raise ValueError(msg)
|
|
298
|
+
|
|
299
|
+
def wrapper(func: Callable[..., F]) -> Callable[..., F]:
|
|
300
|
+
@wraps(func)
|
|
301
|
+
def inner(*args: P.args, **kwargs: P.kwargs) -> F:
|
|
302
|
+
if default is not UNSET:
|
|
303
|
+
cls._set_default(section, setting, default)
|
|
304
|
+
kwargs[name] = cls._parser.get(section, setting) # ty: ignore[invalid-assignment]
|
|
305
|
+
return func(*args, **kwargs)
|
|
306
|
+
|
|
307
|
+
return inner
|
|
308
|
+
return wrapper
|
|
309
|
+
|
|
310
|
+
@classmethod
|
|
311
|
+
def _set_default(cls, section: str, setting: str, value: VT) -> None:
|
|
312
|
+
if cls._parser.get(section, setting, fallback=UNSET) is UNSET:
|
|
313
|
+
cls._set(section, setting, value)
|
|
314
|
+
|
|
315
|
+
@classmethod
|
|
316
|
+
def default(cls, section: str, setting: str, value: VT): # noqa: ANN206
|
|
317
|
+
"""Set a default config value if none are set yet using this descriptor."""
|
|
318
|
+
def wrapper(func: Callable[..., F]) -> Callable[..., F]:
|
|
319
|
+
@wraps(func)
|
|
320
|
+
def inner(*args: P.args, **kwargs: P.kwargs) -> F:
|
|
321
|
+
cls._set_default(section, setting, value)
|
|
322
|
+
return func(*args, **kwargs)
|
|
323
|
+
|
|
324
|
+
return inner
|
|
325
|
+
return wrapper
|
confkit/data_types.py
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
"""Module that contains the base data types used in the config system."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from datetime import UTC, date, datetime, time, timedelta, tzinfo
|
|
9
|
+
from typing import ClassVar, Generic, NotRequired, Required, TypedDict, TypeVar, Unpack, cast, overload
|
|
10
|
+
|
|
11
|
+
from confkit.sentinels import UNSET
|
|
12
|
+
|
|
13
|
+
from .exceptions import InvalidConverterError, InvalidDefaultError
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BaseDataType(ABC, Generic[T]):
|
|
19
|
+
"""Base class used for Config descriptors to define a data type."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, default: T) -> None:
|
|
22
|
+
"""Initialize the base data type."""
|
|
23
|
+
self.default = default
|
|
24
|
+
self.value = default
|
|
25
|
+
self.type = type(default)
|
|
26
|
+
|
|
27
|
+
def __str__(self) -> str:
|
|
28
|
+
"""Return the string representation of the stored value."""
|
|
29
|
+
return str(self.value)
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def convert(self, value: str) -> T:
|
|
33
|
+
"""Convert a string value to the desired type."""
|
|
34
|
+
|
|
35
|
+
def validate(self) -> bool:
|
|
36
|
+
"""Validate that the value matches the expected type."""
|
|
37
|
+
orig_bases: tuple[type, ...] | None = getattr(self.__class__, "__orig_bases__", None)
|
|
38
|
+
|
|
39
|
+
if not orig_bases:
|
|
40
|
+
msg = "No type information available for validation."
|
|
41
|
+
raise InvalidConverterError(msg)
|
|
42
|
+
|
|
43
|
+
# Extract type arguments from the generic base
|
|
44
|
+
for base in orig_bases:
|
|
45
|
+
if hasattr(base, "__args__"):
|
|
46
|
+
type_args = base.__args__
|
|
47
|
+
if type_args:
|
|
48
|
+
for type_arg in type_args:
|
|
49
|
+
if hasattr(type_arg, "__origin__"):
|
|
50
|
+
# For parameterized generics, check against the origin type
|
|
51
|
+
if isinstance(self.value, type_arg.__origin__):
|
|
52
|
+
return True
|
|
53
|
+
elif isinstance(self.value, (self.type, type_arg)):
|
|
54
|
+
return True
|
|
55
|
+
msg = f"Value {self.value} is not any of {type_args}."
|
|
56
|
+
raise InvalidConverterError(msg)
|
|
57
|
+
msg = "This should not have raised. Report to the library maintainers with code: `DTBDT`"
|
|
58
|
+
raise TypeError(msg)
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def cast_optional(default: T | None | BaseDataType[T]) -> BaseDataType[T | None]:
|
|
62
|
+
"""Convert the default value to an Optional data type."""
|
|
63
|
+
if default is None:
|
|
64
|
+
return cast("BaseDataType[T | None]", NoneType())
|
|
65
|
+
return Optional(BaseDataType.cast(default))
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def cast(default: T | BaseDataType[T]) -> BaseDataType[T]:
|
|
69
|
+
"""Convert the default value to a BaseDataType."""
|
|
70
|
+
# We use Cast to shut up type checkers, as we know primitive types will be correct.
|
|
71
|
+
# If a custom type is passed, it should be a BaseDataType subclass, which already has the correct types.
|
|
72
|
+
match default:
|
|
73
|
+
case bool():
|
|
74
|
+
data_type = cast("BaseDataType[T]", Boolean(default))
|
|
75
|
+
case None:
|
|
76
|
+
data_type = cast("BaseDataType[T]", NoneType())
|
|
77
|
+
case int():
|
|
78
|
+
data_type = cast("BaseDataType[T]", Integer(default))
|
|
79
|
+
case float():
|
|
80
|
+
data_type = cast("BaseDataType[T]", Float(default))
|
|
81
|
+
case str():
|
|
82
|
+
data_type = cast("BaseDataType[T]", String(default))
|
|
83
|
+
case BaseDataType():
|
|
84
|
+
data_type = default
|
|
85
|
+
case _:
|
|
86
|
+
msg = (
|
|
87
|
+
f"Unsupported default value type: {type(default).__name__}. "
|
|
88
|
+
"Use a BaseDataType subclass for custom types."
|
|
89
|
+
)
|
|
90
|
+
raise InvalidDefaultError(msg)
|
|
91
|
+
return data_type
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _EnumBase(BaseDataType[T]):
|
|
95
|
+
"""Base class for enum types with common functionality."""
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _strip_comment(value: str) -> str:
|
|
99
|
+
"""Strip inline comments from value.
|
|
100
|
+
|
|
101
|
+
Since hex values use 0x prefix (not #), we can safely strip everything after #.
|
|
102
|
+
"""
|
|
103
|
+
if "#" in value:
|
|
104
|
+
return value.split("#")[0].strip()
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
@abstractmethod
|
|
108
|
+
def _format_allowed_values(self) -> str:
|
|
109
|
+
"""Format the allowed values string. Override in subclasses."""
|
|
110
|
+
raise NotImplementedError
|
|
111
|
+
|
|
112
|
+
def __str__(self) -> str:
|
|
113
|
+
"""Return the string representation with allowed values."""
|
|
114
|
+
if self.value is None:
|
|
115
|
+
return str(self.value)
|
|
116
|
+
return f"{self._get_value_str()} # allowed: {self._format_allowed_values()}"
|
|
117
|
+
|
|
118
|
+
@abstractmethod
|
|
119
|
+
def _get_value_str(self) -> str:
|
|
120
|
+
"""Get the string representation of the current value. Override in subclasses."""
|
|
121
|
+
raise NotImplementedError
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
EnumType = TypeVar("EnumType", bound=enum.Enum)
|
|
125
|
+
class Enum(_EnumBase[EnumType]):
|
|
126
|
+
"""A config value that is an enum."""
|
|
127
|
+
|
|
128
|
+
def convert(self, value: str) -> EnumType:
|
|
129
|
+
"""Convert a string value to an enum."""
|
|
130
|
+
value = self._strip_comment(value)
|
|
131
|
+
parsed_enum_name = value.split(".")[-1]
|
|
132
|
+
return self.value.__class__[parsed_enum_name]
|
|
133
|
+
|
|
134
|
+
def _format_allowed_values(self) -> str:
|
|
135
|
+
"""Format allowed values as comma-separated member names."""
|
|
136
|
+
enum_class = self.value.__class__
|
|
137
|
+
return ", ".join(member.name for member in enum_class)
|
|
138
|
+
|
|
139
|
+
def _get_value_str(self) -> str:
|
|
140
|
+
"""Get the member name."""
|
|
141
|
+
return self.value.name
|
|
142
|
+
|
|
143
|
+
StrEnumType = TypeVar("StrEnumType", bound=enum.StrEnum)
|
|
144
|
+
class StrEnum(_EnumBase[StrEnumType]):
|
|
145
|
+
"""A config value that is an enum."""
|
|
146
|
+
|
|
147
|
+
def convert(self, value: str) -> StrEnumType:
|
|
148
|
+
"""Convert a string value to an enum."""
|
|
149
|
+
value = self._strip_comment(value)
|
|
150
|
+
return self.value.__class__(value) # ty: ignore[invalid-return-type] # this is correct. ty says "Unknown | StrEnum"
|
|
151
|
+
|
|
152
|
+
def _format_allowed_values(self) -> str:
|
|
153
|
+
"""Format allowed values as comma-separated member values."""
|
|
154
|
+
enum_class = self.value.__class__
|
|
155
|
+
return ", ".join(member.value for member in enum_class)
|
|
156
|
+
|
|
157
|
+
def _get_value_str(self) -> str:
|
|
158
|
+
"""Get the member value."""
|
|
159
|
+
return self.value.value
|
|
160
|
+
|
|
161
|
+
IntEnumType = TypeVar("IntEnumType", bound=enum.IntEnum)
|
|
162
|
+
class IntEnum(_EnumBase[IntEnumType]):
|
|
163
|
+
"""A config value that is an enum."""
|
|
164
|
+
|
|
165
|
+
def convert(self, value: str) -> IntEnumType:
|
|
166
|
+
"""Convert a string value to an enum."""
|
|
167
|
+
value = self._strip_comment(value)
|
|
168
|
+
return self.value.__class__(int(value)) # ty: ignore[invalid-return-type] # ty says "Unknown | IntEnum"
|
|
169
|
+
|
|
170
|
+
def _format_allowed_values(self) -> str:
|
|
171
|
+
"""Format allowed values as comma-separated name(value) pairs."""
|
|
172
|
+
enum_class = self.value.__class__
|
|
173
|
+
return ", ".join(f"{member.name}({member.value})" for member in enum_class)
|
|
174
|
+
|
|
175
|
+
def _get_value_str(self) -> str:
|
|
176
|
+
"""Get the member value as string."""
|
|
177
|
+
return str(self.value.value)
|
|
178
|
+
|
|
179
|
+
IntFlagType = TypeVar("IntFlagType", bound=enum.IntFlag)
|
|
180
|
+
class IntFlag(_EnumBase[IntFlagType]):
|
|
181
|
+
"""A config value that is an enum."""
|
|
182
|
+
|
|
183
|
+
def convert(self, value: str) -> IntFlagType:
|
|
184
|
+
"""Convert a string value to an enum."""
|
|
185
|
+
value = self._strip_comment(value)
|
|
186
|
+
return self.value.__class__(int(value)) # ty: ignore[invalid-return-type] # ty says "Unknown | IntFlag"
|
|
187
|
+
|
|
188
|
+
def _format_allowed_values(self) -> str:
|
|
189
|
+
"""Format allowed values as comma-separated name(value) pairs."""
|
|
190
|
+
enum_class = self.value.__class__
|
|
191
|
+
return ", ".join(f"{member.name}({member.value})" for member in enum_class)
|
|
192
|
+
|
|
193
|
+
def _get_value_str(self) -> str:
|
|
194
|
+
"""Get the member value as string."""
|
|
195
|
+
return str(self.value.value)
|
|
196
|
+
|
|
197
|
+
class NoneType(BaseDataType[None]):
|
|
198
|
+
"""A config value that is None."""
|
|
199
|
+
|
|
200
|
+
null_values: ClassVar[set[str]] = {"none", "null", "nil"}
|
|
201
|
+
|
|
202
|
+
def __init__(self) -> None:
|
|
203
|
+
"""Initialize the NoneType data type."""
|
|
204
|
+
super().__init__(None)
|
|
205
|
+
|
|
206
|
+
def convert(self, value: str) -> bool: # type: ignore[reportIncompatibleMethodOverride]
|
|
207
|
+
"""Convert a string value to None."""
|
|
208
|
+
# Ignore type exception as convert should return True/False for NoneType
|
|
209
|
+
# to determine if we have a valid null value or not.
|
|
210
|
+
return value.casefold().strip() in NoneType.null_values
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class String(BaseDataType[str]):
|
|
214
|
+
"""A config value that is a string."""
|
|
215
|
+
|
|
216
|
+
def __init__(self, default: str = "") -> None: # noqa: D107
|
|
217
|
+
super().__init__(default)
|
|
218
|
+
|
|
219
|
+
def convert(self, value: str) -> str:
|
|
220
|
+
"""Convert a string value to a string."""
|
|
221
|
+
return value
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class Float(BaseDataType[float]):
|
|
225
|
+
"""A config value that is a float."""
|
|
226
|
+
|
|
227
|
+
def __init__(self, default: float = 0.0) -> None: # noqa: D107
|
|
228
|
+
super().__init__(default)
|
|
229
|
+
|
|
230
|
+
def convert(self, value: str) -> float:
|
|
231
|
+
"""Convert a string value to a float."""
|
|
232
|
+
return float(value)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class Boolean(BaseDataType[bool]):
|
|
236
|
+
"""A config value that is a boolean."""
|
|
237
|
+
|
|
238
|
+
def __init__(self, default: bool = False) -> None: # noqa: D107, FBT001, FBT002
|
|
239
|
+
super().__init__(default)
|
|
240
|
+
|
|
241
|
+
def convert(self, value: str) -> bool:
|
|
242
|
+
"""Convert a string value to a boolean."""
|
|
243
|
+
if value.lower() in {"true", "1", "yes"}:
|
|
244
|
+
return True
|
|
245
|
+
if value.lower() in {"false", "0", "no"}:
|
|
246
|
+
return False
|
|
247
|
+
msg = f"Cannot convert {value} to boolean."
|
|
248
|
+
raise ValueError(msg)
|
|
249
|
+
|
|
250
|
+
DECIMAL = 10
|
|
251
|
+
HEXADECIMAL = 16
|
|
252
|
+
OCTAL = 8
|
|
253
|
+
BINARY = 2
|
|
254
|
+
|
|
255
|
+
class Integer(BaseDataType[int]):
|
|
256
|
+
"""A config value that is an integer."""
|
|
257
|
+
|
|
258
|
+
# Define constants for common bases
|
|
259
|
+
|
|
260
|
+
def __init__(self, default: int = 0, base: int = DECIMAL) -> None: # noqa: D107
|
|
261
|
+
super().__init__(default)
|
|
262
|
+
self.base = base
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def int_to_base(number: int, base: int) -> int:
|
|
266
|
+
"""Convert an integer to a string representation in a given base."""
|
|
267
|
+
if number == 0:
|
|
268
|
+
return 0
|
|
269
|
+
digits = []
|
|
270
|
+
while number:
|
|
271
|
+
digits.append(str(number % base))
|
|
272
|
+
number //= base
|
|
273
|
+
return int("".join(reversed(digits)))
|
|
274
|
+
|
|
275
|
+
def __str__(self) -> str: # noqa: D105
|
|
276
|
+
if self.base == DECIMAL:
|
|
277
|
+
return str(self.value)
|
|
278
|
+
# Convert the base 10 int to base 5
|
|
279
|
+
self.value = self.int_to_base(int(self.value), self.base)
|
|
280
|
+
return f"{self.base}c{self.value}"
|
|
281
|
+
|
|
282
|
+
def convert(self, value: str) -> int:
|
|
283
|
+
"""Convert a string value to an integer."""
|
|
284
|
+
if "c" in value:
|
|
285
|
+
base_str, val_str = value.split("c")
|
|
286
|
+
base = int(base_str)
|
|
287
|
+
if base != self.base:
|
|
288
|
+
msg = "Base in string does not match base in Integer while converting."
|
|
289
|
+
raise ValueError(msg)
|
|
290
|
+
return int(val_str, self.base)
|
|
291
|
+
return int(value, self.base)
|
|
292
|
+
|
|
293
|
+
class Hex(Integer):
|
|
294
|
+
"""A config value that represents hexadecimal."""
|
|
295
|
+
|
|
296
|
+
def __init__(self, default: int = 0, base: int = HEXADECIMAL) -> None: # noqa: D107
|
|
297
|
+
super().__init__(default, base)
|
|
298
|
+
|
|
299
|
+
def __str__(self) -> str: # noqa: D105
|
|
300
|
+
return f"0x{self.value:x}"
|
|
301
|
+
|
|
302
|
+
def convert(self, value: str) -> int:
|
|
303
|
+
"""Convert a string value to an integer. from hexadecimal."""
|
|
304
|
+
return int(value.removeprefix("0x"), 16)
|
|
305
|
+
|
|
306
|
+
class Octal(Integer):
|
|
307
|
+
"""A config value that represents octal."""
|
|
308
|
+
|
|
309
|
+
def __init__(self, default: int = 0, base: int = OCTAL) -> None: # noqa: D107
|
|
310
|
+
super().__init__(default, base)
|
|
311
|
+
|
|
312
|
+
def __str__(self) -> str: # noqa: D105
|
|
313
|
+
return f"0o{self.value:o}"
|
|
314
|
+
|
|
315
|
+
def convert(self, value: str) -> int:
|
|
316
|
+
"""Convert a string value to an integer from octal."""
|
|
317
|
+
return int(value.removeprefix("0o"), 8)
|
|
318
|
+
|
|
319
|
+
class Binary(BaseDataType[bytes | int]):
|
|
320
|
+
"""A config value that represents binary."""
|
|
321
|
+
|
|
322
|
+
def __init__(self, default: bytes | int = 0) -> None: # noqa: D107
|
|
323
|
+
if isinstance(default, bytes):
|
|
324
|
+
default = int.from_bytes(default)
|
|
325
|
+
super().__init__(default)
|
|
326
|
+
|
|
327
|
+
def __str__(self) -> str: # noqa: D105
|
|
328
|
+
if isinstance(self.value, bytes):
|
|
329
|
+
self.value = int.from_bytes(self.value)
|
|
330
|
+
return f"0b{self.value:b}"
|
|
331
|
+
|
|
332
|
+
def convert(self, value: str) -> int:
|
|
333
|
+
"""Convert a string value to an integer from binary."""
|
|
334
|
+
return int(value.removeprefix("0b"), 2)
|
|
335
|
+
|
|
336
|
+
class Optional(BaseDataType[T | None], Generic[T]):
|
|
337
|
+
"""A config value that is optional, can be None or a specific type."""
|
|
338
|
+
|
|
339
|
+
_none_type = NoneType()
|
|
340
|
+
|
|
341
|
+
def __init__(self, data_type: BaseDataType[T]) -> None:
|
|
342
|
+
"""Initialize the optional data type. Wrapping the provided data type."""
|
|
343
|
+
self._data_type = data_type
|
|
344
|
+
|
|
345
|
+
@property
|
|
346
|
+
def default(self) -> T | None:
|
|
347
|
+
"""Get the default value of the wrapped data type."""
|
|
348
|
+
return self._data_type.default
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def value(self) -> T | None:
|
|
352
|
+
"""Get the current value of the wrapped data type."""
|
|
353
|
+
return self._data_type.value
|
|
354
|
+
|
|
355
|
+
@value.setter
|
|
356
|
+
def value(self, value: T | None) -> None:
|
|
357
|
+
"""Set the current value of the wrapped data type."""
|
|
358
|
+
self._data_type.value = value # type: ignore[reportAttributeAccessIssue]
|
|
359
|
+
|
|
360
|
+
def convert(self, value: str) -> T | None:
|
|
361
|
+
"""Convert a string value to the optional type."""
|
|
362
|
+
if self._none_type.convert(value):
|
|
363
|
+
return None
|
|
364
|
+
return self._data_type.convert(value)
|
|
365
|
+
|
|
366
|
+
def validate(self) -> bool:
|
|
367
|
+
"""Validate that the value is of the wrapped data type or None."""
|
|
368
|
+
if self._data_type.value is None:
|
|
369
|
+
return True
|
|
370
|
+
return self._data_type.validate()
|
|
371
|
+
|
|
372
|
+
def __str__(self) -> str:
|
|
373
|
+
"""Return the string representation of the wrapped data type."""
|
|
374
|
+
return str(self._data_type)
|
|
375
|
+
|
|
376
|
+
class _SequenceType(BaseDataType[Sequence[T]], Generic[T]):
|
|
377
|
+
"""A ABC for sequence types like List and Tuples."""
|
|
378
|
+
|
|
379
|
+
separator = ","
|
|
380
|
+
escape_char = "\\"
|
|
381
|
+
|
|
382
|
+
@overload
|
|
383
|
+
def __init__(self, default: Sequence[T]) -> None: ...
|
|
384
|
+
@overload
|
|
385
|
+
def __init__(self, *, data_type: BaseDataType[T]) -> None: ...
|
|
386
|
+
@overload
|
|
387
|
+
def __init__(
|
|
388
|
+
self,
|
|
389
|
+
default: Sequence[T],
|
|
390
|
+
*,
|
|
391
|
+
data_type: BaseDataType[T] = ...,
|
|
392
|
+
) -> None: ...
|
|
393
|
+
|
|
394
|
+
def __init__(self, default: Sequence[T] = UNSET, *, data_type: BaseDataType[T] = UNSET) -> None:
|
|
395
|
+
"""Initialize the sequence data type."""
|
|
396
|
+
if default is UNSET and data_type is UNSET:
|
|
397
|
+
msg = "Sequence requires either a default with at least one element, or data_type to be specified."
|
|
398
|
+
raise InvalidDefaultError(msg)
|
|
399
|
+
if default is UNSET:
|
|
400
|
+
default = []
|
|
401
|
+
super().__init__(default)
|
|
402
|
+
self._infer_type(default, data_type)
|
|
403
|
+
|
|
404
|
+
def _infer_type(self, default: Sequence[T], data_type: BaseDataType[T]) -> None:
|
|
405
|
+
if len(default) <= 0 and data_type is UNSET:
|
|
406
|
+
msg = "Sequence default must have at least one element to infer type. or specify `data_type=<BaseDataType>`"
|
|
407
|
+
raise InvalidDefaultError(msg)
|
|
408
|
+
if data_type is UNSET:
|
|
409
|
+
self._data_type = BaseDataType[T].cast(default[0])
|
|
410
|
+
else:
|
|
411
|
+
self._data_type = data_type
|
|
412
|
+
|
|
413
|
+
def _convert(self, value: str) -> Sequence[T]:
|
|
414
|
+
"""Convert a string to a Sequence."""
|
|
415
|
+
# Handle empty string as empty list
|
|
416
|
+
if not value:
|
|
417
|
+
return []
|
|
418
|
+
|
|
419
|
+
# Split string but respect escaped separators
|
|
420
|
+
result: list[T] = []
|
|
421
|
+
current = ""
|
|
422
|
+
i = 0
|
|
423
|
+
while i < len(value):
|
|
424
|
+
# Check for escaped separator
|
|
425
|
+
if i < len(value) - 1 and value[i] == self.escape_char and value[i + 1] == self.separator:
|
|
426
|
+
current += self.separator
|
|
427
|
+
i += 2 # Skip both the escape char and the separator
|
|
428
|
+
# Check for escaped escape char
|
|
429
|
+
elif i < len(value) - 1 and value[i] == self.escape_char and value[i + 1] == self.escape_char:
|
|
430
|
+
current += self.escape_char
|
|
431
|
+
i += 2 # Skip both escape chars
|
|
432
|
+
# Handle separator
|
|
433
|
+
elif value[i] == self.separator:
|
|
434
|
+
c = self._data_type.convert(current)
|
|
435
|
+
result.append(c)
|
|
436
|
+
current = ""
|
|
437
|
+
i += 1
|
|
438
|
+
# Handle regular character
|
|
439
|
+
else:
|
|
440
|
+
current += value[i]
|
|
441
|
+
i += 1
|
|
442
|
+
|
|
443
|
+
# Add the last element
|
|
444
|
+
result.append(self._data_type.convert(current))
|
|
445
|
+
|
|
446
|
+
return result
|
|
447
|
+
|
|
448
|
+
def __str__(self) -> str:
|
|
449
|
+
"""Return a string representation of the list."""
|
|
450
|
+
values: list[str] = []
|
|
451
|
+
for item in self.value:
|
|
452
|
+
# Escape escape char
|
|
453
|
+
escaped_item = str(item).replace(self.escape_char, self.escape_char*2)
|
|
454
|
+
# Escape separator
|
|
455
|
+
escaped_item = escaped_item.replace(self.separator, f"{self.escape_char}{self.separator}")
|
|
456
|
+
values.append(escaped_item)
|
|
457
|
+
|
|
458
|
+
return self.separator.join(values)
|
|
459
|
+
|
|
460
|
+
class List(_SequenceType[T], Generic[T]):
|
|
461
|
+
"""A config value that is a list of values."""
|
|
462
|
+
|
|
463
|
+
def convert(self, value: str) -> list[T]:
|
|
464
|
+
"""Convert a string to a list."""
|
|
465
|
+
return list(super()._convert(value))
|
|
466
|
+
|
|
467
|
+
class Tuple(_SequenceType[T], Generic[T]):
|
|
468
|
+
"""A config value that is a tuple of values."""
|
|
469
|
+
|
|
470
|
+
def convert(self, value: str) -> tuple[T, ...]:
|
|
471
|
+
"""Convert a string to a tuple."""
|
|
472
|
+
return tuple(super()._convert(value))
|
|
473
|
+
|
|
474
|
+
class Set(BaseDataType[set[T]], Generic[T]):
|
|
475
|
+
"""A config value that is a set of values."""
|
|
476
|
+
|
|
477
|
+
@overload
|
|
478
|
+
def __init__(self, default: set[T]) -> None: ...
|
|
479
|
+
@overload
|
|
480
|
+
def __init__(self, *, data_type: BaseDataType[T]) -> None: ...
|
|
481
|
+
@overload
|
|
482
|
+
def __init__(
|
|
483
|
+
self,
|
|
484
|
+
default: set[T],
|
|
485
|
+
*,
|
|
486
|
+
data_type: BaseDataType[T] = ...,
|
|
487
|
+
) -> None: ...
|
|
488
|
+
|
|
489
|
+
def __init__(self, default: set[T] = UNSET, *, data_type: BaseDataType[T] = UNSET) -> None:
|
|
490
|
+
"""Initialize the set data type."""
|
|
491
|
+
if default is UNSET and data_type is UNSET:
|
|
492
|
+
msg = "Set requires either a default with at least one element, or data_type to be specified."
|
|
493
|
+
raise InvalidDefaultError(msg)
|
|
494
|
+
if default is UNSET:
|
|
495
|
+
default = set()
|
|
496
|
+
super().__init__(default)
|
|
497
|
+
self._infer_type(default, data_type)
|
|
498
|
+
|
|
499
|
+
def _infer_type(self, default: set[T], data_type: BaseDataType[T]) -> None:
|
|
500
|
+
if len(default) <= 0 and data_type is UNSET:
|
|
501
|
+
msg = "Set default must have at least one element to infer type. or specify `data_type=<BaseDataType>`"
|
|
502
|
+
raise InvalidDefaultError(msg)
|
|
503
|
+
if data_type is UNSET:
|
|
504
|
+
sample_element = default.pop()
|
|
505
|
+
default.add(sample_element)
|
|
506
|
+
self._data_type = BaseDataType[T].cast(sample_element)
|
|
507
|
+
else:
|
|
508
|
+
self._data_type = data_type
|
|
509
|
+
|
|
510
|
+
def convert(self, value: str) -> set[T]:
|
|
511
|
+
"""Convert a string to a set."""
|
|
512
|
+
if not value:
|
|
513
|
+
return set()
|
|
514
|
+
parts = value.split(",")
|
|
515
|
+
return {self._data_type.convert(item.strip()) for item in parts}
|
|
516
|
+
|
|
517
|
+
def __str__(self) -> str:
|
|
518
|
+
"""Return a string representation of the set."""
|
|
519
|
+
return ",".join(str(item) for item in self.value)
|
|
520
|
+
|
|
521
|
+
KT = TypeVar("KT")
|
|
522
|
+
VT = TypeVar("VT")
|
|
523
|
+
class Dict(BaseDataType[dict[KT, VT]], Generic[KT, VT]):
|
|
524
|
+
"""A config value that is a dictionary of string keys and values of type T."""
|
|
525
|
+
|
|
526
|
+
@overload
|
|
527
|
+
def __init__(self, default: dict[KT, VT]) -> None: ...
|
|
528
|
+
@overload
|
|
529
|
+
def __init__(self, *, key_type: BaseDataType[KT], value_type: BaseDataType[VT]) -> None: ...
|
|
530
|
+
@overload
|
|
531
|
+
def __init__(
|
|
532
|
+
self,
|
|
533
|
+
default: dict[KT, VT],
|
|
534
|
+
*,
|
|
535
|
+
key_type: BaseDataType[KT] = ...,
|
|
536
|
+
value_type: BaseDataType[VT] = ...,
|
|
537
|
+
) -> None: ...
|
|
538
|
+
|
|
539
|
+
def __init__(
|
|
540
|
+
self,
|
|
541
|
+
default: dict[KT, VT] = UNSET,
|
|
542
|
+
*,
|
|
543
|
+
key_type: BaseDataType[KT] = UNSET,
|
|
544
|
+
value_type: BaseDataType[VT] = UNSET,
|
|
545
|
+
) -> None:
|
|
546
|
+
"""Initialize the dict data type."""
|
|
547
|
+
if default is UNSET and (key_type is UNSET or value_type is UNSET):
|
|
548
|
+
msg = "Dict requires either a default with at least one key/value pair, or both key_type and value_type to be specified." # noqa: E501
|
|
549
|
+
raise InvalidDefaultError(msg)
|
|
550
|
+
if default is UNSET:
|
|
551
|
+
default = {}
|
|
552
|
+
super().__init__(default)
|
|
553
|
+
|
|
554
|
+
self._infer_key_type(default, key_type)
|
|
555
|
+
self._infer_value_type(default, value_type)
|
|
556
|
+
|
|
557
|
+
def _infer_key_type(self, default: dict[KT, VT], key_type: BaseDataType[KT]) -> None:
|
|
558
|
+
"""Infer the key type from the default dictionary if not provided."""
|
|
559
|
+
if len(default.keys()) <= 0 and key_type is UNSET:
|
|
560
|
+
msg = "Dict default must have at least one key element to infer type. or specify `key_type=<BaseDataType>`"
|
|
561
|
+
raise InvalidDefaultError(msg)
|
|
562
|
+
if key_type is UNSET:
|
|
563
|
+
for key in default:
|
|
564
|
+
self._key_data_type = BaseDataType[KT].cast(key)
|
|
565
|
+
break
|
|
566
|
+
else:
|
|
567
|
+
self._key_data_type = key_type
|
|
568
|
+
|
|
569
|
+
def _infer_value_type(self, default: dict[KT, VT], value_type: BaseDataType[VT]) -> None:
|
|
570
|
+
"""Infer the value type from the default dictionary if not provided."""
|
|
571
|
+
if len(default.values()) <= 0 and value_type is UNSET:
|
|
572
|
+
msg = "Dict default must have at least one value element to infer type. or specify `value_type=<BaseDataType>`"
|
|
573
|
+
raise InvalidDefaultError(msg)
|
|
574
|
+
if value_type is UNSET:
|
|
575
|
+
for value in default.values():
|
|
576
|
+
self._value_data_type = BaseDataType[VT].cast(value)
|
|
577
|
+
break
|
|
578
|
+
else:
|
|
579
|
+
self._value_data_type = value_type
|
|
580
|
+
|
|
581
|
+
def convert(self, value: str) -> dict[KT, VT]:
|
|
582
|
+
"""Convert a string to a dictionary."""
|
|
583
|
+
if not value:
|
|
584
|
+
return {}
|
|
585
|
+
|
|
586
|
+
parts = value.split(",")
|
|
587
|
+
result: dict[KT, VT] = {}
|
|
588
|
+
for part in parts:
|
|
589
|
+
if "=" not in part:
|
|
590
|
+
msg = f"Invalid dictionary entry: {part}. Expected format key=value."
|
|
591
|
+
raise ValueError(msg)
|
|
592
|
+
key_str, val_str = part.split("=", 1)
|
|
593
|
+
key = self._key_data_type.convert(key_str.strip())
|
|
594
|
+
val = self._value_data_type.convert(val_str.strip())
|
|
595
|
+
result[key] = val
|
|
596
|
+
return result
|
|
597
|
+
|
|
598
|
+
def __str__(self) -> str:
|
|
599
|
+
"""Return a string representation of the dictionary."""
|
|
600
|
+
items = [
|
|
601
|
+
f"{self._key_data_type.convert(str(k))}={self._value_data_type.convert(str(v))}"
|
|
602
|
+
for k, v in self.value.items()
|
|
603
|
+
]
|
|
604
|
+
return ",".join(items)
|
|
605
|
+
|
|
606
|
+
class _DateTimeKwargs(TypedDict, total=False):
|
|
607
|
+
year: Required[int]
|
|
608
|
+
month: Required[int]
|
|
609
|
+
day: Required[int]
|
|
610
|
+
hour: int
|
|
611
|
+
minute: int
|
|
612
|
+
second: int
|
|
613
|
+
microsecond: int
|
|
614
|
+
tzinfo: tzinfo | None
|
|
615
|
+
fold: int
|
|
616
|
+
|
|
617
|
+
class DateTime(BaseDataType[datetime]):
|
|
618
|
+
"""A config value that is a datetime."""
|
|
619
|
+
|
|
620
|
+
@overload
|
|
621
|
+
def __init__(self, default: datetime = UNSET) -> None: ...
|
|
622
|
+
@overload
|
|
623
|
+
def __init__(self, **kwargs: Unpack[_DateTimeKwargs]) -> None: ...
|
|
624
|
+
|
|
625
|
+
def __init__(self, default: datetime = UNSET, **kwargs: Unpack[_DateTimeKwargs]) -> None: # pyright: ignore[reportInconsistentOverload]
|
|
626
|
+
"""Initialize the datetime data type. Defaults to current datetime (datetime.now) if not provided."""
|
|
627
|
+
if default is UNSET:
|
|
628
|
+
try:
|
|
629
|
+
default = datetime(**kwargs) # ty: ignore[missing-argument] # noqa: DTZ001
|
|
630
|
+
except TypeError:
|
|
631
|
+
default = datetime.now(tz=UTC)
|
|
632
|
+
super().__init__(default)
|
|
633
|
+
|
|
634
|
+
def convert(self, value: str) -> datetime:
|
|
635
|
+
"""Convert a string value to a datetime."""
|
|
636
|
+
return datetime.fromisoformat(value)
|
|
637
|
+
|
|
638
|
+
def __str__(self) -> str:
|
|
639
|
+
"""Return the string representation of the stored value."""
|
|
640
|
+
return self.value.isoformat()
|
|
641
|
+
|
|
642
|
+
class _DateKwargs(TypedDict):
|
|
643
|
+
year: int
|
|
644
|
+
month: int
|
|
645
|
+
day: int
|
|
646
|
+
|
|
647
|
+
class Date(BaseDataType[date]):
|
|
648
|
+
"""A config value that is a date."""
|
|
649
|
+
|
|
650
|
+
@overload
|
|
651
|
+
def __init__(self, default: date = UNSET) -> None: ...
|
|
652
|
+
@overload
|
|
653
|
+
def __init__(self, **kwargs: Unpack[_DateKwargs]) -> None: ...
|
|
654
|
+
|
|
655
|
+
def __init__(self, default: date = UNSET, **kwargs: Unpack[_DateKwargs]) -> None: # pyright: ignore[reportInconsistentOverload]
|
|
656
|
+
"""Initialize the date data type. Defaults to current date if not provided."""
|
|
657
|
+
if default is UNSET:
|
|
658
|
+
default = date(**kwargs)
|
|
659
|
+
super().__init__(default)
|
|
660
|
+
|
|
661
|
+
def convert(self, value: str) -> date:
|
|
662
|
+
"""Convert a string value to a date."""
|
|
663
|
+
return date.fromisoformat(value)
|
|
664
|
+
|
|
665
|
+
def __str__(self) -> str: # noqa: D105
|
|
666
|
+
return self.value.isoformat()
|
|
667
|
+
|
|
668
|
+
class _TimeKwargs(TypedDict, total=False):
|
|
669
|
+
hour: NotRequired[int]
|
|
670
|
+
minute: NotRequired[int]
|
|
671
|
+
second: NotRequired[int]
|
|
672
|
+
microsecond: NotRequired[int]
|
|
673
|
+
tzinfo: tzinfo | None
|
|
674
|
+
fold: NotRequired[int]
|
|
675
|
+
|
|
676
|
+
class Time(BaseDataType[time]):
|
|
677
|
+
"""A config value that is a time."""
|
|
678
|
+
|
|
679
|
+
@overload
|
|
680
|
+
def __init__(self, default: time = UNSET) -> None: ...
|
|
681
|
+
@overload
|
|
682
|
+
def __init__(self, **kwargs: Unpack[_TimeKwargs]) -> None: ...
|
|
683
|
+
|
|
684
|
+
def __init__(self, default: time = UNSET, **kwargs: Unpack[_TimeKwargs]) -> None:
|
|
685
|
+
"""Initialize the time data type. Defaults to current time if not provided."""
|
|
686
|
+
if default is UNSET:
|
|
687
|
+
default = time(**kwargs) # ty: ignore[missing-argument]
|
|
688
|
+
super().__init__(default)
|
|
689
|
+
|
|
690
|
+
def convert(self, value: str) -> time:
|
|
691
|
+
"""Convert a string value to a time."""
|
|
692
|
+
return time.fromisoformat(value)
|
|
693
|
+
|
|
694
|
+
def __str__(self) -> str: # noqa: D105
|
|
695
|
+
return self.value.isoformat()
|
|
696
|
+
|
|
697
|
+
class _TimeDeltaKwargs(TypedDict, total=False):
|
|
698
|
+
days: NotRequired[float]
|
|
699
|
+
seconds: NotRequired[float]
|
|
700
|
+
microseconds: NotRequired[float]
|
|
701
|
+
milliseconds: NotRequired[float]
|
|
702
|
+
minutes: NotRequired[float]
|
|
703
|
+
hours: NotRequired[float]
|
|
704
|
+
weeks: NotRequired[float]
|
|
705
|
+
|
|
706
|
+
class TimeDelta(BaseDataType[timedelta]):
|
|
707
|
+
"""A config value that is a timedelta."""
|
|
708
|
+
|
|
709
|
+
def __init__(
|
|
710
|
+
self,
|
|
711
|
+
default: timedelta = UNSET,
|
|
712
|
+
**kwargs: Unpack[_TimeDeltaKwargs],
|
|
713
|
+
) -> None:
|
|
714
|
+
"""Initialize the timedelta data type. Defaults to 0 if not provided."""
|
|
715
|
+
if default is UNSET:
|
|
716
|
+
default = timedelta(**kwargs)
|
|
717
|
+
super().__init__(default)
|
|
718
|
+
|
|
719
|
+
def convert(self, value: str) -> timedelta:
|
|
720
|
+
"""Convert a string value to a timedelta."""
|
|
721
|
+
return timedelta(seconds=float(value))
|
|
722
|
+
|
|
723
|
+
def __str__(self) -> str: # noqa: D105
|
|
724
|
+
return str(self.value.total_seconds())
|
confkit/exceptions.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Module for custom exceptions used in the confkit package."""
|
|
2
|
+
|
|
3
|
+
class InvalidDefaultError(ValueError):
|
|
4
|
+
"""Raised when the default value is not set or invalid."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InvalidConverterError(ValueError):
|
|
8
|
+
"""Raised when the converter is not set or invalid."""
|
confkit/ext/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Optional extension modules for confkit.
|
|
2
|
+
|
|
3
|
+
Modules inside this package may rely on optional extras. They are intentionally
|
|
4
|
+
not imported eagerly so users can access the pieces they installed without
|
|
5
|
+
pulling in additional dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__all__: list[str] = []
|
confkit/ext/pydantic.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Helper utilities for working with Pydantic models and confkit."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from pydantic import BaseModel # noqa: TC002
|
|
6
|
+
except ImportError as exc: # pragma: no cover - executed only when optional extra missing
|
|
7
|
+
msg = (
|
|
8
|
+
"confkit.ext.pydantic requires the optional 'pydantic' extra. "
|
|
9
|
+
"Install it via 'pip install "
|
|
10
|
+
"confkit[pydantic]' or 'uv add confkit[pydantic]'."
|
|
11
|
+
)
|
|
12
|
+
raise ImportError(msg) from exc
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def apply_model(config_instance: object, model: BaseModel) -> None:
|
|
16
|
+
"""Apply values from a Pydantic model to matching Config descriptors."""
|
|
17
|
+
for field, value in model.model_dump().items():
|
|
18
|
+
if hasattr(type(config_instance), field):
|
|
19
|
+
setattr(config_instance, field, value)
|
|
20
|
+
|
|
21
|
+
__all__ = ["apply_model"]
|
confkit/py.typed
ADDED
|
File without changes
|
confkit/sentinels.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Private sentinel for missing values."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _MissingSentinel:
|
|
7
|
+
__slots__ = ()
|
|
8
|
+
|
|
9
|
+
def __eq__(self, other: object) -> bool:
|
|
10
|
+
return False
|
|
11
|
+
|
|
12
|
+
def __bool__(self) -> bool:
|
|
13
|
+
return False
|
|
14
|
+
|
|
15
|
+
def __hash__(self) -> int:
|
|
16
|
+
return 0
|
|
17
|
+
|
|
18
|
+
def __repr__(self) -> str:
|
|
19
|
+
return "MISSING"
|
|
20
|
+
|
|
21
|
+
UNSET: Any = _MissingSentinel()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: confkit
|
|
3
|
+
Version: 0.10.0
|
|
4
|
+
Summary: Lightweight and Easy to use configuration manager for Python projects
|
|
5
|
+
Author-email: HEROgold <martijnwieringa28@gmail.com>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Provides-Extra: pydantic
|
|
8
|
+
Requires-Dist: pydantic>=2.12.5; extra == 'pydantic'
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# confkit
|
|
12
|
+
|
|
13
|
+
[](https://github.com/HEROgold/confkit/actions/workflows/test.yml)
|
|
14
|
+
[](https://coveralls.io/github/HEROgold/confkit?branch=master)
|
|
15
|
+
|
|
16
|
+
Type-safe configuration manager for Python projects using descriptors and ConfigParser.
|
|
17
|
+
|
|
18
|
+
Full documentation: [confkit docs](https://HEROgold.github.io/confkit/)
|
|
19
|
+
|
|
20
|
+
## Supported Python Versions
|
|
21
|
+
|
|
22
|
+
confkit follows the [Python version support policy](https://devguide.python.org/versions/) as outlined in the Python Developer's Guide:
|
|
23
|
+
|
|
24
|
+
- We support all active and maintenance releases of Python, starting with 3.11
|
|
25
|
+
- End-of-life (EOL) Python versions are **not** supported
|
|
26
|
+
- We aim to support Python release candidates to stay ahead of the release cycle
|
|
27
|
+
|
|
28
|
+
This ensures that confkit remains compatible with current Python versions while allowing us to leverage modern language features.
|
|
29
|
+
|
|
30
|
+
## What is it?
|
|
31
|
+
|
|
32
|
+
confkit is a Python library that provides type-safe configuration management with automatic type conversion and validation.
|
|
33
|
+
It uses descriptors to define configuration values as class attributes that automatically read from and write to INI files.
|
|
34
|
+
|
|
35
|
+
## What does it do?
|
|
36
|
+
|
|
37
|
+
- Type-safe configuration with automatic type conversion
|
|
38
|
+
- Automatic INI file management
|
|
39
|
+
- Default value handling with file persistence
|
|
40
|
+
- Optional value support
|
|
41
|
+
- Enum support (Enum, StrEnum, IntEnum, IntFlag)
|
|
42
|
+
- Method decorators for injecting configuration values
|
|
43
|
+
- Runtime type validation
|
|
44
|
+
|
|
45
|
+
## Getting Started / Usage
|
|
46
|
+
|
|
47
|
+
For full quickstart, advanced patterns (custom data types, decorators, argparse integration), and runnable examples, visit the documentation site:
|
|
48
|
+
|
|
49
|
+
👉 [confkit documentation site](https://HEROgold.github.io/confkit/usage)
|
|
50
|
+
|
|
51
|
+
Direct entry points:
|
|
52
|
+
|
|
53
|
+
- Quickstart & descriptor patterns: Usage Guide
|
|
54
|
+
- All examples: Examples Overview
|
|
55
|
+
- Custom datatype tutorial: Custom Data Type example
|
|
56
|
+
- API reference: pdoc-generated symbol index
|
|
57
|
+
|
|
58
|
+
You can still browse example source locally under `examples/`.
|
|
59
|
+
|
|
60
|
+
## How to contribute?
|
|
61
|
+
|
|
62
|
+
1. Fork the repository and clone locally
|
|
63
|
+
2. Install dependencies: `uv sync --group test`
|
|
64
|
+
3. Run tests: `pytest .`
|
|
65
|
+
4. Run linting: `ruff check .`
|
|
66
|
+
5. Make changes following existing patterns
|
|
67
|
+
6. Add tests for new functionality
|
|
68
|
+
7. Submit a pull request
|
|
69
|
+
|
|
70
|
+
### Development
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
git clone https://github.com/HEROgold/confkit.git
|
|
74
|
+
cd confkit
|
|
75
|
+
uv sync --group test
|
|
76
|
+
pytest .
|
|
77
|
+
ruff check .
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
#### Building Documentation
|
|
81
|
+
|
|
82
|
+
To build and preview documentation locally:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# Install documentation dependencies
|
|
86
|
+
uv sync --group docs
|
|
87
|
+
|
|
88
|
+
# Generate API documentation with pdoc
|
|
89
|
+
uv run pdoc confkit -o docs/api
|
|
90
|
+
|
|
91
|
+
# Build documentation site with mkdocs
|
|
92
|
+
uv run mkdocs build -d site
|
|
93
|
+
|
|
94
|
+
# Or serve locally for live preview (with auto-reload)
|
|
95
|
+
uv run mkdocs serve
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Documentation is automatically built and deployed to GitHub Pages when changes are pushed to the `master` branch.
|
|
99
|
+
|
|
100
|
+
**After updating code that affects documentation:**
|
|
101
|
+
|
|
102
|
+
1. Update relevant `.md` files in `docs/` directory (examples, reference, etc.)
|
|
103
|
+
2. Run `uv run pdoc confkit -o docs/api` to regenerate API documentation
|
|
104
|
+
3. Preview changes with `uv run mkdocs serve` and verify at `http://127.0.0.1:8000`
|
|
105
|
+
4. Commit both code and documentation changes together
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
confkit/__init__.py,sha256=TVnB-w14Yb1hivFtFvIP89uObg2xXJemfmPLvO6io0s,938
|
|
2
|
+
confkit/config.py,sha256=KRoEsZrw5wYaZYfWT-v4AFdvLNC1WN2xvAkrEw_XLPU,13834
|
|
3
|
+
confkit/data_types.py,sha256=cnQymjNDmar_gl_Je-mRmvYMyY3hV61T6dFAml7SLt8,26497
|
|
4
|
+
confkit/exceptions.py,sha256=IlSnRkQM4hueDxK0kpGzcC3_ZPH9gMGEMtLTuE-zW_g,269
|
|
5
|
+
confkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
confkit/sentinels.py,sha256=qiqeYxW7Mavhedd9RaVHqNn71vok-x9PJBlbYxnmcRY,372
|
|
7
|
+
confkit/ext/__init__.py,sha256=8EWGwP4amL2le1BdzgIew1gI3toVkb0aB4T5xZMS-U8,264
|
|
8
|
+
confkit/ext/pydantic.py,sha256=YpgPDzU4Ax-DOqhwLNpvgCgjA7xs-kHvVNKmadUPj8s,803
|
|
9
|
+
confkit-0.10.0.dist-info/METADATA,sha256=ayBdJW5bSNSKQBjKVEVpyUw_VOSzTlI4DJBr8ADskn0,3649
|
|
10
|
+
confkit-0.10.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
11
|
+
confkit-0.10.0.dist-info/RECORD,,
|