diff --git a/README.md b/README.md index aa44a27..cfe83e0 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ stems/ --- @archive The file can be edited by hand. A line without ` --- ` stops otag with an error naming the file and line, so nothing gets overwritten until it is fixed. Note words starting with `+`, `@` or `#` are read as tags. -An optional `.otag.toml` in the same folder configures `scan` and `write` there: +An optional `.otag.toml` configures `scan` and `write` for its folder and all subfolders. Each file uses the nearest `.otag.toml` above it, which replaces any further up as a whole: ```toml plugins = ["id3", "pdf"] diff --git a/orvanitag/orvani.py b/orvanitag/orvani.py index 39f542a..e9bda06 100644 --- a/orvanitag/orvani.py +++ b/orvanitag/orvani.py @@ -59,8 +59,6 @@ class OrvaniTag: self.CACHEFILE = cache # search index cache ('.otag.cache.json') self.SCHEMA = contexts # Default context schema self.DB = dict() # holds the folder context data - self.CONFIG = dict() # Currently used configuration - self.CONF_FILES = [] # List to keep track of config files in the directory tree self.FILE = '' # holds the folder context file path self.INDEX = self.__emptyindex() # our search index self.__cd('') @@ -72,42 +70,28 @@ class OrvaniTag: return index - def __cd(self, path: str, config: bool=False): + def __cd(self, path: str): """ Change folder context to other directory. Checks if a folder context file exists and loads context DB. path (str): path of the new folder context """ - - # Helper function to load the custom configuration from a folder context - # If none is found, we check if there is one further up the directory tree - def __config(path): - if self.CONF_FILES: # do we have known configurations up the tree? - for conf_file in self.CONF_FILES: - if path == '.': path = './' - # has current directory has a common tree branch with a known configuration? - common = '.' + os.path.commonpath([conf_file, str(path)]) - if common == '.': common = './' - if common == conf_file: - cfile = Path(conf_file) / CONFIG_FILE - if cfile.exists(): - self.CONFIG = tomllib.loads(cfile.read_text(encoding='utf-8')) - continue - else: # if there is no config up the tree, then use defaults - self.CONFIG = dict() - self.FILE = Path(str(path)) / self.FILENAME self.DB = self.__read(self.FILE) if self.FILE.exists() else dict() - cfile = Path(str(path)) / CONFIG_FILE - if cfile.exists(): # load optional local configuration - self.CONFIG = tomllib.loads(cfile.read_text(encoding='utf-8')) - self.CONF_FILES.append(str(path)) # and save it in our known files list - self.CONF_FILES = [x for x in self.CONF_FILES if x != ''] - self.CONF_FILES = sorted(list(set(self.CONF_FILES)), key=lambda x: x.count('/'), reverse=True) - elif config: - __config(path) + + def config(self, folder: str='.') -> dict: + """ + Returns the configuration of a folder: the nearest .otag.toml in it or any parent folder. + A nearer configuration replaces the ones above it entirely. + """ + folder = Path(str(folder) or '.').resolve() + for directory in [folder, *folder.parents]: + cfile = directory / CONFIG_FILE + if cfile.is_file(): + return tomllib.loads(cfile.read_text(encoding='utf-8')) + return dict() def __read(self, file: Path) -> dict: @@ -589,9 +573,7 @@ class OrvaniTag: if self.ENABLE_PLUGINS: if files: for file in files: - fpath = os.path.join(root, file) - self.__cd(root, True) - self.read_metadata(fpath) + self.read_metadata(os.path.join(root, file)) if not recursive: # check if to include subdirectories break @@ -626,10 +608,12 @@ class OrvaniTag: Processes a file with all reader plugins to extract its metadata """ + config = self.config(Path(filename).parent) + # Utilities for reader plugins obj = { 'file' : Path(), - 'config' : self.CONFIG, + 'config' : config, 'schema' : self.SCHEMA, 'context' : self.__context, 'string' : self.__get_argv @@ -638,15 +622,15 @@ class OrvaniTag: for plugin_name, plugin in self.__plugins().items(): # Check if plugin is (de)activated by a local folder configuration - if 'plugins' in self.CONFIG: - if plugin_name not in self.CONFIG['plugins']: + if 'plugins' in config: + if plugin_name not in config['plugins']: continue # Load a list of file types the plugin is able to read suffixes = plugin.TYPES # The plugin's default file types - if plugin_name in self.CONFIG: # check for local folder config overrides - if 'types' in self.CONFIG[plugin_name]: - suffixes = self.CONFIG[plugin_name]['types'] + if plugin_name in config: # check for local folder config overrides + if 'types' in config[plugin_name]: + suffixes = config[plugin_name]['types'] o.file = Path(filename) if o.file.suffix in suffixes: # Does the file match a permitted file type @@ -670,9 +654,7 @@ class OrvaniTag: if self.ENABLE_PLUGINS: if files: for file in files: - fpath = os.path.join(root, file) - self.__cd(root, True) - self.write_metadata(fpath, method) + self.write_metadata(os.path.join(root, file), method) if not recursive: # check if to include subdirectories break @@ -686,10 +668,12 @@ class OrvaniTag: method (str): The name of a plugin able to process the file type """ + config = self.config(Path(filename).parent) + # Utilities for writer plugins obj = { 'file' : Path(), - 'config' : self.CONFIG, + 'config' : config, 'schema' : self.SCHEMA, 'context' : {}, 'collect' : self.__collect_contexts, @@ -700,17 +684,17 @@ class OrvaniTag: for plugin_name, plugin in self.__plugins().items(): # Check if plugin is (de)activated by a local folder configuration - if 'plugins' in self.CONFIG: - if plugin_name not in self.CONFIG['plugins']: + if 'plugins' in config: + if plugin_name not in config['plugins']: continue if plugin.NAME == method: # select the reference plugin # Load a list of file types the plugin is able to read suffixes = plugin.TYPES # Load the plugin's defaults - if plugin_name in self.CONFIG: # Check for local folder config overrides - if 'types' in self.CONFIG[plugin_name]: - suffixes = self.CONFIG[plugin_name]['types'] + if plugin_name in config: # Check for local folder config overrides + if 'types' in config[plugin_name]: + suffixes = config[plugin_name]['types'] for suffix in suffixes: if not suffix in plugin.TYPES: suffixes.remove(suffix) @@ -735,7 +719,7 @@ class OrvaniTag: return [5, plugin_name] return [2, filename] else: - return [4, suffixes, plugin_name, plugin_name in self.CONFIG] + return [4, suffixes, plugin_name, plugin_name in config] return [6, method] diff --git a/tests/test_orvani.py b/tests/test_orvani.py index 33e9f28..c45c0ec 100644 --- a/tests/test_orvani.py +++ b/tests/test_orvani.py @@ -144,7 +144,21 @@ def test_copy_tags_from_subfolder_to_folder(tree): def test_config_without_tags(tree): (tree / orvani.CONFIG_FILE).write_text('plugins = ["pdf"]\n') - assert OrvaniTag().CONFIG == {'plugins': ['pdf']} + assert OrvaniTag().config('.') == {'plugins': ['pdf']} + + +def test_subfolders_inherit_config(tree): + (tree / orvani.CONFIG_FILE).write_text('plugins = ["pdf"]\n') + (tree / 'sub' / 'deeper').mkdir() + assert OrvaniTag().config('sub/deeper') == {'plugins': ['pdf']} + + +def test_nearest_config_wins(tree): + (tree / orvani.CONFIG_FILE).write_text('plugins = ["pdf"]\n') + (tree / 'sub' / orvani.CONFIG_FILE).write_text('plugins = ["id3"]\n') + o = OrvaniTag() + assert o.config('sub') == {'plugins': ['id3']} + assert o.config('.') == {'plugins': ['pdf']} def test_removing_last_entry_keeps_config(tree): diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 0a5d501..5d4281c 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -120,3 +120,14 @@ def test_cmd_list_is_recursive_unless_no_recursive(cwd, monkeypatch, capsys): main() out = capsys.readouterr().out assert 'a.mp3' in out and 'deep.mp3' not in out + + +def test_write_uses_config_of_the_files_folder(cwd): + (cwd / 'sub').mkdir() + (cwd / 'sub' / orvani.CONFIG_FILE).write_text('plugins = ["office"]\n') + writer = PdfWriter() + writer.add_blank_page(72, 72) + writer.write(cwd / 'sub' / 'doc.pdf') + + OrvaniTag().add('sub/doc.pdf', '#lofi') + assert OrvaniTag().write_metadata('sub/doc.pdf', 'pdf') == [6, 'pdf']