Skip to content

Commit 4e474e7

Browse files
Add Secret class
1 parent 84e4e13 commit 4e474e7

8 files changed

Lines changed: 307 additions & 17 deletions

File tree

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: 23 additions & 13 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):
44-
return dataclasses.asdict(obj) # type:ignore[arg-type]
44+
return dataclasses.asdict(obj)
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: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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+
Hardcoding secrets in source code is a security risk. See:
27+
28+
https://www.gitguardian.com/state-of-secrets-sprawl-report-2023
29+
"""
30+
31+
def __init__(self, value: str, *, direct_value: bool = False) -> None:
32+
"""
33+
Create an instance of Secret.
34+
35+
Args:
36+
value: The name of an environment variable reference (prefixed with $), or
37+
a secret if direct_value=True.
38+
direct_value: Must be set to True to allow passing secrets directly
39+
40+
Raises:
41+
ValueError: If hardcoded secret is provided without explicit permission
42+
"""
43+
if not value.startswith("$") and not direct_value:
44+
raise ValueError(
45+
"Hardcoded secrets are not allowed. Either:\n"
46+
"1. Use Secret.from_env('ENV_VAR_NAME') for environment variables\n"
47+
"2. Use Secret('$ENV_VAR_NAME') for env var references\n"
48+
"3. Set direct_value=True if you really need a hardcoded secret"
49+
)
50+
self._value = value
51+
# Validate that we can retrieve a value
52+
value = self.get_value()
53+
if not isinstance(value, str) or not value:
54+
raise ValueError("Secret value must be a non-empty string")
55+
56+
@classmethod
57+
def from_env(cls, env_var: str) -> "Secret":
58+
"""Obtain a secret from an environment variable."""
59+
return cls(f"${env_var}")
60+
61+
@classmethod
62+
def from_plain_text(cls, value: str) -> "Secret":
63+
"""
64+
Create a Secret from a plain text value.
65+
66+
This is intended for secrets that are:
67+
- Generated at runtime
68+
- Loaded from secure storage (databases, key vaults, etc.)
69+
- Received from secure APIs
70+
71+
WARNING: Don't hardcode secrets in source code! This method should only
72+
be used with variables containing secrets obtained from secure sources.
73+
74+
Args:
75+
value: The secret value as plain text
76+
77+
Returns:
78+
Secret: A new Secret instance
79+
"""
80+
return cls(value, direct_value=True)
81+
82+
def get_value(self) -> str:
83+
"""Get the secret value."""
84+
if self._value.startswith("$"):
85+
env_var = self._value[1:] # Remove $ prefix
86+
value = os.getenv(env_var)
87+
if value is None:
88+
raise EnvironmentVariableNotFound(env_var)
89+
return value
90+
return self._value
91+
92+
def __str__(self) -> str:
93+
return "******" # Never expose the actual value
94+
95+
def __repr__(self) -> str:
96+
# Never expose the actual value
97+
# Show the source (env var name) but never the value
98+
if self._value.startswith("$"):
99+
env_var = self._value[1:]
100+
return f"Secret.from_env('{env_var}')"
101+
return "Secret('******')" # For hardcoded secrets
102+
103+
def __eq__(self, other: object) -> bool:
104+
# Allow comparison with strings for convenience
105+
if isinstance(other, str):
106+
# Using constant-time comparison to prevent timing attacks, with
107+
# secrets.compare_digest.
108+
return secrets.compare_digest(self.get_value(), other)
109+
110+
if not isinstance(other, Secret):
111+
return NotImplemented
112+
113+
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: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import pytest
2+
from essentials.secrets import Secret
3+
from essentials.exceptions import EnvironmentVariableNotFound
4+
5+
6+
def test_from_env_success(monkeypatch):
7+
"""Test creating Secret from environment variable."""
8+
monkeypatch.setenv("TEST_SECRET", "secret_value")
9+
secret = Secret.from_env("TEST_SECRET")
10+
assert secret.get_value() == "secret_value"
11+
12+
13+
def test_from_env_missing_raises_exception():
14+
"""Test that missing environment variable raises EnvironmentVariableNotFound."""
15+
with pytest.raises(EnvironmentVariableNotFound):
16+
Secret.from_env("NON_EXISTENT_VAR").get_value()
17+
18+
19+
def test_from_plain_text_success():
20+
"""Test creating Secret from plain text."""
21+
secret = Secret.from_plain_text("my_secret")
22+
assert secret.get_value() == "my_secret"
23+
24+
25+
def test_direct_constructor_with_env_var(monkeypatch):
26+
"""Test direct constructor with environment variable reference."""
27+
monkeypatch.setenv("TEST_SECRET", "secret_value")
28+
secret = Secret("$TEST_SECRET")
29+
assert secret.get_value() == "secret_value"
30+
31+
32+
def test_direct_constructor_with_direct_value():
33+
"""Test direct constructor with direct_value=True."""
34+
secret = Secret("hardcoded_secret", direct_value=True)
35+
assert secret.get_value() == "hardcoded_secret"
36+
37+
38+
def test_hardcoded_secret_without_permission_raises_error():
39+
"""Test that hardcoded secrets without permission raise ValueError."""
40+
with pytest.raises(ValueError, match="Hardcoded secrets are not allowed"):
41+
Secret("hardcoded_secret")
42+
43+
44+
def test_empty_secret_raises_error(monkeypatch):
45+
"""Test that empty secret values raise ValueError."""
46+
monkeypatch.setenv("EMPTY_SECRET", "")
47+
with pytest.raises(ValueError, match="Secret value must be a non-empty string"):
48+
Secret.from_env("EMPTY_SECRET")
49+
50+
51+
def test_str_representation_hides_value(monkeypatch):
52+
"""Test that __str__ never exposes the actual value."""
53+
monkeypatch.setenv("TEST_SECRET", "secret_value")
54+
secret = Secret.from_env("TEST_SECRET")
55+
assert str(secret) == "******"
56+
57+
58+
def test_repr_shows_env_var_name(monkeypatch):
59+
"""Test that __repr__ shows environment variable name but not value."""
60+
monkeypatch.setenv("TEST_SECRET", "secret_value")
61+
secret = Secret.from_env("TEST_SECRET")
62+
assert repr(secret) == "Secret.from_env('TEST_SECRET')"
63+
64+
65+
def test_repr_hides_hardcoded_value():
66+
"""Test that __repr__ hides hardcoded secret values."""
67+
secret = Secret.from_plain_text("secret")
68+
assert repr(secret) == "Secret('******')"
69+
70+
71+
def test_equality_with_string(monkeypatch):
72+
"""Test that Secret can be compared with strings."""
73+
monkeypatch.setenv("TEST_SECRET", "secret_value")
74+
secret = Secret.from_env("TEST_SECRET")
75+
assert secret == "secret_value"
76+
assert secret != "wrong_value"
77+
78+
79+
def test_equality_with_another_secret(monkeypatch):
80+
"""Test that Secrets can be compared with each other."""
81+
monkeypatch.setenv("SECRET1", "same_value")
82+
monkeypatch.setenv("SECRET2", "same_value")
83+
monkeypatch.setenv("SECRET3", "different_value")
84+
85+
secret1 = Secret.from_env("SECRET1")
86+
secret2 = Secret.from_env("SECRET2")
87+
secret3 = Secret.from_env("SECRET3")
88+
89+
assert secret1 == secret2
90+
assert secret1 != secret3
91+
92+
93+
def test_equality_with_incompatible_type(monkeypatch):
94+
"""Test that comparison with incompatible types returns NotImplemented."""
95+
monkeypatch.setenv("TEST_SECRET", "secret_value")
96+
secret = Secret.from_env("TEST_SECRET")
97+
assert secret.__eq__(123) == NotImplemented
98+
assert secret != 123
99+
100+
101+
def test_mixed_secret_sources_comparison(monkeypatch):
102+
"""Test comparing secrets from different sources."""
103+
monkeypatch.setenv("ENV_SECRET", "same_value")
104+
env_secret = Secret.from_env("ENV_SECRET")
105+
plain_secret = Secret.from_plain_text("same_value")
106+
107+
assert env_secret == plain_secret
108+
109+
110+
def test_get_value_called_multiple_times(monkeypatch):
111+
"""Test that get_value works consistently across multiple calls."""
112+
monkeypatch.setenv("TEST_SECRET", "secret_value")
113+
secret = Secret.from_env("TEST_SECRET")
114+
115+
assert secret.get_value() == "secret_value"
116+
assert secret.get_value() == "secret_value"
117+
118+
119+
def test_env_var_change_at_runtime(monkeypatch):
120+
"""Test that Secret picks up environment variable changes."""
121+
monkeypatch.setenv("DYNAMIC_SECRET", "initial_value")
122+
secret = Secret.from_env("DYNAMIC_SECRET")
123+
assert secret.get_value() == "initial_value"
124+
125+
monkeypatch.setenv("DYNAMIC_SECRET", "changed_value")
126+
assert secret.get_value() == "changed_value"
127+
128+
129+
def test_constructor_validation_with_empty_env_var(monkeypatch):
130+
"""Test constructor validation when env var exists but is empty."""
131+
monkeypatch.setenv("EMPTY_VAR", "")
132+
with pytest.raises(ValueError, match="Secret value must be a non-empty string"):
133+
Secret("$EMPTY_VAR")
134+
135+
136+
def test_from_plain_text_with_empty_string():
137+
"""Test that from_plain_text with empty string raises ValueError."""
138+
with pytest.raises(ValueError, match="Secret value must be a non-empty string"):
139+
Secret.from_plain_text("")

0 commit comments

Comments
 (0)