From f04554a52cbb477e623d627257b1a2d9d9f75a2c Mon Sep 17 00:00:00 2001 From: randogoth Date: Sat, 24 Oct 2026 21:06:34 +0300 Subject: [PATCH] fix: repair search, notes, clean, move, import and write, speed up indexing, migrate packaging to pyproject and uv --- .gitignore | 7 +- README.md | 12 +- build/lib/orvanitag/__init__.py | 2 - build/lib/orvanitag/cmd.py | 303 ------- build/lib/orvanitag/orvani.py | 846 ------------------ build/lib/orvanitag/plugins/__init__.py | 0 build/lib/orvanitag/plugins/frontmatter.py | 22 - .../lib/orvanitag/plugins/hashdoubleslash.py | 63 -- build/lib/orvanitag/plugins/id3.py | 42 - build/lib/orvanitag/plugins/office.py | 105 --- build/lib/orvanitag/plugins/pdf.py | 24 - orvanitag.egg-info/PKG-INFO | 52 -- orvanitag.egg-info/SOURCES.txt | 18 - orvanitag.egg-info/dependency_links.txt | 1 - orvanitag.egg-info/entry_points.txt | 3 - orvanitag.egg-info/requires.txt | 1 - orvanitag.egg-info/top_level.txt | 1 - orvanitag/cmd.py | 29 +- orvanitag/orvani.py | 407 ++++----- orvanitag/plugins/office.py | 131 +-- orvanitag/plugins/pdf.py | 33 +- pyproject.toml | 45 + requirements.txt | 3 - setup.py | 29 - tests/test_orvani.py | 179 ++++ tests/test_plugins.py | 109 +++ uv.lock | 532 +++++++++++ 27 files changed, 1147 insertions(+), 1852 deletions(-) delete mode 100644 build/lib/orvanitag/__init__.py delete mode 100644 build/lib/orvanitag/cmd.py delete mode 100644 build/lib/orvanitag/orvani.py delete mode 100644 build/lib/orvanitag/plugins/__init__.py delete mode 100644 build/lib/orvanitag/plugins/frontmatter.py delete mode 100644 build/lib/orvanitag/plugins/hashdoubleslash.py delete mode 100644 build/lib/orvanitag/plugins/id3.py delete mode 100644 build/lib/orvanitag/plugins/office.py delete mode 100644 build/lib/orvanitag/plugins/pdf.py delete mode 100644 orvanitag.egg-info/PKG-INFO delete mode 100644 orvanitag.egg-info/SOURCES.txt delete mode 100644 orvanitag.egg-info/dependency_links.txt delete mode 100644 orvanitag.egg-info/entry_points.txt delete mode 100644 orvanitag.egg-info/requires.txt delete mode 100644 orvanitag.egg-info/top_level.txt create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 setup.py create mode 100644 tests/test_orvani.py create mode 100644 tests/test_plugins.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index bbdd57d..e88c9fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ __pycache__/ -#.context.yaml \ No newline at end of file +#.context.yaml +build/ +dist/ +*.egg-info/ +.venv/ +.pytest_cache/ diff --git a/README.md b/README.md index c1053e7..1773ddb 100644 --- a/README.md +++ b/README.md @@ -20,16 +20,24 @@ Exactly like tasks in todo.txt, files can be categorized by freely defined conte So a as an example an audio-file could be assigned to project `+album` and have a context of `@drums` and tagged with `#boombap`, `#lofi`, `#snare`. It's completely up to you how you use the different means of categorization. +## Installation + +``` +$ uv tool install 'orvanitag[all]' +``` + +The core only needs PyYAML and rich. Reading and writing embedded metadata uses optional extras: `audio` (pytaglib, needs the TagLib C library), `pdf` (pypdf), `office` (olefile, only for legacy `.doc`/`.xls`/`.ppt`) and `markdown` (python-frontmatter). Plugins whose extra is not installed are skipped. + ## Command Line Tool ### Assigning Contextual Tags Let's ``` -$ orvani add [contextual tags ...] +$ otag add [contextual tags ...] ``` Removing contextual tags from a file ``` -$ orvani remove [contextual tags ...] +$ otag remove [contextual tags ...] ``` \ No newline at end of file diff --git a/build/lib/orvanitag/__init__.py b/build/lib/orvanitag/__init__.py deleted file mode 100644 index 9409f5b..0000000 --- a/build/lib/orvanitag/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .orvani import OrvaniTag -from .cmd import main \ No newline at end of file diff --git a/build/lib/orvanitag/cmd.py b/build/lib/orvanitag/cmd.py deleted file mode 100644 index 3a7063a..0000000 --- a/build/lib/orvanitag/cmd.py +++ /dev/null @@ -1,303 +0,0 @@ -import os -import sys -import math -from argparse import ArgumentParser as argp, SUPPRESS, RawTextHelpFormatter as raw -from pathlib import Path -from rich import box -from rich.console import Console -from rich.table import Table -from orvanitag.orvani import OrvaniTag, CONTEXT_FILE, CACHE_FILE, CONTEXTS - -def mktable(query: str, index: dict, filter=None, trunc_len: int=25): - """Creates a rich table from `otag ls` search result - - query (str): The user query - index (dict): The context index returned from `otag ls` - filter (bool): Show only 'files' or 'dirs' - trunc_len (int): Max. filename length beyond which overflow method kicks in - - """ - console = Console() - - if len(index['found']) > 0: # is there a search result? - - # Create table - table = Table(box=box.HORIZONTALS, show_lines=True) - - # File Path Column Header - table.add_column("Path", style="white", no_wrap=False, overflow='fold', max_width=40) - - # Create Column Headers based on Context Schema - ctx = list(CONTEXTS.keys()) - styles = ["cyan", "yellow", "magenta", "green"] - for i, c in enumerate(ctx): - table.add_column(ctx[i].title(), style=styles[i], overflow='fold') - - # Create Rows Based on Search Results - for file in index['found']: - item = Path(file) - - if filter == 'files' and not item.is_file(): # Only show directories (--dirs flag) - continue - if filter == 'dirs' and not item.is_dir(): # Only show files (--files flag) - continue - - # Our own overflow method for filenames longer than 25 characters (file…name.ext) - long_file_pre = math.floor(trunc_len/2-1) - long_file_post = len(item.name) - math.ceil(trunc_len/2-1) - path = f'{item.parent}/' + ((item.name[:long_file_pre] + '…' + item.name[long_file_post:]) if len(item.name) > trunc_len else item.name) - path = path.replace('./', '') - - fc = index['index']['items'][file] - elements = [] - elements.append(path) - - # Fetch contextual data for each file to add to a table row - for c in ctx: - if not c in fc: - fc[c] = None - if fc[c]: - if CONTEXTS[c]: - prefix = CONTEXTS[c] - else: - prefix = '' - if not type(fc[c]) == str: # Add respective context prefix - elements.append(' '.join([prefix + tag for tag in fc[c]])) - else: # Except for notes - elements.append(fc[c]) - else: - elements.append(None) - - table.add_row(*elements) - console.print(table) - else: - console.print(f":thinking_face: [red]No results found for [white]{query}") - -def nice_context(context: dict): - """ - Rich colors for displaying contextual data - - context (dict): context dict - """ - string = '' - if context['projects']: - string += '[cyan]' + ' '.join(['+' + tag for tag in context['projects']]) + '[/cyan] ' - if context['contexts']: - string += '[yellow]' + ' '.join(['@' + tag for tag in context['contexts']]) + '[/yellow] ' - if context['tags']: - string += '[magenta]' + ' '.join(['#' + tag for tag in context['tags']]) + '[/magenta] ' - if 'note' in context: - string += '[green]\'' + context['note'] + '\'[/green] ' - return string - -def main(): - """otag CLI""" - - # Rich Console - console = Console() - - # Detailed Help Text for the --help flag - command_help = """{add,a} FILE CONTEXT # add context to an existing file -{remove,r} FILE CONTEXT # remove context from an existing file -{list,ls,l} QUERY # list all files with queried context in current directory tree - -{copy,cp} SOURCE DEST # copy file and its context to another directory -{move,mv} SOURCE DEST # move file and its context to another directory -{del,rm} FILE # delete file and its context - -{clean,c} PATH # remove all context for nonexisting files -{purge,p} PATH # remove all context for all files in current directory tree - -{write,w} PLUGIN FILE # write all context of a file to the file as metadata -{import,i} FILE # import context for files from a text file -{export,e} FILE # export context for files to a text file -""" - # Command Line Argument Setup - p = argp(description="OrvaniTagger (otag)", formatter_class=raw) - p.add_argument("--recursive", "-r", action="store_false", help="(list,clean,purge,export) Include subdirectories in search") - p.add_argument("--file-operation", "-f", action="store_true", help="(copy,move,del) Do file operation, not just context") - p.add_argument("--path", "-p", action="store", type=str, help="(list,export) Use custom path", default='') - p.add_argument("--exact", "-e", action="store_true", help="(list) Match exact search term") - p.add_argument("--files", action="store_true", help="(list) Only show files, ignore folders") - p.add_argument("--dirs", "-d", action="store_true", help="(list) Only show directories, ignore files") - p.add_argument("--either", "-or", action="store_true", help="(list) Combine search terms by OR instead of AND") - p.add_argument("--cache", "-c", action="store_true", help="(list) Cache index search (useful with deep directory trees)") - p.add_argument("--delete-cache", "-dc", action="store_true", help="(list) Delete cache file") - p.add_argument("command", nargs="?", default=None, metavar='Command', - help=command_help, type=str, - choices=[ - 'add', 'a', - 'remove', 'r', - 'list', 'ls', 'l', - 'copy', 'cp', - 'move', 'mv', - 'del', 'rm', - 'clean', 'c', - 'purge', 'p', - 'write', 'w', - 'import', 'i', - 'export', 'e', - 'scan', 's' - ]) - p.add_argument("file", nargs="?", type=str, help=SUPPRESS) - p.add_argument("context", nargs="*", type=str, help=SUPPRESS) - p.print_usage = p.print_help - args = p.parse_args() - - # Load Orvani - orvani = OrvaniTag() - - # Parse Arguments and Run otag Methods - try: - if args.command in ['add', 'a']: - if args.file and args.context: - fb = orvani.add(args.file, *args.context) - if fb[0] == 0: - console.print(f':label: Entry {nice_context(fb[2])}successfully added to file [strong]{fb[1]}[/strong].') - elif fb[0] == 1: - console.print(f":thinking_face: File [strong]{fb[1]}[/strong] doesn't exist.") - - elif args.command in ['remove', 'r']: - if args.file: - fb = orvani.remove(args.file, *args.context) - if fb[0] == 0: - if fb[2] == None: - console.print(f':wastebasket: All entries successfully removed from file [strong]{fb[1]}[/strong].') - else: - console.print(f':wastebasket: Entry {nice_context(fb[2])}successfully removed from file [strong]{fb[1]}[/strong].') - elif fb[0] == 1: - console.print(f":thinking_face: File [strong]{fb[1]}[/strong] doesn't exist.") - elif fb[0] == 2: - console.print(f":mag: No match found for [white][strong]{fb[1]}[/strong][/white] and {nice_context(fb[2])}") - - elif args.command in ['list', 'ls', 'l']: - query = [args.file] + args.context - if query: - r = orvani.list(args.path, args.exact, args.either, args.recursive, args.cache, args.delete_cache, *query) - filter = None - if args.files: - filter = 'files' - elif args.dirs: - filter = 'dirs' - mktable(' '.join(sys.argv[1:]), r, filter) - - elif args.command in ['copy', 'cp']: - if args.file and args.context: - fb = orvani.move(args.file, args.context[0], True, args.file_operation) - if fb[0] == 0: - console.print(f':dancers: File [strong]{fb[1]}[/strong] with entry {nice_context(fb[3])}successfully copied to [strong]{fb[2]}[/strong]') - elif fb[0] == 1: - if fb[3] == False and fb[4] == True: - console.print(f":thinking_face: Source file [strong]{fb[1]}[/strong] doesn't exist.") - elif fb[3] == True and fb[4] == False: - console.print(f":thinking_face: Destination [strong]{fb[2]}[/strong] doesn't exist.") - elif fb[3] == False and fb[4] == False: - console.print(f":thinking_face: Source file [strong]{fb[1]}[/strong] and destination [strong]{fb[2]}[/strong] don't exist.") - - elif args.command in ['move', 'mv']: - if args.file and args.context: - fb = orvani.move(args.file, args.context[0], False, args.file_operation) - if fb[0] == 0: - console.print(f':dancers: File [strong]{fb[1]}[/strong] with entry {nice_context(fb[3])}successfully moved to [strong]{fb[2]}[/strong]') - elif fb[0] == 1: - if fb[3] == False and fb[4] == True: - console.print(f":thinking_face: Source file [strong]{fb[1]}[/strong] doesn't exist.") - elif fb[3] == True and fb[4] == False: - console.print(f":thinking_face: Destination [strong]{fb[2]}[/strong] doesn't exist.") - elif fb[3] == False and fb[4] == False: - console.print(f":thinking_face: Source file [strong]{fb[1]}[/strong] and destination [strong]{fb[2]}[/strong] don't exist.") - - elif args.command in ['del', 'rm']: - if args.file: - fb = orvani.delete(args.file, args.file_operation) - if fb[0] == 0: - status = f':wastebasket: Entry for [strong]{fb[1]}[/strong] successfully removed.' - if fb[3]: - status += f'\n:wastebasket: File [strong]{fb[1]}[/strong] successfully deleted.' - if not fb[2] == None: - status += f'\n:wastebasket: File [strong]{fb[2]}[/strong] automatically deleted.' - console.print(status) - elif fb[0] == 1: - console.print(f":thinking_face: File [strong]{fb[1]}[/strong] doesn't exist.") - elif fb[0] == 2: - console.print(f":mag: No entry for [strong]{fb[1]}[/strong] found.") - - - elif args.command in ['purge', 'p']: - - def purge(path): - target = Path(path) - if target.is_file(): - target = Path(target.parent) - if target.is_dir(): - context_file = target / CONTEXT_FILE - if context_file.exists(): - context_file.unlink(missing_ok=True) - console.print(f":wastebasket: File [strong]{context_file}[/strong] successfully purged.") - cache_file = target / CACHE_FILE - if cache_file.exists(): - cache_file.unlink(missing_ok=True) - console.print(f":wastebasket: File [strong]{cache_file}[/strong] successfully purged.") - - if not args.file: - args.file = '.' - purge(args.file) - for (root,dirs,files) in os.walk(args.file): - if dirs: - for dir in dirs: - pp = os.path.join(root, dir) - purge(pp) - - elif args.command in ['clean', 'c']: - if not args.file: - args.file = '' - fb = orvani.clean(args.file, args.recursive) - if fb[0] == 0: - sub = '' - if args.recursive: - sub = ' and subdirectories' - console.print(f':broom: {fb[2]} entries cleaned from [strong]{fb[1]}[/strong]{sub}') - - elif args.command in ['write', 'w']: - if args.file and args.context: - fb = orvani.write(args.context[0], args.file) - if fb[0] == 0: - console.print(f':floppy_disk: Metadata {nice_context(fb[2])}successfully written to file [strong]{fb[1]}[/strong].') - if fb[0] == 4: - console.print(f':card_file_box: Plugin [strong]{fb[2]}[/strong] can only write to files types: [strong]{"[/strong], [strong]".join(fb[1])}[/strong].') - if fb[3]: - console.print(f':information: Plugin configuration found in folder context file.') - if fb[0] == 5: - console.print(f':disappointed: Plugin [strong]{fb[1]}[/strong] can\'t write.') - - elif args.command in ['export', 'e']: - fb = orvani.export(args.file) - if fb[0] == 0: - console.print(f':floppy_disk: All entries successfully written to file [strong]{fb[1]}[/strong].') - elif fb[0] == 2: - console.print(f":mag: No entries found.") - - elif args.command in ['import', 'i']: - - fb = orvani.load(args.file) - if fb[0] == 0: - console.print(f':inbox_tray: [strong]{fb[2]}[/strong] entries successfully imported from file [strong]{fb[1]}[/strong].') - elif fb[0] == 1: - console.print(f":thinking_face: File [strong]{fb[1]}[/strong] doesn't exist.") - elif fb[0] == 2: - console.print(f":mag: No entries found in file [strong]{fb[1]}[/strong].") - elif fb[0] == 4: - console.print(f":thinking_face: File [strong]{fb[1]}[/strong] has wrong format. Cannot import.") - - elif args.command in ['scan', 's']: - if not args.file: - args.file = '' - orvani.scan(args.file, args.recursive) - console.print(f':mag: Finished scanning subdirectories.') - - except Exception as e: - raise Exception - #console.print(f":scream: An Exception occurred: [italic]{e}[/italic]") - -if __name__ == '__main__': - main() diff --git a/build/lib/orvanitag/orvani.py b/build/lib/orvanitag/orvani.py deleted file mode 100644 index 40e20e2..0000000 --- a/build/lib/orvanitag/orvani.py +++ /dev/null @@ -1,846 +0,0 @@ -import os -import re -import yaml -import shutil -from pathlib import Path -from importlib import import_module -from types import SimpleNamespace -from pprint import pprint - -class VerboseSafeDumper(yaml.SafeDumper): - def ignore_aliases(self, data): - return True - -# Defaults - -CONTEXT_FILE = '.context.yaml' -CACHE_FILE = '.index.yaml' -CONTEXTS = { - 'projects' : '+', - 'contexts' : '@', - 'tags' : '#', - 'note' : None -} - -DEFAULT_PLUGINS = [ - 'frontmatter', - 'hashdoubleslash', - 'id3', - 'office', - 'pdf' -] - -""" -ERRORS: -0 - Success -1 - File Not Found -2 - Entry not found -3 - Path is wrong type -4 - Wrong File Type -5 - Plugin has no method -""" - -class OrvaniTag: - - INDEX = {} # our search index - CACHEFILE = '' # search index cache ('.index.yaml') - DB = dict() # holds the folder context data - CONFIG = dict() # Currently used configuration - CONF_FILES = [] # List to keep track of config files in the directory tree - FILENAME = '' # folder context data filename ('.context.yaml') - FILE = '' # holds the folder context file path - SCHEMA = {} # Default context schema - ENABLE_PLUGINS = True # Toggle use of plugins - PLUGINS = [] # List of loaded plugins - REGEX = r'\s*([@+]\w+|#\w+)' - - def __init__(self, path: str=CONTEXT_FILE, cache: str=CACHE_FILE, contexts=CONTEXTS, plugins:list=DEFAULT_PLUGINS): - """ - Initialization - - path (str): override default filename for folder context - cache (str): override default filename for cached index - contexts (dict): override the default contexts - """ - - self.PLUGINS = plugins - self.FILENAME = os.path.basename(path) - self.CACHEFILE = cache - self.SCHEMA = contexts - self.INDEX = self.SCHEMA.copy() - for context_key in self.INDEX: - self.INDEX[context_key] = {} - self.INDEX['items'] = {} - self.__cd('') - - - def __cd(self, path: str, config: bool=False): - """ - 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) / self.FILENAME - if cfile.exists(): - conf = yaml.safe_load(cfile.read_text()) - if 'config' in conf: - self.CONFIG = conf['config'] - continue - else: # if there is no config up the tree, then use defaults - self.CONFIG = dict() - - self.FILE = Path(str(path)) / self.FILENAME - if self.FILE.exists(): # is there a folder ocntext file? - y = yaml.safe_load(self.FILE.read_text()) - - if 'items' in y: - self.DB = y['items'] # load items to current context DB - - if 'config' in y: # load optional local configuration - self.CONFIG = y['config'] - 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) - else: - if config: - __config(path) - - else: - self.DB = dict() - - else: - self.DB = dict() - if config: - __config(path) - - - def __save(self): - """ - Save current folder context to file - """ - file = Path(self.FILE) - yf = { 'items' : self.DB } # load current context into dict - if file.is_file(): # does current folder have a context file? - tmp = yaml.safe_load(file.read_text()) # if yes, load it... - if 'config' in tmp: - yf['config'] = tmp['config'] # ...and save its optional configuration - try: - with file.open('w+') as data: # ...then write it all to the file - yaml.dump(yf, data, Dumper=VerboseSafeDumper) - return self.FILE - except: - return 0 - - - def __context(self, argv: list) -> dict: - """ - Creates a context entry for an existing file according to the schema - - argv (list): sets file contexts according to context schema - """ - context = dict() - # Create context dict according to schema - for context_key in self.SCHEMA.keys(): - if self.SCHEMA[context_key] != None: - context.update( { context_key : []}) - - # Parse the argument list - for arg in [x for x in argv if len(x) > 0]: # remove empty list elements - arg.replace(',', '') # remove commas - - # Add each argument list element to context dict based on the - # prefix symbols defined in the schema - for context_key in self.SCHEMA.keys(): - if str(arg)[0] in self.SCHEMA.values(): - # Schema with prefix - if not self.SCHEMA[context_key] == None: - if str(arg).startswith(self.SCHEMA[context_key]): - context[context_key].append(str(arg)[1:]) - else: - # No prefix, so it must be a note - if self.SCHEMA[context_key] == None: - if arg != ' ': - context.update( { context_key : str(arg)}) - else: - context.update( { context_key : None}) - return context - - - def __add(self, path: str, context: dict): - """Programmatically add context""" - ctx_str = self.__collect_contexts(context) - if ctx_str: - argv = self.__get_argv(' '.join(ctx_str)) - self.add(path, *argv) - - - def __makeindex(self, path:str, recursive:bool=True, cache: bool=False, delete_cache: bool=False): - """ - Creates global context index for directory for searching, optionally also a cache file - Loads plugin readers to also read file specific metadata - - path (dict): root directory - recursive (bool): traverse subdirectories - - """ - cachefile = Path(path) / self.CACHEFILE - loaded_from_cache = False - if delete_cache: - cachefile.unlink(missing_ok=True) - if cache and cachefile.is_file(): # load index from cache - self.INDEX = yaml.safe_load(cachefile.read_text()) - loaded_from_cache = True - - else: # index the directory manually - self.__indexfolder(path) # index current directory - - # Dive into the subdirectories - for (root,dirs,files) in os.walk(path, topdown=True): - - if not recursive: # check if to include subdirectories - break - - # index each subdirectory - if dirs: - for dir in dirs: - rpath = os.path.join(root, dir) - self.__indexfolder(rpath) # index subdirectory - - # If we just created an index, write it to cache - if cache and not loaded_from_cache: - with open(cachefile, 'w+') as data: - yaml.dump(self.INDEX, data) - - - def __indexfolder(self, sub: str): - """ - Load a folder's context and add it to the global context index - - sub (str): path of folder - """ - target = Path(sub) - if target.is_dir(): - self.__cd(sub) - if self.DB: - for file in self.DB.keys(): - path = os.path.join(sub, file) - for context_key in self.SCHEMA.keys(): - if context_key in self.DB[file]: - self.__updateindex(path, self.DB[file], context_key) - - - def __updateindex(self, path: str, context: dict, context_key: str): - """ - Adds entries of a single context type of a file to the global context index. - The context entry is associated with the file in the 'items' index - The file is associated with its context entry - - sub (str): path of folder - """ - # Update files index - for key, value in context.items(): - if path in self.INDEX['items']: # check if there is already an entry - # if yes, merge the entries - if key in self.INDEX['items'][path] and isinstance(self.INDEX['items'][path][key], list) and isinstance(value, list): - self.INDEX['items'][path][key] += value - self.INDEX['items'][path][key] = list(set(self.INDEX['items'][path][key])) - # if not, just add them - else: - self.INDEX['items'][path][key] = value - else: # if not, just add the entry - self.INDEX['items'][path] = { key : value } - - # sort the file index alphabetically - self.INDEX['items'] = {key: value for key, value in sorted(self.INDEX['items'].items())} - - # Update context index - # Add each file path to a context entry - - if type(context[context_key]) == list: # is it a context entry - for item in context[context_key]: - filelist = [path] - if context_key in self.INDEX: - if item in self.INDEX[context_key]: - filelist += self.INDEX[context_key][item] - self.INDEX[context_key][item] = list(set(filelist)) - else: # or is it a note - item = context[context_key] - filelist = [path] - if context_key in self.INDEX: - if item in self.INDEX[context_key]: - filelist += self.INDEX[context_key][item] - self.INDEX[context_key][item] = list(set(filelist)) - - - def __search(self, search, exact:bool=False, either:bool=False): - """ - Searches the index and adds search result to it - - search (list): search query list - exact (bool): exact match or partial match - either (bool): combine found items by OR instead of AND - - """ - result = { - 'index' : self.INDEX, - 'found' : [] - } - if search[0]: # is there a search query? - - query = self.__context(search) # turn it into a context dict - return_dict = self.__context([' ']) # create an empty context dict - universal = {i for i in self.SCHEMA if self.SCHEMA[i]==None}.pop() # get the schema key with the None value, so we know which one to use for the note - - if universal in query: # does the query have an entry without context prefix? - query[universal] = [query[universal]] # turn it into a list for better processing - - for context_key in self.SCHEMA.keys(): # turn all values in the empty dict into list types - return_dict[context_key] = [] - - # Iterate Search Query Terms - for param in query.keys(): - needles = query[param] - - # Add the query without content prefix to all other contexts - # It is a wildcard to search all contexts at once - if universal in query.keys(): - needles += query[universal] - - # Run the search and add every found file path to the return dict - # in its respective context (or all of them for the wildcard search) - if needles: - haystack = self.INDEX[param].keys() - for hay in haystack: - for needle in needles: - if exact: - if needle == hay: - if self.INDEX[param][hay] not in return_dict[param]: - return_dict[param] += self.INDEX[param][hay] - else: - if needle in hay: - if self.INDEX[param][hay] not in return_dict[param]: - return_dict[param] += self.INDEX[param][hay] - - if len(search) > 1 and not either: - # we do an AND search by default, but only if there was more than 1 query item - result['found'] = list(set(self.__intersect(return_dict))) - - else: - # for 1 query searches and OR searches we simply add all of the matches - min = [] - for sublist in return_dict.values(): - min += sublist - result['found'] = list(set(min)) - - else: - # return the whole index if there was no query item - result['found'] = self.INDEX['items'].keys() - - # Sort the result directory tree from shallow to deep based on the occurrence of / - result['found'] = sorted(result['found'], key=lambda x: x.count('/')) - - return result - - - def __intersect(self, result: dict): - """ - Returns an intersection of two search queries (AND) - - result (dict): dict with search result lists to be intersected - """ - - mresult = {key: value for key, value in result.items() if value} # remove empty items - - if len(mresult) < 2: # if there is not enough to intersect, just return it - return [] - - common_elements = set(mresult[next(iter(mresult))]) # elements common in all - - for key in mresult.keys(): - # intersect common elements - common_elements = common_elements.intersection(mresult[key]) - - return list(common_elements) - - - def __collect_contexts(self, context: dict): - """ - Reuturns a list of prefixed context entries from a context dict - """ - elements = [] - context_keys = list(CONTEXTS.keys()) - for context_key in context_keys: - if not context_key in context: - context[context_key] = '' - if context[context_key]: - if CONTEXTS[context_key]: - prefix = CONTEXTS[context_key] - else: - prefix = '' - if not type(context[context_key]) == str: - elements.append(' '.join([prefix + tag for tag in context[context_key]])) - else: - elements.append(context[context_key]) - return elements - - - def __get_argv(self, context_string: str): - """ - Make a list of args from a context string to pass as an *argv parameter - """ - - tags = re.findall(self.REGEX, context_string) - note = re.sub(self.REGEX, '', context_string).lstrip() - argv = ' '.join(tags).split(' ') + [note] - argv = [x for x in list(set(argv)) if x != ''] # make unique and remove empty items - return argv - - - def add(self, file: str, *argv: str): - """ - Add context to existing filename and saves local folder context file. - - file (str): path to existing file - argv (list): context parameters passed to be added - - """ - target = Path(file) - - if target.exists(): # Only existing files and folders - self.__cd(target.parent) # Load folder context - context = self.__context(argv) # Create context dict from argument list - - if target.name in self.DB: # Does the file exist in our local context DB? - - # Add entries from argument context to context DB - for context_key in self.SCHEMA.keys(): - if self.SCHEMA[context_key] != None: - # Add to context and remove duplicate entries - self.DB[target.name][context_key] = list(set( self.DB[target.name][context_key] + context[context_key])) - else: - # Add the note - if context_key in context: - self.DB[target.name][context_key] = context[context_key] - - else: # If file or folder does not exist in context DB yet, add it - self.DB.update( { target.name : context } ) - - # Save to file - saved = self.__save() - if saved and context: - return [0, file, context] - else: - return [1, file] - - - def remove(self, file: str, *argv: str): - """ - Remove context of existing filename and saves changed folder context file - - file (str): path to existing file - argv (list): context parameters to be removed - - """ - target = Path(file) - all = False - if target.exists(): # Only existing files and folders - self.__cd(target.parent) # Load folder context - if len(argv) == 0: - all = True - context = self.__context(argv) # Create context dict from argument list - - if target.name in self.DB: # Does the file exist in our local context DB? - - saved = False - if all: - del self.DB[target.name] - saved = self.__save() - return [0, file, None] - else: - for context_key in self.SCHEMA.keys(): - # Only works for context list items - if self.SCHEMA[context_key] != None: - for item in context[context_key]: - if item in self.DB[target.name][context_key]: - self.DB[target.name][context_key].remove(item) - saved = self.__save() - - if saved and self.DB[target.name]: - return [0, file, context] - else: - return [2, file, context] - else: - return [2, file, context] - else: - return [1, file] - - - def delete(self, file: str, fop:bool=False): - """ - Deletes all entries of an existing file and optionally deletes the file - - file (str): path to existing file - fop (bool): if True removes the referenced file - - """ - target = Path(file) - if target.exists(): # Does the target file exist? - self.__cd(target.parent) # Load its local context - else: - return [1, file] - if target.name in self.DB: # Do we have a record in the DB? - del self.DB[target.name] # Delete it - else: - return [2, file] - saved = self.__save() - if not self.DB: # If local context is empty now, we can delete the file - Path(self.FILE).unlink(missing_ok=True) - if fop: # delete the referenced file too? - target.unlink(missing_ok=True) - return [0, file, self.FILE if not self.DB else None, fop] - - - def move(self, source: str, dest: str, retain: bool=False, fop: bool=True): - """ - Copies or moves existing file with contexts to a new destination - - source (str): existing source file - dest (str): the new file destination - retain (bool): if True then copies, if False then moves - fop (bool): if True also does the file operation, if False only moves context data - - """ - # Let's use trusty old os lib over pathlib for this one - # Check if source file and destination folder exist - if os.path.exists(source) and os.path.isdir(os.path.dirname(dest)): - - sourcefolder = os.path.dirname(source) - sourcefile = os.path.basename(source) - destfolder = os.path.dirname(dest) - destfile = os.path.basename(dest) - - # If no destination file name was defined, but fop is True - # use the same as the source - if fop: - if not destfile: - destfile = sourcefile - else: - if not destfile: - destfile = destfolder - destfolder = os.path.dirname(destfolder) - if not sourcefile: - sourcefile = sourcefolder - sourcefolder = os.path.dirname(sourcefolder) - - sourcedata = {} - self.__cd(sourcefolder) # load local context - if self.DB[sourcefile]: # check if we have an entry for this file - sourcedata = self.DB[sourcefile] # load its context data - if not os.path.isdir(destfile): - self.__cd(destfolder) # load local context of destination folder - self.DB.update( { destfile : sourcedata } ) # update its DB with the context data of our source file - self.__save() # and save it - if not retain: # do we copy or move the data? - self.delete(source) # delete for move - if fop: # do we also physically move or copy the reference file? - if retain: - shutil.copy2(source, dest) - else: - shutil.copy2(source, dest) - os.remove(source) - return [0, source, dest, sourcedata] - else: - return [1, source, dest, os.path.isfile(source), os.path.isdir(os.path.dirname(dest))] - - - def clean(self, path: str='.', recursive=True): - """ - Removes context data from folder if files do not exist - - path (str): folder path - - """ - if path == '': - path = '.' - target = Path(path) - count = 0 - if target.is_file(): - target = Path(target.parent) - if target.is_dir(): - self.__cd(path) # load folder context - DB = self.DB.copy() # make a copy of it - for file in self.DB.keys(): # check for each entry if the file actually exists - f = target / file - if not f.is_file(): # if not, delete its entry - if file in self.DB: - del DB[file] - count += 1 - self.DB = DB # make our local working copy of the DB the context DB - self.__save() # save it - if not self.DB: # if it is empty we can delete the context file - Path(self.FILE).unlink(missing_ok=True) - if recursive: # do it all recursively? - for (root,dirs,files) in os.walk(path): - if dirs: - for dir in dirs: - rpath = os.path.join(root, dir) - fb = self.clean(rpath, False) - count += fb[2] - return [0, str(target), count] - - - def list(self, path: str='./', exact=True, either=False, recursive: bool=True, cache: bool=False, delete_cache: bool=False, *search: str): - """ - Returns an index object with query results and optionally saves a cached index - - path (str): the root context for the search and index - filter (str): limit the search to a context - exact (bool): exact search match if True, otherwise partial match - recursive (bool): if True also searches all subfolder contexts - cache (bool): if True loads index from cache file if exists, writes it if it doesn't exist - search (str): the search query - - """ - if path == '': # fix empty path argument - path = './' - self.__makeindex(path, recursive, cache, delete_cache) # index the directories - result = self.__search(search, exact, either) # apply the search query - return result - - - def scan(self, path: str, recursive: bool=False): - - for (root,dirs,files) in os.walk(path, topdown=True): - - # process every file with a reader plugin - if self.ENABLE_PLUGINS: - if files: - for file in files: - fpath = os.path.join(root, file) - self.__cd(root, True) - self.read_metadata(fpath) - - if not recursive: # check if to include subdirectories - break - - - def register_plugin(self, function): - """ - Add a plugin to the plugin list - """ - self.PLUGINS.append(function) - return function - - - def read_metadata(self, filename: str): - """ - Processes a file with all reader plugins to extract its metadata - """ - - # Utilities for reader plugins - obj = { - 'file' : Path(), - 'config' : self.CONFIG, - 'schema' : self.SCHEMA, - 'context' : self.__context, - 'string' : self.get_argv - } - o = SimpleNamespace(**obj) - - for plugin_name in self.PLUGINS: - # Check if plugin is (de)activated by a local folder configuration - if 'plugins' in self.CONFIG: - if plugin_name not in self.CONFIG['plugins']: - continue - # Load the plugin from plugins folder - plugin = import_module('orvanitag.plugins.' + plugin_name) - - # 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'] - - o.file = Path(filename) - if o.file.suffix in suffixes: # Does the file match a permitted file type - if hasattr(plugin, 'reader'): # Does the plugin have a reader method? - - # Process the file using the reader plugin - try: - context = plugin.reader(o) - if context: - self.__add(filename, context) - print(f'Found Metadata for `{filename}`.') - except Exception as e: - print(f'Plugin Error ({plugin_name} reading {filename}): `{e}`') - - - def write_all(self, path: str, method: str, recursive: bool=False): - - for (root,dirs,files) in os.walk(path, topdown=True): - - # process every file with a writer plugin - 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) - - if not recursive: # check if to include subdirectories - break - - - def write_metadata(self, filename: str, method: str): - """ - Writes a file's context entries as metadata to the file using a writer plugin - - filename (str): A valid file path - method (str): The name of a plugin able to process the file type - """ - - # Utilities for writer plugins - obj = { - 'file' : Path(), - 'config' : self.CONFIG, - 'schema' : self.SCHEMA, - 'context' : {}, - 'collect' : self.__collect_contexts, - 'string' : self.get_argv - } - o = SimpleNamespace(**obj) - - for plugin_name in self.PLUGINS: - - # Check if plugin is (de)activated by a local folder configuration - if 'plugins' in self.CONFIG: - if plugin_name not in self.CONFIG['plugins']: - continue - - plugin = import_module('orvanitag.plugins.' + plugin_name) - - 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'] - for suffix in suffixes: - if not suffix in plugin.TYPES: - suffixes.remove(suffix) - if not suffixes: - suffixes = plugin.TYPES - - o.file = Path(filename) - if o.file.suffix in suffixes: - - # Get all local context entries (including file metadata) - self.__makeindex(o.file.parent, recursive=False) - items = self.INDEX['items'] - - for found in items.keys(): - # If there is a file with matching entries in the local context - if str(o.file).replace('./', '') == found.replace('./', ''): - o.context = items[found] - if hasattr(plugin, 'writer'): # If the plugin has a writer method - plugin.writer(o) # Write the context as metadata - return [0, filename, o.context] - else: - return [5, plugin_name] - else: - return [4, suffixes, plugin_name, plugin_name in self.CONFIG] - - - def export(self, filename:str): - """ - Exports the global context index to a text file in a format that can be reimported - - filename (str): optional filename - """ - - # If no filename give, use the default one - if filename == '' or filename == None: - filename = 'context.txt' - - file = Path(filename) - - # Run `otag ls` to create the global context index - index = self.list(file.parent, False, False, True, False, False, '') - - # Write the index as lines to file - items = index['index']['items'] - content = "" - if len(items): - for path in items: - string = ' '.join([x for x in self.__collect_contexts(items[path]) if x != None]) - content += f'{path} --- {string}\n' - file.write_text(content) - return [0, filename] - else: - return [2] - - - def load(self, filename:str): - """ - Imports lines from a text file created by `otag export` to the global context index, - filtering out the already known entries - - filename (str): optional filename - """ - - # If no filename was given, try the default one - if filename == '' or filename == None: - filename = 'context.txt' - file = Path(filename) - if file.exists(): - imported = file.read_text().split('\n') - if len(imported): - # Make an index of all context items that are already known - known = self.list(file.parent, False, False, True, False, False, '')['index']['items'] - count = 0 - for import_line in imported: - if len(import_line) > 5: - line_elements = import_line.split(' --- ') - if len(line_elements) > 1: - context = {} - path = line_elements[0].strip() - new_file = Path(path) - if new_file.exists(): # Check if file exists - string = line_elements[1].strip() - if string: - new_file_context = self.__context(self.__get_argv(string)) - # Check if file is already known and don't import anything we already have - if path in known.keys(): - known_file_context = known[path] - context = new_file_context.copy() - for new_context in new_file_context: - if type(new_file_context[new_context]) == list: - for context_item in new_file_context[new_context]: - if new_context in known_file_context.keys(): - if context_item in known_file_context[new_context]: - context[new_context].remove(context_item) - else: - if new_context in known_file_context.keys(): - if new_file_context[new_context] == known_file_context[new_context]: - del context[new_context] - self.__add(path, context) - else: - return [4, filename] - return [0, filename, count] - else: - return [2, filename] - else: - return [1, filename] \ No newline at end of file diff --git a/build/lib/orvanitag/plugins/__init__.py b/build/lib/orvanitag/plugins/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/build/lib/orvanitag/plugins/frontmatter.py b/build/lib/orvanitag/plugins/frontmatter.py deleted file mode 100644 index ea8e13b..0000000 --- a/build/lib/orvanitag/plugins/frontmatter.py +++ /dev/null @@ -1,22 +0,0 @@ -import frontmatter -from pathlib import Path - -NAME = 'frontmatter' -TYPES = ['.md', '.txt', '.markdown', '.rst'] - -def reader(o): - with open(o.file, 'r') as data: - y = frontmatter.load(data) - if y.metadata: - context = y.metadata.copy() - for meta in y.metadata.keys(): - if not meta in o.schema: - del context[meta] - return context - -def writer(o): - y = frontmatter.load(o.file) - for key in o.context: - y[key] = o.context[key] - with open(o.file, 'w') as data: - data.write(frontmatter.dumps(y)) \ No newline at end of file diff --git a/build/lib/orvanitag/plugins/hashdoubleslash.py b/build/lib/orvanitag/plugins/hashdoubleslash.py deleted file mode 100644 index 28e8130..0000000 --- a/build/lib/orvanitag/plugins/hashdoubleslash.py +++ /dev/null @@ -1,63 +0,0 @@ -import re -from pathlib import Path - -NAME = 'hash//' -REGEX = r'\s*([@+]\w+|#\w+(?'], - '.java' : MLC_C, - '.js' : MLC_C, - '.kt' : MLC_C, - '.md' : ['---', '---'], - '.php' : MLC_C, - '.py' : MLC_Python, - '.rb' : MLC_Perl, - '.rs' : MLC_C, - '.ts' : MLC_C, - '.txt' : ['---', '---'], -} -TYPES = MAP.keys() - -def reader(o): - with o.file.open() as data: - for line_num, line in enumerate(data, start=1): - if line_num > 5: - break - if line.strip().startswith("#// "): - argv = o.string(line.strip()[3:]) - context = o.context(argv) - return context - -def writer(o): - string = PREFIX + ' '.join(o.collect(o.context)) + '\n' - with o.file.open() as data: - content = data.readlines() - num = 0 - line = '' - if len(content): - for num, line in enumerate(content): - if line.strip().startswith("#// ") or num > 3: - break - if not num > 3 and line.strip().startswith("#// "): - content[num] = string - with o.file.open('w') as file: - file.writelines(content) - else: - frontmatter = MAP[o.file.suffix][0] + '\n' + string + MAP[o.file.suffix][1] + '\n' - with o.file.open('r+') as file: - content = file.read() - file.seek(0, 0) - file.write(frontmatter + content) \ No newline at end of file diff --git a/build/lib/orvanitag/plugins/id3.py b/build/lib/orvanitag/plugins/id3.py deleted file mode 100644 index f43e60e..0000000 --- a/build/lib/orvanitag/plugins/id3.py +++ /dev/null @@ -1,42 +0,0 @@ -import re -from pathlib import Path -import taglib - -NAME = 'id3' -TYPES = ['.mp3', '.wav', '.flac', '.ogg'] -REGEX = r'\s*([@+]\w+|#\w+)' - -def reader(o): - universal = {i for i in o.schema if o.schema[i]==None}.pop() - with taglib.File(o.file, save_on_exit=False) as song: - metadata = song.tags - id3map = {} - if 'id3' in o.config: - if 'map' in o.config['id3']: - id3map = o.config['id3']['map'].copy() - for context_key in id3map: - if context_key not in o.schema.keys(): - del id3map[context_key] - else: - ctag = str(id3map[context_key]).upper() - if ctag in metadata: - id3map[context_key] = metadata[ctag] - if 'COMMENT' in metadata: - comment = metadata['COMMENT'] - argv = o.string(comment[0].strip()) - context = o.context(argv) - if not universal in context: - context[universal] = [] - else: - context[universal] = [context[universal]] - if id3map.keys(): - for key in id3map.keys(): - if key in context.keys(): - context[key] += id3map[key] - context[universal] = '; '.join(context[universal]) - return context - -def writer(o): - string = ' '.join(o.collect(o.context)) - with taglib.File(o.file, save_on_exit=True) as song: - song.tags['COMMENT'] = [string] \ No newline at end of file diff --git a/build/lib/orvanitag/plugins/office.py b/build/lib/orvanitag/plugins/office.py deleted file mode 100644 index 3f1d255..0000000 --- a/build/lib/orvanitag/plugins/office.py +++ /dev/null @@ -1,105 +0,0 @@ -import os -import re -import mimetypes -import shutil -from pathlib import Path -from olefile import OleFileIO - -NAME = 'office' -TYPES = ['.doc', '.xls', '.ppt', '.docx', '.xlsx', '.pptx', '.odt', '.ods', '.odp'] -XML_REGEX = r"(?P
)(?P.*?)(?P<\/dc:description>)"
-REGEX = r'\s*([@+]\w+|#\w+)'
-
-# Office Document processing inspired by
-# https://github.com/kappapiana/anonymize/blob/master/anonymize.py
-
-def unzip_file(orig_file, tmp_dir):
-    type = mimetypes.guess_type(orig_file)
-    filetype = {
-        "application/vnd.oasis.opendocument.text" : "odt",
-        "application/vnd.oasis.opendocument.spreadsheet" : "ods",
-        "application/vnd.oasis.opendocument.presentation" : "odp",
-        "application/vnd.openxmlformats-officedocument.wordprocessingml.document" : "docx",
-        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "xlsx",
-        "application/vnd.openxmlformats-officedocument.presentationml.presentation" : "pptx",
-    }
-    if type[0] in filetype:
-        shutil.unpack_archive(orig_file, tmp_dir, 'zip')
-        return filetype[type[0]]
-    else:
-        return type[0]
-
-def get_context_string(meta_file):
-    ctx = ''
-    meta_content = meta_file.read_text()
-    csearch = re.search(XML_REGEX, meta_content)
-    if csearch:
-        return csearch.group('body')
-    
-
-def reader(o):
-    tmp_folder = Path('unzip__' + o.file.stem)
-    file_type = unzip_file(o.file, tmp_folder)
-    tags = note = []
-
-    # OpenOffice / LibreOffice / Open Document Format
-    if file_type in ['odt', 'ods', 'odp']:
-        meta_file = tmp_folder / 'meta.xml'
-        argv = o.string(get_context_string(meta_file))
-        context = o.context(argv)
-        shutil.rmtree(tmp_folder)
-        return context
-
-    # Microsoft Office Format
-    elif file_type in ['docx', 'xlsx', 'pptx']:
-        meta_file = tmp_folder / 'docProps' / 'core.xml'
-        argv = o.string(get_context_string(meta_file))
-        context = o.context(argv)
-        shutil.rmtree(tmp_folder)
-        return context
-
-    # Legacy Microsoft Office Format
-    elif file_type in ['application/msword', 'application/vnd.ms-excel', 'application/vnd.ms-powerpoint']:
-        ole = OleFileIO(o.file)
-        meta = ole.get_metadata()
-        ole.close()
-        if meta.comments:
-            argv = o.string(meta.comments.decode('UTF-8'))
-            context = o.context(argv)
-            return context
-    
-
-def writer(o):
-    string = ' '.join(o.collect(o.context))
-    tmp_folder = Path('unzip__' + o.file.stem)
-    file_type = unzip_file(o.file, tmp_folder)
-    regex = re.compile(XML_REGEX)
-    string = f"\\g
{string}\\g"
-
-    # OpenOffice / LibreOffice / Open Document Format
-    if file_type in ['odt', 'ods', 'odp']:
-        meta_file = tmp_folder / 'meta.xml'
-        with meta_file.open('r+') as file:
-            content = regex.sub(string, file.read())
-            file.seek(0, 0)
-            file.write(content)
-        shutil.make_archive(o.file, "zip", tmp_folder)
-        output_file_zip = str(o.file) + ".zip"
-        os.rename(output_file_zip, o.file)
-        shutil.rmtree(tmp_folder)
-
-    # Microsoft Office Format
-    elif file_type in ['docx', 'xlsx', 'pptx']:
-        meta_file = tmp_folder / 'docProps' / 'core.xml'
-        with meta_file.open('r+') as file:
-            content = regex.sub(string, file.read())
-            file.seek(0, 0)
-            file.write(content)
-        shutil.make_archive(o.file, "zip", tmp_folder)
-        output_file_zip = str(o.file) + ".zip"
-        os.rename(output_file_zip, o.file)
-        shutil.rmtree(tmp_folder) 
-
-    # Legacy Microsoft Office Format
-    elif file_type in ['application/msword', 'application/vnd.ms-excel', 'application/vnd.ms-powerpoint']:
-        print('unsupported file format')
diff --git a/build/lib/orvanitag/plugins/pdf.py b/build/lib/orvanitag/plugins/pdf.py
deleted file mode 100644
index 1b30884..0000000
--- a/build/lib/orvanitag/plugins/pdf.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import re
-import shutil
-from pathlib import Path
-from pdfrw import PdfReader, PdfWriter
-
-NAME = 'pdf'
-TYPES = ['.pdf']
-REGEX = r'\s*([@+]\w+|#\w+)'
-
-def reader(o):
-    pdf = PdfReader(o.file)
-    if pdf.Info.Keywords:
-        tags = re.findall(REGEX, pdf.Info.Keywords.strip())
-        note = re.sub(REGEX, '', pdf.Info.Keywords.strip()).replace('(', '').replace(',', '').replace(')', '').strip()
-        argv = ' '.join(tags).split(' ') + [note]
-        argv = [x for x in argv if x != '']
-        context = o.context(argv)
-        return context
-    
-def writer(o):
-    string = ' '.join(o.collect(o.context))
-    pdf = PdfReader(o.file)
-    pdf.Info.Keywords = string
-    PdfWriter(o.file, trailer=pdf).write()
diff --git a/orvanitag.egg-info/PKG-INFO b/orvanitag.egg-info/PKG-INFO
deleted file mode 100644
index dbfadbd..0000000
--- a/orvanitag.egg-info/PKG-INFO
+++ /dev/null
@@ -1,52 +0,0 @@
-Metadata-Version: 2.1
-Name: orvanitag
-Version: 1.0.0
-Summary: Platform independent, file-based, portable tagging system to organize directories and files.
-Home-page: https://github.com/randogoth/orvanitag
-Author: randogoth
-Author-email: randogoth@posteo.org
-License: UNKNOWN
-Platform: UNKNOWN
-Classifier: Programming Language :: Python :: 3
-Classifier: License :: OSI Approved :: MIT License
-Classifier: Operating System :: OS Independent
-Requires-Python: >=3.6
-Description-Content-Type: text/markdown
-License-File: LICENSE
-
----
-contexts:
-- frrreee
-projects: []
-tags:
-- fool
-- writeme
----
-
-# Orvani Tagger
-
-(From עוֹרְבָנִי, the Hebrew name for th Eurasian Jay)
-
-Platform independent, folder-based, and portable tagging system for organizing and searching files. The command line interface and organizing principles are heavily inspired by the todo.txt standard. 
-
-Every file can be assigned projects, contexts, and tags and have a descriptive note.
-
-Exactly like tasks in todo.txt, files can be categorized by freely defined contexts, defined by the
-`@` prefix or projects, defined by the `+` prefix. In addition, the `#` prefix defines hashtags that can be assigned.
-
-So a as an example an audio-file could be assigned to project `+album` and have a context of `@drums` and tagged with `#boombap`, `#lofi`, `#snare`. It's completely up to you how you use the different means of categorization.
-
-## Command Line Tool
-
-### Assigning Contextual Tags
-
-Let's
-```
-$ orvani add  [contextual tags ...]
-```
-
-Removing contextual tags from a file
-```
-$ orvani remove  [contextual tags ...]
-```
-
diff --git a/orvanitag.egg-info/SOURCES.txt b/orvanitag.egg-info/SOURCES.txt
deleted file mode 100644
index 827e9cb..0000000
--- a/orvanitag.egg-info/SOURCES.txt
+++ /dev/null
@@ -1,18 +0,0 @@
-LICENSE
-README.md
-setup.py
-orvanitag/__init__.py
-orvanitag/cmd.py
-orvanitag/orvani.py
-orvanitag.egg-info/PKG-INFO
-orvanitag.egg-info/SOURCES.txt
-orvanitag.egg-info/dependency_links.txt
-orvanitag.egg-info/entry_points.txt
-orvanitag.egg-info/requires.txt
-orvanitag.egg-info/top_level.txt
-orvanitag/plugins/__init__.py
-orvanitag/plugins/frontmatter.py
-orvanitag/plugins/hashdoubleslash.py
-orvanitag/plugins/id3.py
-orvanitag/plugins/office.py
-orvanitag/plugins/pdf.py
\ No newline at end of file
diff --git a/orvanitag.egg-info/dependency_links.txt b/orvanitag.egg-info/dependency_links.txt
deleted file mode 100644
index 8b13789..0000000
--- a/orvanitag.egg-info/dependency_links.txt
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/orvanitag.egg-info/entry_points.txt b/orvanitag.egg-info/entry_points.txt
deleted file mode 100644
index 2537197..0000000
--- a/orvanitag.egg-info/entry_points.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-[console_scripts]
-otag = orvanitag.cmd:main
-
diff --git a/orvanitag.egg-info/requires.txt b/orvanitag.egg-info/requires.txt
deleted file mode 100644
index f229360..0000000
--- a/orvanitag.egg-info/requires.txt
+++ /dev/null
@@ -1 +0,0 @@
-requests
diff --git a/orvanitag.egg-info/top_level.txt b/orvanitag.egg-info/top_level.txt
deleted file mode 100644
index fc5b9db..0000000
--- a/orvanitag.egg-info/top_level.txt
+++ /dev/null
@@ -1 +0,0 @@
-orvanitag
diff --git a/orvanitag/cmd.py b/orvanitag/cmd.py
index 3a7063a..8811de3 100644
--- a/orvanitag/cmd.py
+++ b/orvanitag/cmd.py
@@ -5,6 +5,7 @@ from argparse import ArgumentParser as argp, SUPPRESS, RawTextHelpFormatter as r
 from pathlib import Path
 from rich import box
 from rich.console import Console
+from rich.markup import escape
 from rich.table import Table
 from orvanitag.orvani import OrvaniTag, CONTEXT_FILE, CACHE_FILE, CONTEXTS
 
@@ -80,13 +81,13 @@ def nice_context(context: dict):
     context (dict): context dict
     """
     string = ''
-    if context['projects']:
+    if context.get('projects'):
         string += '[cyan]' + ' '.join(['+' + tag for tag in context['projects']]) + '[/cyan] '
-    if context['contexts']:
+    if context.get('contexts'):
         string += '[yellow]' + ' '.join(['@' + tag for tag in context['contexts']]) + '[/yellow] '
-    if context['tags']:
+    if context.get('tags'):
         string += '[magenta]' + ' '.join(['#' + tag for tag in context['tags']]) + '[/magenta] '
-    if 'note' in context:
+    if context.get('note'):
         string += '[green]\'' + context['note'] + '\'[/green] '
     return string
 
@@ -144,11 +145,11 @@ def main():
     p.print_usage = p.print_help
     args = p.parse_args()
 
-    # Load Orvani
-    orvani = OrvaniTag()
-
     # Parse Arguments and Run otag Methods
     try:
+        # Load Orvani
+        orvani = OrvaniTag()
+
         if args.command in ['add', 'a']:
             if args.file and args.context:
                 fb = orvani.add(args.file, *args.context)
@@ -193,6 +194,8 @@ def main():
                         console.print(f":thinking_face: Destination [strong]{fb[2]}[/strong] doesn't exist.")
                     elif fb[3] == False and fb[4] == False:
                         console.print(f":thinking_face: Source file [strong]{fb[1]}[/strong] and destination [strong]{fb[2]}[/strong] don't exist.")
+                elif fb[0] == 2:
+                    console.print(f":mag: No entry for [strong]{fb[1]}[/strong] found.")
 
         elif args.command in ['move', 'mv']:
             if args.file and args.context:
@@ -206,6 +209,8 @@ def main():
                         console.print(f":thinking_face: Destination [strong]{fb[2]}[/strong] doesn't exist.")
                     elif fb[3] == False and fb[4] == False:
                         console.print(f":thinking_face: Source file [strong]{fb[1]}[/strong] and destination [strong]{fb[2]}[/strong] don't exist.")
+                elif fb[0] == 2:
+                    console.print(f":mag: No entry for [strong]{fb[1]}[/strong] found.")
 
         elif args.command in ['del', 'rm']:
             if args.file:
@@ -260,7 +265,7 @@ def main():
 
         elif args.command in ['write', 'w']:
             if args.file and args.context:
-                fb = orvani.write(args.context[0], args.file)
+                fb = orvani.write_metadata(args.context[0], args.file)
                 if fb[0] == 0:
                     console.print(f':floppy_disk: Metadata {nice_context(fb[2])}successfully written to file [strong]{fb[1]}[/strong].')
                 if fb[0] == 4:
@@ -269,6 +274,10 @@ def main():
                         console.print(f':information:  Plugin configuration found in folder context file.')
                 if fb[0] == 5:
                     console.print(f':disappointed:  Plugin [strong]{fb[1]}[/strong] can\'t write.')
+                if fb[0] == 2:
+                    console.print(f":mag: No entry for [strong]{fb[1]}[/strong] found.")
+                if fb[0] == 6:
+                    console.print(f":thinking_face: Plugin [strong]{fb[1]}[/strong] not found or its dependencies are not installed.")
 
         elif args.command in ['export', 'e']:
             fb = orvani.export(args.file)
@@ -296,8 +305,8 @@ def main():
             console.print(f':mag: Finished scanning subdirectories.')
     
     except Exception as e:
-        raise Exception
-        #console.print(f":scream: An Exception occurred: [italic]{e}[/italic]")
+        console.print(f":scream: An Exception occurred: [italic]{escape(str(e))}[/italic]")
+        sys.exit(1)
 
 if __name__ == '__main__':
     main()
diff --git a/orvanitag/orvani.py b/orvanitag/orvani.py
index 40e20e2..6bc73e8 100644
--- a/orvanitag/orvani.py
+++ b/orvanitag/orvani.py
@@ -7,10 +7,17 @@ from importlib import import_module
 from types import SimpleNamespace
 from pprint import pprint
 
-class VerboseSafeDumper(yaml.SafeDumper):
+# libyaml is much faster, but PyYAML can be built without it.
+SafeLoader = getattr(yaml, 'CSafeLoader', yaml.SafeLoader)
+SafeDumper = getattr(yaml, 'CSafeDumper', yaml.SafeDumper)
+
+class VerboseSafeDumper(SafeDumper):
     def ignore_aliases(self, data):
         return True
 
+def load_yaml(text: str):
+    return yaml.load(text, Loader=SafeLoader)
+
 # Defaults
 
 CONTEXT_FILE = '.context.yaml'
@@ -42,16 +49,7 @@ ERRORS:
 
 class OrvaniTag:
 
-    INDEX = {} # our search index
-    CACHEFILE = '' # search index cache ('.index.yaml')
-    DB = dict() # holds the folder context data
-    CONFIG = dict() # Currently used configuration
-    CONF_FILES = [] # List to keep track of config files in the directory tree
-    FILENAME = '' # folder context data filename ('.context.yaml')
-    FILE = '' # holds the folder context file path
-    SCHEMA = {} # Default context schema
     ENABLE_PLUGINS = True # Toggle use of plugins
-    PLUGINS = [] # List of loaded plugins
     REGEX = r'\s*([@+]\w+|#\w+)'
 
     def __init__(self, path: str=CONTEXT_FILE, cache: str=CACHE_FILE, contexts=CONTEXTS, plugins:list=DEFAULT_PLUGINS):
@@ -63,17 +61,26 @@ class OrvaniTag:
         contexts (dict): override the default contexts
         """
 
-        self.PLUGINS = plugins
-        self.FILENAME = os.path.basename(path)
-        self.CACHEFILE = cache
-        self.SCHEMA = contexts
-        self.INDEX = self.SCHEMA.copy()
-        for context_key in self.INDEX:
-            self.INDEX[context_key] = {}
-        self.INDEX['items'] = {}
+        self.PLUGINS = list(plugins) # List of plugin names
+        self.MODULES = None # Imported plugin modules, see __plugins()
+        self.FILENAME = os.path.basename(path) # folder context data filename ('.context.yaml')
+        self.CACHEFILE = cache # search index cache ('.index.yaml')
+        self.SCHEMA = contexts # Default context schema
+        self.DB = dict() # holds the folder context data
+        self.CONFIG = dict() # Currently used configuration
+        self.LOCAL_CONFIG = None # The current folder's own config block, rewritten on save
+        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('')
 
 
+    def __emptyindex(self) -> dict:
+        index = {context_key: {} for context_key in self.SCHEMA}
+        index['items'] = {}
+        return index
+
+
     def __cd(self, path: str, config: bool=False):
         """
         Change folder context to other directory.
@@ -94,7 +101,7 @@ class OrvaniTag:
                     if common == conf_file:
                         cfile = Path(conf_file) / self.FILENAME
                         if cfile.exists():
-                            conf = yaml.safe_load(cfile.read_text())
+                            conf = load_yaml(cfile.read_text())
                             if 'config' in conf:
                                 self.CONFIG = conf['config']
                             continue
@@ -102,11 +109,13 @@ class OrvaniTag:
                         self.CONFIG = dict()
 
         self.FILE = Path(str(path)) / self.FILENAME
+        self.LOCAL_CONFIG = None
         if self.FILE.exists(): # is there a folder ocntext file?
-            y = yaml.safe_load(self.FILE.read_text())
+            y = load_yaml(self.FILE.read_text()) or {}
+            self.LOCAL_CONFIG = y.get('config')
 
             if 'items' in y:
-                self.DB = y['items'] # load items to current context DB
+                self.DB = y['items'] or {} # load items to current context DB
 
                 if 'config' in y: # load optional local configuration
                     self.CONFIG = y['config']
@@ -132,16 +141,19 @@ class OrvaniTag:
         """
         file = Path(self.FILE)
         yf = { 'items' : self.DB } # load current context into dict
-        if file.is_file(): # does current folder have a context file?
-            tmp = yaml.safe_load(file.read_text()) # if yes, load it...
-            if 'config' in tmp:
-                yf['config'] = tmp['config'] # ...and save its optional configuration
+        if self.LOCAL_CONFIG is not None:
+            yf['config'] = self.LOCAL_CONFIG
+        # Swap in a finished temp file so a failed write never truncates the existing context.
+        tmp = file.with_name(f'.{file.name}.{os.getpid()}.tmp')
         try:
-            with file.open('w+') as data: # ...then write it all to the file
+            with tmp.open('w') as data:
                 yaml.dump(yf, data, Dumper=VerboseSafeDumper)
-                return self.FILE
-        except:
-            return 0
+            if file.exists():
+                shutil.copymode(file, tmp)
+            os.replace(tmp, file)
+        finally:
+            tmp.unlink(missing_ok=True)
+        return self.FILE
 
 
     def __context(self, argv: list) -> dict:
@@ -157,24 +169,23 @@ class OrvaniTag:
                 context.update( { context_key : []})
         
         # Parse the argument list
-        for arg in [x for x in argv if len(x) > 0]: # remove empty list elements
-            arg.replace(',', '') # remove commas
+        note = []
+        for arg in [str(x) for x in argv if x and str(x).strip()]: # remove empty list elements
 
             # Add each argument list element to context dict based on the
             # prefix symbols defined in the schema
+            if arg[0] in self.SCHEMA.values():
+                for context_key in self.SCHEMA.keys():
+                    if self.SCHEMA[context_key] != None and arg.startswith(self.SCHEMA[context_key]):
+                        context[context_key].append(arg[1:].replace(',', ''))
+            else:
+                # No prefix, so it is part of the note
+                note.append(arg)
+
+        if note:
             for context_key in self.SCHEMA.keys():
-                if str(arg)[0] in self.SCHEMA.values():
-                    # Schema with prefix
-                    if not self.SCHEMA[context_key] == None:
-                        if str(arg).startswith(self.SCHEMA[context_key]):
-                            context[context_key].append(str(arg)[1:])
-                else:
-                    # No prefix, so it must be a note
-                    if self.SCHEMA[context_key] == None:
-                        if arg != ' ':
-                            context.update( { context_key : str(arg)})
-                        else:
-                            context.update( { context_key : None})
+                if self.SCHEMA[context_key] == None:
+                    context.update( { context_key : ' '.join(note)})
         return context
 
 
@@ -189,39 +200,47 @@ class OrvaniTag:
     def __makeindex(self, path:str, recursive:bool=True, cache: bool=False, delete_cache: bool=False):
         """
         Creates global context index for directory for searching, optionally also a cache file
-        Loads plugin readers to also read file specific metadata
 
         path (dict): root directory
         recursive (bool): traverse subdirectories
 
         """
         cachefile = Path(path) / self.CACHEFILE
-        loaded_from_cache = False
         if delete_cache:
             cachefile.unlink(missing_ok=True)
-        if cache and cachefile.is_file(): # load index from cache
-            self.INDEX = yaml.safe_load(cachefile.read_text())
-            loaded_from_cache = True
-        
-        else: # index the directory manually
-            self.__indexfolder(path) # index current directory
 
-            # Dive into the subdirectories
-            for (root,dirs,files) in os.walk(path, topdown=True):
+        # Only folders with a context file contribute to the index
+        folders = []
+        for (root,dirs,files) in os.walk(path, topdown=True):
+            if self.FILENAME in files:
+                folders.append(root)
+            if not recursive: # check if to include subdirectories
+                break
 
-                if not recursive: # check if to include subdirectories
-                    break
-                
-                # index each subdirectory
-                if dirs:
-                    for dir in dirs:
-                        rpath = os.path.join(root, dir)
-                        self.__indexfolder(rpath) # index subdirectory
-            
-            # If we just created an index, write it to cache
-            if cache and not loaded_from_cache:
-                with open(cachefile, 'w+') as data:
-                    yaml.dump(self.INDEX, data)
+        if cache:
+            sources = {}
+            for folder in folders:
+                stat = os.stat(os.path.join(folder, self.FILENAME))
+                sources[folder] = [stat.st_mtime_ns, stat.st_size]
+            if cachefile.is_file(): # load index from cache
+                cached = load_yaml(cachefile.read_text()) or {}
+                # Caches without matching 'sources' (older format or changed context files) are rebuilt
+                if cached.get('sources') == sources and cached.get('recursive') == recursive:
+                    self.INDEX = cached['index']
+                    return
+
+        self.INDEX = self.__emptyindex()
+        for folder in folders:
+            self.__indexfolder(folder)
+
+        # Paths are collected in sets while indexing and sorted once here
+        for context_key in self.SCHEMA.keys():
+            self.INDEX[context_key] = {item: sorted(paths) for item, paths in self.INDEX[context_key].items()}
+        self.INDEX['items'] = dict(sorted(self.INDEX['items'].items()))
+
+        if cache: # If we just created an index, write it to cache
+            with open(cachefile, 'w+') as data:
+                yaml.dump({'recursive': recursive, 'sources': sources, 'index': self.INDEX}, data, Dumper=SafeDumper)
 
 
     def __indexfolder(self, sub: str):
@@ -230,58 +249,27 @@ class OrvaniTag:
 
         sub (str): path of folder
         """
-        target = Path(sub)
-        if target.is_dir():
-            self.__cd(sub)
-        if self.DB:
-            for file in self.DB.keys():
-                path = os.path.join(sub, file)
-                for context_key in self.SCHEMA.keys():
-                    if context_key in self.DB[file]:
-                        self.__updateindex(path, self.DB[file], context_key)
+        self.__cd(sub)
+        for file, context in self.DB.items():
+            self.__updateindex(os.path.join(sub, file), context)
 
 
-    def __updateindex(self, path: str, context: dict, context_key: str):
+    def __updateindex(self, path: str, context: dict):
         """
-        Adds entries of a single context type of a file to the global context index.
+        Adds the entries of a file to the global context index.
         The context entry is associated with the file in the 'items' index
-        The file is associated with its context entry
+        The file is associated with each of its context entries
 
-        sub (str): path of folder
+        path (str): path of the file
+        context (dict): context entry of the file
         """
-        # Update files index
-        for key, value in context.items():
-            if path in self.INDEX['items']: # check if there is already an entry
-                # if yes, merge the entries
-                if key in self.INDEX['items'][path] and isinstance(self.INDEX['items'][path][key], list) and isinstance(value, list):
-                    self.INDEX['items'][path][key] += value
-                    self.INDEX['items'][path][key] = list(set(self.INDEX['items'][path][key]))
-                # if not, just add them
-                else:
-                    self.INDEX['items'][path][key] = value
-            else: # if not, just add the entry
-                self.INDEX['items'][path] = { key : value }
-
-        # sort the file index alphabetically
-        self.INDEX['items'] = {key: value for key, value in sorted(self.INDEX['items'].items())}
-
-        # Update context index
-        # Add each file path to a context entry
-
-        if type(context[context_key]) == list: # is it a context entry
-            for item in context[context_key]:
-                filelist = [path]
-                if context_key in self.INDEX:
-                    if item in self.INDEX[context_key]:
-                        filelist += self.INDEX[context_key][item]
-                self.INDEX[context_key][item] = list(set(filelist))
-        else: # or is it a note
-            item = context[context_key]
-            filelist = [path]
-            if context_key in self.INDEX:
-                if item in self.INDEX[context_key]:
-                    filelist += self.INDEX[context_key][item]
-                self.INDEX[context_key][item] = list(set(filelist))   
+        self.INDEX['items'][path] = dict(context)
+        for context_key in self.SCHEMA.keys():
+            value = context.get(context_key)
+            if not value:
+                continue
+            for item in (value if isinstance(value, list) else [value]):
+                self.INDEX[context_key].setdefault(item, set()).add(path)
 
 
     def __search(self, search, exact:bool=False, either:bool=False):
@@ -297,82 +285,38 @@ class OrvaniTag:
             'index' : self.INDEX,
             'found' : []
         }
-        if search[0]: # is there a search query?
-
-            query = self.__context(search) # turn it into a context dict
-            return_dict = self.__context([' ']) # create an empty context dict
-            universal = {i for i in self.SCHEMA if self.SCHEMA[i]==None}.pop() # get the schema key with the None value, so we know which one to use for the note
-            
-            if universal in query: # does the query have an entry without context prefix?
-                query[universal] = [query[universal]] # turn it into a list for better processing
-            
-            for context_key in self.SCHEMA.keys(): # turn all values in the empty dict into list types
-                return_dict[context_key] = []
-            
-            # Iterate Search Query Terms
-            for param in query.keys():
-                needles = query[param]
-                
-                # Add the query without content prefix to all other contexts
-                # It is a wildcard to search all contexts at once
-                if universal in query.keys():
-                    needles += query[universal]
-
-                # Run the search and add every found file path to the return dict
-                # in its respective context (or all of them for the wildcard search)
-                if needles:
-                    haystack = self.INDEX[param].keys()
-                    for hay in haystack:
-                        for needle in needles:
-                            if exact:
-                                if needle == hay:
-                                    if self.INDEX[param][hay] not in return_dict[param]:
-                                        return_dict[param] += self.INDEX[param][hay]
-                            else:
-                                if needle in hay:
-                                    if self.INDEX[param][hay] not in return_dict[param]:
-                                        return_dict[param] += self.INDEX[param][hay]
-            
-            if len(search) > 1 and not either:
-                # we do an AND search by default, but only if there was more than 1 query item
-                result['found'] = list(set(self.__intersect(return_dict)))
-
-            else:
-                # for 1 query searches and OR searches we simply add all of the matches
-                min = []
-                for sublist in return_dict.values():
-                    min += sublist
-                result['found'] = list(set(min))
-
+        terms = [term for term in search if term]
+        if terms: # is there a search query?
+            # Each term gets its own match set, so AND also works between terms of the same context
+            matches = [self.__match(term, exact) for term in terms]
+            found = set.union(*matches) if either else set.intersection(*matches)
         else:
             # return the whole index if there was no query item
-            result['found'] = self.INDEX['items'].keys()
+            found = self.INDEX['items'].keys()
 
         # Sort the result directory tree from shallow to deep based on the occurrence of /
-        result['found'] = sorted(result['found'], key=lambda x: x.count('/'))
+        result['found'] = sorted(found, key=lambda x: (x.count('/'), x))
 
         return result
-    
 
-    def __intersect(self, result: dict):
+
+    def __match(self, term: str, exact: bool=False) -> set:
         """
-        Returns an intersection of two search queries (AND)
-
-        result (dict): dict with search result lists to be intersected
+        Returns the paths matching a single search term.
+        A term without context prefix is a wildcard that searches all contexts at once.
         """
+        prefixes = {prefix: context_key for context_key, prefix in self.SCHEMA.items() if prefix}
+        if term[0] in prefixes and len(term) > 1:
+            context_keys, needle = [prefixes[term[0]]], term[1:]
+        else:
+            context_keys, needle = self.SCHEMA.keys(), term
 
-        mresult = {key: value for key, value in result.items() if value} # remove empty items
-        
-        if len(mresult) < 2: # if there is not enough to intersect, just return it
-            return []
-        
-        common_elements = set(mresult[next(iter(mresult))]) # elements common in all
-        
-        for key in mresult.keys():
-            # intersect common elements
-            common_elements = common_elements.intersection(mresult[key])
-        
-        return list(common_elements)
+        found = set()
+        for context_key in context_keys:
+            for hay, paths in self.INDEX[context_key].items():
+                if (needle == str(hay)) if exact else (needle in str(hay)):
+                    found.update(paths)
+        return found
 
 
     def __collect_contexts(self, context: dict):
@@ -404,7 +348,7 @@ class OrvaniTag:
         tags = re.findall(self.REGEX, context_string)
         note = re.sub(self.REGEX, '', context_string).lstrip()
         argv = ' '.join(tags).split(' ') + [note]
-        argv = [x for x in list(set(argv)) if x != ''] # make unique and remove empty items
+        argv = [x for x in dict.fromkeys(argv) if x != ''] # make unique and remove empty items
         return argv
 
 
@@ -428,7 +372,7 @@ class OrvaniTag:
                 for context_key in self.SCHEMA.keys():
                     if self.SCHEMA[context_key] != None:
                         # Add to context and remove duplicate entries
-                        self.DB[target.name][context_key] = list(set( self.DB[target.name][context_key] + context[context_key]))
+                        self.DB[target.name][context_key] = list(dict.fromkeys( self.DB[target.name][context_key] + context[context_key]))
                     else:
                         # Add the note
                         if context_key in context:
@@ -475,7 +419,9 @@ class OrvaniTag:
                             for item in context[context_key]:
                                 if item in self.DB[target.name][context_key]:
                                     self.DB[target.name][context_key].remove(item)
-                                    saved = self.__save()
+                                    saved = True
+                    if saved:
+                        saved = self.__save()
 
                 if saved and self.DB[target.name]:
                     return [0, file, context]
@@ -544,23 +490,24 @@ class OrvaniTag:
                     sourcefile = sourcefolder
                     sourcefolder = os.path.dirname(sourcefolder)
             
-            sourcedata = {}
             self.__cd(sourcefolder) # load local context
-            if self.DB[sourcefile]: # check if we have an entry for this file
-                sourcedata = self.DB[sourcefile] # load its context data
-            if not os.path.isdir(destfile):
-                self.__cd(destfolder) # load local context of destination folder
-            self.DB.update( { destfile : sourcedata } ) # update its DB with the context data of our source file
-            self.__save() # and save it
-            if not retain: # do we copy or move the data?
-                self.delete(source) # delete for move
+            sourcedata = self.DB.get(sourcefile) # load its context data
+            if sourcedata is None and not fop: # without an entry there is nothing to move
+                return [2, source]
+            if sourcedata is not None:
+                if not os.path.isdir(destfile):
+                    self.__cd(destfolder) # load local context of destination folder
+                self.DB.update( { destfile : sourcedata } ) # update its DB with the context data of our source file
+                self.__save() # and save it
+                if not retain: # do we copy or move the data?
+                    self.delete(source) # delete for move
             if fop: # do we also physically move or copy the reference file?
                 if retain:
                     shutil.copy2(source, dest)
                 else:
                     shutil.copy2(source, dest)
                     os.remove(source)
-            return [0, source, dest, sourcedata]
+            return [0, source, dest, sourcedata or {}]
         else:
             return [1, source, dest, os.path.isfile(source), os.path.isdir(os.path.dirname(dest))]
 
@@ -583,7 +530,7 @@ class OrvaniTag:
             DB = self.DB.copy() # make a copy of it
             for file in self.DB.keys(): # check for each entry if the file actually exists
                 f = target / file
-                if not f.is_file(): # if not, delete its entry
+                if not f.exists(): # if not, delete its entry
                     if file in self.DB:
                         del DB[file]
                         count += 1
@@ -619,6 +566,18 @@ class OrvaniTag:
         result = self.__search(search, exact, either) # apply the search query
         return result
 
+    def index(self, path):
+        if path == '': # fix empty path argument
+            path = './'
+        self.__makeindex(path) # index the directories
+        return self.INDEX
+    
+    def context(self, filepath: str):
+        if filepath in self.INDEX['items']:
+            context = self.INDEX['items'][filepath]
+            return self.__collect_contexts(context)
+        else:
+            return []
 
     def scan(self, path: str, recursive: bool=False):
 
@@ -641,9 +600,25 @@ class OrvaniTag:
         Add a plugin to the plugin list
         """
         self.PLUGINS.append(function)
+        self.MODULES = None
         return function
 
 
+    def __plugins(self) -> dict:
+        """
+        Imports the plugin modules once. Plugins whose optional dependency
+        is not installed are skipped, so other plugins keep working.
+        """
+        if self.MODULES is None:
+            self.MODULES = {}
+            for plugin_name in self.PLUGINS:
+                try:
+                    self.MODULES[plugin_name] = import_module('orvanitag.plugins.' + plugin_name)
+                except ImportError:
+                    pass
+        return self.MODULES
+
+
     def read_metadata(self, filename: str):
         """
         Processes a file with all reader plugins to extract its metadata
@@ -655,18 +630,16 @@ class OrvaniTag:
             'config' : self.CONFIG,
             'schema' : self.SCHEMA,
             'context' : self.__context,
-            'string' : self.get_argv
+            'string' : self.__get_argv
         }
         o = SimpleNamespace(**obj)
 
-        for plugin_name in self.PLUGINS:
+        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']:
                     continue
-            # Load the plugin from plugins folder
-            plugin = import_module('orvanitag.plugins.' + plugin_name)
-            
+
             # 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
@@ -718,19 +691,17 @@ class OrvaniTag:
             'schema' : self.SCHEMA,
             'context' : {},
             'collect' : self.__collect_contexts,
-            'string' : self.get_argv
+            'string' : self.__get_argv
         }
         o = SimpleNamespace(**obj)
 
-        for plugin_name in self.PLUGINS:
+        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']:
                     continue
 
-            plugin = import_module('orvanitag.plugins.' + plugin_name)
-
             if plugin.NAME == method: # select the reference plugin
 
                 # Load a list of file types the plugin is able to read
@@ -760,8 +731,10 @@ class OrvaniTag:
                                 return [0, filename, o.context]
                             else:
                                 return [5, plugin_name]
+                    return [2, filename]
                 else:
                     return [4, suffixes, plugin_name, plugin_name in self.CONFIG]
+        return [6, method]
 
 
     def export(self, filename:str):
@@ -815,28 +788,20 @@ class OrvaniTag:
                     if len(import_line) > 5:
                         line_elements = import_line.split(' --- ')
                         if len(line_elements) > 1:
-                            context = {}
                             path = line_elements[0].strip()
-                            new_file = Path(path)
-                            if new_file.exists(): # Check if file exists
-                                string = line_elements[1].strip()
-                                if string:
-                                    new_file_context = self.__context(self.__get_argv(string))
-                                # Check if file is already known and don't import anything we already have
-                                if path in known.keys():
-                                    known_file_context = known[path]
-                                    context = new_file_context.copy()
-                                    for new_context in new_file_context:
-                                        if type(new_file_context[new_context]) == list:
-                                            for context_item in new_file_context[new_context]:
-                                                if new_context in known_file_context.keys():
-                                                    if context_item in known_file_context[new_context]:
-                                                        context[new_context].remove(context_item)
-                                        else:
-                                            if new_context in known_file_context.keys():
-                                                if new_file_context[new_context] == known_file_context[new_context]:
-                                                    del context[new_context]
+                            string = line_elements[1].strip()
+                            if string and Path(path).exists(): # Check if file exists
+                                context = self.__context(self.__get_argv(string))
+                                # Don't import anything we already have
+                                known_file_context = known.get(path, {})
+                                for new_context, value in list(context.items()):
+                                    if type(value) == list:
+                                        context[new_context] = [x for x in value if x not in (known_file_context.get(new_context) or [])]
+                                    elif value == known_file_context.get(new_context):
+                                        del context[new_context]
+                                if any(context.values()):
                                     self.__add(path, context)
+                                    count += 1
                         else:
                             return [4, filename]
                 return [0, filename, count]
diff --git a/orvanitag/plugins/office.py b/orvanitag/plugins/office.py
index 3f1d255..d335f37 100644
--- a/orvanitag/plugins/office.py
+++ b/orvanitag/plugins/office.py
@@ -1,105 +1,64 @@
 import os
 import re
-import mimetypes
-import shutil
-from pathlib import Path
-from olefile import OleFileIO
+from xml.sax.saxutils import escape, unescape
+from zipfile import ZipFile
 
 NAME = 'office'
 TYPES = ['.doc', '.xls', '.ppt', '.docx', '.xlsx', '.pptx', '.odt', '.ods', '.odp']
-XML_REGEX = r"(?P
)(?P.*?)(?P<\/dc:description>)"
-REGEX = r'\s*([@+]\w+|#\w+)'
+XML_REGEX = re.compile(r"(?P
)(?P.*?)(?P<\/dc:description>)", re.S)
 
-# Office Document processing inspired by
-# https://github.com/kappapiana/anonymize/blob/master/anonymize.py
+# Detected by suffix because mimetypes doesn't know the ODF/OOXML types on every system
+META_FILES = {
+    '.odt' : 'meta.xml',
+    '.ods' : 'meta.xml',
+    '.odp' : 'meta.xml',
+    '.docx' : 'docProps/core.xml',
+    '.xlsx' : 'docProps/core.xml',
+    '.pptx' : 'docProps/core.xml',
+}
+LEGACY_TYPES = ['.doc', '.xls', '.ppt']
 
-def unzip_file(orig_file, tmp_dir):
-    type = mimetypes.guess_type(orig_file)
-    filetype = {
-        "application/vnd.oasis.opendocument.text" : "odt",
-        "application/vnd.oasis.opendocument.spreadsheet" : "ods",
-        "application/vnd.oasis.opendocument.presentation" : "odp",
-        "application/vnd.openxmlformats-officedocument.wordprocessingml.document" : "docx",
-        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "xlsx",
-        "application/vnd.openxmlformats-officedocument.presentationml.presentation" : "pptx",
-    }
-    if type[0] in filetype:
-        shutil.unpack_archive(orig_file, tmp_dir, 'zip')
-        return filetype[type[0]]
-    else:
-        return type[0]
-
-def get_context_string(meta_file):
-    ctx = ''
-    meta_content = meta_file.read_text()
-    csearch = re.search(XML_REGEX, meta_content)
-    if csearch:
-        return csearch.group('body')
-    
 
 def reader(o):
-    tmp_folder = Path('unzip__' + o.file.stem)
-    file_type = unzip_file(o.file, tmp_folder)
-    tags = note = []
+    suffix = o.file.suffix.lower()
 
-    # OpenOffice / LibreOffice / Open Document Format
-    if file_type in ['odt', 'ods', 'odp']:
-        meta_file = tmp_folder / 'meta.xml'
-        argv = o.string(get_context_string(meta_file))
-        context = o.context(argv)
-        shutil.rmtree(tmp_folder)
-        return context
-
-    # Microsoft Office Format
-    elif file_type in ['docx', 'xlsx', 'pptx']:
-        meta_file = tmp_folder / 'docProps' / 'core.xml'
-        argv = o.string(get_context_string(meta_file))
-        context = o.context(argv)
-        shutil.rmtree(tmp_folder)
-        return context
+    # OpenDocument and Microsoft Office Open XML formats
+    if suffix in META_FILES:
+        with ZipFile(o.file) as archive:
+            meta = archive.read(META_FILES[suffix]).decode('utf-8')
+        found = XML_REGEX.search(meta)
+        if found and found.group('body'):
+            return o.context(o.string(unescape(found.group('body'))))
 
     # Legacy Microsoft Office Format
-    elif file_type in ['application/msword', 'application/vnd.ms-excel', 'application/vnd.ms-powerpoint']:
+    elif suffix in LEGACY_TYPES:
+        # Imported here so the zip based formats work without the optional olefile dependency
+        from olefile import OleFileIO
         ole = OleFileIO(o.file)
         meta = ole.get_metadata()
         ole.close()
         if meta.comments:
-            argv = o.string(meta.comments.decode('UTF-8'))
-            context = o.context(argv)
-            return context
-    
+            return o.context(o.string(meta.comments.decode('UTF-8')))
+
 
 def writer(o):
-    string = ' '.join(o.collect(o.context))
-    tmp_folder = Path('unzip__' + o.file.stem)
-    file_type = unzip_file(o.file, tmp_folder)
-    regex = re.compile(XML_REGEX)
-    string = f"\\g
{string}\\g"
+    suffix = o.file.suffix.lower()
+    if suffix not in META_FILES:
+        raise NotImplementedError(f'writing metadata to {suffix} files is not supported')
 
-    # OpenOffice / LibreOffice / Open Document Format
-    if file_type in ['odt', 'ods', 'odp']:
-        meta_file = tmp_folder / 'meta.xml'
-        with meta_file.open('r+') as file:
-            content = regex.sub(string, file.read())
-            file.seek(0, 0)
-            file.write(content)
-        shutil.make_archive(o.file, "zip", tmp_folder)
-        output_file_zip = str(o.file) + ".zip"
-        os.rename(output_file_zip, o.file)
-        shutil.rmtree(tmp_folder)
-
-    # Microsoft Office Format
-    elif file_type in ['docx', 'xlsx', 'pptx']:
-        meta_file = tmp_folder / 'docProps' / 'core.xml'
-        with meta_file.open('r+') as file:
-            content = regex.sub(string, file.read())
-            file.seek(0, 0)
-            file.write(content)
-        shutil.make_archive(o.file, "zip", tmp_folder)
-        output_file_zip = str(o.file) + ".zip"
-        os.rename(output_file_zip, o.file)
-        shutil.rmtree(tmp_folder) 
-
-    # Legacy Microsoft Office Format
-    elif file_type in ['application/msword', 'application/vnd.ms-excel', 'application/vnd.ms-powerpoint']:
-        print('unsupported file format')
+    meta_file = META_FILES[suffix]
+    string = escape(' '.join(o.collect(o.context)))
+    tmp = o.file.with_name(f'.{o.file.name}.tmp')
+    try:
+        # Copying every ZipInfo keeps entry order and compression, which ODF
+        # requires for its leading, uncompressed 'mimetype' entry
+        with ZipFile(o.file) as source, ZipFile(tmp, 'w') as target:
+            for info in source.infolist():
+                data = source.read(info)
+                if info.filename == meta_file:
+                    meta = XML_REGEX.sub(lambda m: m['pre'] + string + m['post'], data.decode('utf-8'))
+                    data = meta.encode('utf-8')
+                target.writestr(info, data)
+        os.replace(tmp, o.file)
+    finally:
+        tmp.unlink(missing_ok=True)
diff --git a/orvanitag/plugins/pdf.py b/orvanitag/plugins/pdf.py
index 1b30884..8bd89b1 100644
--- a/orvanitag/plugins/pdf.py
+++ b/orvanitag/plugins/pdf.py
@@ -1,24 +1,23 @@
-import re
-import shutil
-from pathlib import Path
-from pdfrw import PdfReader, PdfWriter
+import os
+from pypdf import PdfReader, PdfWriter
 
 NAME = 'pdf'
 TYPES = ['.pdf']
-REGEX = r'\s*([@+]\w+|#\w+)'
 
 def reader(o):
-    pdf = PdfReader(o.file)
-    if pdf.Info.Keywords:
-        tags = re.findall(REGEX, pdf.Info.Keywords.strip())
-        note = re.sub(REGEX, '', pdf.Info.Keywords.strip()).replace('(', '').replace(',', '').replace(')', '').strip()
-        argv = ' '.join(tags).split(' ') + [note]
-        argv = [x for x in argv if x != '']
-        context = o.context(argv)
-        return context
-    
+    meta = PdfReader(o.file).metadata
+    keywords = meta.get('/Keywords') if meta else None
+    if keywords and keywords.strip():
+        return o.context(o.string(keywords.strip()))
+
 def writer(o):
     string = ' '.join(o.collect(o.context))
-    pdf = PdfReader(o.file)
-    pdf.Info.Keywords = string
-    PdfWriter(o.file, trailer=pdf).write()
+    tmp = o.file.with_name(f'.{o.file.name}.tmp')
+    try:
+        with open(o.file, 'rb') as source:
+            pdf = PdfWriter(clone_from=PdfReader(source))
+            pdf.add_metadata({'/Keywords': string})
+            pdf.write(tmp)
+        os.replace(tmp, o.file)
+    finally:
+        tmp.unlink(missing_ok=True)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..0a14b78
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,45 @@
+#// +orvanitag @orga #tagging #python
+
+[build-system]
+requires = ["setuptools>=77"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "orvanitag"
+version = "1.1.0"
+description = "Platform independent, file-based, portable tagging system to organize directories and files."
+readme = "README.md"
+license = "MIT"
+license-files = ["LICENSE"]
+authors = [{ name = "randogoth", email = "randogoth@posteo.org" }]
+requires-python = ">=3.9"
+classifiers = [
+    "Programming Language :: Python :: 3",
+    "Operating System :: OS Independent",
+]
+dependencies = [
+    "PyYAML>=6.0",
+    "rich>=13.4.2",
+]
+
+[project.optional-dependencies]
+audio = ["pytaglib>=2.0.0"]
+pdf = ["pypdf>=4.0"]
+office = ["olefile>=0.46"]
+markdown = ["python-frontmatter>=1.0"]
+all = ["orvanitag[audio,pdf,office,markdown]"]
+
+[project.urls]
+Homepage = "https://github.com/randogoth/orvanitag"
+
+[project.scripts]
+otag = "orvanitag.cmd:main"
+
+[tool.setuptools.packages.find]
+include = ["orvanitag*"]
+
+[dependency-groups]
+dev = [
+    "pytest>=8",
+    "orvanitag[pdf,office,markdown]",
+]
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 14a4040..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-olefile==0.46
-pytaglib==2.0.0
-PyYAML==6.0
\ No newline at end of file
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 7fd8bd4..0000000
--- a/setup.py
+++ /dev/null
@@ -1,29 +0,0 @@
-"""
-#// +orvanitag @orga #tagging #python
-"""
-import setuptools
-
-with open("README.md", "r") as fh:
-    long_description = fh.read()
-
-setuptools.setup(
-    name="orvanitag",
-    version="1.0.0",
-    author="randogoth",
-    author_email="randogoth@posteo.org",
-    description="Platform independent, file-based, portable tagging system to organize directories and files.",
-    long_description=long_description,
-    long_description_content_type="text/markdown",
-    url="https://github.com/randogoth/orvanitag",
-    packages=setuptools.find_packages(),
-    classifiers=[
-        "Programming Language :: Python :: 3",
-        "License :: OSI Approved :: MIT License",
-        "Operating System :: OS Independent",
-    ],
-    entry_points= {
-        'console_scripts': ['otag=orvanitag.cmd:main']
-    },
-    install_requires=['requests'],
-    python_requires='>=3.6',
-)
\ No newline at end of file
diff --git a/tests/test_orvani.py b/tests/test_orvani.py
new file mode 100644
index 0000000..8d58220
--- /dev/null
+++ b/tests/test_orvani.py
@@ -0,0 +1,179 @@
+import os
+
+import pytest
+import yaml
+
+import orvanitag.orvani as orvani
+from orvanitag.orvani import OrvaniTag, DEFAULT_PLUGINS
+
+
+@pytest.fixture
+def tree(tmp_path, monkeypatch):
+    monkeypatch.chdir(tmp_path)
+    for name in ['a.mp3', 'b.mp3', 'c.mp3']:
+        (tmp_path / name).touch()
+    (tmp_path / 'sub').mkdir()
+    (tmp_path / 'sub' / 'd.txt').touch()
+    return tmp_path
+
+
+def found(o, *query, either=False, path=''):
+    return o.list(path, True, either, True, False, False, *query)['found']
+
+
+def items(path='.'):
+    return yaml.safe_load((path / orvani.CONTEXT_FILE).read_text())['items']
+
+
+def test_and_search_within_one_category(tree):
+    o = OrvaniTag()
+    o.add('a.mp3', '#lofi', '#snare')
+    o.add('b.mp3', '#lofi')
+    assert found(o, '#lofi', '#snare') == ['./a.mp3']
+
+
+def test_and_search_across_categories(tree):
+    o = OrvaniTag()
+    o.add('a.mp3', '#lofi', '@drums')
+    o.add('b.mp3', '#lofi')
+    o.add('c.mp3', '@drums')
+    assert found(o, '#lofi', '@drums') == ['./a.mp3']
+
+
+def test_or_search(tree):
+    o = OrvaniTag()
+    o.add('a.mp3', '#lofi')
+    o.add('b.mp3', '#snare')
+    o.add('c.mp3', '#other')
+    assert found(o, '#lofi', '#snare', either=True) == ['./a.mp3', './b.mp3']
+
+
+def test_unprefixed_term_matches_any_category(tree):
+    o = OrvaniTag()
+    o.add('a.mp3', '+lofi')
+    o.add('b.mp3', '#lofi')
+    o.add('c.mp3', '#other')
+    assert found(o, 'lofi') == ['./a.mp3', './b.mp3']
+
+
+def test_empty_query_lists_everything(tree):
+    o = OrvaniTag()
+    o.add('a.mp3', '#lofi')
+    o.add('sub/d.txt', '#doc')
+    assert found(o, None) == ['./a.mp3', './sub/d.txt']
+
+
+def test_multi_word_note(tree):
+    OrvaniTag().add('a.mp3', '#lofi', 'my', 'long', 'note')
+    assert items(tree)['a.mp3']['note'] == 'my long note'
+
+
+def test_trailing_commas_are_stripped(tree):
+    OrvaniTag().add('a.mp3', '#lofi,', '#snare')
+    assert items(tree)['a.mp3']['tags'] == ['lofi', 'snare']
+
+
+def test_clean_keeps_tagged_directories(tree):
+    o = OrvaniTag()
+    o.add('sub', '#folder')
+    o.add('a.mp3', '#lofi')
+    (tree / 'a.mp3').unlink()
+    assert o.clean('', True)[2] == 1
+    assert list(items(tree)) == ['sub']
+
+
+def test_instances_do_not_share_state(tree):
+    first = OrvaniTag()
+    first.register_plugin('extra')
+    assert 'extra' not in OrvaniTag().PLUGINS
+    assert 'extra' not in DEFAULT_PLUGINS
+
+
+def test_index_is_rebuilt_per_list_call(tree):
+    o = OrvaniTag()
+    o.add('a.mp3', '#lofi')
+    o.add('sub/d.txt', '#lofi')
+    assert found(o, '#lofi') == ['./a.mp3', './sub/d.txt']
+    assert found(o, '#lofi', path='sub') == ['sub/d.txt']
+
+
+def test_move_untagged_file(tree):
+    assert OrvaniTag().move('a.mp3', 'sub/a.mp3', False, False)[0] == 2
+
+
+def test_move_tagged_file(tree):
+    o = OrvaniTag()
+    o.add('a.mp3', '#lofi')
+    assert o.move('a.mp3', 'sub/', False, True)[0] == 0
+    assert (tree / 'sub' / 'a.mp3').exists()
+    assert items(tree / 'sub')['a.mp3']['tags'] == ['lofi']
+    assert not (tree / orvani.CONTEXT_FILE).exists()
+
+
+def test_save_keeps_config(tree):
+    (tree / orvani.CONTEXT_FILE).write_text('config:\n  plugins: [pdf]\nitems: {}\n')
+    OrvaniTag().add('a.mp3', '#lofi')
+    data = yaml.safe_load((tree / orvani.CONTEXT_FILE).read_text())
+    assert data['config'] == {'plugins': ['pdf']}
+    assert data['items']['a.mp3']['tags'] == ['lofi']
+
+
+def test_failed_save_keeps_old_file(tree, monkeypatch):
+    o = OrvaniTag()
+    o.add('a.mp3', '#lofi')
+    before = (tree / orvani.CONTEXT_FILE).read_text()
+
+    def broken_dump(*args, **kwargs):
+        raise RuntimeError('disk full')
+    monkeypatch.setattr(orvani.yaml, 'dump', broken_dump)
+
+    with pytest.raises(RuntimeError):
+        o.add('a.mp3', '#snare')
+    assert (tree / orvani.CONTEXT_FILE).read_text() == before
+    assert sorted(os.listdir(tree)) == [orvani.CONTEXT_FILE, 'a.mp3', 'b.mp3', 'c.mp3', 'sub']
+
+
+def test_remove_saves_once(tree, monkeypatch):
+    o = OrvaniTag()
+    o.add('a.mp3', '#lofi', '#snare', '#keep')
+    saves = []
+    save = o._OrvaniTag__save
+    monkeypatch.setattr(o, '_OrvaniTag__save', lambda: saves.append(1) or save())
+    assert o.remove('a.mp3', '#lofi', '#snare')[0] == 0
+    assert len(saves) == 1
+    assert items(tree)['a.mp3']['tags'] == ['keep']
+
+
+def test_cache_is_rebuilt_when_context_changes(tree):
+    o = OrvaniTag()
+    o.add('a.mp3', '#lofi')
+    assert o.list('', True, False, True, True, False, '#lofi')['found'] == ['./a.mp3']
+    assert (tree / orvani.CACHE_FILE).exists()
+
+    o.add('b.mp3', '#lofi')
+    stat = (tree / orvani.CONTEXT_FILE).stat()
+    os.utime(tree / orvani.CONTEXT_FILE, ns=(stat.st_atime_ns, stat.st_mtime_ns + 10**9))
+    assert OrvaniTag().list('', True, False, True, True, False, '#lofi')['found'] == ['./a.mp3', './b.mp3']
+
+
+def test_import_untagged_files(tree):
+    o = OrvaniTag()
+    o.add('a.mp3', '#lofi', 'a note')
+    o.add('sub/d.txt', '@docs')
+    assert o.export('context.txt')[0] == 0
+    (tree / orvani.CONTEXT_FILE).unlink()
+    (tree / 'sub' / orvani.CONTEXT_FILE).unlink()
+
+    assert OrvaniTag().load('context.txt') == [0, 'context.txt', 2]
+    assert items(tree)['a.mp3']['tags'] == ['lofi']
+    assert items(tree)['a.mp3']['note'] == 'a note'
+    assert items(tree / 'sub')['d.txt']['contexts'] == ['docs']
+
+
+def test_import_skips_known_tags_and_empty_lines(tree):
+    o = OrvaniTag()
+    o.add('a.mp3', '#lofi')
+    (tree / 'context.txt').write_text('./a.mp3 --- #lofi #snare\n./b.mp3 --- \n')
+    assert OrvaniTag().load('context.txt') == [0, 'context.txt', 1]
+    assert items(tree)['a.mp3']['tags'] == ['lofi', 'snare']
+    assert 'b.mp3' not in items(tree)
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
new file mode 100644
index 0000000..17edc81
--- /dev/null
+++ b/tests/test_plugins.py
@@ -0,0 +1,109 @@
+import sys
+import zipfile
+
+import pytest
+import yaml
+from pypdf import PdfReader, PdfWriter
+
+import orvanitag.orvani as orvani
+from orvanitag.cmd import main
+from orvanitag.orvani import OrvaniTag
+
+DOCX_CORE = (
+    ''
+    'T'
+    ''
+)
+ODT_META = (
+    ''
+    'old'
+    ''
+)
+
+
+@pytest.fixture
+def cwd(tmp_path, monkeypatch):
+    monkeypatch.chdir(tmp_path)
+    return tmp_path
+
+
+def make_docx(path):
+    with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as z:
+        z.writestr('[Content_Types].xml', '')
+        z.writestr('docProps/core.xml', DOCX_CORE)
+
+
+def make_odt(path):
+    with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as z:
+        z.writestr(zipfile.ZipInfo('mimetype'), 'application/vnd.oasis.opendocument.text')
+        z.writestr('content.xml', '')
+        z.writestr('meta.xml', ODT_META)
+
+
+def roundtrip(cwd, name):
+    o = OrvaniTag()
+    o.add(name, '#lofi', '+proj', 'R&B', 'C:\\1')
+    assert o.write_metadata(name, 'office')[0] == 0
+    (cwd / orvani.CONTEXT_FILE).unlink()
+    OrvaniTag().read_metadata(name)
+    entry = yaml.safe_load((cwd / orvani.CONTEXT_FILE).read_text())['items'][name]
+    assert entry['tags'] == ['lofi']
+    assert entry['projects'] == ['proj']
+    assert entry['note'] == 'R&B C:\\1'
+    assert not list(cwd.glob('unzip__*'))
+
+
+def test_docx_roundtrip(cwd):
+    make_docx(cwd / 'doc.docx')
+    roundtrip(cwd, 'doc.docx')
+    with zipfile.ZipFile(cwd / 'doc.docx') as z:
+        assert z.testzip() is None
+        assert 'T' in z.read('docProps/core.xml').decode()
+
+
+def test_odt_roundtrip_keeps_mimetype_first(cwd):
+    make_odt(cwd / 'doc.odt')
+    roundtrip(cwd, 'doc.odt')
+    with zipfile.ZipFile(cwd / 'doc.odt') as z:
+        first = z.infolist()[0]
+        assert first.filename == 'mimetype'
+        assert first.compress_type == zipfile.ZIP_STORED
+
+
+def test_pdf_roundtrip(cwd):
+    writer = PdfWriter()
+    writer.add_blank_page(72, 72)
+    writer.add_metadata({'/Title': 'T'})
+    writer.write(cwd / 'doc.pdf')
+
+    o = OrvaniTag()
+    o.add('doc.pdf', '#lofi', '@desk', 'a note')
+    assert o.write_metadata('doc.pdf', 'pdf')[0] == 0
+    meta = PdfReader(cwd / 'doc.pdf').metadata
+    assert meta['/Title'] == 'T'
+
+    (cwd / orvani.CONTEXT_FILE).unlink()
+    OrvaniTag().read_metadata('doc.pdf')
+    entry = yaml.safe_load((cwd / orvani.CONTEXT_FILE).read_text())['items']['doc.pdf']
+    assert entry == {'projects': [], 'contexts': ['desk'], 'tags': ['lofi'], 'note': 'a note'}
+
+
+def test_cmd_write(cwd, monkeypatch):
+    make_docx(cwd / 'doc.docx')
+    OrvaniTag().add('doc.docx', '#lofi')
+    monkeypatch.setattr(sys, 'argv', ['otag', 'w', 'office', 'doc.docx'])
+    main()
+    with zipfile.ZipFile(cwd / 'doc.docx') as z:
+        assert '#lofi' in z.read('docProps/core.xml').decode()
+
+
+def test_cmd_prints_errors(cwd, monkeypatch, capsys):
+    (cwd / 'a.mp3').touch()
+    (cwd / orvani.CONTEXT_FILE).write_text('items: [unclosed')
+    monkeypatch.setattr(sys, 'argv', ['otag', 'a', 'a.mp3', '#lofi'])
+    with pytest.raises(SystemExit) as exit:
+        main()
+    assert exit.value.code == 1
+    assert 'expected' in capsys.readouterr().out
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..868b821
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,532 @@
+version = 1
+revision = 3
+requires-python = ">=3.9"
+resolution-markers = [
+    "python_full_version >= '3.10'",
+    "python_full_version < '3.10'",
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version < '3.10'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version >= '3.10'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "3.0.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version < '3.10'",
+]
+dependencies = [
+    { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version >= '3.10'",
+]
+dependencies = [
+    { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
+[[package]]
+name = "olefile"
+version = "0.47"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c", size = 112240, upload-time = "2023-12-01T16:22:53.025Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565, upload-time = "2023-12-01T16:22:51.518Z" },
+]
+
+[[package]]
+name = "orvanitag"
+version = "1.1.0"
+source = { editable = "." }
+dependencies = [
+    { name = "pyyaml" },
+    { name = "rich" },
+]
+
+[package.optional-dependencies]
+all = [
+    { name = "olefile" },
+    { name = "pypdf" },
+    { name = "pytaglib", version = "3.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+    { name = "pytaglib", version = "3.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+    { name = "python-frontmatter", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+    { name = "python-frontmatter", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+]
+audio = [
+    { name = "pytaglib", version = "3.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+    { name = "pytaglib", version = "3.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+]
+markdown = [
+    { name = "python-frontmatter", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+    { name = "python-frontmatter", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+]
+office = [
+    { name = "olefile" },
+]
+pdf = [
+    { name = "pypdf" },
+]
+
+[package.dev-dependencies]
+dev = [
+    { name = "orvanitag", extra = ["markdown", "office", "pdf"] },
+    { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+    { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "olefile", marker = "extra == 'office'", specifier = ">=0.46" },
+    { name = "orvanitag", extras = ["audio", "pdf", "office", "markdown"], marker = "extra == 'all'" },
+    { name = "pypdf", marker = "extra == 'pdf'", specifier = ">=4.0" },
+    { name = "pytaglib", marker = "extra == 'audio'", specifier = ">=2.0.0" },
+    { name = "python-frontmatter", marker = "extra == 'markdown'", specifier = ">=1.0" },
+    { name = "pyyaml", specifier = ">=6.0" },
+    { name = "rich", specifier = ">=13.4.2" },
+]
+provides-extras = ["audio", "pdf", "office", "markdown", "all"]
+
+[package.metadata.requires-dev]
+dev = [
+    { name = "orvanitag", extras = ["pdf", "office", "markdown"] },
+    { name = "pytest", specifier = ">=8" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version < '3.10'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.7.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version >= '3.10'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.21.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
+]
+
+[[package]]
+name = "pypdf"
+version = "6.21.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/28/081f1cbcca7042441dafc8de00f48ba60853f89e5bff92c5f26477346d81/pypdf-6.21.2.tar.gz", hash = "sha256:0a3e1c5c886a1be03646c16a04827cc166c2a0575c971b26a4bf89686551c2d4", size = 7144571, upload-time = "2026-10-21T16:44:55.272Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1b/4d/c9d2d67f204eff463237a1a8f5e25a6923b7b1043982b91fcaf104c8ef60/pypdf-6.21.2-py3-none-any.whl", hash = "sha256:1d44d1a3a200f25062adff0085b02040e4646431ac2ccac22e42679e91466084", size = 407766, upload-time = "2026-10-21T16:44:53.626Z" },
+]
+
+[[package]]
+name = "pytaglib"
+version = "3.1.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version < '3.10'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c3/47/8c191b92368d853a63ddda3fb13e4556458a0f22b7bbbbdbc91db59b7206/pytaglib-3.1.0.tar.gz", hash = "sha256:42447797fc7019d7f007ed4d6ef46650310e382a8920508d56262eb93e0e79d6", size = 511317, upload-time = "2025-11-19T08:05:17.645Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e8/98/5b15e2ab399f0b9c4c02ee0d7046154e6a22f988f3e609ffc32350512207/pytaglib-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:176f34fc49d526f41b2a0aec338d95769df944d7328902a1ee67a47f143aff0f", size = 800283, upload-time = "2025-11-19T08:04:32.066Z" },
+    { url = "https://files.pythonhosted.org/packages/43/3c/f55465b4699f5351993757afdc275bc46eaadd9944745b842e701814f4f1/pytaglib-3.1.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad9f2aee7021f0ed915f5f0fdc08b25adfd451ea7e59338b1f7cad0e4aea2396", size = 1800317, upload-time = "2025-11-19T08:04:33.683Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/02/681a96d1e483dabf1906f66c4d24d59c9976f5e1ed45ffc064a396e193ee/pytaglib-3.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0a2527aca436bc31eb0c28b32e41310b67daac5467071680f2c80bfc8c563dd5", size = 2844506, upload-time = "2025-11-19T08:04:35.293Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/db/060450446a474ddddecc58634b162c704d3eef47b05673ac063a16cebb44/pytaglib-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:701653bbdea2a5bf4e8fe49ba4138477eee21a096a8adf49faa536faeff2d4de", size = 262284, upload-time = "2025-11-19T08:04:36.715Z" },
+    { url = "https://files.pythonhosted.org/packages/60/d7/ff7596e919e36c1349b820becefbaafa94c6942491e02714930597390add/pytaglib-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:359f428c956d0c1585ceeaea464f8affb56f4fb142fce8a184a76b7fb6b03706", size = 741013, upload-time = "2025-11-19T08:04:37.704Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/32/b6bbd8a08cd38d64e8926e41743325af8c659693944898342ad0c9590097/pytaglib-3.1.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84ed18e3c5ee136a0162cb89ea48575a9fd986b37ab42bb79e6fd0580d484a7", size = 1810673, upload-time = "2025-11-19T08:04:38.736Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/b2/a5cc5630b7f9a80622cef7aa48a606d8d0f92d455c988b53b67a5bc58e0d/pytaglib-3.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:12587361eeaa0a463d2d51951b997e035a39631f0536663c1124531618d0931e", size = 2857613, upload-time = "2025-11-19T08:04:39.94Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/85/b78035f6092c7d9aef0fcb13b8b288653388b4accd315ddb9cc0c66c21ba/pytaglib-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4b8037e132d0b69a72559438dec8860e1e979b5703bdc4aae785427be4d1679f", size = 262430, upload-time = "2025-11-19T08:04:41.165Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/b4/0076810b778f98cbf57c9c2250d7f5ace67e2dda0178c7af2639767922bd/pytaglib-3.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:638a45a184d1ebdf92a4f9ec86dae5cde5188d45b1b7510fe5c2d426e6a3eae8", size = 800185, upload-time = "2025-11-19T08:04:42.157Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/14/dd3068d2e284601528ec37a62adceaaacb3e7a6c9ad38b986abbf94f6c9b/pytaglib-3.1.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ce55efefb208757d0cdc07664c21e732e7137fb42cf42bb5a763b47a4a413c0", size = 1823009, upload-time = "2025-11-19T08:04:43.228Z" },
+    { url = "https://files.pythonhosted.org/packages/07/44/49bd29aab93b3b5c1797461640cef9a72165888d60ec20d136f06556b80c/pytaglib-3.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:706dd821a9b1605bad551de01a569e5536bd7016dc6e02b7f3d1481c2bc852ba", size = 2866705, upload-time = "2025-11-19T08:04:44.837Z" },
+    { url = "https://files.pythonhosted.org/packages/4d/0f/dc833b1556e40fe9b6b05ec275519ed5e5470459a2271dbc6d2b73c5fe43/pytaglib-3.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d487b85805d34146acedf5cba85f4a021005ce8eeafeba3b78ba14a89ca6e44e", size = 263031, upload-time = "2025-11-19T08:04:45.939Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/db/833fbbb0c2f9d810ee2bd224b2cefe6a04700dc3d375a0649a30eb852b9a/pytaglib-3.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:066d5b4bec56af5421c5b8948104ee4889d040076a7dd3e50ecd7a4ef4c286a0", size = 799498, upload-time = "2025-11-19T08:04:47.316Z" },
+    { url = "https://files.pythonhosted.org/packages/af/ec/34a6e1965bf1ffdb9600a5e0283c2ff6da351f923b506ce8dd52b34edb50/pytaglib-3.1.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:991162647e94c857ff18340e49b75100d52056aadd0cd3583c97da119404cc46", size = 1815486, upload-time = "2025-11-19T08:04:48.496Z" },
+    { url = "https://files.pythonhosted.org/packages/70/c3/e540c6a6dbf61a1183edfa838f8068ee080b23aa8e7e597a526ece5b3650/pytaglib-3.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e36de34055d27ab6295e1e18321e2c34cd0f110cc788da092da694c88c2f678", size = 2861253, upload-time = "2025-11-19T08:04:50.07Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/45/b9ffb54812b4017de0c627c29d26d7afdbe9327653da11c6883b7cdcb83e/pytaglib-3.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:fe16dd56b5bd7364481fa5b3b989af291f9f6519466843becef7c4649294d7cf", size = 262780, upload-time = "2025-11-19T08:04:51.197Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/78/9ba99907289f89bf01099f6e94014446872ba96728ddf1445522e5f34c74/pytaglib-3.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1f3503eef78034c0e9f630637c3f591b58390c41ac55e58a2e1688d8779d5bc7", size = 799897, upload-time = "2025-11-19T08:04:52.239Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/94/e687af0a228baf7108267a11e4d4cb0662c35fd72244ccb3fb88791ad0bf/pytaglib-3.1.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c378342fc11976ade773e07d4054ed0e34b10968125a31338544bae799aad7", size = 1813402, upload-time = "2025-11-19T08:04:53.42Z" },
+    { url = "https://files.pythonhosted.org/packages/c8/2f/4b54fa9e8f0a49512cd4e830db96a2063d67979d03e662839306c9c3b9ff/pytaglib-3.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:202e16709d3c6f67d271c1138034a8d95716853252cc9222ac8b98b6f4e9d243", size = 2859615, upload-time = "2025-11-19T08:04:55.233Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/bd/e9849aa333654a3080262a2046e433d31c870ae0169aae1523a181d99c7e/pytaglib-3.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f8a312de13b898e91f1a72c585eab996f2614e24352b6eca2807e07ac9811250", size = 271022, upload-time = "2025-11-19T08:04:56.736Z" },
+    { url = "https://files.pythonhosted.org/packages/23/6c/bd6ce628c027997a6059ff45d01552c1776811282a8452f22281b354a6ad/pytaglib-3.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1878f4371427f2dc6fc849645bc316ecd3ab13f2aa9d3be2781ad0d57d313440", size = 802537, upload-time = "2025-11-19T08:04:57.687Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/ba/0127f98c7105d5e1290e8a25c9ab1a94fa66db88770216038bebb2cc9442/pytaglib-3.1.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f261af6c47a8d923bec1329a04d64d94c79f23eb86f8f58eae0c132b311d192", size = 1866541, upload-time = "2025-11-19T08:05:00.671Z" },
+    { url = "https://files.pythonhosted.org/packages/33/c6/7999c3f04c0c2147b207e7a962d851a770b96c8080a220915538f8c287c8/pytaglib-3.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d28566068a4ee39864e08da6f5b9717d9ee2fe084cd6a3ffa9dd165a99891da2", size = 2912249, upload-time = "2025-11-19T08:05:02.249Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/a0/150f2191b50a943445858219701c206a993896b6efadbd96aa2347ad2d45/pytaglib-3.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:157811dce2523e6f37c1dc3cc2c2606354ace44b90577d266451965ba8f16223", size = 276748, upload-time = "2025-11-19T08:05:04.85Z" },
+    { url = "https://files.pythonhosted.org/packages/18/b3/23622caed022fec878ddd887a7c61d0ad64ec1a9c1961a50c818f61ae0c6/pytaglib-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:152a061ca9088825b6ac5ff66788b81c1bb20fac55908ca81b093a0c4e2e230b", size = 800581, upload-time = "2025-11-19T08:05:05.868Z" },
+    { url = "https://files.pythonhosted.org/packages/39/5b/666896733205167494803a5526b5f36a96df6053e1fe211cd823d6dc1dd2/pytaglib-3.1.0-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40327186ee675bfde7a82d8c6b0b3a2163db45bdf2147410d13d9446b1f8dbc6", size = 1800485, upload-time = "2025-11-19T08:05:09.389Z" },
+    { url = "https://files.pythonhosted.org/packages/40/6c/02ef464f4cd427707e11259cdd57669b9a364a1cf2685c6346b4051d1487/pytaglib-3.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5123b7e3ee2dd901da01443b53033f37671344a09e904589fea6c9451e326398", size = 2844429, upload-time = "2025-11-19T08:05:11.15Z" },
+    { url = "https://files.pythonhosted.org/packages/61/c2/faff4f5bb022c203d205b92bccf94421cb564add6c4b8f8033c4aa7b2472/pytaglib-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:da00e730d6de490c3ed7549244f464e93a0cdfde17c70916f328440816715086", size = 262710, upload-time = "2025-11-19T08:05:12.282Z" },
+    { url = "https://files.pythonhosted.org/packages/11/2e/db464ee3d4f57ae51cd34ddc411a89c932d814ffd436003df3a49a9bd87d/pytaglib-3.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:58b3adc0439f3e8e9411ef57b7bd96c8873042174494217717ebda166baf5ab1", size = 793806, upload-time = "2025-11-19T08:05:13.279Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/e8/9d9065ffa13e9bdbd853d54584e526ebb109c98388a5baa439d17a274ff5/pytaglib-3.1.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e3b2b55224b3e630bba3aa6958740e6b5f9fe40c0052965a13fbe6a9015c32", size = 1348097, upload-time = "2025-11-19T08:05:15.15Z" },
+    { url = "https://files.pythonhosted.org/packages/40/82/5d5476f9dfad232a7b5ad5872de134777bd271faab56ec6da0728f138c70/pytaglib-3.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4c2d79bb0cb03e538a2d904b71f3b1eb727ce34d606b8daea15faf71e1a74ac2", size = 259743, upload-time = "2025-11-19T08:05:16.644Z" },
+]
+
+[[package]]
+name = "pytaglib"
+version = "3.2.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version >= '3.10'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c3/ff/4e81cc7aae32c85eaaad614defe6fa9d4da82ea9dc452db9001036acfa64/pytaglib-3.2.0.tar.gz", hash = "sha256:964e32e86edcda107eb3821dd4ca226de04f7f0f99bb4c18737e138b4ed3313f", size = 568593, upload-time = "2026-02-04T14:14:18.069Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/89/05/dbc7d00826b9f81663efda0d528e5888fcb1be05c471282b80f406b1ab11/pytaglib-3.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b6963b0d2b13472cb4dacf808dfc3bf230d2095a9255071ca08795e2fe6d4ffe", size = 765726, upload-time = "2026-02-04T14:13:34.561Z" },
+    { url = "https://files.pythonhosted.org/packages/29/ae/258758464caf3fecc45987739a0af3f1b5b84b09b68c664427be98d5a266/pytaglib-3.2.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da54d6d6dcd729c244d0099b2b9f747d1af27b140b81e1c4535045e79ac28c8e", size = 2043765, upload-time = "2026-02-04T14:13:36.168Z" },
+    { url = "https://files.pythonhosted.org/packages/84/f6/559fad5d14fa35a2f845868e668390cf16574873dc3dc7d80e4f6fec0c04/pytaglib-3.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:106d2c4ba3ea40f350fd349c66e71f02751391037e6e327498bae93e94c22646", size = 3084639, upload-time = "2026-02-04T14:13:38.453Z" },
+    { url = "https://files.pythonhosted.org/packages/44/33/411e3d4663645b10509fd33995652f45666f93ec5ac4b01e0ddf61dd5184/pytaglib-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:934b42c3fed36943fdfbd3896f562fe5d019ed5a67546dbe63550fc249d14573", size = 280764, upload-time = "2026-02-04T14:13:40.064Z" },
+    { url = "https://files.pythonhosted.org/packages/14/79/3b0599ac49907522f1b4f83ddfd3238b3a46d87a309cb74fe95b99a1f316/pytaglib-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5b1737ee5fa109634fdba6bdbe93f3aab999e44579f0c10b4d0af6062132e9f", size = 823360, upload-time = "2026-02-04T14:13:41.379Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/71/4616eed72da73a83c356e3e074985062c37da5efe45240f300b459e9fd32/pytaglib-3.2.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3415cc1116399eac2c7c5a8817d5fbc5f5927fbb3438a954a14fd5359c209929", size = 2055246, upload-time = "2026-02-04T14:13:43.236Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/8e/bad886628059b1c1baae87eda3df3f04c181e9f011be9116d825cbb5c807/pytaglib-3.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:973fa0d997e19de20fd154d0a4f595ac68241c6202a9dc25f23f601c4087a7dc", size = 3097109, upload-time = "2026-02-04T14:13:44.794Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/d4/e98259cd5c19d5aae59981caef683ad3a84ffa0c7726d8fa4445be4fe310/pytaglib-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:a9d1659c6e2574e4ba79f01b5b581d43c7f0144e7a33a640a715df32d1cd43bb", size = 280633, upload-time = "2026-02-04T14:13:46.763Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/52/00ce88712e9eeb2964d042b6d2650817630c31ae185fe61f0532514d273a/pytaglib-3.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e104b4780b36c7c6398da4458f34c3bca059884c7aa17486ee61bb8e6fe3e", size = 822749, upload-time = "2026-02-04T14:13:48.105Z" },
+    { url = "https://files.pythonhosted.org/packages/45/4d/347947d624188aa507393295574c91379be9c10e1eae3a0e03b237494b12/pytaglib-3.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d49ae0ee00cdf3d08c0adf4320c20fbe1dd9475ee946fdbcee821847c297778f", size = 2057878, upload-time = "2026-02-04T14:13:49.559Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/61/a1af243f7aa9f926d04cb8c3e96ab219aa43a2976631aa6c2539b2a1adba/pytaglib-3.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e1cd3b64951c70a9b87ef174b5e34116a1b105e2868e14eb73fd92dac8615002", size = 3100921, upload-time = "2026-02-04T14:13:51.065Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/ff/67f0a245f07fa4d3aa4900d2564c218c1c604ae144603b62c9a5bdca8ebc/pytaglib-3.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:17e6062bde9b6d6aa6d2363e2178c1cbb813bec3f1a8bb0ba617d3c120afe275", size = 281138, upload-time = "2026-02-04T14:13:53.206Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/f3/ebea1d13d7a922369ff4d3f18a961399b57924f015beb67e3ebe0624cfb2/pytaglib-3.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5dd0351f2725e1983e93fd3b36a6bd019353321fdd94d5af84a6a46f34d862f1", size = 822134, upload-time = "2026-02-04T14:13:54.474Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/15/80f7bf84f996aa29d062074247baa1a782e34f679782844838f746cd937d/pytaglib-3.2.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73da0c8026dc0f264c90ccdd49588c1cfc5190df9093a0e2352f0e8909a0224f", size = 2055922, upload-time = "2026-02-04T14:13:55.948Z" },
+    { url = "https://files.pythonhosted.org/packages/92/e5/4599c9f4662bc0024bb6e86ed90de48a9ca6436c00202c7dbffd5f8ce6c3/pytaglib-3.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:42192eb26d18c95eb9c4f1da504f4f67769e2790f91352feecac975b01d0e13b", size = 3095314, upload-time = "2026-02-04T14:13:57.356Z" },
+    { url = "https://files.pythonhosted.org/packages/19/9b/1abd3d1dabb3b4cc07d8a8fb952a7d10ff62cb0ce0ca36612322356135b0/pytaglib-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:41e3087bc9de04f59c16c03aa0d587db05899e7434ac1dc7ccb06412e2bd97b7", size = 280758, upload-time = "2026-02-04T14:13:58.893Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/68/c86a98f629788260f3bcdd963f965e6972fd49f9b12b0c1a23efd5f7f4d2/pytaglib-3.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:42303c007403cefbbb87837ad48b87c2c5f0bfa39a4c7c49fc02da15f97e75cd", size = 822675, upload-time = "2026-02-04T14:14:00.239Z" },
+    { url = "https://files.pythonhosted.org/packages/99/5c/996e8ab5d12ad594257becc6ca986bde15d0baa5f025351a6b2ef0e06bf1/pytaglib-3.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b5d910905962649913a74a0d11a407f989a381a423041794628c5e650501da83", size = 2052270, upload-time = "2026-02-04T14:14:01.809Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/a0/71296ae3e5c3f2fc2c9bde23e2ea9be8b2b4893d3f629f747a80ff54d349/pytaglib-3.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e70335cdd143ef215dcbc3a8e8ccaa9cc56de1523c4944ee487251cdc50c2a28", size = 3095455, upload-time = "2026-02-04T14:14:03.856Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/26/040750252e60503118fb6e1fa1bb7f6c0133050501060df9291666b3ac7f/pytaglib-3.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:550e444771fb4876a6ce002163d381b071edd7c6f64723a39aa13fecdb7b854b", size = 289634, upload-time = "2026-02-04T14:14:05.175Z" },
+    { url = "https://files.pythonhosted.org/packages/1d/a0/5ea945aca2beeea51a3fc527c35197bb78a1077bc9217687afef7d67a242/pytaglib-3.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:caf86c1550631374c883b79e1d867c44431e704d2e4006151de7a63b642f6963", size = 827998, upload-time = "2026-02-04T14:14:06.684Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/8b/7fa95a94170c0513d7707788a2dae6ecc2309160108bc1360c62c397f221/pytaglib-3.2.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8dbb42e62b7f0d50100065467241e0a8aaf8a41ae0432572ded8a6d3721178c", size = 2064736, upload-time = "2026-02-04T14:14:08.219Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/ad/8f3bbd457150da4ac3546042c454e8cd9d31016c9f3c7893b681756c081a/pytaglib-3.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:34f6e1af40059bd090e6061cf1a71cd87ef648a940c074f075dbfa770d269f55", size = 3103922, upload-time = "2026-02-04T14:14:10.299Z" },
+    { url = "https://files.pythonhosted.org/packages/80/48/749f453ad25021e01b12f1a729b5cf17d8756644c4c3d165aed10a523c01/pytaglib-3.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0bbb89ab0cc71b16a565b49113aef3f41bfb5e49a3882eeb401a582d43efffae", size = 299162, upload-time = "2026-02-04T14:14:11.753Z" },
+    { url = "https://files.pythonhosted.org/packages/44/d7/0cf676232b38eb28f0e4f62a0f297138fd99ef2a05c72a291522bffca818/pytaglib-3.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:c88b9b9a21bab8f31adc4fc96ca902af1a3fc7137b12a029ece52aaa79f31ee0", size = 810420, upload-time = "2026-02-04T14:14:13.591Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/79/cc8e94b974bdb71a408310fc4d7b8be5bdbe1bf79d75f286d67a753b89f9/pytaglib-3.2.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30bade38395b01feabcc5d50d14e02603a74afc3e4066a67de4ad19cf4c9ed4a", size = 1372078, upload-time = "2026-02-04T14:14:14.945Z" },
+    { url = "https://files.pythonhosted.org/packages/31/e9/e826e2381536e5d8166f39bd7a63102f1411340889c86bed35e24655a69e/pytaglib-3.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a3a073bae8ce0d76f99f8f3fd91fe5d1fd355710721c3a3a135c29bebcc3df3f", size = 276240, upload-time = "2026-02-04T14:14:16.815Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "8.4.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version < '3.10'",
+]
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "exceptiongroup" },
+    { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } },
+    { name = "packaging" },
+    { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" } },
+    { name = "pygments" },
+    { name = "tomli" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version >= '3.10'",
+]
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+    { name = "iniconfig", version = "2.3.1", source = { registry = "https://pypi.org/simple" } },
+    { name = "packaging" },
+    { name = "pluggy", version = "1.7.0", source = { registry = "https://pypi.org/simple" } },
+    { name = "pygments" },
+    { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
+[[package]]
+name = "python-frontmatter"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version < '3.10'",
+]
+dependencies = [
+    { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e9/21/88aefb4f1de6661b5a003175e21e4a5ad94f5e52b2abf4170a11883c7d81/python_frontmatter-1.2.0.tar.gz", hash = "sha256:5b26ccd3cb85af77feb11d83b922c7bb5aeccb0c9d3fb236b938c600b6322984", size = 16890, upload-time = "2026-05-17T23:42:05.493Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1c/b1/ec12e3e746234006b77dec69d53878253b1da09dbb55fa3cb456083d9069/python_frontmatter-1.2.0-py3-none-any.whl", hash = "sha256:e1ee1d4300450a2f84e778eb4f70edf573da6cd7d463801066f05edc4e819c78", size = 10396, upload-time = "2026-05-17T23:42:04.637Z" },
+]
+
+[[package]]
+name = "python-frontmatter"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version >= '3.10'",
+]
+dependencies = [
+    { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/e8/79cbe69864d44f3b48e70ebee0a872a7d5a4e7150c9f8577ed7a5beefff0/python_frontmatter-1.3.0.tar.gz", hash = "sha256:acc73e477a568dc2a25c9e130c6c68ae8daa8c204c8f7e813db47d6a7280dcf2", size = 8322, upload-time = "2026-05-20T19:21:44.164Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a6/a3/17c284b4f4d8ad50f0f9ba70ad8fcc35c777aeafcdbbffdd91bbdc5ab379/python_frontmatter-1.3.0-py3-none-any.whl", hash = "sha256:9f7dd9260bec99044219159a329f64f039087f9d1a2124c9442556f2fe6f82ec", size = 10562, upload-time = "2026-05-20T19:21:43.323Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
+    { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
+    { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+    { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+    { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+    { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+    { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+    { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+    { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+    { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+    { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+    { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+    { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+    { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+    { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+    { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+    { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+    { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+    { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+    { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+    { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+    { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+    { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+    { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+    { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" },
+    { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" },
+    { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" },
+]
+
+[[package]]
+name = "rich"
+version = "15.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+    { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+    { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
+]
+
+[[package]]
+name = "tomli"
+version = "2.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/22/a6/ab99b60ee52acd949684febabc3005d0045d0f66bebd9cdebd67372d26dd/tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545", size = 163901, upload-time = "2026-10-07T12:22:15.601Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/00/ee01b7ed4579180fff07142d290257f25ba786f23f3ec6005f620933c2f5/tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef", size = 163756, upload-time = "2026-10-07T12:22:16.957Z" },
+    { url = "https://files.pythonhosted.org/packages/72/c2/4efebf65372f6583185f79799312109dddb61102d47e5c33dcfd1a297aca/tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b", size = 268038, upload-time = "2026-10-07T12:22:18.135Z" },
+    { url = "https://files.pythonhosted.org/packages/53/07/5850468e925d898abb36038666f9c333a94d2a223e802a8ba5b6d319d23f/tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56", size = 276422, upload-time = "2026-10-07T12:22:19.567Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/87/f293984cdcf83c054196d4fd3dad44fc68ae55b4b8c44bc76cef360c3150/tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1", size = 272616, upload-time = "2026-10-07T12:22:20.794Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/ce/db582886b3c1219d3fec93ebd669332482e5aee7a91e0f7838d84f2d1759/tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885", size = 276593, upload-time = "2026-10-07T12:22:22.12Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830, upload-time = "2026-10-07T12:22:23.651Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742, upload-time = "2026-10-07T12:22:24.972Z" },
+    { url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332, upload-time = "2026-10-07T12:22:26.117Z" },
+    { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" },
+    { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" },
+    { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" },
+    { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" },
+    { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" },
+    { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" },
+    { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" },
+    { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" },
+    { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" },
+    { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" },
+    { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" },
+    { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" },
+    { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" },
+    { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" },
+    { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" },
+    { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" },
+    { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" },
+    { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" },
+    { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" },
+    { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" },
+    { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" },
+    { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" },
+    { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" },
+    { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" },
+    { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" },
+    { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" },
+    { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" },
+    { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" },
+    { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]