Skip to content

Commit d79f99e

Browse files
Prepare for 1.1.7 🍂
1 parent 84e4e13 commit d79f99e

File tree

8 files changed

+304
-16
lines changed

8 files changed

+304
-16
lines changed

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.1.7] - 2025-10-01 :fallen_leaf:
9+
10+
- Add a `Secret` class to handle secrets in code instead of using plain `str`. This
11+
approach offers several advantages:
12+
13+
1. It encourages loading secrets from environment variables, and discourages programmers
14+
from hardcoding secrets in source code.
15+
1. Avoids accidental exposure of secrets in logs or error messages, by overriding
16+
__str__ and __repr__.
17+
1. It causes exception if someone tries to JSON encode it using the built-in JSON
18+
module, unlike `str`.
19+
1. For convenience, it can be compared directly to strings. It uses constant-time
20+
comparison to prevent timing attacks, with the built-in `secrets.compare_digest`.
21+
1. Environment variables can be changed at runtime, using this class applications can
22+
pick up secret changes without needing to be restarted.
23+
24+
- Add an `EnvironmentVariableNotFound` exception that can be used when an expected env
25+
variable is not set.
26+
- Handle `timedelta` objects in the `FriendlyEncoder` class, by @arthurbrenno.
27+
- Improve the order of `if` statements in the `FriendlyEncoder` class to prioritize the
28+
most frequently encountered types first, which should provide better performance in
29+
typical use cases.
30+
831
## [1.1.6] - 2025-03-29 :snake:
932

1033
- Drop Python 3.6 and Python 3.7 support.

essentials/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.1.6"
1+
__version__ = "1.1.7"

essentials/decorators/logs.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ def after(name, stop_watch, function_call_id, value):
6767
)
6868

6969
def log_decorator(fn):
70-
nonlocal logger
7170
name = fn.__module__ + "." + fn.__name__
7271

7372
if iscoroutinefunction(fn):

essentials/exceptions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,8 @@ class OperationFailedException(Exception):
6060

6161
class SystemException(Exception):
6262
pass
63+
64+
65+
class EnvironmentVariableNotFound(ValueError):
66+
def __init__(self, name: str) -> None:
67+
super().__init__(f"Environment variable {name} not found.")

essentials/json.py

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,28 +20,38 @@ def default(self, obj: Any) -> Any:
2020
try:
2121
return json.JSONEncoder.default(self, obj)
2222
except TypeError:
23-
if hasattr(obj, "model_dump"):
24-
return obj.model_dump()
25-
if hasattr(obj, "dict"):
26-
return obj.dict()
27-
if isinstance(obj, time):
28-
return obj.strftime("%H:%M:%S")
23+
# The ordering prioritizes the most frequently encountered types first,
24+
# which should provide better performance in typical use cases.
25+
26+
# Most common datetime objects first
2927
if isinstance(obj, datetime):
3028
return obj.isoformat()
31-
if isinstance(obj, timedelta):
32-
return obj.total_seconds()
3329
if isinstance(obj, date):
3430
return obj.strftime("%Y-%m-%d")
35-
if isinstance(obj, bytes):
36-
return base64.urlsafe_b64encode(obj).decode("utf8")
31+
if isinstance(obj, time):
32+
return obj.strftime("%H:%M:%S")
33+
34+
# Very common built-in types
3735
if isinstance(obj, UUID):
3836
return str(obj)
39-
if isinstance(obj, Decimal):
40-
return str(obj)
4137
if isinstance(obj, Enum):
4238
return obj.value
39+
if isinstance(obj, Decimal):
40+
return str(obj)
41+
42+
# Common serializable objects
4343
if dataclasses.is_dataclass(obj):
4444
return dataclasses.asdict(obj) # type:ignore[arg-type]
45+
if hasattr(obj, "model_dump"): # Pydantic v2
46+
return obj.model_dump()
47+
if hasattr(obj, "dict"): # Pydantic v1 or similar
48+
return obj.dict()
49+
50+
# Less common types
51+
if isinstance(obj, timedelta):
52+
return obj.total_seconds()
53+
if isinstance(obj, bytes):
54+
return base64.urlsafe_b64encode(obj).decode("utf8")
4555
raise
4656

4757

essentials/secrets.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import os
2+
import secrets
3+
4+
from essentials.exceptions import EnvironmentVariableNotFound
5+
6+
7+
class Secret:
8+
"""
9+
A type that encourages loading secrets from environment variables, and discourages
10+
programmers from hardcoding secrets in source code. This also avoids accidental
11+
exposure of secrets in logs or error messages, by overriding __str__ and __repr__,
12+
and causes exception if someone tries to JSON encode it using the built-in
13+
JSON module. For convenience, it can be compared directly to strings.
14+
It uses constant-time comparison to prevent timing attacks, with
15+
`secrets.compare_digest`.
16+
Another benefit is that environment variables can be changed at runtime, so
17+
applications can pick up secret changes without needing to be restarted.
18+
19+
my_secret = Secret.from_env("MY_SECRET_ENV_VAR")
20+
21+
>>> str(my_secret)
22+
'******'
23+
>>> repr(my_secret)
24+
"Secret('******')"
25+
"""
26+
27+
def __init__(self, value: str, *, direct_value: bool = False) -> None:
28+
"""
29+
Create an instance of Secret.
30+
31+
Args:
32+
value: The name of an environment variable reference (prefixed with $), or
33+
a secret if direct_value=True.
34+
direct_value: Must be set to True to allow passing secrets directly
35+
36+
Raises:
37+
ValueError: If a secret is provided without explicit permission.
38+
"""
39+
if not value.startswith("$") and not direct_value:
40+
raise ValueError(
41+
"Hardcoded secrets are not allowed. Either:\n"
42+
"1. Use Secret.from_env('ENV_VAR_NAME') for environment variables\n"
43+
"2. Use Secret('$ENV_VAR_NAME') for env var references\n"
44+
"3. Set direct_value=True if you need to handle a secret value "
45+
"directly."
46+
)
47+
self._value = value
48+
# Validate that we can retrieve a value
49+
value = self.get_value()
50+
if not isinstance(value, str) or not value:
51+
raise ValueError("Secret value must be a non-empty string")
52+
53+
@classmethod
54+
def from_env(cls, env_var: str) -> "Secret":
55+
"""Obtain a secret from an environment variable."""
56+
return cls(f"${env_var}")
57+
58+
@classmethod
59+
def from_plain_text(cls, value: str) -> "Secret":
60+
"""
61+
Create a Secret from a plain text value.
62+
63+
This is intended for secrets that are:
64+
- Generated at runtime
65+
- Loaded from secure storage (databases, key vaults, etc.)
66+
- Received from secure APIs
67+
68+
WARNING: Don't hardcode secrets in source code! This method should only
69+
be used with variables containing secrets obtained from secure sources.
70+
71+
Args:
72+
value: The secret value as plain text
73+
74+
Returns:
75+
Secret: A new Secret instance
76+
"""
77+
return cls(value, direct_value=True)
78+
79+
def get_value(self) -> str:
80+
"""Get the secret value."""
81+
if self._value.startswith("$"):
82+
env_var = self._value[1:] # Remove $ prefix
83+
value = os.getenv(env_var)
84+
if value is None:
85+
raise EnvironmentVariableNotFound(env_var)
86+
return value
87+
return self._value
88+
89+
def __str__(self) -> str:
90+
return "******" # Never expose the actual value
91+
92+
def __repr__(self) -> str:
93+
# Never expose the actual value
94+
# Show the source (env var name) but never the value
95+
if self._value.startswith("$"):
96+
env_var = self._value[1:]
97+
return f"Secret.from_env('{env_var}')"
98+
return "Secret('******')" # For hardcoded secrets
99+
100+
def __eq__(self, other: object) -> bool:
101+
# Allow comparison with strings for convenience
102+
if isinstance(other, str):
103+
# Using constant-time comparison to prevent timing attacks, with
104+
# secrets.compare_digest.
105+
return secrets.compare_digest(self.get_value(), other)
106+
107+
if not isinstance(other, Secret):
108+
return NotImplemented
109+
110+
return secrets.compare_digest(self.get_value(), other.get_value())

tests/test_meta.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,6 @@ def test_deprecated_async_method():
4646

4747

4848
def test_deprecated_async_method_exc():
49-
with pytest.raises(DeprecatedException):
50-
asyncio.run(async_dep_method2())
49+
with pytest.warns(DeprecationWarning, match="`async_dep_method2` is deprecated."):
50+
with pytest.raises(DeprecatedException):
51+
asyncio.run(async_dep_method2())

tests/test_secrets.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import pytest
2+
3+
from essentials.exceptions import EnvironmentVariableNotFound
4+
from essentials.secrets import Secret
5+
6+
7+
def test_from_env_success(monkeypatch):
8+
"""Test creating Secret from environment variable."""
9+
monkeypatch.setenv("TEST_SECRET", "secret_value")
10+
secret = Secret.from_env("TEST_SECRET")
11+
assert secret.get_value() == "secret_value"
12+
13+
14+
def test_from_env_missing_raises_exception():
15+
"""Test that missing environment variable raises EnvironmentVariableNotFound."""
16+
with pytest.raises(EnvironmentVariableNotFound):
17+
Secret.from_env("NON_EXISTENT_VAR").get_value()
18+
19+
20+
def test_from_plain_text_success():
21+
"""Test creating Secret from plain text."""
22+
secret = Secret.from_plain_text("my_secret")
23+
assert secret.get_value() == "my_secret"
24+
25+
26+
def test_direct_constructor_with_env_var(monkeypatch):
27+
"""Test direct constructor with environment variable reference."""
28+
monkeypatch.setenv("TEST_SECRET", "secret_value")
29+
secret = Secret("$TEST_SECRET")
30+
assert secret.get_value() == "secret_value"
31+
32+
33+
def test_direct_constructor_with_direct_value():
34+
"""Test direct constructor with direct_value=True."""
35+
secret = Secret("hardcoded_secret", direct_value=True)
36+
assert secret.get_value() == "hardcoded_secret"
37+
38+
39+
def test_hardcoded_secret_without_permission_raises_error():
40+
"""Test that hardcoded secrets without permission raise ValueError."""
41+
with pytest.raises(ValueError, match="Hardcoded secrets are not allowed"):
42+
Secret("hardcoded_secret")
43+
44+
45+
def test_empty_secret_raises_error(monkeypatch):
46+
"""Test that empty secret values raise ValueError."""
47+
monkeypatch.setenv("EMPTY_SECRET", "")
48+
with pytest.raises(ValueError, match="Secret value must be a non-empty string"):
49+
Secret.from_env("EMPTY_SECRET")
50+
51+
52+
def test_str_representation_hides_value(monkeypatch):
53+
"""Test that __str__ never exposes the actual value."""
54+
monkeypatch.setenv("TEST_SECRET", "secret_value")
55+
secret = Secret.from_env("TEST_SECRET")
56+
assert str(secret) == "******"
57+
58+
59+
def test_repr_shows_env_var_name(monkeypatch):
60+
"""Test that __repr__ shows environment variable name but not value."""
61+
monkeypatch.setenv("TEST_SECRET", "secret_value")
62+
secret = Secret.from_env("TEST_SECRET")
63+
assert repr(secret) == "Secret.from_env('TEST_SECRET')"
64+
65+
66+
def test_repr_hides_hardcoded_value():
67+
"""Test that __repr__ hides hardcoded secret values."""
68+
secret = Secret.from_plain_text("secret")
69+
assert repr(secret) == "Secret('******')"
70+
71+
72+
def test_equality_with_string(monkeypatch):
73+
"""Test that Secret can be compared with strings."""
74+
monkeypatch.setenv("TEST_SECRET", "secret_value")
75+
secret = Secret.from_env("TEST_SECRET")
76+
assert secret == "secret_value"
77+
assert secret != "wrong_value"
78+
79+
80+
def test_equality_with_another_secret(monkeypatch):
81+
"""Test that Secrets can be compared with each other."""
82+
monkeypatch.setenv("SECRET1", "same_value")
83+
monkeypatch.setenv("SECRET2", "same_value")
84+
monkeypatch.setenv("SECRET3", "different_value")
85+
86+
secret1 = Secret.from_env("SECRET1")
87+
secret2 = Secret.from_env("SECRET2")
88+
secret3 = Secret.from_env("SECRET3")
89+
90+
assert secret1 == secret2
91+
assert secret1 != secret3
92+
93+
94+
def test_equality_with_incompatible_type(monkeypatch):
95+
"""Test that comparison with incompatible types returns NotImplemented."""
96+
monkeypatch.setenv("TEST_SECRET", "secret_value")
97+
secret = Secret.from_env("TEST_SECRET")
98+
assert secret.__eq__(123) == NotImplemented
99+
assert secret != 123
100+
101+
102+
def test_mixed_secret_sources_comparison(monkeypatch):
103+
"""Test comparing secrets from different sources."""
104+
monkeypatch.setenv("ENV_SECRET", "same_value")
105+
env_secret = Secret.from_env("ENV_SECRET")
106+
plain_secret = Secret.from_plain_text("same_value")
107+
108+
assert env_secret == plain_secret
109+
110+
111+
def test_get_value_called_multiple_times(monkeypatch):
112+
"""Test that get_value works consistently across multiple calls."""
113+
monkeypatch.setenv("TEST_SECRET", "secret_value")
114+
secret = Secret.from_env("TEST_SECRET")
115+
116+
assert secret.get_value() == "secret_value"
117+
assert secret.get_value() == "secret_value"
118+
119+
120+
def test_env_var_change_at_runtime(monkeypatch):
121+
"""Test that Secret picks up environment variable changes."""
122+
monkeypatch.setenv("DYNAMIC_SECRET", "initial_value")
123+
secret = Secret.from_env("DYNAMIC_SECRET")
124+
assert secret.get_value() == "initial_value"
125+
126+
monkeypatch.setenv("DYNAMIC_SECRET", "changed_value")
127+
assert secret.get_value() == "changed_value"
128+
129+
130+
def test_constructor_validation_with_empty_env_var(monkeypatch):
131+
"""Test constructor validation when env var exists but is empty."""
132+
monkeypatch.setenv("EMPTY_VAR", "")
133+
with pytest.raises(ValueError, match="Secret value must be a non-empty string"):
134+
Secret("$EMPTY_VAR")
135+
136+
137+
def test_from_plain_text_with_empty_string():
138+
"""Test that from_plain_text with empty string raises ValueError."""
139+
with pytest.raises(ValueError, match="Secret value must be a non-empty string"):
140+
Secret.from_plain_text("")

0 commit comments

Comments
 (0)