Module cfgs

cfgs: ⚙ Serializable hierarchical dataclass settings ⚙

Also:

Simple, correct handling of config, data and cache files. Fully compliant with the XDG Base Directory Specification.

Expand source code
"""
`cfgs`: ⚙ Serializable hierarchical dataclass settings ⚙

Also:

Simple, correct handling of config, data and cache files.
Fully compliant with the XDG Base Directory Specification.
"""

from enum import Enum
from pathlib import Path
from typing import Any, Dict, Optional, Tuple, Union
import copy
import dataclasses as dc
import json
import os
import os
import sys

File = Tuple[Union[Path, str]]
_NONE = object()


class Configs:
    def diff(self, other: Any):
        assert self.__class__ is other.__class__
        result = {}
        for f in dc.fields(self):
            s, o = getattr(self, f.name), getattr(other, f.name)
            if s != o:
                if isinstance(s, Configs):
                    assert isinstance(o, Configs)
                    o = s.diff(o)
                result[f.name] = o

        return result

    def copy_from(self, **kwargs):
        for k, v in kwargs.items():
            attr = getattr(self, k)
            if isinstance(attr, Configs):
                attr.copy_from(**v)
            else:
                setattr(self, k, v)

    def load(self, *files: File):
        for f in files:
            self.copy_from(_load(f))

    def load_from_environ(
        self,
        prefix: str,
        environ: Optional[Dict] = None,
        verbose: bool = True,
    ):
        if environ is None:
            environ = os.environ
        pre = prefix.strip('_').upper() + '_'
        items = sorted(environ.items())
        items = ((k, v) for k, v in items if k.startswith(pre))

        for k, v in items:
            attr_name = k[len(pre):].lower()
            splits = list(_split_address(v, attr_name))
            if len(splits) == 1:
                parent, attr = splits[0]
                str_val = getattr(parent, attr)
                val = _string_value(k, v, str_val)
                setattr(parent, attr, val)
            elif not verbose:
                continue
            elif not splits:
                print('No configs match', k, file=sys.err)
            else:
                print('More than one config matches', k, file=sys.err)


def _split_address(parent, key):
    if key in dir(parent):
        yield parent, key
    else:
        for k in dir(parent):
            if k.startswith(key + '_'):
                k = key[len(key) + 1:]
                new_parent = getattr(parent, k)
                yield from _split_address(new_parent, k)


def _string_value(name, v, original_value):
    if original_value is None or isinstance(v, str):
        return v

    if isinstance(original_value, int):
        return int(v)

    if isinstance(original_value, float):
        return float(v)

    if isinstance(original_value, bool):
        if v.lower() in ('t', 'true'):
            return True
        if v.lower() in ('f', 'false'):
            return False
        raise ValueError(f'Cannot understand bool {name}={v}')

    if isinstance(original_value, Enum):
        return type(original_value)[v]

    return json.loads(v)


def _load(p):
    if p.suffix == '.json':
        return json.loads(p.read_text())

    if p.suffix == '.toml':
        try:
            import tomllib
            return tomllib.loads(p.read_text())
        except ImportError:
            import tomlkit
            return tomlkit.loads(p.read_text())

    if p.suffix == '.yaml':
        import yaml
        return yaml.safe_load(p.read_text())

    raise ValueError('Do not understand suffix=' + p.suffix)


_getenv = os.environ.get
_expandvars = os.path.expandvars


class App:
    """
    `cfg.App` is the main class, but it has no methods - it just holds the
    `config`, `data`, `cache` and `xdg` objects.
    """

    DEFAULT_FORMAT = 'json'
    """The default, default file format for all Apps"""

    def __init__(
        self, name, format=DEFAULT_FORMAT, read_kwds=None, write_kwds=None
    ):
        """
        Arguments:

          name: the name of the App.  `name` is used as a directory
                name so it should not contain any characters illegal in
                pathnames

          format: the format for config and data files from this App
        """

        def path(attrname):
            path = getattr(self.xdg, attrname)
            if attrname.endswith('DIRS'):
                return [os.path.join(i, self.name) for i in path.split(':')]
            return os.path.join(path, self.name)

        _check_filename(name)

        self.name = name
        """The text name of the App"""

        self.xdg = XDG()
        """A `cfg.XFG` as of when the App was constructed."""

        self.cache = Cache(path('XDG_CACHE_HOME'))
        """A `cfg.Cache` that manages cache directories"""

        if format not in FORMATS:
            raise ValueError('Unknown format', format)

        if format == 'configparser':
            self.format = ConfigparserFormat()
            """A `cfgs.Format` representing the data format."""
        else:
            self.format = Format(format, read_kwds, write_kwds)

        h, d = path('XDG_CONFIG_HOME'), path('XDG_CONFIG_DIRS')
        self.config = Directory(h, d, self.format)
        """A `cfgs.Directory` for config files"""

        h, d = path('XDG_DATA_HOME'), path('XDG_DATA_DIRS')
        self.data = Directory(h, d, self.format)
        """A `cfgs.Directory` for data files"""


class XDG:
    """
    The XDG Base Directory Spec mandates six directories for config and data
    files, caches and runtime files, with default values that can be overridden
    through environment variables.  This class takes a snapshot of these six
    directories using the current environment.
    """

    def __init__(self):
        """
        Construct the class with a snapshot of the six XDG base directories
        """

        def get(k, v):
            return _getenv(k) or _expandvars(v)

        self.XDG_CACHE_HOME = get('XDG_CACHE_HOME', '$HOME/.cache')
        """Base directory relative to which
           user-specific non-essential (cached) data should be written
        """

        self.XDG_CONFIG_DIRS = get('XDG_CONFIG_DIRS', '/etc/xdg')
        """A set of preference ordered base directories relative to which
           configuration files should be searched
        """

        self.XDG_CONFIG_HOME = get('XDG_CONFIG_HOME', '$HOME/.config')
        """Base directory relative to which user-specific
           configuration files should be written
        """

        self.XDG_DATA_DIRS = get(
            'XDG_DATA_DIRS', '/usr/local/share/:/usr/share/'
        )
        """A set of preference ordered base directories relative to which
           data files should be searched
        """

        self.XDG_DATA_HOME = get('XDG_DATA_HOME', '$HOME/.local/share')
        """Base directory relative to which user-specific
           data files should be written
        """

        self.XDG_RUNTIME_DIR = get('XDG_RUNTIME_DIR', '')
        """Base directory relative to which
           user-specific runtime files and other file objects should be placed
        """


class Directory:
    """
    An XDG directory of persistent, formatted files
    """

    def __init__(self, home, dirs, format):
        """
        Don't call this constructor directly - use either
        `cfgs.App.config` or `cfgs.App.data` instead.
        """
        self.home = home
        self.dirs = dirs
        assert not isinstance(format, str)
        self.format = format
        self.dirs.insert(0, self.home)

    def open(self, filename=None):
        """
        Open a persistent `cfg.File`.

        Arguments:
          filename: The name of the persistent file. If None,
            `filename` defaults to `cfg.App.name` plus the format suffix

          format: A string representing the file format.  If None,
             first try to guess the filename from the filename, then use
             `self.format`
        """
        if not filename:
            basename = os.path.basename(self.home)
            suffix = FORMAT_TO_SUFFIX[self.format.name]
            filename = '%s%s' % (basename, suffix)
        elif filename.startswith('/'):
            filename = filename[1:]

        return File(self.full_name(filename), self.format)

    def all_files(self, filename):
        """
        Yield all filenames matching the argument in either the home
        directory or any of the search directories
        """
        for p in self.dirs:
            full_path = os.path.join(p, filename)
            try:
                yield open(full_path) and full_path
            except IOError:
                pass

    def full_name(self, filename):
        """
        Return the full name of a file with respect to this XDG directory
        """
        return os.path.join(self.home, filename)


class File:
    """
    A formatted data or config file where you can set and get items,
    and read or write.
    """

    def __init__(self, filename, format):
        """Do not call this constructor directly but use
        `cfg.Directory.open` instead"""

        self.filename = filename
        """The full pathname to the data file"""

        self.contents = {}
        """The contents of the formatted file, read and parsed.

        This will be a `dict` for all formats except `configparser`,
        where it will be a `configparser.SafeConfigParser`.
        """

        os.makedirs(os.path.dirname(self.filename), exist_ok=True)
        self.format = format
        self.read()

    def read(self):
        """Re-read the contents from the file"""
        try:
            with open(self.filename) as fp:
                self.contents = self.format.read(fp)
        except IOError:
            self.contents = self.format.create()
        return self.contents

    def write(self):
        """Write the contents to the file"""
        with open(self.filename, 'w') as fp:
            self.format.write(self.contents, fp)

    def as_dict(self):
        """Return a deep copy of the contents as a dict"""
        return self.format.as_dict(self.contents)

    def clear(self):
        """Clear the contents without writing"""
        self.contents.clear()

    def __enter__(self):
        return self

    def __exit__(self, *args):
        self.write()


class Cache:
    """
    A class that creates caches
    """

    def __init__(self, dirname):
        """Do not call this constructor - instead use `cfgs.App.cache` """

        self.dirname = dirname
        """The full path of the root directory for all cache directories"""

    def directory(self, name='cache', cache_size=0):
        """
        Return a `cfgs.CacheDirectory`

        Arguments:
          name: The relative pathname of the cache directory

          cache_size: The number of bytes allowed in the cache.
              The default of 0 means "unlimited cache size"
        """
        name = os.path.join(self.dirname, name)
        return CacheDirectory(name, cache_size)


class CacheDirectory:
    def __init__(self, dirname, cache_size):
        """Do not call this constructor - use `cfgs.Cache.directory`"""

        self.dirname = dirname
        """The full path to this cache directory"""

        self.cache_size = cache_size
        """
        The number of bytes allowed in the cache.
        0 means "unlimited cache size"
        """

        _makedirs(self.dirname)
        self.prune()

    def open(self, filename, size_guess=0, binary=False):
        """
        Open a cached file in this directory.

        If the file already exists, it is opened for read.

        Otherwise the cache is pruned and the file is opened for write.

        Arguments:
          filename: the name of the file, relative to the cache directory
          size_guess: A guess as to how large the file will be, in bytes
          binary: if True, the file is opened in binary mode

        """
        if '/' in filename:
            raise ValueError('Subdirectories are not allowed in caches')

        bin = 'b' if binary else ''

        full = os.path.join(self.dirname, filename)
        if os.path.exists(full):
            return open(full, 'r' + bin)

        self.prune(size_guess)
        return open(full, 'w' + bin)

    def prune(self, bytes_needed=0):
        """
        Prune the cache to generate at least `bytes_needed` of free space,
        if this is possible.
        """
        if not self.cache_size:
            return

        files = os.listdir(self.dirname)
        info = {f: os.stat(os.path.join(self.dirname, f)) for f in files}
        required_size = sum(s.st_size for f, s in info.items()) + bytes_needed
        if required_size <= self.cache_size:
            return

        # Delete oldest items first
        for f, s in sorted(info.items(), key=lambda x: x[1].st_mtime):
            os.remove(os.path.join(self.dirname, f))
            required_size -= s.st_size
            if required_size <= self.cache_size:
                return


def _check_filename(filename):
    # Just a heuristic - names might pass this test and still not
    # be valid i.e. CON on Windows.
    bad_chars = _BAD_CHARS.intersection(set(filename))
    if bad_chars:
        bad_chars = ''.join(sorted(bad_chars))
        raise ValueError('Invalid characters in filename: "%s"' % bad_chars)


SUFFIX_TO_FORMAT = {
    '.cfg': 'configparser',
    '.ini': 'configparser',
    '.json': 'json',
    '.toml': 'toml',
    '.yaml': 'yaml',
    '.yml': 'yaml',
}
"""
Map file suffixes to the file format - the partial inverse
to `cfgs.FORMAT_TO_SUFFIX`
"""

FORMAT_TO_SUFFIX = {
    'configparser': '.ini',
    'json': '.json',
    'toml': '.toml',
    'yaml': '.yml',
}
"""
Map file formats to file suffix - the partial inverse
to `cfgs.SUFFIX_TO_FORMAT`
"""

FORMATS = set(SUFFIX_TO_FORMAT.values())
"""A list of all formats that `cfgs` understands."""


class Format:
    def __init__(self, format, read_kwds, write_kwds):
        self.name = format
        """The name of this format"""

        self._read_kwds = read_kwds or {}
        self._write_kwds = write_kwds or {}
        self._parser = __import__(format)

    def read(self, fp):
        """Read contents from an open file in this format"""
        load = getattr(self._parser, 'safe_load', self._parser.load)
        return load(fp, **self._read_kwds)

    def write(self, contents, fp):
        """Write contents in this format to an open file"""
        dump = getattr(self._parser, 'safe_dump', self._parser.dump)
        return dump(contents, fp, **self._write_kwds)

    def create(self):
        """Return new, empty contents"""
        return {}

    def as_dict(self, contents):
        """Convert the contents to a dict"""
        return copy.deepcopy(contents)


class ConfigparserFormat(Format):
    name = 'configparser'
    """The name of the configparser format"""

    def __init__(self):
        self._parser = __import__(self.name)

    def read(self, fp):
        """Read contents from an open file in this format"""
        contents = self.create()
        contents.readfp(fp)
        return contents

    def write(self, contents, fp):
        """Write contents in this format to an open file"""
        contents.write(fp)

    def create(self):
        """Return new, empty contents"""
        return self._parser.SafeConfigParser()

    def as_dict(self, contents):
        """Convert the contents to a dict"""
        return {k: dict(v) for k, v in contents.items()}


def _makedirs(f):  # For Python 2 compatibility
    try:
        os.makedirs(f)
    except Exception:
        pass


_BAD_CHARS = set('/\\?%*:|"<>\';')

Global variables

var FORMATS

A list of all formats that cfgs understands.

var FORMAT_TO_SUFFIX

Map file formats to file suffix - the partial inverse to SUFFIX_TO_FORMAT

var SUFFIX_TO_FORMAT

Map file suffixes to the file format - the partial inverse to FORMAT_TO_SUFFIX

Classes

class App (name, format='json', read_kwds=None, write_kwds=None)

cfg.App is the main class, but it has no methods - it just holds the config, data, cache and xdg objects.

Arguments

name: the name of the App. name is used as a directory name so it should not contain any characters illegal in pathnames

format: the format for config and data files from this App

Expand source code
class App:
    """
    `cfg.App` is the main class, but it has no methods - it just holds the
    `config`, `data`, `cache` and `xdg` objects.
    """

    DEFAULT_FORMAT = 'json'
    """The default, default file format for all Apps"""

    def __init__(
        self, name, format=DEFAULT_FORMAT, read_kwds=None, write_kwds=None
    ):
        """
        Arguments:

          name: the name of the App.  `name` is used as a directory
                name so it should not contain any characters illegal in
                pathnames

          format: the format for config and data files from this App
        """

        def path(attrname):
            path = getattr(self.xdg, attrname)
            if attrname.endswith('DIRS'):
                return [os.path.join(i, self.name) for i in path.split(':')]
            return os.path.join(path, self.name)

        _check_filename(name)

        self.name = name
        """The text name of the App"""

        self.xdg = XDG()
        """A `cfg.XFG` as of when the App was constructed."""

        self.cache = Cache(path('XDG_CACHE_HOME'))
        """A `cfg.Cache` that manages cache directories"""

        if format not in FORMATS:
            raise ValueError('Unknown format', format)

        if format == 'configparser':
            self.format = ConfigparserFormat()
            """A `cfgs.Format` representing the data format."""
        else:
            self.format = Format(format, read_kwds, write_kwds)

        h, d = path('XDG_CONFIG_HOME'), path('XDG_CONFIG_DIRS')
        self.config = Directory(h, d, self.format)
        """A `cfgs.Directory` for config files"""

        h, d = path('XDG_DATA_HOME'), path('XDG_DATA_DIRS')
        self.data = Directory(h, d, self.format)
        """A `cfgs.Directory` for data files"""

Class variables

var DEFAULT_FORMAT

The default, default file format for all Apps

Instance variables

var cache

A cfg.Cache that manages cache directories

var config

A Directory for config files

var data

A Directory for data files

var name

The text name of the App

var xdg

A cfg.XFG as of when the App was constructed.

class Cache (dirname)

A class that creates caches

Do not call this constructor - instead use App.cache

Expand source code
class Cache:
    """
    A class that creates caches
    """

    def __init__(self, dirname):
        """Do not call this constructor - instead use `cfgs.App.cache` """

        self.dirname = dirname
        """The full path of the root directory for all cache directories"""

    def directory(self, name='cache', cache_size=0):
        """
        Return a `cfgs.CacheDirectory`

        Arguments:
          name: The relative pathname of the cache directory

          cache_size: The number of bytes allowed in the cache.
              The default of 0 means "unlimited cache size"
        """
        name = os.path.join(self.dirname, name)
        return CacheDirectory(name, cache_size)

Instance variables

var dirname

The full path of the root directory for all cache directories

Methods

def directory(self, name='cache', cache_size=0)

Return a CacheDirectory

Arguments

name: The relative pathname of the cache directory

cache_size: The number of bytes allowed in the cache. The default of 0 means "unlimited cache size"

Expand source code
def directory(self, name='cache', cache_size=0):
    """
    Return a `cfgs.CacheDirectory`

    Arguments:
      name: The relative pathname of the cache directory

      cache_size: The number of bytes allowed in the cache.
          The default of 0 means "unlimited cache size"
    """
    name = os.path.join(self.dirname, name)
    return CacheDirectory(name, cache_size)
class CacheDirectory (dirname, cache_size)

Do not call this constructor - use Cache.directory()

Expand source code
class CacheDirectory:
    def __init__(self, dirname, cache_size):
        """Do not call this constructor - use `cfgs.Cache.directory`"""

        self.dirname = dirname
        """The full path to this cache directory"""

        self.cache_size = cache_size
        """
        The number of bytes allowed in the cache.
        0 means "unlimited cache size"
        """

        _makedirs(self.dirname)
        self.prune()

    def open(self, filename, size_guess=0, binary=False):
        """
        Open a cached file in this directory.

        If the file already exists, it is opened for read.

        Otherwise the cache is pruned and the file is opened for write.

        Arguments:
          filename: the name of the file, relative to the cache directory
          size_guess: A guess as to how large the file will be, in bytes
          binary: if True, the file is opened in binary mode

        """
        if '/' in filename:
            raise ValueError('Subdirectories are not allowed in caches')

        bin = 'b' if binary else ''

        full = os.path.join(self.dirname, filename)
        if os.path.exists(full):
            return open(full, 'r' + bin)

        self.prune(size_guess)
        return open(full, 'w' + bin)

    def prune(self, bytes_needed=0):
        """
        Prune the cache to generate at least `bytes_needed` of free space,
        if this is possible.
        """
        if not self.cache_size:
            return

        files = os.listdir(self.dirname)
        info = {f: os.stat(os.path.join(self.dirname, f)) for f in files}
        required_size = sum(s.st_size for f, s in info.items()) + bytes_needed
        if required_size <= self.cache_size:
            return

        # Delete oldest items first
        for f, s in sorted(info.items(), key=lambda x: x[1].st_mtime):
            os.remove(os.path.join(self.dirname, f))
            required_size -= s.st_size
            if required_size <= self.cache_size:
                return

Instance variables

var cache_size

The number of bytes allowed in the cache. 0 means "unlimited cache size"

var dirname

The full path to this cache directory

Methods

def open(self, filename, size_guess=0, binary=False)

Open a cached file in this directory.

If the file already exists, it is opened for read.

Otherwise the cache is pruned and the file is opened for write.

Arguments

filename: the name of the file, relative to the cache directory size_guess: A guess as to how large the file will be, in bytes binary: if True, the file is opened in binary mode

Expand source code
def open(self, filename, size_guess=0, binary=False):
    """
    Open a cached file in this directory.

    If the file already exists, it is opened for read.

    Otherwise the cache is pruned and the file is opened for write.

    Arguments:
      filename: the name of the file, relative to the cache directory
      size_guess: A guess as to how large the file will be, in bytes
      binary: if True, the file is opened in binary mode

    """
    if '/' in filename:
        raise ValueError('Subdirectories are not allowed in caches')

    bin = 'b' if binary else ''

    full = os.path.join(self.dirname, filename)
    if os.path.exists(full):
        return open(full, 'r' + bin)

    self.prune(size_guess)
    return open(full, 'w' + bin)
def prune(self, bytes_needed=0)

Prune the cache to generate at least bytes_needed of free space, if this is possible.

Expand source code
def prune(self, bytes_needed=0):
    """
    Prune the cache to generate at least `bytes_needed` of free space,
    if this is possible.
    """
    if not self.cache_size:
        return

    files = os.listdir(self.dirname)
    info = {f: os.stat(os.path.join(self.dirname, f)) for f in files}
    required_size = sum(s.st_size for f, s in info.items()) + bytes_needed
    if required_size <= self.cache_size:
        return

    # Delete oldest items first
    for f, s in sorted(info.items(), key=lambda x: x[1].st_mtime):
        os.remove(os.path.join(self.dirname, f))
        required_size -= s.st_size
        if required_size <= self.cache_size:
            return
class ConfigparserFormat
Expand source code
class ConfigparserFormat(Format):
    name = 'configparser'
    """The name of the configparser format"""

    def __init__(self):
        self._parser = __import__(self.name)

    def read(self, fp):
        """Read contents from an open file in this format"""
        contents = self.create()
        contents.readfp(fp)
        return contents

    def write(self, contents, fp):
        """Write contents in this format to an open file"""
        contents.write(fp)

    def create(self):
        """Return new, empty contents"""
        return self._parser.SafeConfigParser()

    def as_dict(self, contents):
        """Convert the contents to a dict"""
        return {k: dict(v) for k, v in contents.items()}

Ancestors

Inherited members

class Configs
Expand source code
class Configs:
    def diff(self, other: Any):
        assert self.__class__ is other.__class__
        result = {}
        for f in dc.fields(self):
            s, o = getattr(self, f.name), getattr(other, f.name)
            if s != o:
                if isinstance(s, Configs):
                    assert isinstance(o, Configs)
                    o = s.diff(o)
                result[f.name] = o

        return result

    def copy_from(self, **kwargs):
        for k, v in kwargs.items():
            attr = getattr(self, k)
            if isinstance(attr, Configs):
                attr.copy_from(**v)
            else:
                setattr(self, k, v)

    def load(self, *files: File):
        for f in files:
            self.copy_from(_load(f))

    def load_from_environ(
        self,
        prefix: str,
        environ: Optional[Dict] = None,
        verbose: bool = True,
    ):
        if environ is None:
            environ = os.environ
        pre = prefix.strip('_').upper() + '_'
        items = sorted(environ.items())
        items = ((k, v) for k, v in items if k.startswith(pre))

        for k, v in items:
            attr_name = k[len(pre):].lower()
            splits = list(_split_address(v, attr_name))
            if len(splits) == 1:
                parent, attr = splits[0]
                str_val = getattr(parent, attr)
                val = _string_value(k, v, str_val)
                setattr(parent, attr, val)
            elif not verbose:
                continue
            elif not splits:
                print('No configs match', k, file=sys.err)
            else:
                print('More than one config matches', k, file=sys.err)

Methods

def copy_from(self, **kwargs)
Expand source code
def copy_from(self, **kwargs):
    for k, v in kwargs.items():
        attr = getattr(self, k)
        if isinstance(attr, Configs):
            attr.copy_from(**v)
        else:
            setattr(self, k, v)
def diff(self, other: Any)
Expand source code
def diff(self, other: Any):
    assert self.__class__ is other.__class__
    result = {}
    for f in dc.fields(self):
        s, o = getattr(self, f.name), getattr(other, f.name)
        if s != o:
            if isinstance(s, Configs):
                assert isinstance(o, Configs)
                o = s.diff(o)
            result[f.name] = o

    return result
def load(self, *files: Tuple[Union[pathlib.Path, str]])
Expand source code
def load(self, *files: File):
    for f in files:
        self.copy_from(_load(f))
def load_from_environ(self, prefix: str, environ: Optional[Dict] = None, verbose: bool = True)
Expand source code
def load_from_environ(
    self,
    prefix: str,
    environ: Optional[Dict] = None,
    verbose: bool = True,
):
    if environ is None:
        environ = os.environ
    pre = prefix.strip('_').upper() + '_'
    items = sorted(environ.items())
    items = ((k, v) for k, v in items if k.startswith(pre))

    for k, v in items:
        attr_name = k[len(pre):].lower()
        splits = list(_split_address(v, attr_name))
        if len(splits) == 1:
            parent, attr = splits[0]
            str_val = getattr(parent, attr)
            val = _string_value(k, v, str_val)
            setattr(parent, attr, val)
        elif not verbose:
            continue
        elif not splits:
            print('No configs match', k, file=sys.err)
        else:
            print('More than one config matches', k, file=sys.err)
class Directory (home, dirs, format)

An XDG directory of persistent, formatted files

Don't call this constructor directly - use either App.config or App.data instead.

Expand source code
class Directory:
    """
    An XDG directory of persistent, formatted files
    """

    def __init__(self, home, dirs, format):
        """
        Don't call this constructor directly - use either
        `cfgs.App.config` or `cfgs.App.data` instead.
        """
        self.home = home
        self.dirs = dirs
        assert not isinstance(format, str)
        self.format = format
        self.dirs.insert(0, self.home)

    def open(self, filename=None):
        """
        Open a persistent `cfg.File`.

        Arguments:
          filename: The name of the persistent file. If None,
            `filename` defaults to `cfg.App.name` plus the format suffix

          format: A string representing the file format.  If None,
             first try to guess the filename from the filename, then use
             `self.format`
        """
        if not filename:
            basename = os.path.basename(self.home)
            suffix = FORMAT_TO_SUFFIX[self.format.name]
            filename = '%s%s' % (basename, suffix)
        elif filename.startswith('/'):
            filename = filename[1:]

        return File(self.full_name(filename), self.format)

    def all_files(self, filename):
        """
        Yield all filenames matching the argument in either the home
        directory or any of the search directories
        """
        for p in self.dirs:
            full_path = os.path.join(p, filename)
            try:
                yield open(full_path) and full_path
            except IOError:
                pass

    def full_name(self, filename):
        """
        Return the full name of a file with respect to this XDG directory
        """
        return os.path.join(self.home, filename)

Methods

def all_files(self, filename)

Yield all filenames matching the argument in either the home directory or any of the search directories

Expand source code
def all_files(self, filename):
    """
    Yield all filenames matching the argument in either the home
    directory or any of the search directories
    """
    for p in self.dirs:
        full_path = os.path.join(p, filename)
        try:
            yield open(full_path) and full_path
        except IOError:
            pass
def full_name(self, filename)

Return the full name of a file with respect to this XDG directory

Expand source code
def full_name(self, filename):
    """
    Return the full name of a file with respect to this XDG directory
    """
    return os.path.join(self.home, filename)
def open(self, filename=None)

Open a persistent cfg.File.

Arguments

filename: The name of the persistent file. If None, filename defaults to cfg.App.name plus the format suffix

format: A string representing the file format. If None, first try to guess the filename from the filename, then use self.format

Expand source code
def open(self, filename=None):
    """
    Open a persistent `cfg.File`.

    Arguments:
      filename: The name of the persistent file. If None,
        `filename` defaults to `cfg.App.name` plus the format suffix

      format: A string representing the file format.  If None,
         first try to guess the filename from the filename, then use
         `self.format`
    """
    if not filename:
        basename = os.path.basename(self.home)
        suffix = FORMAT_TO_SUFFIX[self.format.name]
        filename = '%s%s' % (basename, suffix)
    elif filename.startswith('/'):
        filename = filename[1:]

    return File(self.full_name(filename), self.format)
class File (filename, format)

A formatted data or config file where you can set and get items, and read or write.

Do not call this constructor directly but use cfg.Directory.open instead

Expand source code
class File:
    """
    A formatted data or config file where you can set and get items,
    and read or write.
    """

    def __init__(self, filename, format):
        """Do not call this constructor directly but use
        `cfg.Directory.open` instead"""

        self.filename = filename
        """The full pathname to the data file"""

        self.contents = {}
        """The contents of the formatted file, read and parsed.

        This will be a `dict` for all formats except `configparser`,
        where it will be a `configparser.SafeConfigParser`.
        """

        os.makedirs(os.path.dirname(self.filename), exist_ok=True)
        self.format = format
        self.read()

    def read(self):
        """Re-read the contents from the file"""
        try:
            with open(self.filename) as fp:
                self.contents = self.format.read(fp)
        except IOError:
            self.contents = self.format.create()
        return self.contents

    def write(self):
        """Write the contents to the file"""
        with open(self.filename, 'w') as fp:
            self.format.write(self.contents, fp)

    def as_dict(self):
        """Return a deep copy of the contents as a dict"""
        return self.format.as_dict(self.contents)

    def clear(self):
        """Clear the contents without writing"""
        self.contents.clear()

    def __enter__(self):
        return self

    def __exit__(self, *args):
        self.write()

Instance variables

var contents

The contents of the formatted file, read and parsed.

This will be a dict for all formats except configparser, where it will be a configparser.SafeConfigParser.

var filename

The full pathname to the data file

Methods

def as_dict(self)

Return a deep copy of the contents as a dict

Expand source code
def as_dict(self):
    """Return a deep copy of the contents as a dict"""
    return self.format.as_dict(self.contents)
def clear(self)

Clear the contents without writing

Expand source code
def clear(self):
    """Clear the contents without writing"""
    self.contents.clear()
def read(self)

Re-read the contents from the file

Expand source code
def read(self):
    """Re-read the contents from the file"""
    try:
        with open(self.filename) as fp:
            self.contents = self.format.read(fp)
    except IOError:
        self.contents = self.format.create()
    return self.contents
def write(self)

Write the contents to the file

Expand source code
def write(self):
    """Write the contents to the file"""
    with open(self.filename, 'w') as fp:
        self.format.write(self.contents, fp)
class Format (format, read_kwds, write_kwds)
Expand source code
class Format:
    def __init__(self, format, read_kwds, write_kwds):
        self.name = format
        """The name of this format"""

        self._read_kwds = read_kwds or {}
        self._write_kwds = write_kwds or {}
        self._parser = __import__(format)

    def read(self, fp):
        """Read contents from an open file in this format"""
        load = getattr(self._parser, 'safe_load', self._parser.load)
        return load(fp, **self._read_kwds)

    def write(self, contents, fp):
        """Write contents in this format to an open file"""
        dump = getattr(self._parser, 'safe_dump', self._parser.dump)
        return dump(contents, fp, **self._write_kwds)

    def create(self):
        """Return new, empty contents"""
        return {}

    def as_dict(self, contents):
        """Convert the contents to a dict"""
        return copy.deepcopy(contents)

Subclasses

Instance variables

var name

The name of this format

Methods

def as_dict(self, contents)

Convert the contents to a dict

Expand source code
def as_dict(self, contents):
    """Convert the contents to a dict"""
    return copy.deepcopy(contents)
def create(self)

Return new, empty contents

Expand source code
def create(self):
    """Return new, empty contents"""
    return {}
def read(self, fp)

Read contents from an open file in this format

Expand source code
def read(self, fp):
    """Read contents from an open file in this format"""
    load = getattr(self._parser, 'safe_load', self._parser.load)
    return load(fp, **self._read_kwds)
def write(self, contents, fp)

Write contents in this format to an open file

Expand source code
def write(self, contents, fp):
    """Write contents in this format to an open file"""
    dump = getattr(self._parser, 'safe_dump', self._parser.dump)
    return dump(contents, fp, **self._write_kwds)
class XDG

The XDG Base Directory Spec mandates six directories for config and data files, caches and runtime files, with default values that can be overridden through environment variables. This class takes a snapshot of these six directories using the current environment.

Construct the class with a snapshot of the six XDG base directories

Expand source code
class XDG:
    """
    The XDG Base Directory Spec mandates six directories for config and data
    files, caches and runtime files, with default values that can be overridden
    through environment variables.  This class takes a snapshot of these six
    directories using the current environment.
    """

    def __init__(self):
        """
        Construct the class with a snapshot of the six XDG base directories
        """

        def get(k, v):
            return _getenv(k) or _expandvars(v)

        self.XDG_CACHE_HOME = get('XDG_CACHE_HOME', '$HOME/.cache')
        """Base directory relative to which
           user-specific non-essential (cached) data should be written
        """

        self.XDG_CONFIG_DIRS = get('XDG_CONFIG_DIRS', '/etc/xdg')
        """A set of preference ordered base directories relative to which
           configuration files should be searched
        """

        self.XDG_CONFIG_HOME = get('XDG_CONFIG_HOME', '$HOME/.config')
        """Base directory relative to which user-specific
           configuration files should be written
        """

        self.XDG_DATA_DIRS = get(
            'XDG_DATA_DIRS', '/usr/local/share/:/usr/share/'
        )
        """A set of preference ordered base directories relative to which
           data files should be searched
        """

        self.XDG_DATA_HOME = get('XDG_DATA_HOME', '$HOME/.local/share')
        """Base directory relative to which user-specific
           data files should be written
        """

        self.XDG_RUNTIME_DIR = get('XDG_RUNTIME_DIR', '')
        """Base directory relative to which
           user-specific runtime files and other file objects should be placed
        """

Instance variables

var XDG_CACHE_HOME

Base directory relative to which user-specific non-essential (cached) data should be written

var XDG_CONFIG_DIRS

A set of preference ordered base directories relative to which configuration files should be searched

var XDG_CONFIG_HOME

Base directory relative to which user-specific configuration files should be written

var XDG_DATA_DIRS

A set of preference ordered base directories relative to which data files should be searched

var XDG_DATA_HOME

Base directory relative to which user-specific data files should be written

var XDG_RUNTIME_DIR

Base directory relative to which user-specific runtime files and other file objects should be placed