Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions comprehensiveconfig/json.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from datetime import datetime
import json
from typing import Any
from . import configio
from . import spec

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

@classmethod
def dump_value(cls, node: spec.AnyConfigField, value):
"""convert value into json_serializable object that can be used as a key or value"""
match node:
case type() | spec.Section():
case spec.ConfigurationFieldABCMeta() | spec.Section():
return cls.dump_section(value)
case spec.Table(_, type() | spec.Section() | spec.ConfigUnion()):
case spec.Table(
_,
spec.ConfigurationFieldABCMeta() | spec.Section() | spec.ConfigUnion(),
):
return {
cls.dump_value(key, key): cls.dump_value(val, val)
for key, val in value.items()
Expand All @@ -25,14 +31,28 @@ def dump_value(cls, node: spec.AnyConfigField, value):
return value.name
case spec.ConfigEnum(_, False):
return value.value
case _:
case str() | int() | float() | datetime() | dict() | None:
return value
case _:
# magic method to make writing new field types possible
if hasattr(node, "__write_json_value__"):
return value.__write_json_value__(
node, value
) # return a json serializable object

@classmethod
def dumps(cls, node) -> str:
match node:
case spec.Section():
return json.dumps(cls.dump_section(node), indent=4)
case spec.ConfigurationField():
if not node._has_default:
raise ValueError("Field has no default value")

dumped_value = cls.dump_value(node, node._default_value)
if isinstance(dumped_value, dict):
return json.dumps(dumped_value)
return str(dumped_value)
case _:
raise ValueError(node)

Expand All @@ -47,5 +67,6 @@ def load(cls, file):
return json.load(f)
return json.load(file)

# just alias the name
loads = json.loads
@classmethod
def loads(cls, data: str) -> dict[str, Any]:
return json.loads(data)
70 changes: 67 additions & 3 deletions comprehensiveconfig/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import re
from types import UnionType
import types
from typing import Any, Self, Type, Union
from typing import Any, Protocol, Self, Type, Union
import typing


Expand Down Expand Up @@ -51,7 +51,7 @@ class BaseConfigurationField(ABC):

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

_parent: Type[Self] | None
_parent: Type["BaseConfigurationField"] | None
"""The parent to this node"""

_field_variable: None | str
Expand Down Expand Up @@ -177,7 +177,7 @@ class Section(BaseConfigurationField, metaclass=ConfigurationFieldABCMeta):

__slots__ = "_value"

_FIELDS: dict[str, AnyConfigField]
_FIELDS: dict[str, ConfigurationField]
_SECTIONS: dict[str, Type]
_ALL_FIELDS: dict[str, AnyConfigField | Type]
_FIELD_NAME_MAP: dict[str, str]
Expand Down Expand Up @@ -772,6 +772,69 @@ def _validate_value(self, value: Any, name: str | None = None, /):
super()._validate_value(self.get_value(value), name)


class ConfigObjectType[T](Protocol):
"""A protocol to define necessary methods for a ConfigObject field's type"""

@classmethod
def from_config(cls, config_value: Any) -> T:
"""A constructor for this object if the value we are using comes from configuration"""
...


class ConfigObject[T: ConfigObjectType](ConfigurationField):
"""A custom object field allowing you to write arbitrary objects that are supported by the writer you are using.
This can also be used for objects that implement writer-specific magic-methods.

These include:
- `__write_toml_value__(field, value) -> str` (writing a regular toml-parsable value as a string)
- `__write_toml_full__(field, value) -> str` (Directly write line(s) of toml when encountering this object)
- `__write_json_value__(field, value) -> int | float | datetime | str | None` \
(When encountering this object- convert it to a json serializable format)
"""

__slots__ = "_type"
__match_args__ = ("_type", "_by_name")

_holds: T

_type: Type[T]
"""The object type"""

def __init__(
self,
_type: Type[T],
default_value: T | _NoDefaultValueT = NoDefaultValue,
/,
*args,
**kwargs,
):
self._type = _type
return super().__init__(default_value, *args, **kwargs)

def get_value(self, value: Any):
if isinstance(value, self._type):
return value
return self.__call__(value)

def __call__(self, value: Any):
if isinstance(value, self._type):
return value
return self._type.from_config(value)

def __get__(self, instance, owner) -> T:
return super().__get__(instance, owner)

def __set__(self, instance, value: T | Any):
if isinstance(value, self._type):
return super().__set__(instance, value)
super().__set__(instance, self.get_value(value))

def _validate_value(self, value: Any, name: str | None = None, /):
if isinstance(value, self._type):
super()._validate_value(value, name)
super()._validate_value(self.get_value(value), name)


__all__ = [
"ConfigurationField",
"NoDefaultValue",
Expand All @@ -785,4 +848,5 @@ def _validate_value(self, value: Any, name: str | None = None, /):
"TableSpec",
"List",
"ConfigEnum",
"ConfigObject",
]
163 changes: 99 additions & 64 deletions comprehensiveconfig/toml.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@ def full_section_name(node) -> list[str]:

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

# "base" is all the junk/lines at the beginning of our section.

if node._parent is not None:
base = [f"\n[{'.'.join(full_section_name(node)[1:])}]"]
else:
Expand All @@ -45,108 +48,139 @@ def dump_section(cls, node) -> list:
for line in node.__doc__.split("\n"):
base.append(f"# {line}")

sorted_values: dict[int, dict[str, Any]] = {}
sorted_values: dict[int, dict[spec.ConfigurationField, Any]] = {}
for name, value in node._value.items():
field = node._ALL_FIELDS[name]
if field._sorting_order not in sorted_values.keys():
sorted_values[field._sorting_order] = {name: value}
continue
sorted_values[field._sorting_order][name] = value
sorted_values[field._sorting_order] = {}
sorted_values[field._sorting_order][field] = value

return [
*base,
*(
*base, # dump all base lines
*( # append all of these dumped fields as lines
(
"\n".join(cls.dump_section(value))
if isinstance(value, spec.Section)
else cls.dump_field(node, name, node._FIELD_VAR_MAP[name], value)
else cls.dump_field(field, value)
)
for sub_dict in sorted_values.values()
for name, value in sub_dict.items()
for field, value in sub_dict.items()
),
]

@classmethod
def format_value(cls, value) -> str:
def format_value(cls, field, value) -> str:
"""Format individual values into properly represented strings of valid toml values."""
match value:
case int() | float():
return str(value)
case str():
return f'"{escape(value)}"'
case list():
return f"[{", ".join([str(cls.format_value(inner_val)) for inner_val in value])}]"
return f"[{", ".join([str(cls.format_value(field, inner_val)) for inner_val in value])}]"
case dict():
return f"{{ {", ".join([f"{key} = {cls.format_value(inner_val)}" for key, inner_val in value.items()])} }}"
return f"{{ {", ".join([f"{key} = {cls.format_value(field, inner_val)}" for key, inner_val in value.items()])} }}"
case enum.Enum():
return f"{cls.format_value(value.value)}"
return f"{cls.format_value(field, value.value)}"
case _:
# magic method to make writing new field types possible
if hasattr(value, "__write_toml_value__"):
return str(value.__write_toml_value__(field, value))
# No known way exists to write this field:
raise ValueError(value)

@classmethod
def dump_field(
cls, node: spec.AnyConfigField, original_name: str, field_name: str, value
) -> str:
if isinstance(node, spec.Section):
field = node.get_field(original_name)
def dump_table(cls, table_node: spec.Table, value) -> str:
for name, val in value.items():
if not isinstance(val, spec.Section):
continue
val._name = name
val._parent = table_node

section_name = ".".join(full_section_name(table_node)[1:])

return f"\n[{section_name}]\n{"\n".join(cls.dumps(val) for key, val in value.items())}"

@classmethod
def create_basic_field_doc(cls, field: spec.ConfigurationField) -> str:
"""generates our basic field_doc"""
if field._inline_doc and field.doc:
doc_comment = " "
elif field.doc:
doc_comment = "\n"
else:
field = node
match field:
case spec.Table(spec.Text(), type() | spec.ConfigUnion()) as table_node:
for name, val in value.items():
if not isinstance(val, spec.Section):
continue
val._name = name
val._parent = table_node
return ""

return doc_comment + f"# {"\n# ".join(field.doc.split("\n"))}"

@classmethod
def dump_enum(cls, field: spec.ConfigEnum, value):
if isinstance(value, spec.Section):
return "\n".join(cls.dump_section(value))

by_name = field._by_name
field_doc = " " if field._inline_doc and field.doc else "\n"

if field.doc:
field_doc += f"# {"\n# ".join(field.doc.split("\n"))}"

section_name = ".".join(full_section_name(table_node)[1:])
if field._enum.__doc__:
delimeter = "\n## - "
doc_comment = f"# {"\n# ".join(field._enum.__doc__.split("\n"))}\n#"
else:
delimeter = "\n# - "
doc_comment = ""

doc_comment += f"# Available Options for {field._name}:{delimeter}"
if by_name:
doc_comment += delimeter.join(
member for member in field._enum.__members__.keys()
)
return f"{field._name} = {cls.format_value(field, value.name)}{field_doc}\n{doc_comment}"
doc_comment += delimeter.join(
str(member.value) for member in field._enum.__members__.values()
)
return f"{field._name} = {cls.format_value(field, value.value)}{field_doc}\n{doc_comment}"

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())}"
@classmethod
def dump_field(cls, field: spec.AnyConfigField, value) -> str:
"""dump a field object given its value"""
match field:
case spec.Table(spec.Text(), type() | spec.ConfigUnion()) as table_node:
return cls.dump_table(table_node, value)
case spec.Section():
return "\n".join(cls.dump_section(node))
return "\n".join(cls.dump_section(field))
case spec.ConfigEnum(_, by_name):
if isinstance(value, spec.Section):
return "\n".join(cls.dump_section(value))
return cls.dump_enum(field, value)
case spec.ConfigurationField():
# magic method to make writing new field types possible
if hasattr(field, "__write_toml_full__"):
return str(value.__write_toml_full__(field, value))

field_doc = " " if field._inline_doc and field.doc else "\n"

if field.doc:
field_doc += f"# {"\n# ".join(field.doc.split("\n"))}"

if field._enum.__doc__:
delimeter = "\n## - "
doc_comment = f"# {"\n# ".join(field._enum.__doc__.split("\n"))}\n#"
else:
delimeter = "\n# - "
doc_comment = ""

doc_comment += f"# Available Options for {field_name}:{delimeter}"
if by_name:
doc_comment += delimeter.join(
member for member in field._enum.__members__.keys()
)
return f"{field_name} = {cls.format_value(value.name)}{field_doc}\n{doc_comment}"
doc_comment += delimeter.join(
str(member.value) for member in field._enum.__members__.values()
)
return f"{field_name} = {cls.format_value(value.value)}{field_doc}\n{doc_comment}"
case _:
if isinstance(value, spec.Section):
return "\n".join(cls.dump_section(value))
real_field = node._ALL_FIELDS[original_name]
doc_comment = (
" " if real_field._inline_doc and real_field.doc else "\n"
)

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

@classmethod
def dumps(cls, node) -> str:
match node:
case spec.Section():
return "\n".join(cls.dump_section(node))

case spec.ConfigurationField():
# Dump passed in node to the best of our ability. This typically looks like dumping its default value
if not node._has_default:
raise ValueError("Node does not have a default value")
return cls.dump_field(
node,
node._default_value,
)
case _:
raise ValueError(node)

Expand All @@ -161,5 +195,6 @@ def load(cls, file):
return tomllib.load(f)
return tomllib.load(file)

# just alias the name
loads = tomllib.loads
@classmethod
def loads(cls, data: str) -> dict[str, Any]:
return tomllib.loads(data)
Loading