fix: inherit .otag.toml from parent folders and use the file's own folder config

This commit is contained in:
randogoth 2026-10-24 21:25:10 +03:00
parent 81d9d62db3
commit 042a60713a
4 changed files with 59 additions and 50 deletions

View file

@ -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"]

View file

@ -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]

View file

@ -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):

View file

@ -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']