[docs]classUserDefaults(collections.abc.MutableMapping):"""Contains user defaults read from the user TOML configuration file. Upon intialisation, an instance of this class will read the user configuration file defined by the first argument. If the input file is specified as a relative path, then it is considered relative to the environment variable ``${XDG_CONFIG_HOME}``, or its default setting (which is operating system dependent, c.f. `XDG defaults`_). This object may be used (with limited functionality) like a dictionary. In this mode, objects of this class read and write values to the ``DEFAULT`` section. The ``len()`` method will also return the number of variables set at the ``DEFAULT`` section as well. Parameters ---------- path The path, absolute or relative, to the file containing the user defaults to read. If `path` is a relative path, then it is considered relative to the directory defined by the environment variable ``${XDG_CONFIG_HOME}`` (read `XDG defaults <https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html>`_ for details on the default location of this directory in the various operating systems). The tilde (`~`) character may be used to represent the user home, and is automatically expanded. logger A logger to use for messaging operations. If not set, use this module's logger. Attributes ---------- path The current path to the user defaults base file. """def__init__(self,path:str|pathlib.Path,logger:logging.Logger|None=None,)->None:self.logger=loggerorlogging.getLogger(__name__)self.path=pathlib.Path(path).expanduser()ifnotself.path.is_absolute():self.path=xdg.xdg_config_home()/self.pathself.logger.info(f"User configuration file set to `{str(self.path)}'")self.data:dict[str,typing.Any]={}self.read()
[docs]defread(self)->None:"""Read configuration file, replaces any internal values."""ifself.path.exists():self.logger.debug("User configuration file exists, reading contents...")self.data.clear()withself.path.open("rb")asf:contents=f.read()# Support for legacy JSON file format. Remove after sometime# FYI: today is September 16, 2022try:data=json.loads(contents)self.logger.warning(f"Converting `{str(self.path)}' from (legacy) JSON "f"to (new) TOML format")self.update(data)self.write()self.clear()# reload contentswithself.path.open("rb")asf:contents=f.read()exceptValueError:passself.data.update(tomli.load(io.BytesIO(contents)))else:self.logger.debug("Initializing empty user configuration...")
[docs]defwrite(self)->None:"""Store any modifications done on the user configuration."""ifself.path.exists():backup=pathlib.Path(str(self.path)+"~")self.logger.debug(f"Backing-up {str(self.path)} -> {str(backup)}")self.path.rename(backup)withself.path.open("wb")asf:tomli_w.dump(self.data,f)self.logger.info(f"Wrote configuration at {str(self.path)}")
def__str__(self)->str:t=io.BytesIO()tomli_w.dump(self.data,t)returnt.getvalue().decode(encoding="utf-8")def__getitem__(self,k:str)->typing.Any:ifkinself.data:returnself.data[k]if"."ink:# search for a key with a matching name after the "."parts=k.split(".")base=self.dataforninrange(len(parts)):ifparts[n]inbase:base=base[parts[n]]if(notisinstance(base,dict))and(n<(len(parts)-1)):# this is an actual value, not another dict whereas it# should not as we have more parts to gobreakelse:breaksubkey=".".join(parts[(n+1):])ifsubkeyinbase:returnbase[subkey]# otherwise, defaults to the default behaviourreturnself.data.__getitem__(k)def__setitem__(self,k:str,v:typing.Any)->None:assertisinstance(k,str)if"."ink:# sets nested subsectionsparts=k.split(".")base=self.dataforninrange(len(parts)-1):base=base.setdefault(parts[n],{})ifnotisinstance(base,dict):raiseKeyError(f"You are trying to set configuration key "f"{k}, but {'.'.join(parts[:(n+1)])} is already a "f"variable set in the file, and not a section")base[parts[-1]]=vreturnv# otherwise, defaults to the default behaviourreturnself.data.__setitem__(k,v)def__delitem__(self,k:str)->None:assertisinstance(k,str)if"."ink:# search for a key with a matching name after the "."parts=k.split(".")base=self.dataforninrange(len(parts)-1):ifparts[n]inbase:base=base[parts[n]]ifnotisinstance(base,dict):# this is an actual value, not another dict whereas it# should not as we have more parts to gobreakelse:breaksubkey=".".join(parts[(n+1):])ifsubkeyinbase:delbase[subkey]returnNone# otherwise, defaults to the default behaviourreturnself.data.__delitem__(k)def__iter__(self)->typing.Iterator[str]:returnself.data.__iter__()def__len__(self)->int:returnself.data.__len__()