From 3e4185913b584f5cc9a54f16f3c223861e11ec18 Mon Sep 17 00:00:00 2001 From: Simeon Simeonov Date: Thu, 16 Apr 2026 17:14:23 +0200 Subject: Introduce a new marco - %e and migrate the project from GitHub to Codeberg --- .ruff.toml | 8 +++-- CHANGELOG.md | 29 +++++++++++------ README.md | 18 ++++++----- pyproject.toml | 54 +++++++++++++++++++++++++++++--- setup.cfg | 45 -------------------------- src/etoolkit/__init__.py | 6 ++-- src/etoolkit/__main__.py | 18 +++++------ src/etoolkit/etoolkit.py | 19 ++++++----- tests/conftest.py | 30 +++++++++--------- tests/test_cli.py | 11 +------ tests/test_envtoolkit_instance.py | 2 +- tests/test_envtoolkit_instance_static.py | 2 +- 12 files changed, 126 insertions(+), 116 deletions(-) delete mode 100644 setup.cfg diff --git a/.ruff.toml b/.ruff.toml index add2491..769f83c 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,11 +1,11 @@ cache-dir = "~/.cache/ruff" indent-width = 4 line-length = 79 -target-version = "py312" +target-version = "py310" [lint] select = ["ALL"] -ignore = ["ANN", "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"] +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 @@ -42,6 +42,10 @@ ignore = ["ANN", "COM812", "D105", "D202", "D203", "D205", "D211", "D212", "D400 fixable = ["ALL"] unfixable = [] +[lint.per-file-ignores] +"tests/*.py" = ["ANN"] # Do not require annotations for tests + + [format] # Like Black, use double quotes for strings. quote-style = "single" diff --git a/CHANGELOG.md b/CHANGELOG.md index 916a9a2..fe4373f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,28 @@ # Changelog -## [2.1.0](https://github.com/blackm0re/etoolkit/tree/2.1.0) (2024-12-26) +## [2.2.0](https://codeberg.org/sgs/etoolkit/releases/tag/2.2.0) (2026-04-16) -[Full Changelog](https://github.com/blackm0re/etoolkit/compare/2.0.0...2.1.0) +[Full Changelog](https://codeberg.org/sgs/etoolkit/compare/2.1.0...2.2.0) + +**Changes:** + +- new *%e* macro - current env. var. value (for the same key) + +- migrated the project from GitHub to Codeberg + + +## [2.1.0](https://codeberg.org/sgs/etoolkit/releases/tag/2.1.0) (2024-12-26) + +[Full Changelog](https://codeberg.org/sgs/etoolkit/compare/2.0.0...2.1.0) **Changes:** - add encrypted values to *ETOOLKIT_SENSITIVE* implicitly -## [2.0.0](https://github.com/blackm0re/etoolkit/tree/2.0.0) (2024-05-13) +## [2.0.0](https://codeberg.org/sgs/etoolkit/releases/tag/2.0.0) (2024-05-13) -[Full Changelog](https://github.com/blackm0re/etoolkit/compare/1.2.0...2.0.0) +[Full Changelog](https://codeberg.org/sgs/etoolkit/compare/1.2.0...2.0.0) **Changes:** @@ -24,9 +35,9 @@ - new etoolkit.EtoolkitInstance API (not compatible with v1) -## [1.2.0](https://github.com/blackm0re/etoolkit/tree/1.2.0) (2022-04-04) +## [1.2.0](https://codeberg.org/sgs/etoolkit/releases/tag/1.2.0) (2022-04-04) -[Full Changelog](https://github.com/blackm0re/etoolkit/compare/1.1.0...1.2.0) +[Full Changelog](https://codeberg.org/sgs/etoolkit/compare/1.1.0...1.2.0) **Changes:** @@ -35,9 +46,9 @@ - new *%p* macro - parent value (for the same key) -## [1.1.0](https://github.com/blackm0re/etoolkit/tree/1.1.0) (2022-03-14) +## [1.1.0](https://codeberg.org/sgs/etoolkit/releases/tag/1.1.0) (2022-03-14) -[Full Changelog](https://github.com/blackm0re/etoolkit/compare/1.0.0...1.1.0) +[Full Changelog](https://codeberg.org/sgs/etoolkit/compare/1.0.0...1.1.0) **Changes:** @@ -50,7 +61,7 @@ - use *setuptools* instead of the deprecated *distutils* -# [1.0.0](https://github.com/blackm0re/etoolkit/tree/1.0.0) (2021-12-23) +# [1.0.0](https://codeberg.org/sgs/etoolkit/releases/tag/1.0.0) (2021-12-23) **Changes:** diff --git a/README.md b/README.md index b4f7efe..bdd97e3 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ goals were set: ## Requirements -Apart from Python >= 3.8, the only requirement is +Apart from Python >= 3.10, the only requirement is [cryptography](https://pypi.org/project/cryptography/) @@ -183,7 +183,7 @@ Most users (including me) will simply use the command line interface (CLI). The *etoolkit* CLI loads the configuration located by default in *~/.etoolkit.json*. That file is based on -[etoolkit_sample.json](https://github.com/blackm0re/etoolkit/blob/master/etoolkit_sample.json). +[etoolkit_sample.json](https://codeberg.org/sgs/etoolkit/src/branch/master/etoolkit_sample.json). The *"instances"* part of the configuration defines unique instances, each of which represents its own environment with its own defined env. variables. @@ -262,6 +262,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** - the current value of the env. var. corresponding to the same key + - **%h** - the home directory of the user running *etoolkit* (~/) - **%i** - the name of the instance that is about to be loaded @@ -395,18 +397,18 @@ bottom of your bash startup file: ``` A complete bash completion script for *etoolkit* can be found here: -[https://github.com/blackm0re/etoolkit/blob/master/completion/etoolkit.bash](https://github.com/blackm0re/etoolkit/blob/master/completion/etoolkit.bash) +[https://codeberg.org/sgs/etoolkit/src/branch/master/completion/etoolkit.bash](https://codeberg.org/sgs/etoolkit/src/branch/master/completion/etoolkit.bash) ## Changelog A complete changelog can be found at: -[https://github.com/blackm0re/etoolkit/blob/master/CHANGELOG.md](https://github.com/blackm0re/etoolkit/blob/master/CHANGELOG.md) +[https://codeberg.org/sgs/etoolkit/src/branch/master/CHANGELOG.md](https://codeberg.org/sgs/etoolkit/src/branch/master/CHANGELOG.md) ## Support and contributing -*etoolkit* is hosted on GitHub: https://github.com/blackm0re/etoolkit +*etoolkit* is hosted on Codeberg: https://codeberg.org/sgs/etoolkit ## Author @@ -414,11 +416,11 @@ A complete changelog can be found at: Simeon Simeonov - sgs @ LiberaChat -## [License](https://github.com/blackm0re/etoolkit/blob/master/LICENSE) +## [License](https://codeberg.org/sgs/etoolkit/src/branch/master/LICENSE) -Copyright (C) 2021-2024 Simeon Simeonov +Copyright (C) 2021-2026 Simeon Simeonov All rights reserved. -[Licensed](https://github.com/blackm0re/etoolkit/blob/master/LICENSE) under the +[Licensed](https://codeberg.org/sgs/etoolkit/src/branch/master/LICENSE) under the GNU General Public License v3.0 or later. SPDX-License-Identifier: GPL-3.0-or-later diff --git a/pyproject.toml b/pyproject.toml index 86a1527..e44adbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,42 @@ -[build-system] -requires = [ - "setuptools>=51", - "wheel" +[project] +name = "etoolkit" +description = "A simple toolkit for setting environment variables in a flexible way" +dynamic = ["version"] +license = "GPL-3.0-or-later" +authors = [{name = "Simeon Simeonov", email = "sgs@pichove.org"}] +readme = "README.md" +requires-python = ">= 3.10" + +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Operating System :: POSIX", + "Topic :: Security :: Cryptography", ] -build-backend = "setuptools.build_meta" +dependencies = [ + "cryptography>=3.2" +] + + +[project.scripts] +etoolkit = "etoolkit.__main__:main" + + +[project.urls] +Homepage = "https://codeberg.org/sgs/etoolkit" +Repository = "https://codeberg.org/sgs/etoolkit" +Issues = "https://codeberg.org/sgs/etoolkit/issues" +Changelog = "https://codeberg.org/sgs/etoolkit/src/branch/master/CHANGELOG.md" [tool.pytest.ini_options] @@ -16,3 +48,15 @@ testpaths = [ pythonpath = [ "src" ] + + +[tool.setuptools.dynamic] +version = {attr = "etoolkit.__version__"} + + +[build-system] +requires = [ + "setuptools >= 77.0.3", + "wheel" +] +build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 1838e18..0000000 --- a/setup.cfg +++ /dev/null @@ -1,45 +0,0 @@ -[metadata] -name = etoolkit -version = attr: etoolkit.__version__ -author = attr: etoolkit.__author__ -author_email = sgs@pichove.org -description = A simple toolkit for setting environment variables in a flexible way -long_description = file: README.md -long_description_content_type = text/markdown -url = https://github.com/blackm0re/etoolkit - -classifiers = - Development Status :: 5 - Production/Stable - Environment :: Console - Intended Audience :: Developers - Intended Audience :: System Administrators - License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: 3.12 - Programming Language :: Python :: 3.13 - Programming Language :: Python :: 3.14 - Operating System :: POSIX - Topic :: Security :: Cryptography - -project_urls = - Bug Tracker = https://github.com/blackm0re/etoolkit/issues - Source = https://github.com/blackm0re/etoolkit - -[options] -package_dir = - = src -packages = find: -python_requires = >=3.9 - -install_requires = - cryptography>=3.2 - -[options.packages.find] -where = src - -[options.entry_points] -console_scripts = - etoolkit = etoolkit.__main__:main diff --git a/src/etoolkit/__init__.py b/src/etoolkit/__init__.py index 4cf5d6e..fc32b61 100644 --- a/src/etoolkit/__init__.py +++ b/src/etoolkit/__init__.py @@ -1,5 +1,5 @@ # etoolkit -# Copyright (C) 2021-2024 Simeon Simeonov +# Copyright (C) 2021-2026 Simeon Simeonov # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,11 +18,11 @@ from .etoolkit import EtoolkitInstance, EtoolkitInstanceError __author__ = 'Simeon Simeonov' -__version__ = '2.1.0' +__version__ = '2.2.0' __license__ = 'GPL3' -def int_or_str(value): +def int_or_str(value: int | str) -> int | str: """Returns int value of value when possible""" try: return int(value) diff --git a/src/etoolkit/__main__.py b/src/etoolkit/__main__.py index a2f2f20..cef3d42 100644 --- a/src/etoolkit/__main__.py +++ b/src/etoolkit/__main__.py @@ -1,5 +1,5 @@ # etoolkit -# Copyright (C) 2021-2024 Simeon Simeonov +# Copyright (C) 2021-2026 Simeon Simeonov # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -50,7 +50,7 @@ class EtoolkitCLIHandler: a part of the etoolkit API """ - def __init__(self, args: argparse.Namespace, config_dict: dict): + def __init__(self, args: argparse.Namespace, config_dict: dict) -> None: """ :param args: The parsed argparse arguments sent by the caller :type args: argparse.Namespace @@ -69,7 +69,7 @@ class EtoolkitCLIHandler: self._password_from_env = os.environ.get('ETOOLKIT_MASTER_PASSWORD') - def decrypt_value(self): + def decrypt_value(self) -> None: """ Interactive method for decrypting value(s) @@ -110,7 +110,7 @@ class EtoolkitCLIHandler: break return - def encrypt_value(self): + def encrypt_value(self) -> None: """ Interactive method for encrypting value(s) @@ -155,7 +155,7 @@ class EtoolkitCLIHandler: break return - def generate_master_password_hash(self): + def generate_master_password_hash(self) -> None: """ Interactive method for generating password hash @@ -168,7 +168,7 @@ class EtoolkitCLIHandler: ) print(f'Master password hash: {phash}') - def list(self): + def list(self) -> None: """Lists all instances defined in the config file""" for instance_name in sorted( @@ -179,7 +179,7 @@ class EtoolkitCLIHandler: ): print(instance_name) - def load_instance(self): + def load_instance(self) -> None: """Loads a single specified instance from the config file""" inst = etoolkit.EtoolkitInstance( @@ -208,7 +208,7 @@ class EtoolkitCLIHandler: os.environ.get('SHELL', 'bash').split(), check=False ) - def reencrypt(self): + def reencrypt(self) -> None: """ Interactive method that prints new configuration data (JSON) to stdout @@ -277,7 +277,7 @@ class EtoolkitCLIHandler: ) -def main(inargs=None): +def main(inargs: list = None) -> None: """main entry point""" parser = argparse.ArgumentParser( diff --git a/src/etoolkit/etoolkit.py b/src/etoolkit/etoolkit.py index a933a01..f5ce2a6 100644 --- a/src/etoolkit/etoolkit.py +++ b/src/etoolkit/etoolkit.py @@ -1,5 +1,5 @@ # etoolkit -# Copyright (C) 2021-2024 Simeon Simeonov +# Copyright (C) 2021-2026 Simeon Simeonov # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -19,6 +19,7 @@ import base64 import getpass import hashlib import os +from collections.abc import Callable from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM @@ -33,7 +34,7 @@ class EtoolkitInstanceError(Exception): class EtoolkitInstance: """A basic class representing a single instance""" - def __init__(self, name: str, data: dict): + def __init__(self, name: str, data: dict) -> None: """ :param name: Instance name :type name: str @@ -76,14 +77,14 @@ class EtoolkitInstance: ] @property - def master_password(self) -> str: + def master_password(self) -> str | None: """master_password-property""" if self._master_password is not None: return self._master_password return None if self._parent is None else self._parent.master_password @master_password.setter - def master_password(self, value): + def master_password(self, value: str) -> None: """master_password-property setter""" self._master_password = value if self._parent is not None and self._parent.master_password is None: @@ -95,7 +96,7 @@ class EtoolkitInstance: return self._master_password_hash @master_password_hash.setter - def master_password_hash(self, value): + def master_password_hash(self, value: str) -> None: """master_password_hash-property setter""" self._master_password_hash = value @@ -105,12 +106,12 @@ class EtoolkitInstance: return self._name @property - def prompt_func(self): + def prompt_func(self) -> Callable[[str, bool], str]: """prompt_func-property""" return self._prompt_func @prompt_func.setter - def prompt_func(self, value): + def prompt_func(self, value: Callable[[str, bool], str]) -> None: """prompt_func-property setter""" self._prompt_func = value if self._parent is not None and self._parent.prompt_func is None: @@ -294,7 +295,7 @@ class EtoolkitInstance: ) @staticmethod - def parse_value(value, macros: dict): + def parse_value(value: object, macros: dict) -> object: """ Returns the value with all macros replaced by their values @@ -412,6 +413,8 @@ 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, '') diff --git a/tests/conftest.py b/tests/conftest.py index a4463fd..6cc8b9f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ # etoolkit -# Copyright (C) 2021-2024 Simeon Simeonov +# Copyright (C) 2021-2026 Simeon Simeonov # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -20,7 +20,7 @@ import json import pytest -@pytest.fixture() +@pytest.fixture def config_data(): """config_data for testing""" @@ -59,7 +59,7 @@ def config_data(): } -@pytest.fixture() +@pytest.fixture def config_file(tmp_path, config_data): """Temporary config file for testing that includes config_data""" @@ -68,7 +68,7 @@ def config_file(tmp_path, config_data): return str(cf) -@pytest.fixture() +@pytest.fixture def long_encrypted_value(): """enc. value corresponding to 'Nobody expects the Spanish inquisition'""" @@ -79,25 +79,25 @@ def long_encrypted_value(): ) -@pytest.fixture() +@pytest.fixture def long_value(): """standard value (> 32 bytes)""" return 'Nobody expects the Spanish inquisition' -@pytest.fixture() +@pytest.fixture def master_password(): """Master passord""" return 'The very secret passwd' -@pytest.fixture() +@pytest.fixture def new_master_password(): """Master passord""" return 'New very secret passwd' -@pytest.fixture() +@pytest.fixture def non_random_bytes_32(): """always use the same bytes instead of os.urandom(32)""" @@ -107,7 +107,7 @@ def non_random_bytes_32(): ) -@pytest.fixture() +@pytest.fixture def non_random_bytes_57(): """always use the same bytes instead of os.urandom(57)""" @@ -118,14 +118,14 @@ def non_random_bytes_57(): ) -@pytest.fixture() +@pytest.fixture def nonexistent_config_file(tmp_path): """temporary config file for testing that includes config_data""" return str(tmp_path / 'etoolkitt.json') -@pytest.fixture() +@pytest.fixture def password_hash(): """password hash for testing, corresponding to 'The very secret passwd'""" @@ -135,7 +135,7 @@ def password_hash(): ) -@pytest.fixture() +@pytest.fixture def short_encrypted_value(): """enc. value corresponding to 'secret1'""" @@ -145,7 +145,7 @@ def short_encrypted_value(): ) -@pytest.fixture() +@pytest.fixture def short_encrypted_value_v1(): """enc. value (enc-val 1) corresponding to 'secret1'""" @@ -155,13 +155,13 @@ def short_encrypted_value_v1(): ) -@pytest.fixture() +@pytest.fixture def short_value(): """standard value (< 32 bytes)""" return 'secret1' -@pytest.fixture() +@pytest.fixture def wrong_master_password(): """Wrong master passord""" return 'the very secret passwd' diff --git a/tests/test_cli.py b/tests/test_cli.py index 953abf4..b7913da 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,5 @@ # etoolkit -# Copyright (C) 2021-2024 Simeon Simeonov +# Copyright (C) 2021-2026 Simeon Simeonov # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -38,7 +38,6 @@ def test_decrypt_v1(binput, capsys, config_file, master_password): ): with pytest.raises(SystemExit) as exit_info: main(['-c', f'{config_file}', '-d']) - assert exit_info.type == SystemExit assert exit_info.value.code == 0 assert capsys.readouterr().out.strip() == 'Decrypted value: bar' @@ -60,7 +59,6 @@ def test_decrypt_v2( ): with pytest.raises(SystemExit) as exit_info: main(['-c', f'{config_file}', '-d']) - assert exit_info.type == SystemExit assert exit_info.value.code == 0 assert capsys.readouterr().out.strip() == ( f'Decrypted value: {short_value}' @@ -88,7 +86,6 @@ def test_encrypt_with_echo( ): with pytest.raises(SystemExit) as exit_info: main(['-c', f'{config_file}', '-e', '-E']) - assert exit_info.type == SystemExit assert exit_info.value.code == 0 assert capsys.readouterr().out.strip() == ( f'Encrypted value: {short_encrypted_value}' @@ -116,7 +113,6 @@ def test_encrypt_without_echo( ): with pytest.raises(SystemExit) as exit_info: main(['-c', f'{config_file}', '-e']) - assert exit_info.type == SystemExit assert exit_info.value.code == 0 assert capsys.readouterr().out.strip() == ( f'Encrypted value: {short_encrypted_value}' @@ -139,11 +135,9 @@ def test_list(capsys, config_file, nonexistent_config_file): with pytest.raises(SystemExit) as exit_info: main(['-c', nonexistent_config_file, '-l']) - assert exit_info.type == SystemExit assert exit_info.value.code == errno.EIO with pytest.raises(SystemExit) as exit_info: main(['-c', config_file, '-l']) - assert exit_info.type == SystemExit assert exit_info.value.code == 0 assert capsys.readouterr().out.strip() == f'dev{os.linesep}secret' @@ -153,7 +147,6 @@ def test_help(capsys): with pytest.raises(SystemExit) as exit_info: main(['-h']) - assert exit_info.type == SystemExit assert exit_info.value.code == 0 assert capsys.readouterr().out.startswith('usage: etoolkit') @@ -169,7 +162,6 @@ def test_generate_master_password_hash( urandom.return_value = non_random_bytes_32 with pytest.raises(SystemExit) as exit_info: main(['--generate-master-password-hash']) - assert exit_info.type == SystemExit assert exit_info.value.code == 0 assert capsys.readouterr().out.strip() == ( 'Master password hash: pbkdf2_sha256$500000$uYpZM1VfAGq0CDZL2duITs076' @@ -182,7 +174,6 @@ def test_version(capsys): with pytest.raises(SystemExit) as exit_info: main(['-v']) - assert exit_info.type == SystemExit assert exit_info.value.code == 0 assert capsys.readouterr().out.startswith( f'etoolkit {etoolkit.__version__}' diff --git a/tests/test_envtoolkit_instance.py b/tests/test_envtoolkit_instance.py index f555f81..5137e9f 100644 --- a/tests/test_envtoolkit_instance.py +++ b/tests/test_envtoolkit_instance.py @@ -1,5 +1,5 @@ # etoolkit -# Copyright (C) 2021-2024 Simeon Simeonov +# Copyright (C) 2021-2026 Simeon Simeonov # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/tests/test_envtoolkit_instance_static.py b/tests/test_envtoolkit_instance_static.py index ee5cf9e..e13d439 100644 --- a/tests/test_envtoolkit_instance_static.py +++ b/tests/test_envtoolkit_instance_static.py @@ -1,5 +1,5 @@ # etoolkit -# Copyright (C) 2021-2024 Simeon Simeonov +# Copyright (C) 2021-2026 Simeon Simeonov # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by -- cgit v1.3