Source code for maelzel.core.config

"""

CoreConfig: Configuration for maelzel.core
============================w==============

At any given moment there is one active configuration (an instance of :class:`CoreConfig`,
itself a subclass of `dict`).
The configuration allows to set default values for many settings and customize different
aspects of **maelzel.core**:

* notation (default page size, rendered image scaling, etc). Prefix: *show*
* playback (default audio backend, instrument, etc). Prefix: *play*
* offline rendering. Prefix: *rec*
* quantization (complexity, quantization strategy, etc). Prefix: *quant*
* etc.

Settings can be modified by simply changing the values of the active config dict::

    # Get the active config
    >>> from maelzel.core import *
    >>> config = Workspace.active.config
    >>> config['show.pageSize'] = 'A3'

A config has a :ref:`set of valid keys <coreconfigkeys>`. An attempt to set an unknown key will
result in an error. Values are also validated regarding their type and accepted
values, range, etc.::

    >>> config['foo'] = 'bar'
    KeyError: 'Unknown key foo'
    >>> config['show.pageSize'] = 'Z1'
    ValueError: key show.pageSize should be one of {'a2', 'a4', 'a3'}, got Z1


Alternative key format
----------------------

For convenience keys are case- and punctuation- independent. This allows
to create a new CoreConfig as ``CoreConfig(show_staff_size=10)`` instead of
``CoreConfig(updates={'show.staffSize': 10})`` or query the same key as
``staffsize = config['show_staff_size']`` instead of ``staffsize = config['show.staffSize']``


Persistence
-----------

Modifications to a configuration can be made persistent by saving the config.

    >>> from maelzel.core import *
    # Set the reference frequency to 443
    >>> conf = getWorkspace().config
    >>> conf['A4'] = 443
    # Set lilypond as default rendering backend
    >>> conf['show.backend'] = 'lilypond'
    >>> conf.save()

In a future session these changes will be picked up as default:

    >>> from maelzel.core import *
    >>> conf = getWorkspace().config
    >>> conf['A4']
    443

.. seealso::

    :ref:`workspace_mod`

--------------------

.. _rootconfig:

Root Config
-----------

When ``maelzel.core`` is first imported the persisted config is read (if no persisted
config is found the builtin default is used). This is the *root config* and is used as a
prototype for any subsequent :class:`CoreConfig` created. This enables you to
modify default values based on your personal setup (for example, you can set the
default rendering samplerate to 48000 if that fits your workflow better).

Example
~~~~~~~

    >>> from maelzel.core import *
    >>> config = getWorkspace().config
    >>> config['rec.sr']
    44100
    >>> rootConfig['rec.sr'] = 48000
    >>> newconfig = CoreConfig()
    >>> newconfig['rec.sr']
    48000
    >>> rootConfig.save()   # If you want this as default, save it for any future sessions

---------------------

.. _activeconfig:

Active config
-------------

In order to create a configuration specific for a particular task it is possible
to create a new config with :class:`~maelzel.core.config.CoreConfig` or by
cloning any CoreConfig.

    >>> from maelzel.core import *
    >>> newconfig = CoreConfig({'show.pageSize': 'a3'}, active=True)

Also creating a new :class:`~maelzel.core.workspace.Workspace` will create a new
config based on the root config:

    >>> from maelzel.core import *
    # Create a config to work with old tuning and display notation using a3 page size
    >>> w = Workspace(updates={'A4': 435, 'show.pageSize': 'a3'}, active=True)
    # do something with this, then deactivate the workspace
    >>> n = Note("4A")
    >>> print(n.freq)
    435
    # Play with default instr
    >>> n.play()
    # When finished, deactivate it to return to previous Workspace
    >>> w.deactivate()
    >>> Note("4A").freq
    442

It is also possible to create a temporary config as a context manager. The config
will be active only within its context:

    >>> from maelzel.core import *
    >>> scale = Chain([Note(m, dur=0.5) for m in range(60, 72)])
    >>> with CoreConfig({'show.pageSize':'a3'}):
    ...     scale.show()
"""
from __future__ import annotations

import os

import configdict
from configdict import ConfigDict
from maelzel.core._common import logger
from maelzel.core import configdata
from functools import cache


import typing as _t
if _t.TYPE_CHECKING:
    from maelzel.scoring.render import RenderOptions
    from maelzel.scoring.quantprofile import QuantizationProfile
    import maelzel.scoring.enharmonics as enharmonics
    from maelzel.core.workspace import Workspace


__all__ = (
    'CoreConfig',
)


class _UNKNOWN:
    pass


def _syncCsoundengineTheme(theme: str):
    import csoundengine
    csoundengine.config['html_theme'] = theme


def _resetImageCacheCallback(config: CoreConfig, force=False):
    from . import workspace
    if force or config is workspace.getWorkspace().config:
        from . import mobj
        mobj.clearImageCache()


def _propagateA4(config: CoreConfig, a4: float) -> None:
    from . import workspace
    w = workspace.Workspace.active
    assert w is not None
    # Is it the active config?
    if config is w.config:
        w.a4 = a4


#####################################
#            CoreConfig             #
#####################################


[docs] class CoreConfig(ConfigDict): """ A CoreConfig is a ``dict`` like object which controls many aspects of **maelzel.core** A **CoreConfig** reads its settings from a persistent copy. This persistent version is generated whenever the user calls the method :meth:`CoreConfig.save`. When **maelzel.core** is imported it reads this configuration and creates the **root**, which is an instance of :class:`CoreConfig` and can be accessed via the class variable :attr:``CoreConfig.root`` Notice that a configuration, in order to modify the behaviour of the environment, needs to be either actively used (passed as an argument to any function accepting a configuration object) or set as active via :func:`~maelzel.core.workspace.setConfig` or by calling its :meth:`CoreConfig.activate` method. Args: updates: if given, a dict which will be used to update the newly created instance source: either a ConfigDict to use as prototype; 'root', to use the root config (the last saved config); 'load' to reload the last saved config This ConfigDict will be a copy of that prototype active: if True, set this CoreConfig as active (modifying the current Workspace) kws: any keywords will be used to update the config and must be valid keys for a CoreConfig .. admonition:: See Also :ref:`Configuration Keys <coreconfigkeys>` for documentation on the keys and their possible values .. seealso:: :func:`maelzel.core.workspace.makeConfig` """ _root: _t.ClassVar[CoreConfig | None] = None _defaultName: _t.ClassVar[str] = 'maelzel.core' _keyToType: _t.ClassVar[dict[str, type | tuple[type, ...]]] = {} _listHiddenKeysAtTheEnd: _t.ClassVar[bool] = True # A config callback has the form (config: CoreConfig, key: str, val: Any) -> None # It is called with the config being modified, the key being modified and the new value _builtinCallbacks = { 'htmlTheme': lambda config, key, val: _syncCsoundengineTheme(val), r"(show|quant|\.quant)\..+": lambda config, key, val: _resetImageCacheCallback(config, force=True), "A4": lambda config, key, val: _propagateA4(config, val), } def __init__(self, updates: dict[str, _t.Any] | None = None, source: ConfigDict | str = 'root', active=False, **kws): self._hash: int = 0 self._defaultPlayArgsDict: dict | None = None load = source == 'load' or (source == 'root' and CoreConfig._root is None) super().__init__(CoreConfig._defaultName, default=configdata.defaultdict, persistent=False, validator=configdata.validator, docs=configdata.docs, load=load, hiddenPrefix='.', strict=False) if not load: if source == 'root': if not hasattr(CoreConfig, 'root') or CoreConfig._root is None: raise RuntimeError("CoreConfig not initialized!") source = CoreConfig._root if isinstance(source, ConfigDict): d = dict(source) dict.update(self, d) else: # Whenever loading, update root CoreConfig._root = self self._previousState: tuple[Workspace, CoreConfig] | None = None self.readonly = False """If True, trying to modify this dict will raise a ReadOnlyException""" for regex, func in self._builtinCallbacks.items(): self.registerCallback(func, pattern=regex) self.registerCallback(self._changedCallback) if updates: self.update(updates) if kws: kws = self._normalizeDict(kws) kws = {k: v for k, v in kws.items() if k in self.keys()} self.update(kws) if active: self.activate()
[docs] @classmethod def root(cls) -> CoreConfig: root = CoreConfig._root assert root is not None return root
def _changedCallback(self, cfg: ConfigDict, key: str, val): self._hash = 0 self._defaultPlayArgsDict = None def _ipython_key_completions_(self): return self.keys() def _repr_keys(self) -> list[str]: # This places "hidden" entries (entries starting with a dot) at the end # of any list if not CoreConfig._listHiddenKeysAtTheEnd: return super()._repr_keys() lastchr = chr(9999) return sorted(self.keys(), key=lambda k: k if not k.startswith(".") else lastchr + k)
[docs] @classmethod def read(cls, path: str): """ Create a new CoreConfig from the saved config The path points to a .yaml config saved via :meth:`CoreConfig.save` Args: path: the path to a config Returns: the new CoreConfig """ out = CoreConfig() out.load(path) return out
[docs] def copy(self) -> CoreConfig: """Create a copy of this config""" return CoreConfig(source=self)
[docs] def clone(self, updates: dict | None = None, **kws) -> CoreConfig: """ Create a new CoreConfig from this config with modified values Keywords which are not valid symbols (keys containing periods, like 'quant.nestedTuples') can be used by removing any invalid characters. Also, config keys are not case sensitive. In the case above, ``quantNestedTuples`` could be used .. note:: A :class:`CoreConfig` can be used as a context manager to activate it temporarily. Used together with this method it can be used to modify any rendering or playback condition for the code within that block. Args: updates: a dictionary of updates to apply to the new config **kws: keyword arguments to pass to the new config. Returns: the new CoreConfig Example ~~~~~~~ Render an object with modified quantization .. code-block:: python >>> from maelzel.core import * >>> cfg = getWorkspace().config >>> with cfg.clone(quantNestedTuples=False): ... Chain(...).show() """ if kws: kws = self._normalizeDict(kws) return CoreConfig(updates=updates, source=self, **kws)
[docs] def getType(self, key: str) -> type | tuple[type, ...]: if (t := self._keyToType.get(key, _UNKNOWN)) is not _UNKNOWN: return t t = super().getType(key) self._keyToType[key] = t return t
def __hash__(self): if self._hash: return self._hash self._hash = super().__hash__() return self._hash
[docs] @cache def makeRenderOptions(self) -> RenderOptions: """ Create RenderOptions based on this config Returns: a RenderOptions instance """ from maelzel.core import notation return notation.makeRenderOptionsFromConfig(self)
[docs] @cache def makeQuantizationProfile(self) -> QuantizationProfile: """ Create a QuantizationProfile from this config """ from maelzel.core import notation return notation.makeQuantizationProfileFromConfig(self)
[docs] @cache def makeEnharmonicOptions(self) -> enharmonics.EnharmonicOptions: """ Create EnharmonicOptions from this config The returned object is used within maelzel.scoring.enharmonics to determine the best to Returns: a :class:`maelzel.scoring.enharmonics.EnharmonicOptions` """ from maelzel.core import notation return notation.makeEnharmonicOptionsFromConfig(self)
def __enter__(self): from . import workspace w = workspace.Workspace.active self._previousState = (w, w.config) w.config = self return self def __exit__(self, exc_type, exc_val, exc_tb): assert self._previousState is not None workspace, prevconfig = self._previousState assert workspace.isActive() and workspace.config is self and prevconfig workspace.config = prevconfig
[docs] def activate(self) -> None: """ Make this config the active config This is just a shortcut for ``setConfig(self)`` """ from . import workspace active = workspace.Workspace.active assert active is not None active.config = self
[docs] def saveKey(self, key: str) -> None: conf = CoreConfig() conf[key] = self[key] conf.save()
[docs] def reset(self, removesaved=False) -> None: """ Reset this config to its defaults Args: removesaved: if True, remove any saved config """ super().reset() from maelzel.core.presetmanager import presetManager if '.piano' in presetManager.presetdefs: self['play.instr'] = '.piano' if removesaved: path = self.getPath() if os.path.exists(path): os.remove(path)
[docs] @classmethod def removeSaved(cls): """Remove the saved default config""" path = configdict.configPathFromName(cls._defaultName) if os.path.exists(path): logger.debug("Removing default config at '%s'", path) os.remove(path)
def _makeDefaultPlayArgsDict(self, copy=True) -> dict: """ Creates the dict for a default PlayArgs This is used as the base for each event created Args: copy: if True, the returned dict is a copy and can be modified Returns: a dict to be passed to PlayArgs """ if self._defaultPlayArgsDict is not None: d = self._defaultPlayArgsDict else: d = dict(delay=0, chan=1, gain=self['play.gain'], fade=self['play.fade'], instr=self['play.instr'], pitchinterpol=self['play.pitchInterpol'], fadeshape=self['play.fadeShape'], priority=1, position=-1, sustain=0, transpose=0) self._defaultPlayArgsDict = d return d.copy() if copy else d