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) --- .ruff.toml | 25 +++--- CHANGELOG.md | 11 +++ README.md | 24 +++++- pyproject.toml | 14 +++ src/etoolkit/__init__.py | 2 +- src/etoolkit/__main__.py | 51 +++++------ src/etoolkit/etoolkit.py | 141 ++++++++++++++++++++----------- tests/conftest.py | 38 ++++----- tests/test_cli.py | 91 +++++++++++--------- tests/test_envtoolkit_instance.py | 17 ++-- tests/test_envtoolkit_instance_static.py | 80 ++++++++---------- 11 files changed, 287 insertions(+), 207 deletions(-) diff --git a/.ruff.toml b/.ruff.toml index 769f83c..da8c030 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -2,36 +2,34 @@ cache-dir = "~/.cache/ruff" indent-width = 4 line-length = 79 target-version = "py310" +namespace-packages = ["tests"] + [lint] select = ["ALL"] -ignore = ["COM812", "D105", "D202", "D203", "D205", "D211", "D212", "D400", "D401", "D403", "D415", "ERA001", "FBT001", "FBT002", "PTH111", "RUF012", "RUF013", "S101", "TRY300", "BLE001", "UP020", "C901", "D200", "D402", "EM101", "EM102", "FBT003", "INP001", "PLR0912", "PLR0913", "PLR0915", "PLR2004", "PLW2901", "S603", "T201", "TRY003", "TRY400"] -# D105 - Missing docstring in magic method -# D200 - One-line docstring should fit on one line -# D203 - 1 blank line required before class docstring +ignore = ["BLE001", "COM812", "D203", "D205", "D212", "D400", "D401", "D403", "D415", "FBT001", "FBT002", "PTH111", "S101", "UP020", "D200", "D402", "EM101", "EM102", "FBT003", "PLR0913", "PLR2004", "PLW2901", "S603", "T201", "TRY003", "TRY400"] + +# BLE001 - Do not catch blind exception: `Exception` +# COM812 - Trailing comma missing # D205 - 1 blank line required between summary line and description +# D212 - Multi-line docstring summary should start at the first line +# D400 - First line should end with a period +# D401 - First line of docstring should be in imperative mood # D403 - First word of the first line should be capitalized: `str` -> `Str` +# D415 - First line should end with a period, question mark, or exclamation point # FBT001 - Boolean-typed positional argument in function definition # FBT002 - Boolean default positional argument in function definition # PTH111 - `os.path.expanduser()` should be replaced by `Path.expanduser()` -# RUF012 - Mutable class attributes should be annotated with `typing.ClassVar` -# RUF013 - PEP 484 prohibits implicit `Optional` # S101 - Use of `assert` detected # TRY300 - Consider moving this statement to an `else` block # TRY400 - Use `logging.exception` instead of `logging.error` # UP020 - Use builtin `open` # Project specific -# C901 - `X` is too complex -# D200 - One-line docstring should fit on one line -# D402 - First line should not be the function's signature (bug in ruff 0.4.4) # EM101 - Exception must not use a string literal, assign to variable first # EM102 - Exception must not use an f-string literal, assign to variable first # FBT003 - Boolean positional value in function call -# INP001 - File `tests/test_envtoolkit_instance_static.py` is part of an implicit namespace package. Add an `__init__.py`. -# PLR0912 - Too many branches # PLR0913 - Too many arguments in function definition -# PLR0915 - Too many statements # PLR2004 - Magic value used in comparison, consider replacing `X` with a constant variable # PLW2901 - `for` loop variable `value` overwritten by assignment target # S603 - `subprocess` call: check for execution of untrusted input @@ -42,9 +40,6 @@ ignore = ["COM812", "D105", "D202", "D203", "D205", "D211", "D212", "D400", "D40 fixable = ["ALL"] unfixable = [] -[lint.per-file-ignores] -"tests/*.py" = ["ANN"] # Do not require annotations for tests - [format] # Like Black, use double quotes for strings. diff --git a/CHANGELOG.md b/CHANGELOG.md index fe4373f..004f29d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [2.3.0](https://codeberg.org/sgs/etoolkit/releases/tag/2.3.0) (2026-05-04) + +[Full Changelog](https://codeberg.org/sgs/etoolkit/compare/2.2.0...2.3.0) + +**Changes:** + +- add support for the %e{key} format + +- add support for type checkers (ty) + + ## [2.2.0](https://codeberg.org/sgs/etoolkit/releases/tag/2.2.0) (2026-04-16) [Full Changelog](https://codeberg.org/sgs/etoolkit/compare/2.1.0...2.2.0) diff --git a/README.md b/README.md index bdd97e3..dae06b8 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,20 @@ for processes that were not spawned by that same *etoolkit* session. ## Installation -### pip (pypi) +### Arch Linux ```bash - pip install etoolkit + # fetch sgs' developer key from the keyserver and sign it + pacman-key --recv-keys A6645797661E2F473DD3FF06BCE70555C3BB08F7 + pacman-key --lsign-key A6645797661E2F473DD3FF06BCE70555C3BB08F7 + + # add sgs' repository in /etc/pacman.conf and require packages to be signed + [sgs] + Server = https://pkg.pichove.org/archlinux/$repo/os/$arch + SigLevel = PackageRequired + + # install etoolkit + pacman -S python-etoolkit ``` @@ -72,6 +82,14 @@ for processes that were not spawned by that same *etoolkit* session. emerge dev-python/etoolkit ``` + +### pip (pypi) + + ```bash + pip install etoolkit + ``` + + ## Encryption & decryption scheme The etoolkit encryption format is currently at version 2. @@ -262,6 +280,8 @@ When all values are fetched from a given instance (and its parents) and then decrypted, they are further processed by replacing macros with their corresponding values. Currently the following macros are supported: +- **%e{MYVAR}** - the current value of the env. var. *MYVAR* + - **%e** - the current value of the env. var. corresponding to the same key - **%h** - the home directory of the user running *etoolkit* (~/) diff --git a/pyproject.toml b/pyproject.toml index e44adbb..4f7a722 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,14 @@ dependencies = [ ] +[dependency-groups] +dev = [ + "pytest>=6", + "ruff>=0.15.0", + "ty>=0.0.31", +] + + [project.scripts] etoolkit = "etoolkit.__main__:main" @@ -42,6 +50,7 @@ Changelog = "https://codeberg.org/sgs/etoolkit/src/branch/master/CHANGELOG.md" [tool.pytest.ini_options] minversion = "6.0" addopts = "-s" +cache_dir = "~/.cache/pytest_cache" testpaths = [ "tests" ] @@ -54,6 +63,11 @@ pythonpath = [ version = {attr = "etoolkit.__version__"} +[tool.ty.environment] +python-version = "3.10" +root = ["./src"] + + [build-system] requires = [ "setuptools >= 77.0.3", 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) diff --git a/tests/conftest.py b/tests/conftest.py index 6cc8b9f..2e8eaf1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,14 +16,14 @@ """Common fixtures""" import json +import pathlib import pytest @pytest.fixture -def config_data(): +def config_data() -> dict: """config_data for testing""" - return { 'general': { 'MASTER_PASSWORD_HASH': ( @@ -60,18 +60,16 @@ def config_data(): @pytest.fixture -def config_file(tmp_path, config_data): +def config_file(tmp_path: pathlib.Path, config_data: dict) -> str: """Temporary config file for testing that includes config_data""" - cf = tmp_path / 'etoolkit.json' cf.write_text(json.dumps(config_data)) return str(cf) @pytest.fixture -def long_encrypted_value(): +def long_encrypted_value() -> str: """enc. value corresponding to 'Nobody expects the Spanish inquisition'""" - return ( 'enc-val$2$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80o=$' 'UWP5YeRsh5/2vZ2J1UOS+BJti73Kbp6C1pJmCo8hF' @@ -80,27 +78,26 @@ def long_encrypted_value(): @pytest.fixture -def long_value(): +def long_value() -> str: """standard value (> 32 bytes)""" return 'Nobody expects the Spanish inquisition' @pytest.fixture -def master_password(): +def master_password() -> str: """Master passord""" return 'The very secret passwd' @pytest.fixture -def new_master_password(): +def new_master_password() -> str: """Master passord""" return 'New very secret passwd' @pytest.fixture -def non_random_bytes_32(): +def non_random_bytes_32() -> bytes: """always use the same bytes instead of os.urandom(32)""" - return ( b'\xb9\x8aY3U_\x00j\xb4\x086K\xd9\xdb\x88N' b'\xcd;\xe8$#\xfa\x12\x05\x12\x0c~\x17\x89\xa7\xf3J' @@ -108,9 +105,8 @@ def non_random_bytes_32(): @pytest.fixture -def non_random_bytes_57(): +def non_random_bytes_57() -> bytes: """always use the same bytes instead of os.urandom(57)""" - return ( b'D$\x99\xaa\xafiZ\xb4C\xa0%XTz)\xca\xedK\xcd\xa2F~\xff+\xa1[\xe2\xaa' b'\xb2\xd3\x07\x13\xedb\xc2\x84\xfe\tS\r\xf0\x02_\xef\xe3\xde\xf1?e' @@ -119,16 +115,14 @@ def non_random_bytes_57(): @pytest.fixture -def nonexistent_config_file(tmp_path): +def nonexistent_config_file(tmp_path: pathlib.Path) -> str: """temporary config file for testing that includes config_data""" - return str(tmp_path / 'etoolkitt.json') @pytest.fixture -def password_hash(): +def password_hash() -> str: """password hash for testing, corresponding to 'The very secret passwd'""" - return ( 'pbkdf2_sha256$500000$UY3o78KUM1Btzxk3k3JCsijnwtJ2lx+hH9NewpVKxo8=$' 'tHwDm8OVKanC4DoYTigTCb0R3lQIa/CbBYj0B3TZtHg=' @@ -136,9 +130,8 @@ def password_hash(): @pytest.fixture -def short_encrypted_value(): +def short_encrypted_value() -> str: """enc. value corresponding to 'secret1'""" - return ( 'enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviqrLTBxM=$' '+YYrZbwTBuG0Pl+WMQrvxLUtq5j8qYuQqzoIwgoGt7AaWZCJz+E7qoDeg3wke70ST8U=' @@ -146,9 +139,8 @@ def short_encrypted_value(): @pytest.fixture -def short_encrypted_value_v1(): +def short_encrypted_value_v1() -> str: """enc. value (enc-val 1) corresponding to 'secret1'""" - return ( 'enc-val$1$/cXpEMoZrTlb9yokGhw8tLTSUkqnqJ4ZoAkurNgMYx' 'w=$1VdkSMcZnLRwLiu1M8VlYcbelwmiVNY=' @@ -156,12 +148,12 @@ def short_encrypted_value_v1(): @pytest.fixture -def short_value(): +def short_value() -> str: """standard value (< 32 bytes)""" return 'secret1' @pytest.fixture -def wrong_master_password(): +def wrong_master_password() -> str: """Wrong master passord""" return 'the very secret passwd' diff --git a/tests/test_cli.py b/tests/test_cli.py index b7913da..6e28378 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -26,9 +26,13 @@ from etoolkit.__main__ import main @unittest.mock.patch('builtins.input') -def test_decrypt_v1(binput, capsys, config_file, master_password): +def test_decrypt_v1( + binput: unittest.mock.MagicMock, + capsys: pytest.CaptureFixture, + config_file: str, + master_password: str, +) -> None: """Tests v1 decryption via the CLI interface""" - binput.return_value = ( 'enc-val$1$rye0sMGEnd35gOWyISE1FQa6dzS+8/jf6aopMO5tPr4=$' 'RjnRY0bUJWFOiejTlM3OhKNimQ==' @@ -44,15 +48,14 @@ def test_decrypt_v1(binput, capsys, config_file, master_password): @unittest.mock.patch('builtins.input') def test_decrypt_v2( - binput, - capsys, - config_file, - master_password, - short_encrypted_value, - short_value, -): + binput: unittest.mock.MagicMock, + capsys: pytest.CaptureFixture, + config_file: str, + master_password: str, + short_encrypted_value: str, + short_value: str, +) -> None: """Tests v2 decryption via the CLI interface""" - binput.return_value = short_encrypted_value with unittest.mock.patch.dict( os.environ, {'ETOOLKIT_MASTER_PASSWORD': master_password} @@ -68,17 +71,16 @@ def test_decrypt_v2( @unittest.mock.patch('os.urandom') @unittest.mock.patch('builtins.input') def test_encrypt_with_echo( - binput, - urandom, - capsys, - non_random_bytes_57, - config_file, - master_password, - short_encrypted_value, - short_value, -): + binput: unittest.mock.MagicMock, + urandom: unittest.mock.MagicMock, + capsys: pytest.CaptureFixture, + non_random_bytes_57: bytes, + config_file: str, + master_password: str, + short_encrypted_value: str, + short_value: str, +) -> None: """Tests encryption via the CLI interface""" - binput.return_value = short_value urandom.return_value = non_random_bytes_57 with unittest.mock.patch.dict( @@ -95,17 +97,16 @@ def test_encrypt_with_echo( @unittest.mock.patch('os.urandom') @unittest.mock.patch('getpass.getpass') def test_encrypt_without_echo( - getpass, - urandom, - capsys, - non_random_bytes_57, - config_file, - master_password, - short_encrypted_value, - short_value, -): + getpass: unittest.mock.MagicMock, + urandom: unittest.mock.MagicMock, + capsys: pytest.CaptureFixture, + non_random_bytes_57: bytes, + config_file: str, + master_password: str, + short_encrypted_value: str, + short_value: str, +) -> None: """Tests encryption via the CLI interface""" - getpass.return_value = short_value urandom.return_value = non_random_bytes_57 with unittest.mock.patch.dict( @@ -119,20 +120,26 @@ def test_encrypt_without_echo( ) -def test_fetch_encrypted_value(config_file, master_password, short_value): +def test_fetch_encrypted_value( + config_file: str, master_password: str, short_value: str +) -> None: """Tests decryption of encrypted value""" - with unittest.mock.patch.dict( os.environ, {'ETOOLKIT_MASTER_PASSWORD': master_password} ): assert os.environ.get('ETOOLKIT_TEST_PASSWORD') is None - main(['-c', f'{config_file}', '-q', '-s', '/bin/false', 'secret']) + with pytest.raises(SystemExit) as exit_info: + main(['-c', f'{config_file}', '-q', '-s', '/bin/false', 'secret']) + assert exit_info.value.code == 0 assert os.environ.get('ETOOLKIT_TEST_PASSWORD') == short_value -def test_list(capsys, config_file, nonexistent_config_file): +def test_list( + capsys: pytest.CaptureFixture, + config_file: str, + nonexistent_config_file: str, +) -> None: """Tests list via the CLI interface""" - with pytest.raises(SystemExit) as exit_info: main(['-c', nonexistent_config_file, '-l']) assert exit_info.value.code == errno.EIO @@ -142,9 +149,8 @@ def test_list(capsys, config_file, nonexistent_config_file): assert capsys.readouterr().out.strip() == f'dev{os.linesep}secret' -def test_help(capsys): +def test_help(capsys: pytest.CaptureFixture) -> None: """Dummy test checking if the CLI is available at all""" - with pytest.raises(SystemExit) as exit_info: main(['-h']) assert exit_info.value.code == 0 @@ -154,10 +160,12 @@ def test_help(capsys): @unittest.mock.patch('os.urandom') @unittest.mock.patch('getpass.getpass') def test_generate_master_password_hash( - getpass, urandom, capsys, non_random_bytes_32 -): + getpass: unittest.mock.MagicMock, + urandom: unittest.mock.MagicMock, + capsys: pytest.CaptureFixture, + non_random_bytes_32: bytes, +) -> None: """Tests master password hash generation via the CLI interface""" - getpass.return_value = 'The very secret passwd' urandom.return_value = non_random_bytes_32 with pytest.raises(SystemExit) as exit_info: @@ -169,9 +177,8 @@ def test_generate_master_password_hash( ) -def test_version(capsys): +def test_version(capsys: pytest.CaptureFixture) -> None: """Dummy test checking if the CLI is available at all""" - with pytest.raises(SystemExit) as exit_info: main(['-v']) assert exit_info.value.code == 0 diff --git a/tests/test_envtoolkit_instance.py b/tests/test_envtoolkit_instance.py index 5137e9f..3235736 100644 --- a/tests/test_envtoolkit_instance.py +++ b/tests/test_envtoolkit_instance.py @@ -20,9 +20,8 @@ import pytest import etoolkit -def test_instantiation(config_data): +def test_instantiation(config_data: dict) -> None: """Tests for object instatiation""" - with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info: instance = etoolkit.EtoolkitInstance('devv', config_data) assert exc_info.type is etoolkit.EtoolkitInstanceError @@ -43,10 +42,12 @@ def test_instantiation(config_data): def test_get_environ( - config_data, master_password, short_value, wrong_master_password -): + config_data: dict, + master_password: str, + short_value: str, + wrong_master_password: str, +) -> None: """Tests the EtoolkitInstance.get_environ method""" - instance = etoolkit.EtoolkitInstance('secret', config_data) with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info: env = instance.get_environ() @@ -68,16 +69,14 @@ def test_get_environ( assert 'ETOOLKIT_TEST_PASSWORD2' in instance.sensitive_env_variables -def test_get_full_name(config_data): +def test_get_full_name(config_data: dict) -> None: """Tests the EtoolkitInstance.get_full_name method""" - instance = etoolkit.EtoolkitInstance('secret', config_data) assert instance.get_full_name('->') == '_default->secret' -def test_parent_vars(config_data): +def test_parent_vars(config_data: dict) -> None: """Tests the EtoolkitInstance.get_full_name method""" - instance = etoolkit.EtoolkitInstance('dev', config_data) assert instance.get_full_name('->') == '_default->dev' assert instance.get_environ()['ETOOLKIT_TEST_PYTHONPATH'] == ( diff --git a/tests/test_envtoolkit_instance_static.py b/tests/test_envtoolkit_instance_static.py index e13d439..8c21a0f 100644 --- a/tests/test_envtoolkit_instance_static.py +++ b/tests/test_envtoolkit_instance_static.py @@ -23,9 +23,10 @@ import etoolkit @unittest.mock.patch('getpass.getpass') -def test_confirm_password_prompt(getpass, password_hash, master_password): +def test_confirm_password_prompt( + getpass: unittest.mock.MagicMock, password_hash: str, master_password: str +) -> None: """Tests the static EtoolkitInstance.confirm_password_prompt method""" - getpass.return_value = master_password assert ( etoolkit.EtoolkitInstance.confirm_password_prompt(password_hash) @@ -37,9 +38,10 @@ def test_confirm_password_prompt(getpass, password_hash, master_password): ) -def test_decrypt_v1(master_password, short_encrypted_value_v1, short_value): +def test_decrypt_v1( + master_password: str, short_encrypted_value_v1: str, short_value: str +) -> None: """Tests the static EtoolkitInstance.decrypt method""" - assert ( etoolkit.EtoolkitInstance.decrypt( master_password, short_encrypted_value_v1 @@ -59,10 +61,9 @@ def test_decrypt_v1(master_password, short_encrypted_value_v1, short_value): def test_decrypt_v2_no_padding( - master_password, long_encrypted_value, long_value -): + master_password: str, long_encrypted_value: str, long_value: str +) -> None: """Tests the static EtoolkitInstance.decrypt method for v2 - no padding""" - assert ( etoolkit.EtoolkitInstance.decrypt( master_password, long_encrypted_value @@ -80,10 +81,9 @@ def test_decrypt_v2_no_padding( def test_decrypt_v2_with_padding( - master_password, short_encrypted_value, short_value -): + master_password: str, short_encrypted_value: str, short_value: str +) -> None: """Tests the static EtoolkitInstance.decrypt method for v2 with padding""" - assert ( etoolkit.EtoolkitInstance.decrypt( master_password, short_encrypted_value @@ -99,9 +99,8 @@ def test_decrypt_v2_with_padding( assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}' -def test_encrypt_no_padding(master_password, long_value): +def test_encrypt_no_padding(master_password: str, long_value: str) -> None: """Tests the static EtoolkitInstance.encrypt method with a long string""" - edata = etoolkit.EtoolkitInstance.encrypt(master_password, long_value) assert edata.startswith('enc-val$2$') assert len(edata) == 131 @@ -111,9 +110,8 @@ def test_encrypt_no_padding(master_password, long_value): ) -def test_encrypt_with_padding(master_password, short_value): +def test_encrypt_with_padding(master_password: str, short_value: str) -> None: """Tests the static EtoolkitInstance.encrypt method with a short string""" - edata = etoolkit.EtoolkitInstance.encrypt(master_password, short_value) assert edata.startswith('enc-val$2$') assert len(edata) == 123 @@ -125,14 +123,13 @@ def test_encrypt_with_padding(master_password, short_value): @unittest.mock.patch('os.urandom') def test_encrypt_staticly_no_padding( - urandom, - master_password, - non_random_bytes_32, - long_encrypted_value, - long_value, -): + urandom: unittest.mock.MagicMock, + master_password: str, + non_random_bytes_32: bytes, + long_encrypted_value: str, + long_value: str, +) -> None: """Tests the EtoolkitInstance.encrypt method always with the same salt""" - urandom.return_value = non_random_bytes_32 edata = etoolkit.EtoolkitInstance.encrypt(master_password, long_value) assert edata == long_encrypted_value @@ -144,14 +141,13 @@ def test_encrypt_staticly_no_padding( @unittest.mock.patch('os.urandom') def test_encrypt_staticly_with_padding( - urandom, - master_password, - non_random_bytes_57, - short_encrypted_value, - short_value, -): + urandom: unittest.mock.MagicMock, + master_password: str, + non_random_bytes_57: bytes, + short_encrypted_value: str, + short_value: str, +) -> None: """Tests the EtoolkitInstance.encrypt method always with the same salt""" - urandom.return_value = non_random_bytes_57 edata = etoolkit.EtoolkitInstance.encrypt(master_password, short_value) assert edata == short_encrypted_value @@ -160,9 +156,8 @@ def test_encrypt_staticly_with_padding( ) -def test_get_new_password_hash(master_password): +def test_get_new_password_hash(master_password: str) -> None: """Tests the static EtoolkitInstance.get_new_password_hash method""" - new_hash = etoolkit.EtoolkitInstance.get_new_password_hash(master_password) # all pbkdf2 params are the same / hardcoded for the time being assert new_hash.startswith('pbkdf2_sha256$500000$') @@ -173,9 +168,8 @@ def test_get_new_password_hash(master_password): ) -def test_parse_value(): +def test_parse_value() -> None: """Tests the static EtoolkitInstance.parse_value method""" - assert ( etoolkit.EtoolkitInstance.parse_value('t%bs%t', {'%b': 'e', '%t': 't'}) == 'test' @@ -183,10 +177,9 @@ def test_parse_value(): def test_password_matches( - password_hash, master_password, wrong_master_password -): + password_hash: str, master_password: str, wrong_master_password: str +) -> None: """Tests the static EtoolkitInstance.password_matches method""" - assert etoolkit.EtoolkitInstance.password_matches( master_password, password_hash ) @@ -197,16 +190,15 @@ def test_password_matches( @unittest.mock.patch('os.urandom') def test_reencrypt_staticly_with_padding( - urandom, - master_password, - new_master_password, - non_random_bytes_57, - short_encrypted_value, - short_encrypted_value_v1, - short_value, -): + urandom: unittest.mock.MagicMock, + master_password: str, + new_master_password: str, + non_random_bytes_57: bytes, + short_encrypted_value: str, + short_encrypted_value_v1: str, + short_value: str, +) -> None: """Tests the EtoolkitInstance.reencrypt method always with the same salt""" - urandom.return_value = non_random_bytes_57 # reencrypt (migrate) v1 to current using the same password edata = etoolkit.EtoolkitInstance.reencrypt( -- cgit v1.3