diff --git a/TODO.md b/TODO.md index bfb31b7..aa784e5 100644 --- a/TODO.md +++ b/TODO.md @@ -2,8 +2,7 @@ ## v0.4.0 -- `DictConfig` subclass of `Config` (unstructured configs loaded from JSON/TOML) -- `DCMixin` classmethod to coerce this class's settings or field settings to another type? +- `DataclassMixin` classmethod to coerce this class's settings or field settings to another type? - E.g. to adapt new settings to a parent class (`CLIAdapterDataclass` example) ## v0.4.1 @@ -11,7 +10,7 @@ - `mkdocs` output in `package_data`? - `_docs` subdirectory? - Pre-commit hook to run `mkdocs build` - - Takse only a second, but could use file hashes to prevent redundant build + - Takes only a second, but could use file hashes to prevent redundant build, e.g. `sha1sum docs/*.md | sha1sum | head -c 40` - Need some hook (post-tag?) to require the docs be up-to-date - documentation - Dataclass mixins/settings @@ -42,6 +41,7 @@ - NOTE: the parsed values themselves have a `_trivia` attribute storing various formatting info - Use field metadata (`help`?) as comment prior to the field - For `None`, serialize as commented field? +- `JSON5Dataclass`? - `ArgparseDataclass` - Support subparsers - Test subparsers, groups, mutually exclusive groups diff --git a/docs/config.md b/docs/config.md index 54c310f..304d7bb 100644 --- a/docs/config.md +++ b/docs/config.md @@ -88,22 +88,28 @@ def print_current_username(): >>> print_current_username() admin -# update only the local copy ->>> cfg.database.username = 'test' +# update the config by mutating a local reference +>>> cfg.database.username = 'test1' >>> print_current_username() -admin +test1 + +# update the config with another object +>>> from copy import deepcopy +>>> cfg2 = deepcopy(cfg) +>>> cfg2.database.username = 'test2' +>>> cfg2.update_config() # update the global config ->>> cfg.update_config() +>>> cfg2.update_config() >>> print_current_username() -test +test2 ``` Sometimes it is useful to modify the configs temporarily: ```python >>> print_current_username() -test +test2 >>> cfg.database.username = 'temporary' # temporarily update global config with the local version @@ -113,7 +119,7 @@ temporary # global config reverts back to its value before 'as_config' was called >>> print_current_username() -test +test2 ``` ## Details diff --git a/fancy_dataclass/cli.py b/fancy_dataclass/cli.py index e7a53eb..a5a443e 100644 --- a/fancy_dataclass/cli.py +++ b/fancy_dataclass/cli.py @@ -114,30 +114,30 @@ def configure_argument(cls, parser: ArgumentParser, name: str) -> None: parser: parser object to update with a new argument name: Name of the argument to configure""" kwargs: Dict[str, Any] = {} - field = cls.__dataclass_fields__[name] # type: ignore[attr-defined] - if field.metadata.get('parse_exclude', False): # exclude the argument from the parser + fld = cls.__dataclass_fields__[name] # type: ignore[attr-defined] + settings = ArgparseDataclassFieldSettings.coerce(cls._field_settings(fld)) + if settings.parse_exclude: # exclude the argument from the parser return - group_name = field.metadata.get('group') - if group_name is not None: # add argument to a group instead of the main parser + if (group_name := settings.group) is not None: # add argument to a group instead of the main parser for group in getattr(parser, '_action_groups', []): # get argument group with the given name if getattr(group, 'title', None) == group_name: break else: # group not found, so create it group_kwargs = {} - if issubclass_safe(field.type, ArgparseDataclass): # get kwargs from nested ArgparseDataclass - group_kwargs = field.type.parser_kwargs() + if issubclass_safe(fld.type, ArgparseDataclass): # get kwargs from nested ArgparseDataclass + group_kwargs = fld.type.parser_kwargs() group = parser.add_argument_group(group_name, **group_kwargs) parser = group - if issubclass_safe(field.type, ArgparseDataclass): + if issubclass_safe(fld.type, ArgparseDataclass): # recursively configure a nested ArgparseDataclass field - field.type.configure_parser(parser) + fld.type.configure_parser(parser) return # determine the type of the parser argument for the field - tp = field.metadata.get('type', field.type) - action = field.metadata.get('action', 'store') + tp = settings.type or fld.type + action = settings.action or 'store' origin_type = get_origin(tp) if origin_type is not None: # compound type - if type_is_optional(tp): + if type_is_optional(tp): # type: ignore[arg-type] kwargs['default'] = None if origin_type == ClassVar: # by default, exclude ClassVars from the parser return @@ -151,18 +151,19 @@ def configure_argument(cls, parser: ArgumentParser, name: str) -> None: raise ValueError(f'cannot infer type of items in field {name!r}') if issubclass_safe(origin_type, list) and (action == 'store'): kwargs['nargs'] = '*' # allow multiple arguments by default - if issubclass_safe(tp, IntEnum): # use a bare int type + if issubclass_safe(tp, IntEnum): # type: ignore[arg-type] + # use a bare int type tp = int kwargs['type'] = tp # determine the default value - if field.default == MISSING: - if field.default_factory != MISSING: - kwargs['default'] = field.default_factory() + if fld.default == MISSING: + if fld.default_factory != MISSING: + kwargs['default'] = fld.default_factory() else: - kwargs['default'] = field.default + kwargs['default'] = fld.default # get the names of the arguments associated with the field - if 'args' in field.metadata: - args = field.metadata['args'] + args = settings.args + if args is not None: if isinstance(args, str): args = [args] # argument is positional if it is explicitly given without a leading dash @@ -171,26 +172,26 @@ def configure_argument(cls, parser: ArgumentParser, name: str) -> None: # no default available, so make the field a required option kwargs['required'] = True else: - argname = field.name.replace('_', '-') + argname = fld.name.replace('_', '-') positional = (tp is not bool) and ('default' not in kwargs) if positional: args = [argname] else: # use a single dash for 1-letter names - prefix = '-' if (len(field.name) == 1) else '--' + prefix = '-' if (len(fld.name) == 1) else '--' args = [prefix + argname] - if field.metadata.get('args') and (not positional): + if args and (not positional): # store the argument based on the name of the field, and not whatever flag name was provided - kwargs['dest'] = field.name - if field.type is bool: # use boolean flag instead of an argument + kwargs['dest'] = fld.name + if fld.type is bool: # use boolean flag instead of an argument kwargs['action'] = 'store_true' for key in ('type', 'required'): with suppress(KeyError): kwargs.pop(key) # extract additional items from metadata for key in cls.parser_argument_kwarg_names(): - if key in field.metadata: - kwargs[key] = field.metadata[key] + if key in fld.metadata: + kwargs[key] = fld.metadata[key] if (kwargs.get('action') == 'store_const'): del kwargs['type'] parser.add_argument(*args, **kwargs) diff --git a/fancy_dataclass/config.py b/fancy_dataclass/config.py index 22ee79a..6943e80 100644 --- a/fancy_dataclass/config.py +++ b/fancy_dataclass/config.py @@ -1,8 +1,8 @@ +from abc import ABC, abstractmethod from contextlib import contextmanager -from copy import deepcopy from dataclasses import is_dataclass, make_dataclass from pathlib import Path -from typing import ClassVar, Iterator, Optional, Type +from typing import Any, ClassVar, Dict, Iterator, Optional, Type from typing_extensions import Self @@ -13,9 +13,9 @@ class Config: - """Base class for a collection of configurations. + """Base class for storing a collection of configurations. - This uses a quasi-Singleton pattern by storing a class attribute with the current global configurations, which can be retrieved or updated by the user.""" + Subclasses may store a class attribute, `_config`, with the current global configurations, which can be retrieved or updated by the user.""" _config: ClassVar[Optional[Self]] = None @@ -25,7 +25,8 @@ def get_config(cls) -> Optional[Self]: Returns: Global configuration object (`None` if not set)""" - return deepcopy(cls._config) # type: ignore[return-value] + # return deepcopy(cls._config) # type: ignore[return-value] + return cls._config # type: ignore[return-value] @classmethod def _set_config(cls, config: Optional[Self]) -> None: @@ -53,7 +54,22 @@ def as_config(self) -> Iterator[None]: type(self)._set_config(orig_config) -class ConfigDataclass(Config, DictDataclass, suppress_defaults=False): +class FileConfig(Config, ABC): + """A collection of configurations that can be loaded from a file.""" + + @classmethod + @abstractmethod + def load_config(cls, path: AnyPath) -> Self: + """Loads configurations from a file and sets them to be the global configurations for this class. + + Args: + path: File from which to load configurations + + Returns: + The newly loaded global configurations""" + + +class ConfigDataclass(DictDataclass, FileConfig, suppress_defaults=False): """A dataclass representing a collection of configurations. The configurations can be loaded from a file, the type of which will be inferred from its extension. @@ -75,8 +91,11 @@ def _wrap(tp: type) -> type: return _wrap(dataclass_type_map(cls, _wrap)) # type: ignore[arg-type] @classmethod - def _get_dataclass_type_for_extension(cls, ext: str) -> Type[FileSerializable]: - ext_lower = ext.lower() + def _get_dataclass_type_for_path(cls, path: AnyPath) -> Type[FileSerializable]: + p = Path(path) + if not p.suffix: + raise ValueError(f'filename {p} has no extension') + ext_lower = p.suffix.lower() if ext_lower == '.json': from fancy_dataclass.json import JSONDataclass return JSONDataclass @@ -84,24 +103,34 @@ def _get_dataclass_type_for_extension(cls, ext: str) -> Type[FileSerializable]: from fancy_dataclass.toml import TOMLDataclass return TOMLDataclass else: - raise ValueError(f'unknown config file extension {ext!r}') + raise ValueError(f'unknown config file extension {p.suffix!r}') @classmethod - def load_config(cls, path: AnyPath) -> Self: - """Loads configurations from a file and sets them to be the global configurations for this class. + def load_config(cls, path: AnyPath) -> Self: # noqa: D102 + tp = cls._get_dataclass_type_for_path(path) + new_cls: Type[FileSerializable] = ConfigDataclass._wrap_config_dataclass(tp, cls) # type: ignore + with open(path) as fp: + cfg: Self = coerce_to_dataclass(cls, new_cls._from_file(fp)) + cfg.update_config() + return cfg - Args: - path: File from which to load configurations - Returns: - The newly loaded global configurations""" - p = Path(path) - ext = p.suffix - if not ext: - raise ValueError(f'filename {p} has no extension') - tp = cls._get_dataclass_type_for_extension(ext) - new_cls: Type[FileSerializable] = ConfigDataclass._wrap_config_dataclass(tp, cls) # type: ignore - with open(path) as f: - cfg: Self = coerce_to_dataclass(cls, new_cls._from_file(f)) +class DictConfig(Config, Dict[Any, Any]): + """A collection of configurations, stored as a Python dict. + + To impose a type schema on the configurations, use [`ConfigDataclass`][fancy_dataclass.config.ConfigDataclass] instead. + + The configurations can be loaded from a file, the type of which will be inferred from its extension. + Supported file types are: + + - JSON + - TOML + """ + + @classmethod + def load_config(cls, path: AnyPath) -> Self: # noqa: D102 + tp = ConfigDataclass._get_dataclass_type_for_path(path) + with open(path) as fp: + cfg = cls(tp._text_file_to_dict(fp)) # type: ignore[attr-defined] cfg.update_config() return cfg diff --git a/fancy_dataclass/mixin.py b/fancy_dataclass/mixin.py index 33601ea..ee1c1c8 100644 --- a/fancy_dataclass/mixin.py +++ b/fancy_dataclass/mixin.py @@ -107,7 +107,7 @@ def _configure_mixin_settings(cls: Type['DataclassMixin'], **kwargs: Any) -> Non cls.__settings__ = stype(**d) def _configure_field_settings_type(cls: Type['DataclassMixin']) -> None: - """Sets up the __field_settings_type__ attribute on a `DataclassMixin` subclass at definition type. + """Sets up the __field_settings_type__ attribute on a `DataclassMixin` subclass at definition time. This reconciles any such attributes inherited from multiple parent classes.""" stype = cls.__dict__.get('__field_settings_type__') if stype is None: diff --git a/pyproject.toml b/pyproject.toml index a99cd82..45c4862 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -131,7 +131,7 @@ disable_error_code = ["assignment"] [[tool.mypy.overrides]] module = "tests.test_config" -disable_error_code = ["misc", "union-attr"] +disable_error_code = ["index", "misc", "union-attr"] [[tool.mypy.overrides]] module = "tests.test_inheritance" diff --git a/tests/test_config.py b/tests/test_config.py index 459f053..41f25a1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,7 +9,7 @@ import pytest import tomlkit -from fancy_dataclass.config import ConfigDataclass +from fancy_dataclass.config import ConfigDataclass, DictConfig from fancy_dataclass.json import JSONDataclass from fancy_dataclass.toml import TOMLDataclass @@ -24,16 +24,14 @@ class MyConfig(ConfigDataclass): cfg = MyConfig() assert MyConfig.get_config() is None cfg.update_config() - assert MyConfig.get_config() == cfg - assert MyConfig.get_config() is not cfg + assert MyConfig.get_config() is cfg cfg2 = MyConfig() - assert MyConfig.get_config() == cfg2 assert MyConfig.get_config() is not cfg2 + assert MyConfig.get_config() == cfg2 cfg2.y = 'b' assert cfg2 != cfg with cfg2.as_config(): - assert MyConfig.get_config() == cfg2 - assert MyConfig.get_config() is not cfg2 + assert MyConfig.get_config() is cfg2 # updating instance field affects the global config cfg2.y = 'c' assert MyConfig.get_config().y == 'c' @@ -41,8 +39,7 @@ class MyConfig(ConfigDataclass): MyConfig.clear_config() assert MyConfig.get_config() is None with cfg2.as_config(): - assert MyConfig.get_config() == cfg2 - assert MyConfig.get_config() is not cfg2 + assert MyConfig.get_config() is cfg2 assert MyConfig.get_config() is None @dataclass class OuterConfig(ConfigDataclass): @@ -57,11 +54,10 @@ class OuterConfig(ConfigDataclass): assert MyConfig.get_config() is None # inner instance can update its own singleton outer.inner.update_config() - assert MyConfig.get_config() == outer.inner - assert MyConfig.get_config() is not outer.inner + assert MyConfig.get_config() is outer.inner cfg2.update_config() assert MyConfig.get_config() != outer.inner - assert MyConfig.get_config() == cfg2 + assert MyConfig.get_config() is cfg2 assert OuterConfig.get_config().inner == outer.inner def test_inner_plain(tmpdir): @@ -76,8 +72,7 @@ class Outer(ConfigDataclass): assert Outer.get_config() is None cfg = Outer(Inner()) cfg.update_config() - assert Outer.get_config() == cfg - assert Outer.get_config() is not cfg + assert Outer.get_config() is cfg cfg.inner.x = 2 assert Outer.get_config().inner.x == 2 inner = cfg.inner @@ -97,6 +92,26 @@ class Outer(ConfigDataclass): Outer.load_config(cfg_path) assert Outer.get_config() == Outer(Inner()) +def test_dict_config(): + """Tests behavior of DictConfig.""" + assert DictConfig.get_config() is None + class MyConfig(DictConfig): + ... + assert MyConfig.get_config() is None + cfg = MyConfig() + assert MyConfig.get_config() is None + cfg.update_config() + assert MyConfig.get_config() is cfg + cfg['a'] = 1 + assert MyConfig.get_config()['a'] == 1 + cfg2 = MyConfig() + with cfg2.as_config(): + assert MyConfig.get_config() is cfg2 + assert MyConfig.get_config() == {} + assert MyConfig.get_config() is cfg + cfg2.update_config() + assert MyConfig.get_config() is cfg2 + def test_json(tmpdir): """Tests JSON conversion of ConfigDataclass.""" dt = datetime.now()