pyconfigtree 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ from .base import Node as Node
2
+ from .source import *
3
+ from .parameter import *
pyconfigtree/base.py ADDED
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, TypeVar, TypeAlias, overload
4
+ from enum import Enum, auto
5
+ from types import MappingProxyType
6
+ from collections.abc import Mapping, Callable, Sequence, Awaitable, Generator
7
+
8
+ from .source import ConfigSource
9
+ from .exceptions import LeafNodeError, NodeLoopError, NoSourceError, NodeDuplicateError
10
+ from .source.base import NodeInfo, NodeType
11
+
12
+
13
+ T = TypeVar('T', bound='Node')
14
+
15
+ ON_NODE_ATTACHED_HOOK: TypeAlias = Callable[['Node', 'Node'], Awaitable[Any]]
16
+ ON_NODE_DETACHED_HOOK: TypeAlias = Callable[['Node', 'Node'], Awaitable[Any]]
17
+
18
+
19
+ class BaseHookTypes(Enum):
20
+ ON_NODE_ATTACHED = auto()
21
+ ON_NODE_DETACHED = auto()
22
+
23
+
24
+ class Node:
25
+ _allow_children: bool = True
26
+
27
+ def __init__(
28
+ self,
29
+ node_id: str,
30
+ name: str = '',
31
+ description: str = '',
32
+ source: ConfigSource | None = None,
33
+ flags: set[Any] | None = None,
34
+ on_node_attached_hook: ON_NODE_ATTACHED_HOOK | None = None,
35
+ on_node_detached_hook: ON_NODE_DETACHED_HOOK | None = None,
36
+ ):
37
+ self._id: str = node_id
38
+ self._name = name
39
+ self._description = description
40
+ self._parent: Node | None = None
41
+ self._subnodes: dict[str, Node] = {}
42
+ self._subnodes_proxy = MappingProxyType(self._subnodes)
43
+ self._source = source
44
+ self._flags = flags or set()
45
+
46
+ self._hooks: dict[Any, Callable[..., Awaitable[Any]] | None] = {}
47
+ self.on_node_attached_hook = on_node_attached_hook
48
+ self.on_node_detached_hook = on_node_detached_hook
49
+
50
+ @property
51
+ def flags(self) -> frozenset[Any]:
52
+ return frozenset(self._flags)
53
+
54
+ @property
55
+ def hooks(self) -> Mapping[Any, Callable[..., Awaitable[Any]] | None]:
56
+ return MappingProxyType(self._hooks)
57
+
58
+ @property
59
+ def id(self) -> str:
60
+ return self._id
61
+
62
+ @property
63
+ def name(self) -> str:
64
+ return self._name
65
+
66
+ @property
67
+ def description(self) -> str:
68
+ return self._description
69
+
70
+ @property
71
+ def parent(self) -> Node | None:
72
+ return self._parent
73
+
74
+ @property
75
+ def subnodes(self) -> Mapping[str, Node]:
76
+ return self._subnodes_proxy
77
+
78
+ @property
79
+ def root(self) -> Node:
80
+ node = self
81
+ while node.parent is not None:
82
+ node = node.parent
83
+ return node
84
+
85
+ @property
86
+ def path(self) -> tuple[str, ...]:
87
+ path = [i.id for i in self.chain_to_root()]
88
+ path.reverse()
89
+ return tuple(path)
90
+
91
+ @property
92
+ def source(self) -> ConfigSource | None:
93
+ return self._source
94
+
95
+ @property
96
+ def inherited_source(self) -> ConfigSource | None:
97
+ if self.source is not None:
98
+ return self._source
99
+ if self.parent is not None:
100
+ return self.parent.inherited_source
101
+ return None
102
+
103
+ @property
104
+ def on_node_attached_hook(self) -> ON_NODE_ATTACHED_HOOK | None:
105
+ return self._hooks.get(BaseHookTypes.ON_NODE_ATTACHED)
106
+
107
+ @on_node_attached_hook.setter
108
+ def on_node_attached_hook(self, hook: ON_NODE_ATTACHED_HOOK | None) -> None:
109
+ self._hooks[BaseHookTypes.ON_NODE_ATTACHED] = hook
110
+
111
+ @property
112
+ def on_node_detached_hook(self) -> ON_NODE_DETACHED_HOOK | None:
113
+ return self._hooks.get(BaseHookTypes.ON_NODE_DETACHED)
114
+
115
+ @on_node_detached_hook.setter
116
+ def on_node_detached_hook(self, hook: ON_NODE_DETACHED_HOOK | None) -> None:
117
+ self._hooks[BaseHookTypes.ON_NODE_DETACHED] = hook
118
+
119
+ def _attach_node(self, node: T) -> T:
120
+ self.check_can_attach_node(node)
121
+ node._parent = self
122
+ self._subnodes[node.id] = node
123
+ return node
124
+
125
+ async def attach_node(self, node: T, run_hook: bool = True) -> T:
126
+ node = self._attach_node(node)
127
+ if run_hook:
128
+ await self.run_hook(BaseHookTypes.ON_NODE_ATTACHED, node, self)
129
+ return node
130
+
131
+ @overload
132
+ def _detach_node(self, node: str) -> Node: ...
133
+
134
+ @overload
135
+ def _detach_node(self, node: T) -> T: ...
136
+
137
+ def _detach_node(self, node: T | str) -> T | Node:
138
+ node_id = node if isinstance(node, str) else node.id
139
+ if node_id not in self.subnodes:
140
+ raise KeyError(f'Node {self.path} has no subnode with id {node_id}.')
141
+
142
+ detached_node = self._subnodes.pop(node_id)
143
+ detached_node._parent = None
144
+ return detached_node
145
+
146
+ @overload
147
+ async def detach_node(self, node: str, run_hook: bool = True) -> Node: ...
148
+
149
+ @overload
150
+ async def detach_node(self, node: T, run_hook: bool = True) -> T: ...
151
+
152
+ async def detach_node(self, node: T | str, run_hook: bool = True) -> T | Node:
153
+ detached_node = self._detach_node(node)
154
+ if run_hook:
155
+ await self.run_hook(BaseHookTypes.ON_NODE_DETACHED, detached_node, self)
156
+ return detached_node
157
+
158
+ def get_node_info(self, same_source_only: bool = True) -> NodeInfo:
159
+ return NodeInfo(
160
+ id=self.id,
161
+ name=self.name,
162
+ description=self.description,
163
+ type=NodeType.CONTAINER,
164
+ subnodes={
165
+ k: i.get_node_info(same_source_only=same_source_only)
166
+ for k, i in self.subnodes.items()
167
+ if (same_source_only and self.inherited_source == i.inherited_source)
168
+ or not same_source_only
169
+ },
170
+ )
171
+
172
+ def check_can_be_attached(self) -> None:
173
+ if self._parent is not None:
174
+ raise RuntimeError(
175
+ f'Node {self.path} already has a parent and cannot be attached to another node.',
176
+ )
177
+
178
+ def check_can_attach_node(self, node: Node) -> None:
179
+ if not self._allow_children:
180
+ raise LeafNodeError(f'Node of type {type(self)} cannot contain subnodes.')
181
+
182
+ node.check_can_be_attached()
183
+
184
+ if node is self:
185
+ raise NodeLoopError('Node cannot be attached to itself.')
186
+
187
+ if node.id in self.subnodes:
188
+ raise NodeDuplicateError(
189
+ f'Node {self.path} already contains a subnode with id {node.id}.',
190
+ )
191
+
192
+ for i in node.chain_to_tails():
193
+ if i is self:
194
+ raise NodeLoopError('Node loop.') # todo
195
+ return
196
+
197
+ def chain_to_root(self) -> Generator[Node, None, None]:
198
+ node: Node | None = self
199
+ while node is not None:
200
+ yield node
201
+ node = node.parent
202
+
203
+ def chain_to_tails(self) -> Generator[Node, None, None]:
204
+ yield self
205
+ for i in self.subnodes.values():
206
+ yield from i.chain_to_tails()
207
+
208
+ def is_child_of(self, node: Node | Sequence[str], direct: bool = True) -> bool:
209
+ path = node.path if isinstance(node, Node) else tuple(node)
210
+ self_path = self.path
211
+
212
+ if direct:
213
+ if len(self_path) != len(path) + 1:
214
+ return False
215
+ else:
216
+ if len(self_path) <= len(path):
217
+ return False
218
+
219
+ return self_path[: len(path)] == path
220
+
221
+ def is_parent_of(self, node: Node | Sequence[str], direct: bool = True) -> bool:
222
+ path = node.path if isinstance(node, Node) else node
223
+ self_path = self.path
224
+
225
+ if direct:
226
+ if len(self_path) != len(path) - 1:
227
+ return False
228
+ else:
229
+ if len(self_path) >= len(path):
230
+ return False
231
+
232
+ return path[: len(self_path)] == self_path
233
+
234
+ async def save(self, same_source_only: bool = True) -> None:
235
+ if self.source is None:
236
+ if not self.inherited_source:
237
+ raise NoSourceError(f'Cannot save node {self.path}: source not specified.')
238
+ for i in self.chain_to_root():
239
+ if i.source is not None:
240
+ return await i.save(same_source_only=same_source_only)
241
+ else:
242
+ node_info = self.get_node_info()
243
+ return await self.source.save(data=node_info)
244
+
245
+ async def load(self, validate: bool = True, run_hook: bool = False) -> None:
246
+ if self.source is None:
247
+ raise NoSourceError(f'Cannot load node {self.path}: source not specified.')
248
+
249
+ data = await self.source.load()
250
+ await self.load_from_dict(data, validate=validate, run_hook=run_hook)
251
+
252
+ async def load_from_dict(
253
+ self,
254
+ data_dict: dict[str, Any],
255
+ validate: bool = True,
256
+ run_hook: bool = False,
257
+ ) -> None:
258
+ for k, data in data_dict.items():
259
+ if k not in self.subnodes:
260
+ continue
261
+ node = self.subnodes[k]
262
+ await node.load_from_dict(
263
+ data_dict=data_dict[k],
264
+ validate=validate,
265
+ run_hook=run_hook,
266
+ )
267
+
268
+ async def run_hook(self, hook_identifier: Any, *args: Any, **kwargs: Any) -> Any:
269
+ hook = self.hooks.get(hook_identifier)
270
+ if hook is not None:
271
+ await hook(*args, **kwargs)
272
+ if self.parent is not None:
273
+ await self.parent.run_hook(hook_identifier, *args, **kwargs)
@@ -0,0 +1,34 @@
1
+ __all__ = [
2
+ 'PyConfigTreeError',
3
+ 'ValidationError',
4
+ 'SerializationError',
5
+ 'DeserializationError',
6
+ 'NoSourceError',
7
+ 'NodeLoopError',
8
+ 'NodeDuplicateError',
9
+ 'LeafNodeError',
10
+ ]
11
+
12
+
13
+ class PyConfigTreeError(Exception): ...
14
+
15
+
16
+ class ValidationError(PyConfigTreeError): ...
17
+
18
+
19
+ class SerializationError(PyConfigTreeError): ...
20
+
21
+
22
+ class DeserializationError(PyConfigTreeError): ...
23
+
24
+
25
+ class NoSourceError(PyConfigTreeError): ...
26
+
27
+
28
+ class NodeLoopError(PyConfigTreeError): ...
29
+
30
+
31
+ class NodeDuplicateError(PyConfigTreeError): ...
32
+
33
+
34
+ class LeafNodeError(PyConfigTreeError): ...
@@ -0,0 +1,9 @@
1
+ from .base import (
2
+ Parameter as Parameter,
3
+ MutableParameter as MutableParameter,
4
+ )
5
+ from .int_parameter import IntParameter as IntParameter
6
+ from .bool_parameter import BoolParameter as BoolParameter
7
+ from .float_parameter import FloatParameter as FloatParameter
8
+ from .choice_parameter import Choice as Choice
9
+ from .string_parameter import StringParameter as StringParameter
@@ -0,0 +1,267 @@
1
+ __all__ = [
2
+ 'ParameterHookTypes',
3
+ 'Serializer',
4
+ 'Deserializer',
5
+ 'Validator',
6
+ 'Parameter',
7
+ 'MutableParameter',
8
+ 'TypedParameter',
9
+ 'ON_PARAMETER_VALUE_CHANGED_HOOK',
10
+ '_MutableParameterKwargs',
11
+ ]
12
+
13
+
14
+ from typing import Any, Type, Generic, TypeVar, Protocol, TypeAlias
15
+ from enum import Enum, auto
16
+ from asyncio import Lock
17
+ from collections.abc import Callable, Awaitable
18
+
19
+ from typing_extensions import Self, Unpack, Required, TypedDict, NotRequired
20
+
21
+ from pyconfigtree.exceptions import ValidationError, DeserializationError
22
+
23
+ from ..base import Node
24
+ from ..source.base import ALLOWED_TYPES, NodeInfo, NodeType
25
+
26
+
27
+ ON_PARAMETER_VALUE_CHANGED_HOOK: TypeAlias = Callable[['MutableParameter[Any]'], Awaitable[Any]]
28
+
29
+
30
+ class ParameterHookTypes(Enum):
31
+ PARAMETER_VALUE_CHANGED = auto()
32
+
33
+
34
+ _VALUE_contra = TypeVar('_VALUE_contra', contravariant=True)
35
+ _VALUE_co = TypeVar('_VALUE_co', covariant=True)
36
+ _NODE = TypeVar('_NODE', contravariant=True)
37
+
38
+
39
+ class Serializer(Protocol[_NODE, _VALUE_contra]):
40
+ def __call__(self, node: _NODE, value: _VALUE_contra) -> ALLOWED_TYPES: ...
41
+
42
+
43
+ class Deserializer(Protocol[_NODE, _VALUE_co]):
44
+ def __call__(self, node: _NODE, value: ALLOWED_TYPES) -> _VALUE_co: ...
45
+
46
+
47
+ class Validator(Protocol[_NODE, _VALUE_contra]):
48
+ async def __call__(self, node: _NODE, value: _VALUE_contra) -> None: ...
49
+
50
+
51
+ T = TypeVar('T')
52
+
53
+
54
+ class Parameter(Node, Generic[T]):
55
+ _allow_children = False
56
+
57
+ def __init__(
58
+ self,
59
+ node_id: str,
60
+ value: T,
61
+ name: str = '',
62
+ description: str = '',
63
+ ) -> None:
64
+ super().__init__(node_id=node_id, name=name, description=description)
65
+ self._value = value
66
+
67
+ @property
68
+ def value(self) -> T:
69
+ return self._value
70
+
71
+ async def load_from_dict(
72
+ self,
73
+ data_dict: dict[str, Any],
74
+ validate: bool = True,
75
+ run_hook: bool = False,
76
+ ) -> None:
77
+ # Parameter is immutable and its value cannot be set.
78
+ return
79
+
80
+
81
+ _VALUE_TYPE = TypeVar('_VALUE_TYPE') # Parameter value type
82
+ _PARAM_CLASS = TypeVar('_PARAM_CLASS') # Parameter class
83
+
84
+
85
+ class _CommonMutableParameterKwargs(TypedDict, Generic[_PARAM_CLASS, _VALUE_TYPE]):
86
+ name: NotRequired[str]
87
+ description: NotRequired[str]
88
+ value: NotRequired[_VALUE_TYPE | None]
89
+ default_value: NotRequired[_VALUE_TYPE | None]
90
+ default_factory: NotRequired[Callable[[], _VALUE_TYPE] | None]
91
+ validator: NotRequired[Validator[_PARAM_CLASS, _VALUE_TYPE] | None]
92
+ on_value_changed_hook: NotRequired[ON_PARAMETER_VALUE_CHANGED_HOOK | None]
93
+
94
+
95
+ class _MutableParameterKwargs(
96
+ _CommonMutableParameterKwargs[_PARAM_CLASS, _VALUE_TYPE], Generic[_PARAM_CLASS, _VALUE_TYPE]
97
+ ):
98
+ serializer: Required[Serializer[_PARAM_CLASS, _VALUE_TYPE]]
99
+ deserializer: Required[Deserializer[_PARAM_CLASS, _VALUE_TYPE]]
100
+
101
+
102
+ class _TypedParameterKwargs(
103
+ _CommonMutableParameterKwargs[_PARAM_CLASS, _VALUE_TYPE], Generic[_PARAM_CLASS, _VALUE_TYPE]
104
+ ):
105
+ serializer: NotRequired[Serializer[_PARAM_CLASS, _VALUE_TYPE]]
106
+ deserializer: NotRequired[Deserializer[_PARAM_CLASS, _VALUE_TYPE]]
107
+
108
+
109
+ class MutableParameter(Parameter[T], Generic[T]):
110
+ def __init__(
111
+ self,
112
+ node_id: str,
113
+ *,
114
+ name: str = '',
115
+ description: str = '',
116
+ value: T | None = None,
117
+ default_value: T | None = None,
118
+ default_factory: Callable[[], T] | None = None,
119
+ validator: Validator[Self, T] | None = None,
120
+ serializer: Serializer[Self, T],
121
+ deserializer: Deserializer[Self, T],
122
+ on_value_changed_hook: ON_PARAMETER_VALUE_CHANGED_HOOK | None = None,
123
+ ) -> None:
124
+ if value is None and default_value is None:
125
+ raise ValueError('Either `default_value` or `default_factory` must be specified.')
126
+ if value is not None and default_factory is not None:
127
+ raise ValueError(
128
+ 'Either `default_value` or `default_factory` must be specified, '
129
+ 'but not both of them.',
130
+ )
131
+
132
+ self._default_factory = default_factory
133
+ self._default_value = default_value
134
+ self._changing_lock = Lock()
135
+ self._validator = validator
136
+ self._serializer = serializer
137
+ self._deserializer = deserializer
138
+
139
+ super().__init__(
140
+ node_id=node_id,
141
+ value=value if value is not None else self.default_value,
142
+ name=name,
143
+ description=description,
144
+ )
145
+
146
+ self.on_value_changed_hook = on_value_changed_hook
147
+
148
+ @property
149
+ def default_value(self) -> T:
150
+ if self._default_factory is not None:
151
+ return self._default_factory()
152
+ return self._default_value
153
+
154
+ @property
155
+ def serializer(self) -> Serializer[Self, T]:
156
+ return self._serializer
157
+
158
+ @property
159
+ def deserializer(self) -> Deserializer[Self, T]:
160
+ return self._deserializer
161
+
162
+ @property
163
+ def validator(self) -> Validator[Self, T] | None:
164
+ return self._validator
165
+
166
+ @property
167
+ def on_value_changed_hook(self) -> ON_PARAMETER_VALUE_CHANGED_HOOK | None:
168
+ return self.hooks.get(ParameterHookTypes.PARAMETER_VALUE_CHANGED)
169
+
170
+ @on_value_changed_hook.setter
171
+ def on_value_changed_hook(self, hook: ON_PARAMETER_VALUE_CHANGED_HOOK | None) -> None:
172
+ self._hooks[ParameterHookTypes.PARAMETER_VALUE_CHANGED] = hook
173
+
174
+ def get_node_info(self, same_source_only: bool = True) -> NodeInfo:
175
+ return NodeInfo(
176
+ id=self.id,
177
+ name=self.name,
178
+ description=self.description,
179
+ type=NodeType.LEAF,
180
+ value=self.serialize(),
181
+ )
182
+
183
+ async def load_from_dict(
184
+ self,
185
+ data_dict: Any,
186
+ validate: bool = True,
187
+ run_hook: bool = False,
188
+ ) -> None:
189
+ await self.set_value(
190
+ data_dict,
191
+ save=False,
192
+ run_hook=run_hook,
193
+ validate=validate,
194
+ )
195
+
196
+ async def set_value(
197
+ self,
198
+ value: Any,
199
+ *,
200
+ deserialize: bool = True,
201
+ validate: bool = True,
202
+ run_hook: bool = True,
203
+ save: bool = True,
204
+ ) -> None:
205
+ async with self._changing_lock:
206
+ if deserialize:
207
+ value = self.deserialize(value)
208
+ if validate:
209
+ await self.validate(value)
210
+
211
+ self._value = value
212
+ if save:
213
+ await self.save()
214
+
215
+ if run_hook:
216
+ await self.run_hook(ParameterHookTypes.PARAMETER_VALUE_CHANGED, self)
217
+
218
+ def serialize(self) -> ALLOWED_TYPES:
219
+ return self.serializer(self, self.value)
220
+
221
+ def deserialize(self, value: Any) -> T:
222
+ return self.deserializer(self, value)
223
+
224
+ async def validate(self, value: T) -> None:
225
+ if self.validator is not None:
226
+ await self.validator(self, value)
227
+
228
+
229
+ TT = TypeVar('TT')
230
+
231
+
232
+ class TypedParameter(MutableParameter[TT], Generic[TT]):
233
+ _DEFAULT_SERIALIZER: Serializer[Self, TT]
234
+ _DEFAULT_DESERIALIZER: Deserializer[Self, TT]
235
+ _VALUE_TYPE: Type[TT]
236
+
237
+ def __init_subclass__(cls, **kwargs: Any) -> None:
238
+ if cls is TypedParameter:
239
+ return
240
+
241
+ for i in [
242
+ '_DEFAULT_SERIALIZER',
243
+ '_DEFAULT_DESERIALIZER',
244
+ '_VALUE_TYPE',
245
+ ]:
246
+ if i not in cls.__dict__:
247
+ raise TypeError(f'`{cls.__name__}` must define `{i}`.')
248
+
249
+ def __init__(self, node_id: str, **kwargs: Unpack[_TypedParameterKwargs[Self, TT]]) -> None:
250
+ super().__init__(node_id=node_id, **kwargs)
251
+
252
+ def deserialize(self, value: Any) -> TT:
253
+ res = super().deserialize(value)
254
+ if not isinstance(res, self._VALUE_TYPE):
255
+ raise DeserializationError(
256
+ f'Deserialized value of `{self.__class__.__name__}` must be an instance of '
257
+ f'`{self._VALUE_TYPE.__name__}`, not `{type(res)}`.',
258
+ )
259
+ return res
260
+
261
+ async def validate(self, value: Any) -> None:
262
+ if not isinstance(value, self._VALUE_TYPE):
263
+ raise ValidationError(
264
+ f'Value of `{self.__class__.__name__}` must be an instance of '
265
+ f'`{self._VALUE_TYPE.__name__}`, not `{type(value)}`.',
266
+ )
267
+ return await super().validate(value)
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ __all__ = [
5
+ 'bool_serializer',
6
+ 'bool_deserializer',
7
+ 'BoolParameter',
8
+ ]
9
+
10
+
11
+ from typing import Any
12
+
13
+ from .base import TypedParameter
14
+
15
+
16
+ def bool_serializer(node: 'BoolParameter', value: bool) -> bool:
17
+ return value
18
+
19
+
20
+ def bool_deserializer(node: 'BoolParameter', value: Any) -> bool:
21
+ return bool(value)
22
+
23
+
24
+ class BoolParameter(TypedParameter[bool]):
25
+ _DEFAULT_SERIALIZER = staticmethod(bool_serializer)
26
+ _DEFAULT_DESERIALIZER = staticmethod(bool_deserializer)
27
+ _VALUE_TYPE = bool
28
+
29
+ async def on(self, save: bool = True, run_hook: bool = True, validate: bool = True) -> None:
30
+ await self.set_value(
31
+ True,
32
+ deserialize=False,
33
+ validate=validate,
34
+ run_hook=run_hook,
35
+ save=save,
36
+ )
37
+
38
+ async def off(self, save: bool = True, run_hook: bool = True, validate: bool = True) -> None:
39
+ await self.set_value(
40
+ False,
41
+ deserialize=False,
42
+ validate=validate,
43
+ run_hook=run_hook,
44
+ save=save,
45
+ )
46
+
47
+ async def toggle(
48
+ self, save: bool = True, run_hook: bool = True, validate: bool = True
49
+ ) -> None:
50
+ await self.set_value(
51
+ not self.value,
52
+ deserialize=False,
53
+ validate=validate,
54
+ run_hook=run_hook,
55
+ save=save,
56
+ )
57
+
58
+ async def next_value(
59
+ self, save: bool = True, run_hook: bool = True, validate: bool = True
60
+ ) -> bool:
61
+ await self.toggle(save=save, run_hook=run_hook, validate=validate)
62
+ return self.value
@@ -0,0 +1,92 @@
1
+ from typing import Any, Generic, TypeVar
2
+ from types import MappingProxyType
3
+ from dataclasses import dataclass
4
+ from collections.abc import Mapping, Sequence
5
+
6
+ from typing_extensions import Self, Unpack
7
+
8
+ from .base import TypedParameter, _MutableParameterKwargs
9
+
10
+
11
+ T = TypeVar('T')
12
+
13
+
14
+ @dataclass(kw_only=True, frozen=True)
15
+ class Choice(Generic[T]):
16
+ id: str
17
+ name: str = ''
18
+ description: str = ''
19
+ value: T
20
+
21
+ def __post_init__(self) -> None:
22
+ if not self.id:
23
+ raise ValueError('Choice ID cannot be empty.')
24
+
25
+ if not isinstance(id, str):
26
+ raise TypeError('Choice ID must be a string.')
27
+
28
+ def __str__(self) -> str:
29
+ return self.id
30
+
31
+
32
+ def choice_serializer(node: 'ChoiceParameter[Any]', value: Choice[Any]) -> str:
33
+ return value.id
34
+
35
+
36
+ def choice_deserializer(node: 'ChoiceParameter[Any]', value: Any) -> Choice[Any]:
37
+ value = str(value)
38
+ return node.choices.get(value, node.choices[node.fallback_choice_id])
39
+
40
+
41
+ class ChoiceParameter(TypedParameter[Choice[T]], Generic[T]):
42
+ _DEFAULT_SERIALIZER = choice_serializer
43
+ _DEFAULT_DESERIALIZER = choice_deserializer
44
+ _VALUE_TYPE = Choice
45
+
46
+ def __init__(
47
+ self,
48
+ node_id: str,
49
+ *,
50
+ choices: Sequence[Choice[T]],
51
+ fallback_choice_id: str,
52
+ **kwargs: Unpack[_MutableParameterKwargs[Self, Choice[T]]],
53
+ ) -> None:
54
+ if not choices:
55
+ raise ValueError('At least 1 choice must be provided.')
56
+
57
+ self._choices: dict[str, Choice[T]] = {}
58
+ for i in choices:
59
+ if i.id in self._choices:
60
+ raise ValueError('Duplicate choice ID.') # todo
61
+ self._choices[i.id] = i
62
+
63
+ if fallback_choice_id not in self._choices:
64
+ raise ValueError('Fallback choice ID does not exists.')
65
+ self._fallback_choice_id = fallback_choice_id
66
+
67
+ super().__init__(node_id=node_id, **kwargs)
68
+
69
+ @property
70
+ def choices(self) -> Mapping[str, Choice[T]]:
71
+ return MappingProxyType(self._choices)
72
+
73
+ @property
74
+ def fallback_choice_id(self) -> str:
75
+ return self._fallback_choice_id
76
+
77
+ async def set_value(
78
+ self,
79
+ value: Choice[T] | str,
80
+ *,
81
+ deserialize: bool = True,
82
+ validate: bool = True,
83
+ run_hook: bool = True,
84
+ save: bool = True,
85
+ ) -> None:
86
+ choice = self.choices.get(value) if isinstance(value, str) else value
87
+ if choice not in self.choices.values():
88
+ raise ValueError('Invalid choice.')
89
+
90
+ await super().set_value(
91
+ choice, deserialize=deserialize, validate=validate, run_hook=run_hook, save=save
92
+ )
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ __all__ = [
5
+ 'float_serializer',
6
+ 'float_deserializer',
7
+ 'FloatParameter',
8
+ ]
9
+
10
+
11
+ from typing import Any
12
+
13
+ from .base import TypedParameter
14
+
15
+
16
+ def float_serializer(node: FloatParameter, value: float) -> float:
17
+ return value
18
+
19
+
20
+ def float_deserializer(node: FloatParameter, value: Any) -> float:
21
+ return float(value)
22
+
23
+
24
+ class FloatParameter(TypedParameter[float]):
25
+ _DEFAULT_SERIALIZER = staticmethod(float_serializer)
26
+ _DEFAULT_DESERIALIZER = staticmethod(float_deserializer)
27
+ _VALUE_TYPE = float
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ __all__ = [
5
+ 'int_serializer',
6
+ 'int_deserializer',
7
+ 'IntParameter',
8
+ ]
9
+
10
+
11
+ from typing import Any
12
+
13
+ from .base import TypedParameter
14
+
15
+
16
+ def int_serializer(node: IntParameter, value: int) -> int:
17
+ return value
18
+
19
+
20
+ def int_deserializer(node: IntParameter, value: Any) -> int:
21
+ return int(value)
22
+
23
+
24
+ class IntParameter(TypedParameter[int]):
25
+ _DEFAULT_SERIALIZER = staticmethod(int_serializer)
26
+ _DEFAULT_DESERIALIZER = staticmethod(int_deserializer)
27
+ _VALUE_TYPE = int
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Generic, TypeVar
5
+ from json import JSONDecodeError
6
+ from collections.abc import Callable, Iterable, Awaitable
7
+
8
+ from typing_extensions import Self, Unpack
9
+
10
+ from pyconfigtree.exceptions import DeserializationError
11
+
12
+ from .base import ALLOWED_TYPES, ParameterHookTypes, _TypedParameterKwargs
13
+
14
+
15
+ __all__ = [
16
+ 'list_serializer',
17
+ 'list_deserializer',
18
+ 'ListParameter',
19
+ ]
20
+
21
+
22
+ from typing import Any
23
+
24
+ from pyconfigtree.exceptions import SerializationError
25
+
26
+ from .base import TypedParameter
27
+
28
+
29
+ def list_serializer(value: list[Any]) -> list[ALLOWED_TYPES]:
30
+ result: list[ALLOWED_TYPES] = []
31
+ for i in value:
32
+ if isinstance(i, (int, str, float, bool)):
33
+ result.append(i)
34
+ elif isinstance(i, list):
35
+ result.append(list_serializer(i))
36
+ else:
37
+ raise SerializationError(f'Unable to serialize list item {i!r}.')
38
+ return result
39
+
40
+
41
+ def list_deserializer(value: Any) -> list[Any]:
42
+ if isinstance(value, str):
43
+ try:
44
+ value = json.loads(value)
45
+ except JSONDecodeError:
46
+ raise DeserializationError(f'Unable to convert string {value!r} to list.')
47
+
48
+ if isinstance(value, Iterable):
49
+ return [str(i) if not isinstance(i, (int, str, float, bool)) else i for i in value]
50
+
51
+ raise DeserializationError(f'Unable to convert {value!r} to list of strings.')
52
+
53
+
54
+ T = TypeVar('T')
55
+
56
+
57
+ class ListParameter(TypedParameter[list[T]], Generic[T]):
58
+ _DEFAULT_SERIALIZER = staticmethod(list_serializer)
59
+ _DEFAULT_DESERIALIZER = staticmethod(list_deserializer)
60
+ _VALUE_TYPE = list
61
+
62
+ def __init__(
63
+ self,
64
+ node_id: str,
65
+ *,
66
+ item_deserializer: Callable[[Any], T] | None = None,
67
+ add_item_validator: Callable[[T, Self], Awaitable[None]] | None = None,
68
+ remove_item_validator: Callable[[T, Self], Awaitable[None]] | None = None,
69
+ **kwargs: Unpack[_TypedParameterKwargs[Self, list[T]]],
70
+ ) -> None:
71
+ super().__init__(node_id=node_id, **kwargs)
72
+ self.item_deserializer = item_deserializer
73
+ self.add_item_validator = add_item_validator
74
+ self.remove_item_validator = remove_item_validator
75
+
76
+ async def add_item(
77
+ self,
78
+ item: T,
79
+ deserialize: bool = True,
80
+ validate: bool = True,
81
+ run_hook: bool = True,
82
+ save: bool = True,
83
+ ) -> None:
84
+ async with self._changing_lock:
85
+ if deserialize:
86
+ item = await self.deserialize_item(item)
87
+ if validate:
88
+ await self.add_item_validate(item)
89
+
90
+ self._value.append(item)
91
+ if save:
92
+ await self.save()
93
+
94
+ if run_hook:
95
+ await self.run_hook(ParameterHookTypes.PARAMETER_VALUE_CHANGED, self)
96
+
97
+ async def pop_item(
98
+ self, index: int, validate: bool = True, run_hook: bool = True, save: bool = True
99
+ ) -> T | None:
100
+ if index < 0 or index >= len(self.value):
101
+ return None
102
+
103
+ async with self._changing_lock:
104
+ if validate:
105
+ item = self._value[index]
106
+ await self.remove_item_validate(item)
107
+
108
+ result = self._value.pop(index)
109
+ if save:
110
+ await self.save()
111
+
112
+ if run_hook:
113
+ await self.run_hook(ParameterHookTypes.PARAMETER_VALUE_CHANGED, self)
114
+
115
+ return result
116
+
117
+ async def remove_item(
118
+ self, item: T, validate: bool = True, run_hook: bool = True, save: bool = True
119
+ ) -> None:
120
+ if item not in self._value:
121
+ return
122
+
123
+ async with self._changing_lock:
124
+ if validate:
125
+ await self.remove_item_validate(item)
126
+
127
+ self._value.remove(item)
128
+ if save:
129
+ await self.save()
130
+
131
+ if run_hook:
132
+ await self.run_hook(ParameterHookTypes.PARAMETER_VALUE_CHANGED, self)
133
+
134
+ async def add_item_validate(self, item: T) -> None:
135
+ if self.add_item_validator is not None:
136
+ await self.add_item_validator(item, self)
137
+
138
+ async def remove_item_validate(self, item: T) -> None:
139
+ if self.remove_item_validator is not None:
140
+ await self.remove_item_validator(item, self)
141
+
142
+ def deserialize_item(self, item: Any) -> T:
143
+ return item if self.item_deserializer is None else self.item_deserializer(item)
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ __all__ = [
5
+ 'str_serializer',
6
+ 'str_deserializer',
7
+ 'StringParameter',
8
+ ]
9
+
10
+
11
+ from typing import Any
12
+
13
+ from .base import TypedParameter
14
+
15
+
16
+ def str_serializer(node: StringParameter, value: str) -> str:
17
+ return value
18
+
19
+
20
+ def str_deserializer(node: StringParameter, value: Any) -> str:
21
+ return str(value)
22
+
23
+
24
+ class StringParameter(TypedParameter[str]):
25
+ _DEFAULT_SERIALIZER = staticmethod(str_serializer)
26
+ _DEFAULT_DESERIALIZER = staticmethod(str_deserializer)
27
+ _VALUE_TYPE = str
pyconfigtree/py.typed ADDED
File without changes
@@ -0,0 +1,2 @@
1
+ from .base import ConfigSource as ConfigSource
2
+ from .json import JSONSource as JSONSource
@@ -0,0 +1,42 @@
1
+ from typing import Any, Union, TypeAlias
2
+ from abc import ABC, abstractmethod
3
+ from enum import Enum, auto
4
+ from dataclasses import field, dataclass
5
+
6
+
7
+ SIMPLE_TYPES: TypeAlias = int | float | bool | str
8
+ CONTAINER_TYPES: TypeAlias = (
9
+ dict[str, Union[SIMPLE_TYPES, 'CONTAINER_TYPES']]
10
+ | list[Union[SIMPLE_TYPES, 'CONTAINER_TYPES']]
11
+ )
12
+ ALLOWED_TYPES: TypeAlias = SIMPLE_TYPES | CONTAINER_TYPES
13
+
14
+
15
+ class ConfigSource(ABC):
16
+ @abstractmethod
17
+ async def load(self) -> dict[str, Any]: ...
18
+
19
+ @abstractmethod
20
+ async def save(self, data: 'NodeInfo') -> None: ...
21
+
22
+ @abstractmethod
23
+ def __eq__(self, other: Any) -> bool: ...
24
+
25
+
26
+ class NodeType(Enum):
27
+ CONTAINER = auto()
28
+ LEAF = auto()
29
+
30
+
31
+ @dataclass
32
+ class NodeInfo:
33
+ id: str
34
+ name: str
35
+ description: str
36
+ type: NodeType
37
+ subnodes: dict[str, 'NodeInfo'] = field(default_factory=dict)
38
+ value: ALLOWED_TYPES | None = None
39
+
40
+ def __post_init__(self) -> None:
41
+ if self.type is NodeType.CONTAINER and self.value is not None:
42
+ raise ValueError(f'Node of type {self.type} cannot contain value.')
@@ -0,0 +1,41 @@
1
+ import json
2
+ from typing import Any
3
+ from pathlib import Path
4
+
5
+ from .base import NodeInfo, NodeType, ConfigSource
6
+
7
+
8
+ class JSONSource(ConfigSource):
9
+ def __init__(self, path: str | Path, encoding: str = 'utf-8') -> None:
10
+ self._path = Path(path)
11
+ self._encoding = encoding
12
+
13
+ async def load(self) -> dict[str, Any]:
14
+ with open(self._path, 'r', encoding=self.encoding) as f:
15
+ return json.load(f) # type: ignore
16
+
17
+ async def save(self, data: NodeInfo) -> None:
18
+ dicted = self.node_info_to_dict(data)
19
+ with open(self._path, 'w', encoding=self.encoding) as f:
20
+ json.dump(dicted, f, indent=4)
21
+ return
22
+
23
+ def node_info_to_dict(self, node: NodeInfo) -> Any:
24
+ if node.type is NodeType.LEAF:
25
+ return node.value
26
+ return {k: self.node_info_to_dict(v) for k, v in node.subnodes.items()}
27
+
28
+ @property
29
+ def path(self) -> Path:
30
+ return self._path
31
+
32
+ @property
33
+ def encoding(self) -> str:
34
+ return self._encoding
35
+
36
+ def __eq__(self, other: Any) -> bool:
37
+ if isinstance(other, JSONSource):
38
+ return self.path == other.path
39
+ if isinstance(other, (str, Path)):
40
+ return self.path == Path(other)
41
+ return False
@@ -0,0 +1,43 @@
1
+ from typing import Any
2
+ from pathlib import Path
3
+
4
+ import tomli_w
5
+ import tomllib # type: ignore
6
+
7
+ from .base import NodeInfo, NodeType, ConfigSource
8
+
9
+
10
+ class TOMLSource(ConfigSource):
11
+ def __init__(self, path: str | Path, encoding: str = 'utf-8') -> None:
12
+ self._path = Path(path)
13
+ self._encoding = encoding
14
+
15
+ async def load(self) -> dict[str, Any]:
16
+ with open(self._path, 'rb') as f:
17
+ return tomllib.load(f) # type: ignore
18
+
19
+ async def save(self, data: NodeInfo) -> None:
20
+ dicted = self.node_info_to_dict(data)
21
+ with open(self._path, 'w', encoding='utf-8') as f:
22
+ f.write(tomli_w.dumps(dicted, multiline_strings=True, indent=4))
23
+ return
24
+
25
+ def node_info_to_dict(self, node: NodeInfo) -> Any:
26
+ if node.type is NodeType.LEAF:
27
+ return node.value
28
+ return {k: self.node_info_to_dict(v) for k, v in node.subnodes.items()}
29
+
30
+ @property
31
+ def path(self) -> Path:
32
+ return self._path
33
+
34
+ @property
35
+ def encoding(self) -> str:
36
+ return self._encoding
37
+
38
+ def __eq__(self, other: Any) -> bool:
39
+ if isinstance(other, TOMLSource):
40
+ return self.path == other.path
41
+ if isinstance(other, (str, Path)):
42
+ return self.path == Path(other)
43
+ return False
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyconfigtree
3
+ Version: 0.1.0
4
+ Summary: A lightweight yet powerful mini-framework for building hierarchical, tree-structured configs.
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: typing-extensions>=4.15.0
7
+ Provides-Extra: toml
8
+ Requires-Dist: tomli-w>=1.0.0; extra == 'toml'
@@ -0,0 +1,19 @@
1
+ pyconfigtree/__init__.py,sha256=TFRM-hfFZvku9kXrzzHJs8cUXDn6kB2SbodICyhKv48,78
2
+ pyconfigtree/base.py,sha256=1gQG7V803Omw-iAXPuq7IiwQZ0A7qwCBTILx2wUvtIU,8955
3
+ pyconfigtree/exceptions.py,sha256=6WrZE6y9_TsgpTb2DPMC-jEv9r9O0z9GmxUUlkPPhXQ,588
4
+ pyconfigtree/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ pyconfigtree/parameter/__init__.py,sha256=kHEB8nrbMjsQh-zFk5c11Js0Ko3KZeLw44HEK-Hk5Pc,381
6
+ pyconfigtree/parameter/base.py,sha256=lHVjdApfr1kiU8WkcLIg3epecVqWkFjSXziSrntl2R8,8237
7
+ pyconfigtree/parameter/bool_parameter.py,sha256=t9piL_6w_FnXG46KoYFRkOxbwTwzUHYWpjou7GwyyIk,1605
8
+ pyconfigtree/parameter/choice_parameter.py,sha256=YFlawqSRjmjAkpXGEVvxq66dEXsFeilX3v1O39hx0Hg,2644
9
+ pyconfigtree/parameter/float_parameter.py,sha256=WRx-rY2426U9IFoliSOJ9TZ-Regeg9lCglqHgbDYkvM,550
10
+ pyconfigtree/parameter/int_parameter.py,sha256=6UQgFLF6Wcali3TcS60LYaoaN2pvrYMtFWIphU46S34,518
11
+ pyconfigtree/parameter/list_parameter.py,sha256=kZJA0CNq6j2UK92QxkxJQ5iR6sAsgJsAQfOcsCoIAN8,4365
12
+ pyconfigtree/parameter/string_parameter.py,sha256=5uR_RfXm3O3hl-voocM6Pha97TyxuQ96FhzCbyoxHiM,530
13
+ pyconfigtree/source/__init__.py,sha256=7O2UoHfcjlQZKeQkJzxXdh73ovkj2et_yQoqqvndqTc,90
14
+ pyconfigtree/source/base.py,sha256=cSelegHJUkTAGnq1zxLCbkq0OPih4d-HrQ95J6aot0o,1088
15
+ pyconfigtree/source/json.py,sha256=gkOgDid4M7rClD6NhORedtRN79KS0RSPWNHUin9JTl4,1252
16
+ pyconfigtree/source/toml.py,sha256=WlgMvXc8pJfBUNcneA7609ryJ1BVTiT_mIp-cz5MgRI,1295
17
+ pyconfigtree-0.1.0.dist-info/METADATA,sha256=AqmWUG8tXPRW6GC8ugzFqpHJNUrU7gxCSeRQbxpmM-w,292
18
+ pyconfigtree-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
19
+ pyconfigtree-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any