Re-usable code for Python 3 projects.
Supported Python versions: >=3.14.10,<3.16.
uv add python3-commonsSome features require extra dependencies. You can install them individually or all at once:
api-client: Forpython3_commons.api_clientaudit: Forpython3_commons.zeep_audit(ZeepAuditPlugin; installszeep,lxmland theobject-storageextra)authn: Forpython3_commons.authauthz: Forpython3_commons.permissionscache: Forpython3_commons.cachedatabase: Forpython3_commons.dbobject-storage: Forpython3_commons.object_storagesoap-client: Forpython3_commons.soap_clientall: Install all optional dependencies
uv add "python3-commons[all]"An LRU cache decorator for asynchronous functions that handles await and prevents the dogpile effect (mitigating multiple concurrent calls for the same key by waiting for the first one to finish).
from python3_commons.async_functools import async_lru_cache
@async_lru_cache(maxsize=128)
async def fetch_expensive_data(item_id: int):
# This function will only be called once for a given item_id
# even if multiple tasks await it simultaneously.
return await db.get(item_id)
# Usage
result = await fetch_expensive_data(42)An async request() context manager for aiohttp with standardized error mapping to Python exceptions and optional audit logging to Object Storage.
For GET requests, HTTP error status codes and network failures are mapped to built-in Python exceptions (until 1.0, the status mapping applies to GET only: responses to other methods are yielded as they are, whatever the status, so check response.status yourself; network failures are mapped for every method):
400 Bad Request->ValueError401 Unauthorized,403 Forbidden->PermissionError404 Not Found->LookupError429 Too Many Requests->InterruptedError- Other 4xx/5xx responses ->
aiohttp.ClientResponseError - Connection errors ->
ConnectionRefusedError/ConnectionResetError
Audit logging captures the outgoing request (formatted as an executable curl command) and the incoming response body, writing them asynchronously to Object Storage.
Audit data is written only under ApiClientSettings.audit_path (API_CLIENT_AUDIT_PATH), a full object storage path such as s3://bucket/audit. A value without a scheme is a local filesystem path (~ is expanded, a relative path resolves against the working directory) and is normalised to a file:// URL:
<audit_path>/<YYYY/MM/DD>/<audit_name>/<uri>/<method>_<timestamp>_<request_id>_{request,response}.txt
OBJECT_STORAGE_WORK_PATH is not used for audit. Without API_CLIENT_AUDIT_PATH, audit is disabled.
To enable audit logging:
-
Configure Object Storage & S3 credentials:
# API client audit path (full object storage path) export API_CLIENT_AUDIT_PATH="s3://bucket/audit" # S3 credentials export S3_ACCESS_KEY_ID="your-access-key-id" export S3_SECRET_ACCESS_KEY="your-secret-access-key" export S3_REGION="us-east-1" # Optional configurations: # export S3_ENDPOINT="http://localhost:3900" # S3-compatible store; an http:// endpoint enables plain HTTP # export S3_ADDRESSING_STYLE="virtual" # virtual-hosted addressing (default: path); AWS_VIRTUAL_HOSTED_STYLE_REQUEST applies when it is unset # export AWS_ALLOW_INVALID_CERTIFICATES="true" # skip TLS certificate verification # export S3_ALLOW_HTTP="true" # allow plain HTTP (only the exact value "true" is honoured)
Credentials and storage options are read from the process environment by object-storage-client, not from
s3_settings(no code path in python3-commons readsS3Settings).object_storage.get_client()builds a profile-lessObjectStorageClient(), which signs with these variables (AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYalso work, andAWS_REGIONandAWS_ENDPOINTcan stand in forS3_REGIONandS3_ENDPOINT). See its configuration docs for the full list (GCS and Azure included).ObjectStorageClient(profile="NAME")switchess3://to profile-only credentials: the named AWS profile is then the only source,S3_ACCESS_KEY_ID/AWS_ACCESS_KEY_IDare not used andAWS_PROFILEis not read. See AWS profiles and SSO.python3_commons.object_storagealways uses the profile-less client.The audit path can also be configured directly in Python:
from python3_commons.conf import get_api_client_settings get_api_client_settings().audit_path = "s3://bucket/audit"
Audit data is stored raw: the records hold the request and response as sent, including
Authorizationheaders, cookies, OIDC passwords, client secrets and tokens. To encrypt them at rest, setOSC_KEYRING_FILE, see Encrypting object storage.Audit keys are built without empty path segments: repeated or leading slashes are dropped, so
a//bis stored asa/b.Audit is best-effort and bounded: a storage failure or a hang never fails or stalls the request. Each write is limited to
API_CLIENT_AUDIT_TIMEOUTseconds (default5;request(audit_timeout=...)overrides it per call), and a dropped record logs one WARNING, at most once per 60 seconds per process, with the number of suppressed ones. Response bodies are stored as received (raw bytes), capped atAPI_CLIENT_AUDIT_MAX_BODY_BYTES(default1048576) with a truncation marker. Key segments built from the URI path andaudit_nameare percent-quoted (only-_.~stay as they are),.and..become_, and each segment is cut at 200 characters; the query string and fragment are not part of the key. -
Pass
audit_nametorequest(): Provide a non-empty string identifier (such as the target service or operation name) in theaudit_nameargument. If omitted (defaultNone), audit logging is disabled for that call. If storage is not configured, audit logging is safely bypassed. Every request withaudit_namewrites a request record (acurlcommand), including plainGETs, and a response record unless the body is empty.
request() takes an aiohttp.ClientSession that you manage:
from aiohttp import ClientSession
from python3_commons.api_client import request
async with ClientSession() as session:
# 1. GET request with audit logging enabled
async with request(
session,
base_url="https://api.example.com",
uri="/users/123",
method="get",
headers={"Accept": "application/json"},
audit_name="user_service",
) as response:
user = await response.json()
# 2. POST request with JSON body and audit logging
async with request(
session,
base_url="https://api.example.com",
uri="/users",
method="post",
json={"name": "Alice", "role": "admin"},
audit_name="user_service",
) as response:
result = await response.json()
# 3. Request without audit logging (audit_name omitted)
async with request(
session,
base_url="https://api.example.com",
uri="/health",
method="get",
) as response:
status = await response.json()Async SOAP client support for zeep (extra soap-client), with optional S3 auditing of the SOAP envelopes.
from python3_commons.soap_client import soap_client
async with soap_client("https://example.com/service?wsdl") as client:
result = await client.service.GetData(id=42)ZeepAuditPlugin (module python3_commons.zeep_audit, extra audit) is a zeep plugin that writes every
request and response envelope under API_CLIENT_AUDIT_PATH, the same setting as the API client audit
(see Enabling Audit Logging); without it, nothing is stored. Pass it through plugins:
from python3_commons.soap_client import soap_client
from python3_commons.zeep_audit import ZeepAuditPlugin
async with soap_client("https://example.com/service?wsdl", plugins=[ZeepAuditPlugin("billing")]) as client:
result = await client.service.GetData(id=42)Envelopes are stored as <audit_path>/<YYYY/MM/DD>/<audit_name>/<operation>/<time>_<id>_{egress,ingress}.xml.
get_history_plugin(client) returns the zeep HistoryPlugin attached to a client, or None.
Importing python3_commons.audit or python3_commons.api_client does not load zeep or lxml; only
python3_commons.zeep_audit does.
SQLAlchemy async engine and session management with pool tuning, health checks, dynamic query builders, and declarative base models.
from python3_commons.db import AsyncSessionManager, is_healthy
from python3_commons.db.helpers import get_query
from python3_commons.conf import DBSettings
# Configuration
configs = {
"default": DBSettings(
dsn="postgresql+asyncpg://user:pass@localhost/db",
pool_size=20,
pool_timeout=30,
statement_timeout=30,
)
}
manager = AsyncSessionManager(configs)
# Usage
async with manager.get_session_context("default") as session:
result = await session.execute(...)
# Engine health check (executes SELECT 1 with timeout)
healthy = await is_healthy(manager.get_engine("default"))
# Dynamic query filtering and sorting
where_clause, order_clauses = get_query(
search={"name": "Alice"},
order_by="-created_at,name",
columns={
# name: (column, case_insensitive, cast_func, exact_match)
"name": (User.name, True, str, False),
"created_at": (User.created_at, False, str, True),
},
)Thin wrapper for object-storage-client supporting S3, GCS, Azure, and local file storage. Credentials and storage options come from the process environment, read by object-storage-client (see Enabling Audit Logging for the S3 variables and its configuration docs). Storage functions accept a required work_path parameter to define the base storage location. object_storage_settings (OBJECT_STORAGE_WORK_PATH) is the setting for that location: your code reads it and passes it as work_path; the module functions do not read it themselves.
from python3_commons import object_storage
import io
# Upload an object (bytes, any buffer, or io.BytesIO)
await object_storage.put_object(work_path="s3://my-bucket", path="uploads/file.txt", data=io.BytesIO(b"Hello World"))
# Download an object as bytes
content = await object_storage.get_object("s3://my-bucket", "uploads/file.txt")
# Stream an object
async with object_storage.get_object_stream("s3://my-bucket", "uploads/file.txt") as stream:
data = await stream.read()
# List objects with a prefix
async for obj in object_storage.list_objects("s3://my-bucket", "uploads/"):
print(obj['Key'])
# Delete single or multiple objects
await object_storage.remove_object("s3://my-bucket", "uploads/file.txt")
await object_storage.remove_objects("s3://my-bucket", object_names=["uploads/file1.txt", "uploads/file2.txt"])ObjectStorage binds one normalised root and an optional client, and follows the 1.0 rules already: keys must be
relative (no absolute URLs, . or .. segments), there is no prefix deduplication, and an empty remove_objects
prefix needs remove_all=True. Errors are raised with an object storage url: ... note and are not logged.
from python3_commons.object_storage import ObjectStorage
storage = ObjectStorage("s3://my-bucket/uploads") # or ObjectStorage(root, client=...), ObjectStorage(root, profile="dev")
await storage.put_object("file.txt", b"Hello World")
content = await storage.get_object("file.txt")The module functions and ObjectStorage() share object_storage.default_client(); tests can swap it with
object_storage.set_default_client(fake) and restore it with set_default_client(None).
Set OSC_KEYRING_FILE to the path of a keyring file and everything python3-commons writes to object storage is
encrypted at rest, audit records included (one keyring for all of it). Nothing else needs configuring, and without the
variable nothing changes.
OSC_KEYRING_FILE is the variable of the osc CLI of object-storage-client.
The Python binding does not read it, so object_storage.get_client() does: it reads the variable when the client is
first used (never at import) and returns an EncryptedObjectStorageClient. One variable therefore turns the keyring on
for both osc and the application, so setting it for osc in an application's environment also switches on
encryption in the application. OSC_KEYRING_COMMAND (like OSC_REQUIRE_ENCRYPTION) is read by the osc CLI only and
is not supported by python3-commons: a process that sets only OSC_KEYRING_COMMAND writes plaintext.
Create a keyring with the osc CLI (installed with cargo, it is not part of the wheel):
umask 077
osc keyring new > keyring
export OSC_KEYRING_FILE="$PWD/keyring"On Unix the file must not be writable by its group, must not be accessible to others, and must be owned by the
running user (or root). Group read is allowed and the file may be a symlink. For Kubernetes Secret volumes, see the
Kubernetes section of object-storage-client's
docs/ENCRYPTION.md
(defaultMode: 0440 with fsGroup, and a whole-volume mount, never subPath), and its "Core dumps" section for any
process that holds a keyring.
What to expect:
- Data is written unchanged, credentials included: audit records hold the raw request and response,
Authorizationheaders, cookies, OIDC passwords, client secrets and tokens, by design. The keyring is what protects them. - A keyring that cannot be loaded makes
put_objectraiseEncryptionError. Audit drops the record with its rate-limited WARNING and the request goes on; the record is never written in plaintext. The failure is not cached: fix the file and the next call works, without a restart. An application that wants to refuse to start with a bad keyring callsobject_storage.get_client()in its lifespan, in the worker and not before a fork. - With the keyring set,
get_client()returns anEncryptedObjectStorageClient. It has no pre-signed URL methods; use itswithout_encryption()for those. - Object names are not encrypted (for audit records: the date,
audit_name, URI, method and timestamp). list_objectsreports the stored (ciphertext) size of an encrypted object asSize.- Copy or move encrypted objects only through an encrypting client or
osc --keyring. The header names the object's path, so a copy re-encrypts, the plain client refuses to copy an encrypted object to another name, and afile://object moved withmvbecomes undecryptable.
Reads always accept plaintext. When an object is not encrypted (written before the keyring was set, for example),
get_object and get_object_stream log one WARNING naming the object (object storage: <url> is not encrypted; read as plaintext) and read it through without_encryption(), and get_object_streams and get_objects do the same.
That costs a second request. UnknownKeyError and DecryptionError are not retried and propagate. For a stream only
the first read falls back; a NotEncryptedError on a later read propagates.
Security consequence: with the keyring on, a planted plaintext object is accepted, with a WARNING. Anyone who can write
to the store can put a plaintext object under a name python3-commons reads, or replace an encrypted one, and it is read
as genuine. An encrypted object whose first three bytes are damaged is also read through the plain view, and its raw
stored bytes are returned. The keyring protects the confidentiality of what python3-commons writes, not the
authenticity of what it reads. A caller that needs authenticity reads through its own
ObjectStorageClient().with_encryption(Keyring.from_file(path)), which raises instead.
There is no read-back helper for audit records. Read them with osc --keyring keyring get ... or with your own
encrypting client. UnknownKeyError "may also indicate tampering": it is derived from unauthenticated header data.
No code and no restart: the keyring reloads its file within about 5 seconds. Run this under umask 077; osc keyring add and osc keyring activate read the current keyring on stdin and write the new one to stdout.
osc keyring add < keyring > keyring.new && mv keyring.new keyring
# wait until every reader has the new key (each reader's Keyring.status() lists it)
osc keyring activate <new key ID> < keyring > keyring.new && mv keyring.new keyring
osc --keyring keyring enc reencrypt <root>/ --from <old key ID> # for every root, see belowRemove an old key only after osc enc scan shows that no object uses it; otherwise reads raise UnknownKeyError.
Rotate long before about 2**32 wraps per key (Keyring.wraps). A different OSC_KEYRING_FILE value needs a restart.
<root>/ is the normalised root plus a trailing slash, for every root python3-commons writes under: the audit root and
each work_path the application uses. osc takes store URLs, and python3-commons normalises every root to one: a
value without a scheme is a local path and becomes a file:// URL (a relative path resolves against the working
directory). This prints the normalised form (file:///var/audit/ here, s3://bucket/audit/ for an s3:// root):
python -c "from python3_commons.audit import normalize_audit_root as n; print(n('/var//audit/') + '/')"Client for OpenID Connect authentication, supporting configuration fetching, JWKS, and token acquisition.
Create one client per event loop in your application lifespan, use it directly, and close it with aclose() on shutdown. The client owns one aiohttp session for its whole lifetime, so connections are reused.
from python3_commons.auth import OIDCClient
from pydantic import HttpUrl
client = OIDCClient(
authority_url=HttpUrl("https://auth.example.com/realms/myrealm"),
client_id="my-app-client",
client_secret="secret"
)
# in the application lifespan
token_response = await client.fetch_token(username="user", password="password")
print(token_response.access_token)
# on shutdown (idempotent)
await client.aclose()async with client: around each call still works and is safe to share between concurrent tasks: the session is reference-counted, so it closes only when the last task has left and no call is in flight, and a task that leaves never cuts off another task's request. Each time concurrency drops to zero the session is closed and the next use builds a new one, so the per-call pattern pays for a new session on every sequential request; the lifespan pattern avoids that.
async with client:
jwks = await client.get_jwks()Pass session= to use your own aiohttp.ClientSession; the client then never closes it. A client that is idle can move to another event loop (for example a module-level instance across test loops) and rebuilds its loop-bound state; using it from a second loop while it is busy raises RuntimeError('OIDCClient is in use on another event loop').
Async caching using Valkey (Redis-compatible) with automatic Msgpack serialization for complex types. Configure the connection with VALKEY_DSN (or VALKEY_SENTINEL_DSN for Sentinel).
from python3_commons import cache
# Store any serializable value (msgspec Structs, Pydantic models, dicts, ...)
await cache.store("user:123", {"name": "Alice", "role": "admin"}, ttl=3600)
user_data = await cache.get("user:123", default={})
# Decode into a typed object
user = await cache.get("user:123", data_type=User)
# Hashes: whole dict or individual fields
await cache.store_dict("settings:123", {"theme": "dark", "page_size": 50}, ttl=3600)
settings = await cache.get_dict("settings:123")
await cache.set_dict_item("settings:123", "theme", "light")
theme = await cache.get_dict_item("settings:123", "theme", default="dark")
# Lists and sets
await cache.store_sequence("recent:123", ["a", "b"], ttl=600)
recent = await cache.get_sequence("recent:123")
await cache.add_set_item("active_users", "user:123")
is_active = await cache.has_set_item("active_users", "user:123")ValkeyCache holds one client and exposes the same commands as methods, already under the 1.0 contract: writes
return bool and deletes return int, collection writes (store_sequence, store_dict, store_set) replace the
key (use append_sequence / update_dict to merge), a non-positive ttl raises ValueError, and with
fail_open=True (the default) only transient errors (connection, timeout, server loading) are swallowed.
from python3_commons.cache import ValkeyCache, close_default_cache, get_default_cache
async with ValkeyCache.from_settings() as vcache: # reads VALKEY_* through get_valkey_settings()
await vcache.store("user:123", {"name": "Alice"}, ttl=3600)
# or one cache per event loop, created on first use and closed in the lifespan
vcache = get_default_cache()
...
await close_default_cache()VALKEY_SOCKET_TIMEOUT (60), VALKEY_SOCKET_CONNECT_TIMEOUT (10), VALKEY_HEALTH_CHECK_INTERVAL (30),
VALKEY_RETRIES (5), VALKEY_MAX_CONNECTIONS (unbounded; a number gives a blocking pool) and VALKEY_POOL_TIMEOUT
(20) tune the connection; VALKEY_SENTINELS, VALKEY_SENTINEL_SERVICE_NAME (myprimary) and
VALKEY_SENTINEL_PASSWORD configure Sentinel. The module functions above keep their 0.29.x behaviour until 1.0;
get_sequence(..., _type=...) is deprecated in favour of container=.
The cache never takes your application down. If Valkey is unreachable or times out, every function logs the error and carries on:
- Reads return their fallback:
get()andget_dict_item()returndefault,get_sequence()returns an empty sequence,has_set_item()andexists()returnFalse, and the rest returnNone. - Writes and deletes do nothing.
store_bytes(..., if_not_set=True)returnsNone, so treat a falsy result as "not set".
A JSONFormatter for structured JSON logging and a filter_maker utility for level-based log filtering, compatible with standard Python logging.
import logging
from python3_commons.log.formatters import JSONFormatter
from python3_commons.log.filters import filter_maker
handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())
# Filter to process logs up to INFO level only
handler.addFilter(filter_maker("INFO"))
logging.getLogger().addHandler(handler)
logger = logging.getLogger("app")
logger.info("User logged in", extra={"user_id": "abc-123", "ip": "1.2.3.4"})Enhanced JSON and Msgpack serialization for types not handled by default (Decimal, datetime, date, dataclasses, Pydantic models).
from python3_commons.serializers.msgspec import serialize_msgpack, deserialize_msgpack
from decimal import Decimal
from datetime import datetime
data = {
"amount": Decimal("150.75"),
"timestamp": datetime.now(),
"tags": {"finance", "internal"}
}
# Serialize to Msgpack
binary = serialize_msgpack(data)
# Deserialize back
restored = deserialize_msgpack(binary)Without data_type, decoding is untyped: the Decimal and datetime above come back as str and the set as a
list. Pass the type to get the original values back:
from dataclasses import dataclass
@dataclass
class Payment:
amount: Decimal
timestamp: datetime
tags: set[str]
binary = serialize_msgpack(Payment(Decimal("150.75"), datetime.now(), {"finance"}))
payment = deserialize_msgpack(binary, data_type=Payment) # Decimal, datetime and set againDatabase-backed Role-Based Access Control (RBAC) permission checking for users and API keys.
from python3_commons.permissions import has_user_permission, has_api_key_permission
from uuid import UUID
user_uuid = UUID("...")
allowed = await has_user_permission(session, user_uuid, "reports.view")
api_key_uuid = UUID("...")
key_allowed = await has_api_key_permission(session, api_key_uuid, "reports.view")Typed application settings powered by Pydantic Settings:
Each settings class has a cached getter (get_db_settings() and so on), so importing python3-commons reads no environment variables; call the getters you need in your application lifespan to fail fast on bad settings. The old module attributes (settings, db_settings, valkey_settings, s3_settings, api_client_settings, object_storage_settings) still work with a DeprecationWarning and are removed in 1.0. get_oidc_settings() returns a shared OIDCSettings.
CommonSettings/get_common_settings(): General logging level and format.DBSettings/get_db_settings(): PostgreSQL database settings (DB_environment prefix, withDB_PASSalias for password).S3Settings/get_s3_settings(): legacy S3 settings (S3_environment prefix). Nothing in python3-commons reads them to configure object storage:object-storage-clientreadsS3_*/AWS_*from the environment itself.ObjectStorageSettings/get_object_storage_settings(): Object storage work path (OBJECT_STORAGE_environment prefix:WORK_PATH, normalised to a URL likes3://bucket/data; a value without a scheme becomes afile://URL, a blank oneNone) for your code to read and pass aswork_path; the module functions do not read it.ApiClientSettings/get_api_client_settings(): API client configuration (API_CLIENT_environment prefix:AUDIT_PATHfull object storage path likes3://bucket/audit).OIDCSettings: OpenID Connect configuration (OIDC_environment prefix), including the checks of tokens by verifiers built on it:AUDIENCE,ISSUER(expectediss, not checked when empty),LEEWAY(clock skew forexpandnbf, seconds, default 0),JWKS_REFRESH_INTERVAL(min seconds between JWKS refreshes on a token of an unknown key, default 60).ValkeySettings/get_valkey_settings(): Valkey/Redis cache configuration (VALKEY_environment prefix).
File system traversal helpers:
from python3_commons.fs import iter_files
from pathlib import Path
# Recursively iterate files, ignoring hidden directories
for file_path in iter_files(Path("./data"), recursive=True):
print(file_path)A collection of useful utility functions:
to_snake_case(text): Converts strings to snake_case.round_decimal(value, places): RoundsDecimalvalues.tries(n): An async retry decorator.log_execution_time: An async decorator to log how long a function takes.date_from_string/datetime_from_string: Flexible date/time parsing.date_range(start, end): Generator yielding dates in a range.request_to_curl: Converts request parameters to acurlcommand string.SingletonMeta: Thread-safe singleton metaclass.replace_origin(url, host_url): Replaces scheme, host, and port of a URL.parse_string_list: Parses comma-separated string lists into tuples.
from python3_commons.helpers import tries, log_execution_time, SingletonMeta
@tries(3)
@log_execution_time
async def flaky_network_call():
...
class AppService(metaclass=SingletonMeta):
passEfficiently generate CSV data as a byte stream from an async generator of tuples.
from python3_commons.generators import tuple_csv_stream
async def generate_rows():
for i in range(1000):
yield (i, f"Name {i}", 10.5 * i)
async for chunk in tuple_csv_stream(generate_rows(), header=("ID", "Name", "Value")):
# Send chunk to HTTP response or write to file
passThe integration tests (tests/integration, marker integration) are run locally; there is no CI job for them. The
Valkey and PostgreSQL tests start containers with testcontainers, through podman:
systemctl --user start podman.socket
REQUIRE_CONTAINERS=1 uv run pytest -m integration --no-cov -p no:cacheproviderDOCKER_HOSTdefaults to$XDG_RUNTIME_DIR/podman/podman.sockwhen that socket exists; set it to use another runtime.- Without
REQUIRE_CONTAINERS=1a test whose container cannot start is skipped. With it the test fails, so a green run proves the containers ran. - The encryption and encrypted-audit tests need the
oscCLI onPATHto make a keyring, and are skipped without it:CARGO_REGISTRIES_BIXORITY_INDEX="sparse+https://codeberg.org/api/packages/bixority/cargo/" cargo install object-storage-client --registry bixority. tests/integration/test_auth.pytalks to a real OIDC provider (Cognito) and is skipped unlessTEST_OIDC_AUTHORITY_URLis set (also underREQUIRE_CONTAINERS=1). It readsTEST_OIDC_CLIENT_ID,TEST_OIDC_CLIENT_SECRET,TEST_OIDC_USERNAMEandTEST_OIDC_PASSWORD.