Skip to content

Commit 976262e

Browse files
committed
Merge branch 'staging' of https://github.com/ArachnidAbby/ComprehensiveConfig into staging
2 parents ecd0a41 + 6d66ccd commit 976262e

10 files changed

Lines changed: 392 additions & 73 deletions

File tree

comprehensiveconfig/json.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
from datetime import datetime
12
import json
3+
from typing import Any
24
from . import configio
35
from . import spec
46

@@ -13,10 +15,14 @@ def dump_section(cls, node: spec.Section):
1315

1416
@classmethod
1517
def dump_value(cls, node: spec.AnyConfigField, value):
18+
"""convert value into json_serializable object that can be used as a key or value"""
1619
match node:
17-
case type() | spec.Section():
20+
case spec.ConfigurationFieldABCMeta() | spec.Section():
1821
return cls.dump_section(value)
19-
case spec.Table(_, type() | spec.Section() | spec.ConfigUnion()):
22+
case spec.Table(
23+
_,
24+
spec.ConfigurationFieldABCMeta() | spec.Section() | spec.ConfigUnion(),
25+
):
2026
return {
2127
cls.dump_value(key, key): cls.dump_value(val, val)
2228
for key, val in value.items()
@@ -25,14 +31,28 @@ def dump_value(cls, node: spec.AnyConfigField, value):
2531
return value.name
2632
case spec.ConfigEnum(_, False):
2733
return value.value
28-
case _:
34+
case str() | int() | float() | datetime() | dict() | None:
2935
return value
36+
case _:
37+
# magic method to make writing new field types possible
38+
if hasattr(node, "__write_json_value__"):
39+
return value.__write_json_value__(
40+
node, value
41+
) # return a json serializable object
3042

3143
@classmethod
3244
def dumps(cls, node) -> str:
3345
match node:
3446
case spec.Section():
3547
return json.dumps(cls.dump_section(node), indent=4)
48+
case spec.ConfigurationField():
49+
if not node._has_default:
50+
raise ValueError("Field has no default value")
51+
52+
dumped_value = cls.dump_value(node, node._default_value)
53+
if isinstance(dumped_value, dict):
54+
return json.dumps(dumped_value)
55+
return str(dumped_value)
3656
case _:
3757
raise ValueError(node)
3858

@@ -47,5 +67,6 @@ def load(cls, file):
4767
return json.load(f)
4868
return json.load(file)
4969

50-
# just alias the name
51-
loads = json.loads
70+
@classmethod
71+
def loads(cls, data: str) -> dict[str, Any]:
72+
return json.loads(data)

comprehensiveconfig/spec.py

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import re
44
from types import UnionType
55
import types
6-
from typing import Any, Self, Type, Union
6+
from typing import Any, Protocol, Self, Type, Union
77
import typing
88

99

@@ -51,7 +51,7 @@ class BaseConfigurationField(ABC):
5151

5252
__slots__ = ("_field_variable", "_parent", "_value")
5353

54-
_parent: Type[Self] | None
54+
_parent: Type["BaseConfigurationField"] | None
5555
"""The parent to this node"""
5656

5757
_field_variable: None | str
@@ -177,7 +177,7 @@ class Section(BaseConfigurationField, metaclass=ConfigurationFieldABCMeta):
177177

178178
__slots__ = "_value"
179179

180-
_FIELDS: dict[str, AnyConfigField]
180+
_FIELDS: dict[str, ConfigurationField]
181181
_SECTIONS: dict[str, Type]
182182
_ALL_FIELDS: dict[str, AnyConfigField | Type]
183183
_FIELD_NAME_MAP: dict[str, str]
@@ -772,6 +772,69 @@ def _validate_value(self, value: Any, name: str | None = None, /):
772772
super()._validate_value(self.get_value(value), name)
773773

774774

775+
class ConfigObjectType[T](Protocol):
776+
"""A protocol to define necessary methods for a ConfigObject field's type"""
777+
778+
@classmethod
779+
def from_config(cls, config_value: Any) -> T:
780+
"""A constructor for this object if the value we are using comes from configuration"""
781+
...
782+
783+
784+
class ConfigObject[T: ConfigObjectType](ConfigurationField):
785+
"""A custom object field allowing you to write arbitrary objects that are supported by the writer you are using.
786+
This can also be used for objects that implement writer-specific magic-methods.
787+
788+
These include:
789+
- `__write_toml_value__(field, value) -> str` (writing a regular toml-parsable value as a string)
790+
- `__write_toml_full__(field, value) -> str` (Directly write line(s) of toml when encountering this object)
791+
- `__write_json_value__(field, value) -> int | float | datetime | str | None` \
792+
(When encountering this object- convert it to a json serializable format)
793+
"""
794+
795+
__slots__ = "_type"
796+
__match_args__ = ("_type", "_by_name")
797+
798+
_holds: T
799+
800+
_type: Type[T]
801+
"""The object type"""
802+
803+
def __init__(
804+
self,
805+
_type: Type[T],
806+
default_value: T | _NoDefaultValueT = NoDefaultValue,
807+
/,
808+
*args,
809+
**kwargs,
810+
):
811+
self._type = _type
812+
return super().__init__(default_value, *args, **kwargs)
813+
814+
def get_value(self, value: Any):
815+
if isinstance(value, self._type):
816+
return value
817+
return self.__call__(value)
818+
819+
def __call__(self, value: Any):
820+
if isinstance(value, self._type):
821+
return value
822+
return self._type.from_config(value)
823+
824+
def __get__(self, instance, owner) -> T:
825+
return super().__get__(instance, owner)
826+
827+
def __set__(self, instance, value: T | Any):
828+
if isinstance(value, self._type):
829+
return super().__set__(instance, value)
830+
super().__set__(instance, self.get_value(value))
831+
832+
def _validate_value(self, value: Any, name: str | None = None, /):
833+
if isinstance(value, self._type):
834+
super()._validate_value(value, name)
835+
super()._validate_value(self.get_value(value), name)
836+
837+
775838
__all__ = [
776839
"ConfigurationField",
777840
"NoDefaultValue",
@@ -785,4 +848,5 @@ def _validate_value(self, value: Any, name: str | None = None, /):
785848
"TableSpec",
786849
"List",
787850
"ConfigEnum",
851+
"ConfigObject",
788852
]

comprehensiveconfig/toml.py

Lines changed: 99 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,13 @@ def full_section_name(node) -> list[str]:
3232

3333
class TomlWriter(configio.ConfigurationWriter):
3434
@classmethod
35-
def dump_section(cls, node) -> list:
35+
def dump_section(cls, node) -> list[str]:
36+
"""Dump a spec.Section node and return a list of output lines."""
3637
if " " in node._name:
3738
raise ValueError(node._name)
3839

40+
# "base" is all the junk/lines at the beginning of our section.
41+
3942
if node._parent is not None:
4043
base = [f"\n[{'.'.join(full_section_name(node)[1:])}]"]
4144
else:
@@ -45,108 +48,139 @@ def dump_section(cls, node) -> list:
4548
for line in node.__doc__.split("\n"):
4649
base.append(f"# {line}")
4750

48-
sorted_values: dict[int, dict[str, Any]] = {}
51+
sorted_values: dict[int, dict[spec.ConfigurationField, Any]] = {}
4952
for name, value in node._value.items():
5053
field = node._ALL_FIELDS[name]
5154
if field._sorting_order not in sorted_values.keys():
52-
sorted_values[field._sorting_order] = {name: value}
53-
continue
54-
sorted_values[field._sorting_order][name] = value
55+
sorted_values[field._sorting_order] = {}
56+
sorted_values[field._sorting_order][field] = value
5557

5658
return [
57-
*base,
58-
*(
59+
*base, # dump all base lines
60+
*( # append all of these dumped fields as lines
5961
(
6062
"\n".join(cls.dump_section(value))
6163
if isinstance(value, spec.Section)
62-
else cls.dump_field(node, name, node._FIELD_VAR_MAP[name], value)
64+
else cls.dump_field(field, value)
6365
)
6466
for sub_dict in sorted_values.values()
65-
for name, value in sub_dict.items()
67+
for field, value in sub_dict.items()
6668
),
6769
]
6870

6971
@classmethod
70-
def format_value(cls, value) -> str:
72+
def format_value(cls, field, value) -> str:
73+
"""Format individual values into properly represented strings of valid toml values."""
7174
match value:
7275
case int() | float():
7376
return str(value)
7477
case str():
7578
return f'"{escape(value)}"'
7679
case list():
77-
return f"[{", ".join([str(cls.format_value(inner_val)) for inner_val in value])}]"
80+
return f"[{", ".join([str(cls.format_value(field, inner_val)) for inner_val in value])}]"
7881
case dict():
79-
return f"{{ {", ".join([f"{key} = {cls.format_value(inner_val)}" for key, inner_val in value.items()])} }}"
82+
return f"{{ {", ".join([f"{key} = {cls.format_value(field, inner_val)}" for key, inner_val in value.items()])} }}"
8083
case enum.Enum():
81-
return f"{cls.format_value(value.value)}"
84+
return f"{cls.format_value(field, value.value)}"
8285
case _:
86+
# magic method to make writing new field types possible
87+
if hasattr(value, "__write_toml_value__"):
88+
return str(value.__write_toml_value__(field, value))
89+
# No known way exists to write this field:
8390
raise ValueError(value)
8491

8592
@classmethod
86-
def dump_field(
87-
cls, node: spec.AnyConfigField, original_name: str, field_name: str, value
88-
) -> str:
89-
if isinstance(node, spec.Section):
90-
field = node.get_field(original_name)
93+
def dump_table(cls, table_node: spec.Table, value) -> str:
94+
for name, val in value.items():
95+
if not isinstance(val, spec.Section):
96+
continue
97+
val._name = name
98+
val._parent = table_node
99+
100+
section_name = ".".join(full_section_name(table_node)[1:])
101+
102+
return f"\n[{section_name}]\n{"\n".join(cls.dumps(val) for key, val in value.items())}"
103+
104+
@classmethod
105+
def create_basic_field_doc(cls, field: spec.ConfigurationField) -> str:
106+
"""generates our basic field_doc"""
107+
if field._inline_doc and field.doc:
108+
doc_comment = " "
109+
elif field.doc:
110+
doc_comment = "\n"
91111
else:
92-
field = node
93-
match field:
94-
case spec.Table(spec.Text(), type() | spec.ConfigUnion()) as table_node:
95-
for name, val in value.items():
96-
if not isinstance(val, spec.Section):
97-
continue
98-
val._name = name
99-
val._parent = table_node
112+
return ""
113+
114+
return doc_comment + f"# {"\n# ".join(field.doc.split("\n"))}"
115+
116+
@classmethod
117+
def dump_enum(cls, field: spec.ConfigEnum, value):
118+
if isinstance(value, spec.Section):
119+
return "\n".join(cls.dump_section(value))
120+
121+
by_name = field._by_name
122+
field_doc = " " if field._inline_doc and field.doc else "\n"
123+
124+
if field.doc:
125+
field_doc += f"# {"\n# ".join(field.doc.split("\n"))}"
100126

101-
section_name = ".".join(full_section_name(table_node)[1:])
127+
if field._enum.__doc__:
128+
delimeter = "\n## - "
129+
doc_comment = f"# {"\n# ".join(field._enum.__doc__.split("\n"))}\n#"
130+
else:
131+
delimeter = "\n# - "
132+
doc_comment = ""
133+
134+
doc_comment += f"# Available Options for {field._name}:{delimeter}"
135+
if by_name:
136+
doc_comment += delimeter.join(
137+
member for member in field._enum.__members__.keys()
138+
)
139+
return f"{field._name} = {cls.format_value(field, value.name)}{field_doc}\n{doc_comment}"
140+
doc_comment += delimeter.join(
141+
str(member.value) for member in field._enum.__members__.values()
142+
)
143+
return f"{field._name} = {cls.format_value(field, value.value)}{field_doc}\n{doc_comment}"
102144

103-
return f"\n[{section_name}]\n{"\n".join(cls.dumps(val) if isinstance(val, spec.Section) else cls.dump_field(val, key, key, val) for key, val in value.items())}"
145+
@classmethod
146+
def dump_field(cls, field: spec.AnyConfigField, value) -> str:
147+
"""dump a field object given its value"""
148+
match field:
149+
case spec.Table(spec.Text(), type() | spec.ConfigUnion()) as table_node:
150+
return cls.dump_table(table_node, value)
104151
case spec.Section():
105-
return "\n".join(cls.dump_section(node))
152+
return "\n".join(cls.dump_section(field))
106153
case spec.ConfigEnum(_, by_name):
107-
if isinstance(value, spec.Section):
108-
return "\n".join(cls.dump_section(value))
154+
return cls.dump_enum(field, value)
155+
case spec.ConfigurationField():
156+
# magic method to make writing new field types possible
157+
if hasattr(field, "__write_toml_full__"):
158+
return str(value.__write_toml_full__(field, value))
109159

110-
field_doc = " " if field._inline_doc and field.doc else "\n"
111-
112-
if field.doc:
113-
field_doc += f"# {"\n# ".join(field.doc.split("\n"))}"
114-
115-
if field._enum.__doc__:
116-
delimeter = "\n## - "
117-
doc_comment = f"# {"\n# ".join(field._enum.__doc__.split("\n"))}\n#"
118-
else:
119-
delimeter = "\n# - "
120-
doc_comment = ""
121-
122-
doc_comment += f"# Available Options for {field_name}:{delimeter}"
123-
if by_name:
124-
doc_comment += delimeter.join(
125-
member for member in field._enum.__members__.keys()
126-
)
127-
return f"{field_name} = {cls.format_value(value.name)}{field_doc}\n{doc_comment}"
128-
doc_comment += delimeter.join(
129-
str(member.value) for member in field._enum.__members__.values()
130-
)
131-
return f"{field_name} = {cls.format_value(value.value)}{field_doc}\n{doc_comment}"
132-
case _:
133160
if isinstance(value, spec.Section):
134161
return "\n".join(cls.dump_section(value))
135-
real_field = node._ALL_FIELDS[original_name]
136-
doc_comment = (
137-
" " if real_field._inline_doc and real_field.doc else "\n"
138-
)
139162

140-
if real_field.doc:
141-
doc_comment += f"# {"\n# ".join(real_field.doc.split("\n"))}"
142-
return f"{field_name} = {cls.format_value(value)}{doc_comment}"
163+
return f"{field._name} = {cls.format_value(field, value)}{cls.create_basic_field_doc(field)}"
164+
case _:
165+
# magic method to make writing new field types possible
166+
if hasattr(field, "__write_toml_full__"):
167+
return str(value.__write_toml_full__(field, value))
168+
# No known way exists to write this field:
169+
raise ValueError(field)
143170

144171
@classmethod
145172
def dumps(cls, node) -> str:
146173
match node:
147174
case spec.Section():
148175
return "\n".join(cls.dump_section(node))
149-
176+
case spec.ConfigurationField():
177+
# Dump passed in node to the best of our ability. This typically looks like dumping its default value
178+
if not node._has_default:
179+
raise ValueError("Node does not have a default value")
180+
return cls.dump_field(
181+
node,
182+
node._default_value,
183+
)
150184
case _:
151185
raise ValueError(node)
152186

@@ -161,5 +195,6 @@ def load(cls, file):
161195
return tomllib.load(f)
162196
return tomllib.load(file)
163197

164-
# just alias the name
165-
loads = tomllib.loads
198+
@classmethod
199+
def loads(cls, data: str) -> dict[str, Any]:
200+
return tomllib.loads(data)

0 commit comments

Comments
 (0)