Skip to content

Commit 18a9014

Browse files
authored
Merge pull request #1 from rnag/v0.3.0-release
V0.3.0 release
2 parents dc26bc8 + 624b55a commit 18a9014

12 files changed

Lines changed: 190 additions & 97 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Publish package on main branch if it's tagged with 'v*'
22
# Ref: https://github.community/t/run-workflow-on-push-tag-on-specific-branch/17519
33

4-
name: build & release
4+
name: release
55

66
# Controls when the action will run.
77
on:

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
- name: Install dependencies
3232
run: |
3333
python -m pip install --upgrade pip
34-
pip install .[test]
34+
pip install .[local,test]
3535
- name: Lint with ruff
3636
run: |
3737
# stop the build if there are Python syntax errors or undefined names

.idea/inspectionProfiles/Project_Default.xml

Lines changed: 4 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/inspectionProfiles/profiles_settings.xml

Lines changed: 0 additions & 6 deletions
This file was deleted.

.idea/secrets-cache.iml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

HISTORY.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# History
22

3+
## 0.3.0 (2025-09-13)
4+
5+
* Fix so local caching via TOML works.
6+
* `get_secret()`: Add `force_refresh` and `raw` parameters
7+
* Attempt to `JSON.loads` secret string from AWS Secrets Manager by default.
8+
39
## 0.2.0 (2025-09-13)
410

511
* Add core caching logic.

README.md

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# Secrets Cache
22

3-
![PyPI version](https://img.shields.io/pypi/v/secrets-cache.svg)
3+
[![PyPI version](https://img.shields.io/pypi/v/secrets-cache.svg)](https://pypi.org/project/secrets-cache/)
4+
[![PyPI license](https://img.shields.io/pypi/l/secrets-cache.svg)](https://pypi.org/project/secrets-cache/)
5+
[![PyPI Python versions](https://img.shields.io/pypi/pyversions/secrets-cache.svg)](https://pypi.org/project/secrets-cache/)
6+
[![GitHub Actions](https://github.com/rnag/py-secrets-cache/actions/workflows/release.yml/badge.svg)](https://github.com/rnag/py-secrets-cache/actions/workflows/release.yml)
47
[![Documentation Status](https://readthedocs.org/projects/secrets-cache/badge/?version=latest)](https://secrets-cache.readthedocs.io/en/latest/?version=latest)
58

69
Cache secrets locally from AWS Secrets Manager and other secret stores, with optional local caching for development or Lambda-friendly usage.
@@ -17,40 +20,48 @@ Install the base package (minimal, Lambda-friendly):
1720
pip install secrets-cache[lambda]
1821
````
1922

20-
Install with **local cache support** (TOML) for testing / development:
23+
For local development or testing (with local TOML caching, AWS SDK):
2124

2225
```bash
2326
pip install secrets-cache[local]
2427
```
2528

26-
Install with CLI support:
29+
Optional CLI tools:
2730

2831
```bash
2932
pip install secrets-cache[cli]
3033
```
3134

32-
You can also combine extras:
35+
## Usage
3336

34-
```bash
35-
pip install "secrets-cache[local,cli]"
37+
### Fetch a secret from AWS Secrets Manager
38+
39+
```python
40+
from secrets_cache import get_secret
41+
42+
# Returns JSON-decoded dict if possible
43+
db_creds = get_secret("prod/AppBeta/MySQL")
44+
45+
# Returns raw string
46+
raw_value = get_secret("prod/AppBeta/MySQL", raw=True)
47+
48+
# Force refresh from AWS, ignoring cache
49+
fresh_value = get_secret("prod/AppBeta/MySQL", force_refresh=True)
3650
```
3751

38-
## Usage
52+
### Fetch a parameter from AWS SSM Parameter Store
3953

4054
```python
41-
from secrets_cache import get_secret, get_param
42-
43-
# Get a secret from AWS Secrets Manager
44-
my_secret = get_secret("my-secret-name", region="us-east-1")
55+
from secrets_cache import get_param
4556
46-
# Get a parameter from AWS SSM Parameter Store
47-
my_param = get_param("/my/parameter/name", region="us-east-1")
57+
api_url = get_param("prod/AppBeta/API_URL")
4858
```
4959

5060
**Notes:**
5161

52-
* By default, secrets are cached **in memory** to reduce repeated AWS calls.
53-
* If `local` extra is installed, secrets are also stored in `~/.secrets_cache.toml` for local caching.
62+
* Secrets and parameters are **cached in-memory** and optionally in a **local TOML file** (`~/.secrets_cache.toml`) for repeated calls.
63+
* Default cache TTL is **1 week** (configurable via `SECRETS_CACHE_TTL` environment variable).
64+
* AWS region defaults to `AWS_REGION` environment variable or `us-east-1`.
5465
* Module-level caches persist across **warm AWS Lambda invocations**, so repeated calls in the same container are very fast.
5566

5667
## Features

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "secrets-cache"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
description = "Cache secrets locally from AWS Secrets Manager and other secret stores."
55
readme = "README.md"
66
authors = [
@@ -38,9 +38,9 @@ cli = [
3838
'typer', # CLI / optional but handy
3939
]
4040
local = [
41-
'boto3>=1.26', # AWS SDK for fetching secrets
41+
'boto3>=1.26', # AWS SDK for fetching secrets
42+
'tomli-w>=1.0', # TOML writer for Python <3.11
4243
'tomli>=2.0; python_version<"3.11"', # TOML reader for Python <3.11
43-
'tomli-w>=1.0; python_version<"3.11"', # TOML writer for Python <3.11
4444
]
4545
lambda = [] # Lambda-friendly, skips boto3
4646
test = [

src/secrets_cache/_aws.py

Lines changed: 55 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,17 @@
1-
"""Main module."""
2-
3-
from pathlib import Path
1+
"""AWS module."""
2+
from json import loads
43

54
import boto3
5+
from botocore.exceptions import ClientError
66

77
from ._cache import cached_fetch
8-
9-
10-
# Optional: import TOML only if available
11-
try:
12-
import tomllib # Python 3.11+
13-
except ImportError:
14-
try:
15-
import tomli as tomllib # Python 3.10
16-
except ImportError:
17-
tomllib = None
18-
19-
try:
20-
import tomli_w
21-
except ImportError:
22-
tomli_w = None
23-
8+
from ._constants import DEFAULT_CACHE_TTL, DEFAULT_AWS_REGION
249

2510
# Module-level caches
26-
_secret_cache = {}
27-
_param_cache = {}
2811
_boto_clients = {}
2912

30-
# TOML cache file (optional)
31-
_CACHE_FILE = Path.home() / '.secrets_cache.toml'
3213

33-
34-
def get_boto_client(service: str, region: str = 'us-east-1'):
14+
def get_boto_client(service: str, region: str):
3515
"""Cache boto3 clients per service/region."""
3616
key = service, region
3717
_client = _boto_clients.get(key)
@@ -40,49 +20,65 @@ def get_boto_client(service: str, region: str = 'us-east-1'):
4020
return _client
4121

4222

43-
def _read_toml_cache():
44-
if not tomllib or not _CACHE_FILE.exists():
45-
return {}
46-
with _CACHE_FILE.open('rb') as f:
47-
return tomllib.load(f)
48-
49-
50-
def _write_toml_cache(data):
51-
if not tomli_w or not _CACHE_FILE.parent.exists():
52-
return
53-
with _CACHE_FILE.open('wb') as f:
54-
tomli_w.dump(data, f)
23+
def get_secret(name: str,
24+
region: str = DEFAULT_AWS_REGION,
25+
ttl: int = DEFAULT_CACHE_TTL,
26+
force_refresh: bool = False,
27+
raw: bool = False) -> str | bytes | dict:
28+
"""Get secret from AWS Secrets Manager with optional caching."""
29+
value = cached_fetch('secretsmanager', name, region, _fetch_secret, ttl, force_refresh)
5530

31+
if raw:
32+
return value
5633

57-
def get_secret(name: str, region: str = 'us-east-1', ttl: int = 7 * 24 * 3600):
58-
"""Get secret from AWS Secrets Manager with optional caching."""
59-
return cached_fetch(_secret_cache, name, region, _fetch_secret, ttl)
34+
# Try to parse JSON (most Secrets Manager use case)
35+
try:
36+
return loads(value)
37+
except (ValueError, TypeError):
38+
return value
6039

6140

62-
def get_param(name: str, region: str = 'us-east-1', ttl: int = 7 * 24 * 3600):
41+
def get_param(name: str,
42+
region: str = DEFAULT_AWS_REGION,
43+
ttl: int = DEFAULT_CACHE_TTL,
44+
force_refresh: bool = False) -> str:
6345
"""Get parameter from AWS SSM Parameter Store with optional caching."""
64-
return cached_fetch(_param_cache, name, region, _fetch_param, ttl)
46+
return cached_fetch('ssm', name, region, _fetch_param, ttl, force_refresh)
6547

6648

67-
def _fetch_secret(name: str, region: str = 'us-east-1'):
49+
def _fetch_secret(name: str, region: str) -> str | bytes | None:
6850
client = get_boto_client('secretsmanager', region)
69-
resp = client.get_secret_value(SecretId=name)
70-
value = resp['SecretString']
7151

72-
# Update local TOML cache if available
73-
data = _read_toml_cache()
74-
data[name] = value
75-
_write_toml_cache(data)
76-
return value
77-
78-
79-
def _fetch_param(name: str, region: str = 'us-east-1'):
52+
try:
53+
resp = client.get_secret_value(
54+
SecretId=name
55+
)
56+
except ClientError as e:
57+
# For a list of exceptions thrown, see
58+
# https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html
59+
err_code = e.response['Error']['Code']
60+
if err_code == 'ResourceNotFoundException':
61+
print(f'The requested secret {name} was not found')
62+
elif err_code == 'InvalidRequestException':
63+
print('The request was invalid due to:', e)
64+
elif err_code == 'InvalidParameterException':
65+
print('The request had invalid params:', e)
66+
elif err_code == 'DecryptionFailure':
67+
print('The requested secret can\'t be decrypted using the provided KMS key:', e)
68+
elif err_code == 'InternalServiceError':
69+
print('An error occurred on service side:', e)
70+
else:
71+
# Secrets Manager decrypts the secret value using the associated KMS CMK
72+
# Depending on whether the secret was a string or binary, only one of these fields will be populated
73+
if (secret := resp.get('SecretString')) is not None:
74+
return secret
75+
return resp['SecretBinary']
76+
77+
78+
def _fetch_param(name: str, region: str):
8079
client = get_boto_client('ssm', region)
80+
8181
resp = client.get_parameter(Name=name, WithDecryption=True)
82-
value = resp['Parameter']['Value']
8382

84-
# Update local TOML cache if available
85-
data = _read_toml_cache()
86-
data[name] = value
87-
_write_toml_cache(data)
88-
return value
83+
param = resp['Parameter']['Value']
84+
return param

src/secrets_cache/_cache.py

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,90 @@
1+
"""Caching module."""
2+
from pathlib import Path
13
from time import time
24

35

4-
def cached_fetch(cache: dict, key: str, region: str, fetcher, ttl: int):
5-
"""Fetch from cache or call fetcher."""
6-
now = time()
7-
entry = cache.get(key)
6+
# TOML cache file (optional)
7+
_CACHE_FILE = Path.home() / '.secrets_cache.toml'
8+
9+
10+
# Lazy-loaded cache
11+
_cache = None
12+
13+
14+
# Optional: import TOML only if available
15+
try:
16+
import tomllib # Python 3.11+
17+
except ImportError:
18+
try:
19+
# noinspection SpellCheckingInspection
20+
import tomli as tomllib # Python 3.10
21+
except ImportError:
22+
# noinspection SpellCheckingInspection
23+
tomllib = None
24+
25+
try:
26+
import tomli_w
27+
except ImportError:
28+
tomli_w = None
29+
30+
31+
def _load_cache() -> dict:
32+
global _cache
33+
34+
if _cache is None:
35+
if tomllib and _CACHE_FILE.exists():
36+
with _CACHE_FILE.open('rb') as f:
37+
_cache = tomllib.load(f)
38+
else:
39+
_cache = {}
40+
41+
return _cache
42+
43+
44+
def _save_cache() -> None:
45+
if (_cache is None
46+
or not tomli_w
47+
or not _CACHE_FILE.parent.exists()):
48+
return
49+
50+
with _CACHE_FILE.open('wb') as f:
51+
tomli_w.dump(_cache, f) # type: ignore
52+
53+
54+
def get_cached_value(service: str, name: str, now: float, ttl: int):
55+
data = _load_cache()
56+
service_cache = data.get(service, {})
57+
entry = service_cache.get(name)
58+
859
if entry and (now - entry['fetched_at'] < ttl):
960
return entry['value']
1061

62+
return None
63+
64+
65+
def update_cache(service: str, name: str, value: str, now: float):
66+
data = _load_cache()
67+
final_value = {'value': value, 'fetched_at': now}
68+
69+
service_cache = data.get(service)
70+
71+
if service_cache is None:
72+
data[service] = {name: final_value}
73+
else:
74+
service_cache[name] = final_value
75+
76+
_save_cache()
77+
78+
79+
def cached_fetch(service: str, key: str, region: str, fetcher, ttl: int, force_refresh: bool):
80+
"""Fetch from cache or call fetcher."""
81+
now = int(time())
82+
83+
if not force_refresh:
84+
if (value := get_cached_value(service, key, now, ttl)) is not None:
85+
return value
86+
1187
value = fetcher(key, region)
12-
cache[key] = {'value': value, 'fetched_at': now}
88+
if value is not None:
89+
update_cache(service, key, value, now)
1390
return value

0 commit comments

Comments
 (0)