Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Comment thread
rsgalloway marked this conversation as resolved.
pytest tests -q

# Install dryrun target to simulate installation
dryrun:
Expand All @@ -51,4 +52,4 @@ install: build
dist --force --yes

# Phony targets
.PHONY: build dryrun install clean
.PHONY: all build dryrun install clean test pytest
2 changes: 1 addition & 1 deletion lib/envstack/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
41 changes: 34 additions & 7 deletions lib/envstack/encrypt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
rsgalloway marked this conversation as resolved.
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."""

Expand Down Expand Up @@ -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:
Expand All @@ -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.

Expand All @@ -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.

Expand Down Expand Up @@ -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)
Expand All @@ -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.

Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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.

Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/env/test.env
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading