Skip to content

Commit

Permalink
feat: sketch of a new configuration manager
Browse files Browse the repository at this point in the history
This will eventually fix
#397
  • Loading branch information
mih committed Oct 1, 2024
1 parent 5a88620 commit 3a27441
Show file tree
Hide file tree
Showing 20 changed files with 1,618 additions and 43 deletions.
92 changes: 91 additions & 1 deletion datalad_next/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,101 @@
This modules provides the central ``ConfigManager`` class.
.. todo::
Mention ``defaults``, ``manager``, and ``legacy_cfg``
Validation of configuration item values
There are two ways to do validation and type conversion. on-access, or
on-load. Doing it on-load would allow to reject invalid configuration
immediately. But it might spend time on items that never get accessed.
On-access might waste cycles on repeated checks, and possible complain later
than useful. Here we nevertheless run a validator on-access in the default
implementation. Particular sources may want to override this, or ensure that
the stored value that is passed to a validator is already in the best possible
form to make re-validation the cheapest.
.. currentmodule:: datalad_next.config
.. autosummary::
:toctree: generated
ConfigManager
LegacyConfigManager
LegacyEnvironment
GitConfig
SystemGitConfig
GlobalGitConfig
LocalGitConfig
GitEnvironment
ImplementationDefault
defaults
dialog
legacy_register_config
legacy_cfg
"""

from datalad.config import ConfigManager
__all__ = [
'ConfigManager',
'LegacyConfigManager',
'LegacyEnvironment',
'GitConfig',
'SystemGitConfig',
'GlobalGitConfig',
'LocalGitConfig',
'GitEnvironment',
'ImplementationDefault',
'defaults',
'dialog',
'legacy_register_config',
'legacy_cfg',
]

# TODO: eventually replace with
# from .legacy import ConfigManager
from datalad.config import ConfigManager # type: ignore

from . import dialog
from .default import (
ImplementationDefault,
legacy_register_config,
)
from .default import (
load_legacy_defaults as _load_legacy_defaults,
)
from .env import LegacyEnvironment
from .git import (
GitConfig,
GlobalGitConfig,
LocalGitConfig,
SystemGitConfig,
)
from .gitenv import GitEnvironment
from .legacy import ConfigManager as LegacyConfigManager
from .manager import ConfigManager as NextGenConfigManager

# instance for registering all defaults
defaults = ImplementationDefault()
# load up with legacy registrations for now
_load_legacy_defaults(defaults)

# order reflects precedence rule, first source with a key takes precedence
manager = NextGenConfigManager(defaults=defaults)
legacy_cfg = LegacyConfigManager()

ConfigManager.__doc__ = """\
Do not use anymore
.. deprecated:: 1.6
The use of this class is discouraged. It is a legacy import from the
``datalad`` package, and a near drop-in replacement is provided with
:class:`LegacyConfigManager`. Moreover, a :class:`LegacyConfigManager`-based
instance of a global configuration manager is available as a
:obj:`datalad_next.config.legacy_cfg` object in this module.
New implementation are encourage to use the
:obj:`datalad_next.config.manager` object (and instance of
:class:`MultiConfiguration`) to query and manipulate configuration items.
"""
128 changes: 128 additions & 0 deletions datalad_next/config/default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
from __future__ import annotations

import logging
from typing import (
Any,
Callable,
)

# momentarily needed for the legacy_register_config()
# implementation.
from datalad.interface.common_cfg import definitions # type: ignore
from datalad.support.extensions import ( # type: ignore
register_config as _legacy_register_config,
)
from datasalad.settings import Defaults

from datalad_next.config.dialog import get_dialog_class_from_legacy_ui_label
from datalad_next.config.item import (
ConfigurationItem,
UnsetValue,
)
from datalad_next.constraints import (
Constraint,
EnsureNone,
)

lgr = logging.getLogger('datalad.config')


class ImplementationDefault(Defaults):
def __str__(self):
return 'ImplementationDefaults'


#
# legacy support tooling from here.
# non of this is executed by the code above. It has to be triggered manually
# and pointed to an instance of ImplementationDefaults
#

def load_legacy_defaults(source: ImplementationDefault) -> None:
for name, cfg in definitions.items():
if 'default' not in cfg:
lgr.debug(
'Configuration %r has no default(_fn), not registering',
name
)
continue

cfg_props = cfg._props
ui = cfg_props.get('ui', None)
if ui is not None:
dialog = get_dialog_class_from_legacy_ui_label(ui[0])(
title=ui[1]['title'],
text=ui[1].get('text', ''),
)
else:
dialog = None

coercer = cfg_props.get('type')
if name == 'datalad.tests.temp.dir':
# https://github.com/datalad/datalad/issues/7662
coercer = coercer | EnsureNone()

default = cfg_props.get('default', UnsetValue)
default_fn = cfg_props.get('default_fn')

source[name] = ConfigurationItem(
default_fn if default_fn else default,
validator=coercer,
lazy=default_fn is not None,
dialog=dialog,
store_target=get_store_target_from_destination_label(
cfg_props.get('destination'),
),
)


def legacy_register_config(
source: ImplementationDefault,
name: str,
title: str,
*,
default: Any = UnsetValue,
default_fn: Callable | type[UnsetValue] = UnsetValue,
description: str | None = None,
type: Constraint | None = None, # noqa: A002
dialog: str | None = None,
scope: str | type[UnsetValue] = UnsetValue,
):
source[name] = ConfigurationItem(
default_fn if default_fn else default,
validator=type,
lazy=default_fn is not None,
dialog=None if dialog is None
else get_dialog_class_from_legacy_ui_label(dialog)(
title=title,
text=description or '',
),
store_target=get_store_target_from_destination_label(scope),
)

# lastly trigger legacy registration
_legacy_register_config(
name=name,
title=title,
default=default,
default_fn=default_fn,
description=description,
type=type,
dialog=dialog,
scope=scope,
)


def get_store_target_from_destination_label(
label: str | UnsetValue | None,
) -> str | None:
if label in (None, UnsetValue):
return None
if label == 'global':
return 'GlobalGitConfig'
if label == 'local':
return 'LocalGitConfig'
if label == 'dataset':
return 'DatasetBranchConfig'
msg = f'unsupported configuration destination label {label!r}'
raise ValueError(msg)
44 changes: 44 additions & 0 deletions datalad_next/config/dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from __future__ import annotations

from dataclasses import dataclass

__all__ = [
'Dialog',
'Question',
'YesNo',
'Choice',
]


# only from PY3.10
# @dataclass(kw_only=True)
@dataclass
class Dialog:
title: str
text: str | None = None


@dataclass
class Question(Dialog):
pass


@dataclass
class YesNo(Dialog):
pass


@dataclass
class Choice(Dialog):
pass


def get_dialog_class_from_legacy_ui_label(label: str) -> type[Dialog]:
"""Recode legacy `datalad.interface.common_cfg` UI type label"""
if label == 'yesno':
return YesNo
elif label == 'question':
return Question
else:
msg = f'unknown UI type label {label!r}'
raise ValueError(msg)
57 changes: 57 additions & 0 deletions datalad_next/config/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from __future__ import annotations

import json
import logging
from os import environ
from typing import (
Any,
)

from datasalad.settings import (
CachingSource,
)

from datalad_next.config.item import ConfigurationItem


lgr = logging.getLogger('datalad.config')


class LegacyEnvironment(CachingSource):
"""
All loaded items have a ``store_target`` of ``Environment``, assuming
that if they are loaded from the environment, a modification can
also target the environment again.
"""

def load(self) -> None:
# not resetting here, incremental load
for k, v in self._load_legacy_overrides().items():
self._items[k] = ConfigurationItem(value=v)
for k in environ:
if not k.startswith('DATALAD_'):
continue
# translate variable name to config item key
item_key = k.replace('__', '-').replace('_', '.').lower()
self._items[item_key] = ConfigurationItem(value=environ[k])

def _load_legacy_overrides(self) -> dict[str, Any]:
try:
return {
str(k): v
for k, v in json.loads(
environ.get("DATALAD_CONFIG_OVERRIDES_JSON", '{}')
).items()
}
except json.decoder.JSONDecodeError as exc:
lgr.warning(
"Failed to load DATALAD_CONFIG_OVERRIDES_JSON: %s",
exc,
)
return {}

def __str__(self):
return 'LegacyEnvironment'

def __repr__(self):
return 'LegacyEnvironment()'
Loading

0 comments on commit 3a27441

Please sign in to comment.