diff --git a/config/linux.py b/config/linux.py index 07876481..73100800 100644 --- a/config/linux.py +++ b/config/linux.py @@ -9,7 +9,6 @@ import os import pathlib import sys from configparser import ConfigParser -from typing import Optional, Union, List from config import AbstractConfig, appname, logger assert sys.platform == 'linux' @@ -19,97 +18,100 @@ class LinuxConfig(AbstractConfig): """Linux implementation of AbstractConfig.""" SECTION = 'config' - + # TODO: I dislike this, would rather use a sane config file format. But here we are. __unescape_lut = {'\\': '\\', 'n': '\n', ';': ';', 'r': '\r', '#': '#'} __escape_lut = {'\\': '\\', '\n': 'n', ';': ';', '\r': 'r'} - def __init__(self, filename: Optional[str] = None) -> None: - """ - Initialize LinuxConfig instance. - - :param filename: Optional file name to use for configuration storage. - """ + def __init__(self, filename: str | None = None) -> None: super().__init__() - - # Initialize directory paths + # http://standards.freedesktop.org/basedir-spec/latest/ar01s03.html xdg_data_home = pathlib.Path(os.getenv('XDG_DATA_HOME', default='~/.local/share')).expanduser() self.app_dir_path = xdg_data_home / appname self.app_dir_path.mkdir(exist_ok=True, parents=True) self.plugin_dir_path = self.app_dir_path / 'plugins' self.plugin_dir_path.mkdir(exist_ok=True) + self.respath_path = pathlib.Path(__file__).parent.parent + self.internal_plugin_dir_path = self.respath_path / 'plugins' self.default_journal_dir_path = None # type: ignore + self.identifier = f'uk.org.marginal.{appname.lower()}' # TODO: Unused? - # Configure the filename config_home = pathlib.Path(os.getenv('XDG_CONFIG_HOME', default='~/.config')).expanduser() - self.filename = pathlib.Path(filename) if filename is not None else config_home / appname / f'{appname}.ini' + + self.filename = config_home / appname / f'{appname}.ini' + if filename is not None: + self.filename = pathlib.Path(filename) + self.filename.parent.mkdir(exist_ok=True, parents=True) - # Initialize the configuration - self.config = ConfigParser(comment_prefixes=('#',), interpolation=None) - self.config.read(self.filename) + self.config: ConfigParser | None = ConfigParser(comment_prefixes=('#',), interpolation=None) + self.config.read(self.filename) # read() ignores files that dont exist # Ensure that our section exists. This is here because configparser will happily create files for us, but it # does not magically create sections try: - self.config[self.SECTION].get("this_does_not_exist") + self.config[self.SECTION].get("this_does_not_exist", fallback=None) except KeyError: - logger.info("Config section not found. Backing up existing file (if any) and re-adding a section header") + logger.info("Config section not found. Backing up existing file (if any) and readding a section header") if self.filename.exists(): - backup_filename = self.filename.parent / f'{appname}.ini.backup' - backup_filename.write_bytes(self.filename.read_bytes()) + (self.filename.parent / f'{appname}.ini.backup').write_bytes(self.filename.read_bytes()) + self.config.add_section(self.SECTION) - # Set 'outdir' if not specified or invalid - outdir = self.get_str('outdir') - if outdir is None or not pathlib.Path(outdir).is_dir(): + if (outdir := self.get_str('outdir')) is None or not pathlib.Path(outdir).is_dir(): self.set('outdir', self.home) def __escape(self, s: str) -> str: """ - Escape special characters in a string. + Escape a string using self.__escape_lut. - :param s: The input string. - :return: The escaped string. + This does NOT support multi-character escapes. + + :param s: str - String to be escaped. + :return: str - The escaped string. """ - escaped_chars = [] - + out = "" for c in s: - escaped_chars.append(self.__escape_lut.get(c, c)) + if c not in self.__escape_lut: + out += c + continue - return ''.join(escaped_chars) + out += '\\' + self.__escape_lut[c] + + return out def __unescape(self, s: str) -> str: """ - Unescape special characters in a string. + Unescape a string. - :param s: The input string. - :return: The unescaped string. + :param s: str - The string to unescape. + :return: str - The unescaped string. """ - unescaped_chars = [] + out: list[str] = [] i = 0 while i < len(s): - current_char = s[i] - if current_char != '\\': - unescaped_chars.append(current_char) + c = s[i] + if c != '\\': + out.append(c) i += 1 continue - if i == len(s) - 1: + # We have a backslash, check what its escaping + if i == len(s)-1: raise ValueError('Escaped string has unescaped trailer') - unescaped = self.__unescape_lut.get(s[i + 1]) + unescaped = self.__unescape_lut.get(s[i+1]) if unescaped is None: - raise ValueError(f'Unknown escape: \\{s[i + 1]}') + raise ValueError(f'Unknown escape: \\ {s[i+1]}') - unescaped_chars.append(unescaped) + out.append(unescaped) i += 2 - return "".join(unescaped_chars) + return "".join(out) - def __raw_get(self, key: str) -> Optional[str]: + def __raw_get(self, key: str) -> str | None: """ Get a raw data value from the config file. @@ -121,7 +123,7 @@ class LinuxConfig(AbstractConfig): return self.config[self.SECTION].get(key) - def get_str(self, key: str, *, default: Optional[str] = None) -> str: + def get_str(self, key: str, *, default: str | None = None) -> str: """ Return the string referred to by the given key if it exists, or the default. @@ -129,28 +131,29 @@ class LinuxConfig(AbstractConfig): """ data = self.__raw_get(key) if data is None: - return default or "" + return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default if '\n' in data: - raise ValueError('Expected string, but got list') + raise ValueError('asked for string, got list') return self.__unescape(data) - def get_list(self, key: str, *, default: Optional[list] = None) -> list: + def get_list(self, key: str, *, default: list | None = None) -> list: """ Return the list referred to by the given key if it exists, or the default. Implements :meth:`AbstractConfig.get_list`. """ data = self.__raw_get(key) + if data is None: - return default or [] + return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default split = data.split('\n') if split[-1] != ';': raise ValueError('Encoded list does not have trailer sentinel') - return [self.__unescape(item) for item in split[:-1]] + return list(map(self.__unescape, split[:-1])) def get_int(self, key: str, *, default: int = 0) -> int: """ @@ -159,47 +162,55 @@ class LinuxConfig(AbstractConfig): Implements :meth:`AbstractConfig.get_int`. """ data = self.__raw_get(key) + if data is None: return default try: return int(data) - except ValueError as e: - raise ValueError(f'Failed to convert {key=} to int') from e - def get_bool(self, key: str, *, default: Optional[bool] = None) -> bool: + except ValueError as e: + raise ValueError(f'requested {key=} as int cannot be converted to int') from e + + def get_bool(self, key: str, *, default: bool | None = None) -> bool: """ Return the bool referred to by the given key if it exists, or the default. Implements :meth:`AbstractConfig.get_bool`. """ if self.config is None: - raise ValueError('Attempt to use a closed config') + raise ValueError('attempt to use a closed config') data = self.__raw_get(key) if data is None: - return default or False + return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default return bool(int(data)) - def set(self, key: str, val: Union[int, str, List[str]]) -> None: + def set(self, key: str, val: int | str | list[str]) -> None: """ Set the given key's data to the given value. Implements :meth:`AbstractConfig.set`. """ if self.config is None: - raise ValueError('Attempt to use a closed config') + raise ValueError('attempt to use a closed config') + + to_set: str | None = None if isinstance(val, bool): to_set = str(int(val)) + elif isinstance(val, str): to_set = self.__escape(val) + elif isinstance(val, int): to_set = str(val) + elif isinstance(val, list): to_set = '\n'.join([self.__escape(s) for s in val] + [';']) + else: - raise ValueError(f'Unexpected type for value {type(val).__name__}') + raise ValueError(f'Unexpected type for value {type(val)=}') self.config.set(self.SECTION, key, to_set) self.save() @@ -211,7 +222,7 @@ class LinuxConfig(AbstractConfig): Implements :meth:`AbstractConfig.delete`. """ if self.config is None: - raise ValueError('Attempt to delete from a closed config') + raise ValueError('attempt to use a closed config') self.config.remove_option(self.SECTION, key) self.save() @@ -223,7 +234,7 @@ class LinuxConfig(AbstractConfig): Implements :meth:`AbstractConfig.save`. """ if self.config is None: - raise ValueError('Attempt to save a closed config') + raise ValueError('attempt to use a closed config') with open(self.filename, 'w', encoding='utf-8') as f: self.config.write(f) @@ -235,4 +246,4 @@ class LinuxConfig(AbstractConfig): Implements :meth:`AbstractConfig.close`. """ self.save() - self.config = None # type: ignore + self.config = None