From 7fd7db9cd12d59f7c523dc2a1a167f7445e5b2f3 Mon Sep 17 00:00:00 2001 From: Simeon Simeonov Date: Mon, 4 May 2026 18:38:58 +0200 Subject: Add support for the %e{key} format and add support for type checkers (ty) --- src/etoolkit/__init__.py | 2 +- src/etoolkit/__main__.py | 51 +++++++++-------- src/etoolkit/etoolkit.py | 141 +++++++++++++++++++++++++++++++---------------- 3 files changed, 122 insertions(+), 72 deletions(-) (limited to 'src') diff --git a/src/etoolkit/__init__.py b/src/etoolkit/__init__.py index fc32b61..416be15 100644 --- a/src/etoolkit/__init__.py +++ b/src/etoolkit/__init__.py @@ -18,7 +18,7 @@ from .etoolkit import EtoolkitInstance, EtoolkitInstanceError __author__ = 'Simeon Simeonov' -__version__ = '2.2.0' +__version__ = '2.3.0' __license__ = 'GPL3' diff --git a/src/etoolkit/__main__.py b/src/etoolkit/__main__.py index cef3d42..33e5741 100644 --- a/src/etoolkit/__main__.py +++ b/src/etoolkit/__main__.py @@ -61,10 +61,11 @@ class EtoolkitCLIHandler: self._args = args self._config_dict = config_dict - self._password_hash = None + self._password_hash: str = '' + if 'general' in config_dict: self._password_hash = config_dict['general'].get( - 'MASTER_PASSWORD_HASH' + 'MASTER_PASSWORD_HASH', '' ) self._password_from_env = os.environ.get('ETOOLKIT_MASTER_PASSWORD') @@ -168,9 +169,28 @@ class EtoolkitCLIHandler: ) print(f'Master password hash: {phash}') + def handle_args(self) -> None: + """Runs the handler""" + if self._args.decrypt_value: + self.decrypt_value() + return + if self._args.encrypt_value: + self.encrypt_value() + return + if self._args.password_hash: + self.generate_master_password_hash() + return + if self._args.list: + self.list() + return + if self._args.reencrypt: + self.reencrypt() + return + + self.load_instance() + def list(self) -> None: """Lists all instances defined in the config file""" - for instance_name in sorted( filter( lambda s: not s.startswith('_'), @@ -181,7 +201,6 @@ class EtoolkitCLIHandler: def load_instance(self) -> None: """Loads a single specified instance from the config file""" - inst = etoolkit.EtoolkitInstance( self._args.instance, self._config_dict ) @@ -277,9 +296,8 @@ class EtoolkitCLIHandler: ) -def main(inargs: list = None) -> None: +def main(inargs: list | None = None) -> None: """main entry point""" - parser = argparse.ArgumentParser( prog=__package__, epilog=( @@ -435,26 +453,11 @@ def main(inargs: list = None) -> None: raise SystemExit(errno.EIO) from exp try: etoolkit_cli_handler = EtoolkitCLIHandler(args, config_dict) - if args.decrypt_value: - etoolkit_cli_handler.decrypt_value() - sys.exit(0) - if args.encrypt_value: - etoolkit_cli_handler.encrypt_value() - sys.exit(0) - if args.password_hash: - etoolkit_cli_handler.generate_master_password_hash() - sys.exit(0) - if args.list: - etoolkit_cli_handler.list() - sys.exit(0) - if args.reencrypt: - etoolkit_cli_handler.reencrypt() - sys.exit(0) - - etoolkit_cli_handler.load_instance() + etoolkit_cli_handler.handle_args() + sys.exit(0) except KeyboardInterrupt: logger.debug('KeyboardInterrupt') - print(os.linesep) + print('\n') sys.exit(0) except etoolkit.EtoolkitInstanceError as err: logger.error('EtoolkitInstanceError: %s', err) diff --git a/src/etoolkit/etoolkit.py b/src/etoolkit/etoolkit.py index f5ce2a6..9369571 100644 --- a/src/etoolkit/etoolkit.py +++ b/src/etoolkit/etoolkit.py @@ -19,7 +19,7 @@ import base64 import getpass import hashlib import os -from collections.abc import Callable +from typing import Protocol from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM @@ -31,6 +31,24 @@ class EtoolkitInstanceError(Exception): """EtoolkitInstanceError - Generic exceptions related to instances""" +class PromptFuncProtocol(Protocol): + """Specialized callable for prompt functions""" + + def __call__(self, password_hash: str = '', confirm: bool = True) -> str: + """ + Prompts for master password and then for confirmation if `confirm` True + + :param password_hash: Hash to compare with instead of confirm (def. '') + :type password_hash: str + + :param confirm: Confirm the password (and see if there is a match) + :type confirm: bool + + :return: Password provided by the user + :rtype: str + """ + + class EtoolkitInstance: """A basic class representing a single instance""" @@ -47,9 +65,12 @@ class EtoolkitInstance: self._env = None self._raw_env_variables = {} self._sensitive_env_variables = [] - self._master_password = None - self._master_password_hash = None - self._prompt_func = None # function to use when prompting for input + self._master_password: str | None = None + self._master_password_hash: str = '' + + # function to use when prompting for input + self._prompt_func: PromptFuncProtocol | None = None + try: self._instance_data = data['instances'][name] except KeyError as err: @@ -106,12 +127,12 @@ class EtoolkitInstance: return self._name @property - def prompt_func(self) -> Callable[[str, bool], str]: + def prompt_func(self) -> PromptFuncProtocol | None: """prompt_func-property""" return self._prompt_func @prompt_func.setter - def prompt_func(self, value: Callable[[str, bool], str]) -> None: + def prompt_func(self, value: PromptFuncProtocol) -> None: """prompt_func-property setter""" self._prompt_func = value if self._parent is not None and self._parent.prompt_func is None: @@ -130,12 +151,12 @@ class EtoolkitInstance: @staticmethod def confirm_password_prompt( - password_hash: str = None, confirm: bool = True + password_hash: str = '', confirm: bool = True ) -> str: """ Prompts for master password and then for confirmation if `confirm` True - :param password_hash: Hash to compare with instead of confirm + :param password_hash: Hash to compare with instead of confirm (def. '') :type password_hash: str :param confirm: Confirm the password (and see if there is a match) @@ -158,8 +179,8 @@ class EtoolkitInstance: print('The passwords are either empty or do not match') continue return pass1.strip() - except Exception as e: - raise EtoolkitInstanceError('Prompt error') from e + except Exception as exp: + raise EtoolkitInstanceError('Prompt error') from exp @staticmethod def decrypt(password: str, edata: str) -> str: @@ -206,14 +227,14 @@ class EtoolkitInstance: data = data[2 : -int(data[:2].decode())] return data.decode() - except InvalidTag as e: + except InvalidTag as err: raise EtoolkitInstanceError( f'Invalid tag when decrypting: {edata}' - ) from e - except Exception as e: + ) from err + except Exception as exp: raise EtoolkitInstanceError( f'Error when decrypting: {edata}' - ) from e + ) from exp @staticmethod def encrypt(password: str, data: str) -> str: @@ -268,6 +289,28 @@ class EtoolkitInstance: f'{base64.b64encode(edata).decode()}' ) + @staticmethod + def get_global_macros() -> dict[str, str]: + """ + Returns a dict for global macro mapping + + Globals are the same for all instances + + :return: macro: replacement value dict + :rtype: dict + """ + macros = {'%h': os.path.expanduser('~'), '%u': getpass.getuser()} + + # unpack defined environment variables + for key, value in os.environ.items(): + if not isinstance(value, str): + # should not happen + continue + + macros[f'%e{{{key}}}'] = value + + return macros + @staticmethod def get_new_password_hash(password: str) -> str: """ @@ -295,23 +338,23 @@ class EtoolkitInstance: ) @staticmethod - def parse_value(value: object, macros: dict) -> object: + def parse_value(value: str, macros: dict) -> str: """ Returns the value with all macros replaced by their values - If `value` is not of type 'str' simply return `value` - :param value: A simple value - :type value: object + :type value: str :param macros: Macros mapping :type macros: dict :return: New value with all macros replaced by their values - :rtype: object + :rtype: str """ if not isinstance(value, str): - return value + raise EtoolkitInstanceError( + "Environment variable value not of the type 'str' detected" + ) for key, val in macros.items(): value = value.replace(key, val) return value @@ -332,7 +375,7 @@ class EtoolkitInstance: :return: True if the password matches or password_hash is None, :rtype: bool """ - if password_hash is None: + if not password_hash: return True # format: pbkdf2_hashalgo$ietarations$salt-base64$key-base64 try: @@ -401,11 +444,9 @@ class EtoolkitInstance: if self._env is not None: return self._env - macros = { - '%h': os.path.expanduser('~'), - '%i': self.name, - '%u': getpass.getuser(), - } + macros = self.get_global_macros() + macros['%i'] = self.name + new_env = {} for key, value in sorted( self._raw_env_variables.items(), key=lambda x: x[0] @@ -413,14 +454,12 @@ class EtoolkitInstance: if not value: # perhaps unset instead of skipping? continue - if isinstance(value, str) and '%e' in value: - macros['%e'] = os.environ.get(key, '') - if isinstance(value, str) and '%p' in value: - macros['%p'] = ( - self._parent.get_environ().get(key, '') - if self._parent is not None - else '' - ) + macros['%e'] = os.environ.get(key, '') + macros['%p'] = ( + self._parent.get_environ().get(key, '') + if self._parent is not None + else '' + ) if isinstance(value, str) and value.startswith('enc-val$'): value = self._decrypt_value(value) if key not in self._sensitive_env_variables: @@ -469,7 +508,7 @@ class EtoolkitInstance: return self._parent.get_full_name(delimiter) + delimiter + self.name def get_reencrypted_instance_data( - self, new_password: str, password: str = None + self, new_password: str, password: str | None = None ) -> dict: """ Returns new instance data (dict) containing new encrypted values @@ -493,16 +532,18 @@ class EtoolkitInstance: if password is None: password = self._master_password - if password is None and self._prompt_func is None: - password = os.environ.get('ETOOLKIT_MASTER_PASSWORD') - if password is None: - raise EtoolkitInstanceError( - 'Neither password or prompt function set' + if password is None: + if self._prompt_func is None: + password = os.environ.get('ETOOLKIT_MASTER_PASSWORD') + else: + password = self._prompt_func( + self._master_password_hash, confirm=False ) if password is None: - password = self._prompt_func( - self._master_password_hash, confirm=False + # the password is still not set + raise EtoolkitInstanceError( + 'Neither password or prompt function set' ) new_data = dict(self._instance_data) @@ -529,7 +570,10 @@ class EtoolkitInstance: :return: Decrypted value :rtype: str """ - if self._master_password is None: + if self._master_password is not None: + master_password = self._master_password + else: + # master password not set for this instance if self._prompt_func is None: if ( mp_from_env := os.environ.get('ETOOLKIT_MASTER_PASSWORD') @@ -537,10 +581,13 @@ class EtoolkitInstance: raise EtoolkitInstanceError( 'Neither password or prompt function set' ) - self.master_password = mp_from_env + master_password = mp_from_env else: - # use master_password setter in order to propagate to parent - self.master_password = self._prompt_func( + master_password = self._prompt_func( self._master_password_hash, confirm=False ) - return self.decrypt(self._master_password, evalue) + + # use master_password setter in order to propagate to parent + self.master_password = master_password + + return self.decrypt(master_password, evalue) -- cgit v1.3