diff --git a/comprehensiveconfig/json.py b/comprehensiveconfig/json.py index 2a372e8..e061341 100644 --- a/comprehensiveconfig/json.py +++ b/comprehensiveconfig/json.py @@ -1,4 +1,6 @@ +from datetime import datetime import json +from typing import Any from . import configio from . import spec @@ -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() @@ -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) @@ -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) diff --git a/comprehensiveconfig/spec.py b/comprehensiveconfig/spec.py index 5575751..eb04311 100644 --- a/comprehensiveconfig/spec.py +++ b/comprehensiveconfig/spec.py @@ -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 @@ -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 @@ -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] @@ -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", @@ -785,4 +848,5 @@ def _validate_value(self, value: Any, name: str | None = None, /): "TableSpec", "List", "ConfigEnum", + "ConfigObject", ] diff --git a/comprehensiveconfig/toml.py b/comprehensiveconfig/toml.py index 8492bb3..ada0aed 100644 --- a/comprehensiveconfig/toml.py +++ b/comprehensiveconfig/toml.py @@ -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: @@ -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) @@ -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) diff --git a/comprehensiveconfig/utility.py b/comprehensiveconfig/utility.py new file mode 100644 index 0000000..ec5cda4 --- /dev/null +++ b/comprehensiveconfig/utility.py @@ -0,0 +1,58 @@ +import enum +from typing import Type + +import comprehensiveconfig + + +class ExampleEnum(enum.Enum): + example = 10 + something_cool = 20 + + +class ConfigurationWriterTestCase( + comprehensiveconfig.ConfigSpec, + auto_load=False, + create_file=False, +): + """A Configuration Spec to be used as a test-case for any user-made config writers.""" + + class Bar(comprehensiveconfig.spec.Section, name="burger"): + """Section comment example""" + + test_section_item = comprehensiveconfig.spec.Text("clean", doc="Example doc 1") + + test_text = comprehensiveconfig.spec.Text( + "clean", doc="Example doc 2", inline_doc=False + ) + test_int = comprehensiveconfig.spec.Integer(20) + test_float = comprehensiveconfig.spec.Float(20.20) + test_dict = comprehensiveconfig.spec.Table( + {10: "burgers"}, + key_type=comprehensiveconfig.spec.Integer(), + value_type=comprehensiveconfig.spec.Text(), + ) + test_list = comprehensiveconfig.spec.List( + [10, 20, 30], inner_type=comprehensiveconfig.spec.Integer() + ) + + test_enum_value = comprehensiveconfig.spec.ConfigEnum( + ExampleEnum, ExampleEnum.example + ) + test_enum_name = comprehensiveconfig.spec.ConfigEnum( + ExampleEnum, ExampleEnum.example, by_name=True + ) + + +def test_writer_dumps(writer: Type[comprehensiveconfig.configio.ConfigurationWriter]): + """Test the dumps capabilities of a configuration writer""" + config_value = ConfigurationWriterTestCase(None) + + output: str = writer.dumps(config_value) + assert output # ensure an output is created + + for line in output.split("\n"): + assert not line.endswith(" "), f'"{line}"' # ensure no trailing whitespace + + # Ensure all nodes are individually writable + for node in config_value._FIELDS.values(): + assert writer.dumps(node), node diff --git a/docs/source/fields.rst b/docs/source/fields.rst index 2b1b18a..1b8909e 100644 --- a/docs/source/fields.rst +++ b/docs/source/fields.rst @@ -4,6 +4,9 @@ Fields .. py:currentmodule:: comprehensiveconfig.spec +.. role:: pycode(code) + :language: python + Fields are the most basic unit in Comprehensive Config. They are used to define named values in your configuration file. @@ -237,3 +240,37 @@ Module :type: dict[Any, T] A reversed mapping of values and enum variants (instances) in the enumeration type + +.. py:class:: comprehensiveconfig.spec.ConfigObjectType[T](Protocol) + + A protocol that describes exactly what any serializable object must contain. + + .. py:classmethod:: from_config(config_value: Any) + + :param Any ConfigValue: The incoming value when we want to construct an object of :py:type:`T` from a configuration file being loaded. + + + +.. py:class:: comprehensiveconfig.spec.ConfigObject[T: ConfigObjectType](_type: Type[T], default_value: T | _NoDefaultValueT = NoDefaultValue, /, *args, **kwargs) + + :param Type[T] _type: The class of the enum we want to represent in this field. + :param T | NoDefaultValue default_value: Default value of our field + + This is a way to use an existing python enum (:py:class:`enum.Enum`) as a validated field. + + .. important:: + + Objects must be supported by the specific writer (:py:class:`comprehensiveconfig.configio.ConfigurationWriter`) or must + implement any necessary magic methods required to have them work generically in an existing config-writer. + + This (by default) includes: + - :pycode:`def __write_toml_value__(self, field, value) -> str` (writing a regular toml-parsable value as a string) + - :pycode:`def __write_toml_full__(self, field, value) -> str` (Directly write line(s) of toml when encountering this object) + - :pycode:`def __write_json_value__(self, field, value) -> int | float | datetime | str | None` (When encountering this object- convert it to a json serializable format) + + .. py:attribute:: _holds + :type: T + + .. py:attribute:: _Type + :type: Type[T] + diff --git a/docs/source/globaltoc.rst b/docs/source/globaltoc.rst index 3f4a6d5..8b43376 100644 --- a/docs/source/globaltoc.rst +++ b/docs/source/globaltoc.rst @@ -18,4 +18,11 @@ writers.rst json_writer.rst - toml_writer.rst \ No newline at end of file + toml_writer.rst + +.. toctree:: + :glob: + :maxdepth: 2 + :caption: Utilities: + + utilities.rst \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index 17bc1c8..a3c088a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -35,6 +35,13 @@ loading as well as complex validators for incoming configuration values. json_writer.rst toml_writer.rst +.. toctree:: + :glob: + :maxdepth: 3 + :caption: Utilities: + + utilities.rst + Module ******** diff --git a/docs/source/utilities.rst b/docs/source/utilities.rst new file mode 100644 index 0000000..4586422 --- /dev/null +++ b/docs/source/utilities.rst @@ -0,0 +1,29 @@ +Utilities +=========== + +.. py:currentmodule:: comprehensiveconfig.utility + + +This module is additional utility functions that are useful for building/extending functionality on top of this library. + +.. important:: + This module is NOT included in the `__init__.py` for comprehensiveconfig. + + This means you must import it like this: :code:`import comprehensiveconfig.utility` + +Module +******** + +.. py:function:: test_writer_dumps(writer: Type[ConfigurationWriter]) + + :param Type[ConfigurationWriter] writer: The writer we are testing the dumping functionality of. + + Test that a writer is functioning properly. + This currently means: + - no trailing whitespace + - being able to dump ALL node types (Not just :py:class:`comprehensiveconfig.spec.Section`) + + .. warning:: + + This is NOT a full test suite. This runs a simple case to ensure that what you are using is *mostly* working. + This just makes writing smaller custom writer's easier. If you plan to publish a larger writer on pypi or github, then write more tests! \ No newline at end of file diff --git a/tests/test_fields.py b/tests/test_fields.py index f09e43d..9bcd246 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,4 +1,5 @@ import enum +from typing import Any, Self import pytest @@ -248,6 +249,57 @@ class Bar(comprehensiveconfig.spec.Section, name="burger"): assert Foo.Bar.test == "bean" +@pytest.mark.parametrize(("filename", "writer"), parameterize_values) +def test_config_object(filename, writer): + class SomethingCool: + @classmethod + def from_config(cls, config_value: Any) -> Self: + if not isinstance(config_value, str): + raise ValueError(config_value) + return cls(config_value) + + def __init__(self, value: str): + self.value = value + + def __write_toml_value__(self, field, value: str) -> str: + return self.value + + def __write_json_value__(self, field, value: str) -> str: + return self.value + + def __eq__(self, other): + return isinstance(other, SomethingCool) and self.value == other.value + + class Foo( + comprehensiveconfig.ConfigSpec, + auto_load=True, + writer=writer, + default_file=filename, + create_file=True, + ): + """Basic config""" + + test = comprehensiveconfig.spec.ConfigObject( + SomethingCool, SomethingCool("jenkins") + ) + + assert Foo.test.value == "jenkins" + assert isinstance(Foo.test, SomethingCool) + + assert Foo.test == SomethingCool("jenkins") + Foo.test = SomethingCool("flankins") + assert Foo.test == SomethingCool("flankins") + + try: + Foo.test = 12 + assert False + except ValueError: + assert Foo.test == SomethingCool("flankins") + + output: str = writer.dumps(Foo._INST) + assert output + + @pytest.mark.parametrize(("filename", "writer"), parameterize_values) def test_enum(filename, writer): class ExampleEnum(enum.Enum): diff --git a/tests/test_writers.py b/tests/test_writers.py new file mode 100644 index 0000000..8418757 --- /dev/null +++ b/tests/test_writers.py @@ -0,0 +1,9 @@ +import pytest +import comprehensiveconfig.utility +from tests.conftest import parameterize_values + + +@pytest.mark.parametrize(("filename", "writer"), parameterize_values) +def test_run_utilities_tester_dumps(filename: str, writer): + """Run comprehensiveconfig.utilities.test_writer_dumps""" + comprehensiveconfig.utility.test_writer_dumps(writer)