diff --git a/amanuensis/config/context.py b/amanuensis/config/context.py new file mode 100644 index 0000000..7baf982 --- /dev/null +++ b/amanuensis/config/context.py @@ -0,0 +1,134 @@ +import os +import re + +from amanuensis.config.loader import json_ro, json_rw +from amanuensis.errors import MissingConfigError, ConfigAlreadyExistsError + + +def is_guid(s): + return re.match(r'[0-9a-z]{32}', s.lower()) + + +class ConfigDirectoryContext(): + """ + Base class for CRUD operations on config files in a config + directory. + """ + def __init__(self, path): + self.path = path + if not os.path.isdir(self.path): + raise MissingConfigError(path) + + def new(self, filename): + """ + Creates a JSON file that doesn't already exist. + """ + if not filename.endswith('.json'): + filename = f'{filename}.json' + fpath = os.path.join(self.path, filename) + if os.path.isfile(fpath): + raise ConfigAlreadyExistsError(fpath) + return json_rw(fpath, new=True) + + def read(self, filename): + """ + Loads a JSON file in read-only mode. + """ + if not filename.endswith('.json'): + filename = f'{filename}.json' + fpath = os.path.join(self.path, filename) + if not os.path.isfile(fpath): + raise MissingConfigError(path) + return json_ro(fpath) + + def edit(self, filename): + """ + Loads a JSON file in write mode. + """ + if not filename.endswith('.json'): + filename = f'{filename}.json' + fpath = os.path.join(self.path, filename) + if not os.path.isfile(fpath): + raise MissingConfigError(path) + return json_rw(fpath, new=False) + + def delete(self, filename): + """Deletes a file.""" + if not filename.endswith('.json'): + filename = f'{filename}.json' + fpath = os.path.join(self.path, filename) + if not os.path.isfile(fpath): + raise MissingConfigError(path) + os.delete(fpath) + + +class IndexDirectoryContext(ConfigDirectoryContext): + """ + A lookup layer for getting config directory contexts for lexicon + or user directories. + """ + def __init__(self, path, cdc_type): + super().__init__(path) + self.cdc_type = cdc_type + + def __getitem__(self, key): + """ + Returns a context to the given item. key is treated as the + item's id if it's a guid string, otherwise it's treated as + the item's indexed name and run through the index first. + """ + if not is_guid(key): + with self.index() as index: + iid = index.get(key) + if not iid: + raise MissingConfigError(key) + key = iid + return self.cdc_type(os.path.join(self.path, key)) + + def index(self, edit=False): + if edit: + return self.edit('index') + else: + return self.read('index') + + +class RootConfigDirectoryContext(ConfigDirectoryContext): + """ + Context for the config directory with links to the lexicon and + user contexts. + """ + def __init__(self, path): + super().__init__(path) + self.lexicon = IndexDirectoryContext( + os.path.join(self.path, 'lexicon'), + LexiconConfigDirectoryContext) + self.user = IndexDirectoryContext( + os.path.join(self.path, 'user'), + UserConfigDirectoryContext) + + +class LexiconConfigDirectoryContext(ConfigDirectoryContext): + """ + A config context for a lexicon's config directory. + """ + def __init__(self, path): + super().__init__(path) + self.draft = ConfigDirectoryContext(os.path.join(self.path, 'draft')) + self.src = ConfigDirectoryContext(os.path.join(self.path, 'src')) + + def config(edit=False): + if edit: + return self.edit('config') + else: + return self.read('config') + + +class UserConfigDirectoryContext(ConfigDirectoryContext): + """ + A config context for a user's config directory. + """ + def config(edit=False): + if edit: + return self.edit('config') + else: + return self.read('config') diff --git a/amanuensis/config/loader.py b/amanuensis/config/loader.py index a8b312a..8f3f360 100644 --- a/amanuensis/config/loader.py +++ b/amanuensis/config/loader.py @@ -46,7 +46,9 @@ class ReadOnlyOrderedDict(OrderedDict): raise AttributeError(key) return self[key] + class open_lock(): + """A context manager that opens a file with the specified file lock""" def __init__(self, path, mode, lock_type): self.fd = open(path, mode, encoding='utf8') fcntl.lockf(self.fd, lock_type) @@ -58,15 +60,25 @@ class open_lock(): fcntl.lockf(self.fd, fcntl.LOCK_UN) self.fd.close() + class open_sh(open_lock): + """A context manager that opens a file with a shared lock""" def __init__(self, path, mode): super().__init__(path, mode, fcntl.LOCK_SH) + class open_ex(open_lock): + """A context manager that opens a file with an exclusive lock""" def __init__(self, path, mode): super().__init__(path, mode, fcntl.LOCK_EX) + class json_ro(open_sh): + """ + A context manager that opens a file in a shared, read-only mode. + The contents of the file are read as JSON and returned as a read- + only OrderedDict. + """ def __init__(self, path): super().__init__(path, 'r') self.config = None @@ -75,17 +87,33 @@ class json_ro(open_sh): self.config = json.load(self.fd, object_pairs_hook=ReadOnlyOrderedDict) return self.config + class json_rw(open_ex): - def __init__(self, path): - super().__init__(path, 'r+') + """ + A context manager that opens a file with an exclusive lock. The + file mode defaults to r+, which requires that the file exist. The + file mode can be set to w+ to create a new file by setting the new + kwarg in the ctor. The contents of the file are read as JSON and + returned in an AttrOrderedDict. Any changes to the context dict + will be written out to the file when the context manager exits. + """ + def __init__(self, path, new=False): + mode = 'w+' if new else 'r+' + super().__init__(path, mode) self.config = None + self.new = new def __enter__(self): - self.config = json.load(self.fd, object_pairs_hook=AttrOrderedDict) + if not self.new: + self.config = json.load(self.fd, object_pairs_hook=AttrOrderedDict) + else: + self.config = AttrOrderedDict() return self.config def __exit__(self, exc_type, exc_value, traceback): - self.fd.seek(0) - json.dump(self.config, self.fd, allow_nan=False, indent='\t') - self.fd.truncate() + # Only write the enw value out if there wasn't an exception + if not exc_type: + self.fd.seek(0) + json.dump(self.config, self.fd, allow_nan=False, indent='\t') + self.fd.truncate() super().__exit__(exc_type, exc_value, traceback) diff --git a/amanuensis/errors.py b/amanuensis/errors.py index 0e1a1d4..aa9a02c 100644 --- a/amanuensis/errors.py +++ b/amanuensis/errors.py @@ -3,6 +3,15 @@ class AmanuensisError(Exception): class MissingConfigError(AmanuensisError): """A config file is missing that was expected to be present""" + def __init__(self, path): + super.__init__(self, "A config file or directory was expected to " + f"exist, but could not be found: {path}") + +class ConfigAlreadyExistsError(AmanuensisError): + """Attempted to create a config, but it already exists""" + def __init__(self, path): + super.__init__(self, "Attempted to create a config, but it already " + f"exists: {path}") class MalformedConfigError(AmanuensisError): """A config file could not be read and parsed"""