diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 272f8c9..9b16a5c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,11 +8,12 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: ${{ matrix.python-version }} - run: python -m pip install --upgrade pip - run: pip install pytest - run: pip install -e . diff --git a/CHANGELOG.md b/CHANGELOG.md index cbb3d23..c185d42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.0.2] - 2026-04-01 + +### Changed +- add a self-contained `make test` target that installs `pytest` and the package in editable mode before running the test suite +- mark `all` as a phony Make target to avoid filename collisions + +### Fixed +- remove `return` statements from `finally` blocks in encryption helpers to avoid newer Python `SyntaxWarning`s +- harden optional `cryptography` imports with a decorator-based availability guard so missing crypto dependencies raise a clear runtime error instead of `NoneType` failures + +--- + ## [1.0.1] - 2026-02-08 ### Fixed diff --git a/Makefile b/Makefile index e14dc22..818506f 100644 --- a/Makefile +++ b/Makefile @@ -36,10 +36,11 @@ build: clean # Combined target to build for both platforms all: build -# Test target to verify the build +# Run the pytest suite from an editable install, matching CI behavior test: - $(ENVSTACK_CMD) -- ls -al - ${ENVSTACK_CMD} -- which python + python -m pip install pytest + python -m pip install -e . + pytest tests -q # Install dryrun target to simulate installation dryrun: @@ -51,4 +52,4 @@ install: build dist --force --yes # Phony targets -.PHONY: build dryrun install clean +.PHONY: all build dryrun install clean test pytest diff --git a/lib/envstack/__init__.py b/lib/envstack/__init__.py index a75af53..8d9385a 100644 --- a/lib/envstack/__init__.py +++ b/lib/envstack/__init__.py @@ -34,7 +34,7 @@ """ __prog__ = "envstack" -__version__ = "1.0.1" +__version__ = "1.0.2" from envstack.env import clear, init, revert, save # noqa: F401 from envstack.env import load_environ, resolve_environ # noqa: F401 diff --git a/lib/envstack/encrypt.py b/lib/envstack/encrypt.py index 7b99fce..3cb6642 100644 --- a/lib/envstack/encrypt.py +++ b/lib/envstack/encrypt.py @@ -38,21 +38,42 @@ import os import secrets from base64 import b64decode, b64encode +from functools import wraps from envstack.logger import log # cryptography and _rust dependency may not be available everywhere # ImportError: DLL load failed while importing _rust: Module not found. +CRYPTOGRAPHY_AVAILABLE = False Fernet = None +InvalidToken = type("InvalidToken", (Exception,), {}) +InvalidTag = type("InvalidTag", (Exception,), {}) +padding = None +Cipher = None +algorithms = None +modes = None try: - import cryptography.exceptions from cryptography.fernet import Fernet, InvalidToken + from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + CRYPTOGRAPHY_AVAILABLE = True except ImportError as err: log.debug("cryptography module not available: %s", err) +def require_cryptography(func): + """Guard crypto-backed functions when cryptography is unavailable.""" + + @wraps(func) + def wrapper(*args, **kwargs): + if not CRYPTOGRAPHY_AVAILABLE: + raise RuntimeError("cryptography support is not available") + return func(*args, **kwargs) + + return wrapper + + class Base64Encryptor(object): """Encrypt and decrypt secrets using base64 encoding.""" @@ -84,6 +105,7 @@ def __init__(self, key: str = None, env: dict = os.environ): self.key = self.get_key(env) @classmethod + @require_cryptography def generate_key(csl): """Generate a new 256-bit encryption key.""" if Fernet: @@ -104,6 +126,7 @@ def get_key(self, env: dict = os.environ): return Fernet(key) return key + @require_cryptography def encrypt(self, data: str): """Encrypt a secret using Fernet. @@ -121,9 +144,9 @@ def encrypt(self, data: str): log.error("invalid value: %s", e) except Exception as e: log.error("unhandled error: %s", e) - finally: - return results + return results + @require_cryptography def decrypt(self, data: str): """Decrypt a secret using Fernet. @@ -153,6 +176,7 @@ def __init__(self, key: str = None, env: dict = os.environ): self.key = self.get_key(env) @classmethod + @require_cryptography def generate_key(csl): """Generate a new 256-bit encryption key.""" key = secrets.token_bytes(32) @@ -171,6 +195,7 @@ def get_key(self, env: dict = os.environ): raise ValueError("invalid base64 encoding: %s" % e) return key + @require_cryptography def encrypt_data(self, secret: str): """Encrypt a secret using AES-GCM. @@ -189,6 +214,7 @@ def encrypt_data(self, secret: str): "tag": b64encode(encryptor.tag).decode(), } + @require_cryptography def decrypt_data(self, encrypted_data: dict): """Decrypt a secret using AES-GCM. @@ -218,14 +244,13 @@ def encrypt(self, data: str): results = compact_store(encrypted_data) except binascii.Error as e: log.error("invalid base64 encoding: %s", e) - except cryptography.exceptions.InvalidTag: + except InvalidTag: log.error("invalid encryption key") except ValueError as e: log.error("invalid value: %s", e) except Exception as e: log.error("unhandled error: %s", e) - finally: - return results + return results def decrypt(self, data: str): """Convenience function to decrypt a secret using AES-GCM. @@ -239,7 +264,7 @@ def decrypt(self, data: str): return decrypted.decode() except binascii.Error as e: log.debug("invalid base64 encoding: %s", e) - except cryptography.exceptions.InvalidTag: + except InvalidTag: log.debug("invalid encryption key") except ValueError as e: log.debug("invalid value: %s", e) @@ -248,6 +273,7 @@ def decrypt(self, data: str): return data +@require_cryptography def pad_data(data: str): """Pad data to be block-aligned for AES encryption. @@ -258,6 +284,7 @@ def pad_data(data: str): return padder.update(str(data).encode()) + padder.finalize() +@require_cryptography def unpad_data(data: dict): """Unpad data after decryption. diff --git a/pyproject.toml b/pyproject.toml index 0abb5e1..33528f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "envstack" -version = "1.0.1" +version = "1.0.2" description = "Environment variable composition layer for tools and processes." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.6" diff --git a/tests/fixtures/env/test.env b/tests/fixtures/env/test.env index fc33d7e..006667f 100644 --- a/tests/fixtures/env/test.env +++ b/tests/fixtures/env/test.env @@ -1,7 +1,7 @@ #!/usr/bin/env envstack include: [default] all: &all - PYVERSION: $(python -c "import sys; print(f'{sys.version_info[0]}.{sys.version_info[1]}')") + PYVERSION: $(python -c "import sys; print(repr(f'{sys.version_info[0]}.{sys.version_info[1]}'))") PYTHONPATH: ${DEPLOY_ROOT}/lib/python${PYVERSION} darwin: <<: *all