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 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."""
@@ -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] = []
@@ -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
+ [![Test](https://github.com/HEROgold/confkit/actions/workflows/test.yml/badge.svg)](https://github.com/HEROgold/confkit/actions/workflows/test.yml)
14
+ [![Coverage Status](https://coveralls.io/repos/github/HEROgold/confkit/badge.svg?branch=master)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any