stapel-core
The Django substrate every Stapel module sits on: comm (Action/Function/Task/Signal/Projection inter-module communication over a transactional outbox), the transport-agnostic bus, AppSettings namespaces, step-up verification, self-documenting flows, i18n catalogs, the media/netintel/eventstore/captcha/secrets seams, the privilege gateway, the staff mandate, DRF API conventions (StapelResponse, error registry, permission classes, presenters, the serializer seam and thin-view base) and the URL-mount + cross-service navigation registries. No HTTP surface of its own worth cataloguing and no CTO-facing feature axes — the core is what the feature modules are made of.
Part of the Stapel framework — composable Django apps that deploy as a monolith or as microservices without changing module code.
Install
pip install stapel-core
At a glance
| Fact | Value |
|---|---|
| Version | 0.55.0 |
| Python | >=3.11 (3.11, 3.12, 3.13, 3.14) |
| Django | Django>=5.1,<6.1 |
| Usage surface | 53 |
| Extension points | 17 |
| Error codes | 42 |
Documentation
capabilities.json · llms.txt (for agents)
Quick start for a new Django service
Add to INSTALLED_APPS:
INSTALLED_APPS = [
...
'stapel_core.django',
'stapel_core.django.users', # if using the shared User model
]
Modules
stapel_core.captcha — Pluggable captcha verification
Backend-agnostic captcha interface. Supports Cloudflare Turnstile, Google reCAPTCHA v2, hCaptcha, and custom backends.
Settings (per service, in settings/base.py):
STAPEL_CAPTCHA = {
'BACKEND': env.str('CAPTCHA_BACKEND', 'turnstile'),
'SECRET': env.str('CAPTCHA_SECRET', None), # absent → disabled
}
Auto-disable: if the secret is None or empty, build_verifier
returns NoopVerifier regardless of backend. No separate toggle needed.
DRF integration (add mixin to any serializer):
from stapel_core.django.captcha import CaptchaMixin
class MySerializer(CaptchaMixin, serializers.Serializer):
captcha_token = serializers.CharField(required=False, allow_blank=True)
def validate(self, attrs):
self._require_captcha_if_configured(attrs)
return attrs
Custom backend — subclass CaptchaVerifier and point to it via a dotted
import path:
from stapel_core.captcha import CaptchaVerifier
class MyCaptchaVerifier(CaptchaVerifier):
def verify(self, token: str, ip: str | None = None, *, level: str | None = None) -> bool:
return my_service.check(token, self.secret)
# settings.py
STAPEL_CAPTCHA = {'BACKEND': 'myapp.captcha.MyCaptchaVerifier', 'SECRET': 'my-secret'}
Tiered challenge policy — instead of a binary on/off, protect a view with
a strictness level derived from the client's network class (via
stapel_core.netintel):
from stapel_core.django.captcha import captcha_protected
class RegisterView(APIView):
@captcha_protected(action="register")
def post(self, request): ...
Levels: none < invisible < interactive < interactive+ratelimit < block.
The default matrix (overridable via STAPEL_CAPTCHA["CHALLENGE_MATRIX"],
merged over the defaults) maps residential/unknown → invisible,
datacenter/vpn → interactive, tor → interactive+ratelimit. Per-action
overrides: STAPEL_CAPTCHA["ACTION_OVERRIDES"] = {"register": "+1"} (bump one
level) or {"payout": {"vpn": "block"}}. The whole policy is swappable via
STAPEL_CAPTCHA["CHALLENGE_POLICY"] (dotted path to a ChallengePolicy).
block returns 403 error.403.network_blocked; rate limiting is not done
here — middleware reads request.stapel_challenge_level. With no netintel
provider configured every request classifies as unknown → behavior is
identical to the classic binary captcha.
stapel_core.netintel — IP intelligence (network class + geo)
classify_ip(ip) -> IpProfile{kind, asn, asn_org, country, confidence} and
country_of(ip). Kind vocabulary: residential | datacenter | vpn | tor | unknown. Results are cached in the Django cache; provider errors fail open
to unknown and never raise.
STAPEL_NETINTEL = {
# dotted path / class / instance of a NetIntelProvider (replace seam)
"PROVIDER": "stapel_core.netintel.providers.MaxMindProvider",
"MAXMIND_ASN_DB": "/var/geoip/GeoLite2-ASN.mmdb",
"MAXMIND_COUNTRY_DB": "/var/geoip/GeoLite2-Country.mmdb",
"MAXMIND_ANONYMOUS_DB": "/var/geoip/GeoIP2-Anonymous-IP.mmdb",
}
Built-in providers: NullProvider (default — always unknown),
MaxMindProvider (offline mmdb, pip install stapel-core[netintel-maxmind]),
HttpJsonProvider (ipinfo/IPQS-style HTTP APIs via HTTP_URL_TEMPLATE /
HTTP_API_KEY / HTTP_RESPONSE_MAPPER). client_ip(request) honors
TRUSTED_PROXY_HEADER (default: REMOTE_ADDR only — proxy headers are
spoofable unless your edge overwrites them).
residential is a claim that requires evidence, and MaxMindProvider has
exactly one source of it: the Anonymous-IP database consulted and not listing
the address. Configure MAXMIND_ANONYMOUS_DB or the kind stays unknown
with confidence=None — a known ASN is not evidence of a residence, and the
HOSTING_ASNS fallback list can promote an address to datacenter but never
demote one to residential. asn/asn_org/country still travel with an
unknown profile.
System checks (W-level, never blocking): stapel_core.netintel.W001
(PROVIDER unimportable), W002 (not a NetIntelProvider), W003 (the
seam is configured or depended on, but PROVIDER is still the default
NullProvider, so every rule keyed on network class is dead code).
stapel_core.verification — Step-up verification
Attach an OTP/TOTP/passkey requirement to any endpoint without baking factor logic into it. Without a fresh grant the request is refused with 403 and a structured challenge envelope; any one of the listed factors completes it.
from stapel_core.verification import requires_verification
class PayoutView(APIView):
@requires_verification(scope="payout", factors=["otp_email", "totp"], max_age=300)
def post(self, request): ...
Challenges, grants and stateless tokens live in a fleet-wide cache
namespace (STAPEL_VERIFICATION["GRANT_NAMESPACE"]), not the service's own
KEY_PREFIX, so a step-up completed in one service counts in the peer that
demanded it.
Every record has a public verb that removes it again — and each one reports
what it did instead of returning None:
| Verb | Removes | Returns |
|---|---|---|
drop_challenge(challenge_id) |
the challenge | DropReport |
drop_verification_token(token) |
one stateless verification token | DropReport |
revoke_grants(user_id, scopes) |
that user's grants | list[DropReport] |
from stapel_core.verification import drop_challenge
assert drop_challenge(challenge["challenge_id"]) # truthy only if DROPPED
The report's outcome is DROPPED, NOT_FOUND, STILL_PRESENT or
UNAVAILABLE, and everything but DROPPED is logged with the namespace.
Never delete these keys through django.core.cache.cache: it computes a
different key, removes nothing, and cannot tell you so — the mistake that
killed a consumer release before 0.46.0. Note what that call actually returns
there: False, not None. It was never the absence of a return value that hid
the defect; the return was a truthful answer about a key the module never
writes, which is no evidence about the record you meant.
Since 0.47.0 every removal in the package speaks this one vocabulary
(stapel_core.core.drop), measured the same way — read, delete, read back:
lift_tombstone, unblacklist_user, TokenBlacklist.remove_from_blacklist /
clear_all, OneTimeCodeStore.discard / unblock,
invalidate_mandate_cache, invalidate_policy_cache and
invalidate_membership_cache. The four that returned True for "the call did
not raise" no longer do; the costliest of them, lift_tombstone, is called by
an operator restoring a wrongly deleted user.
stapel_core.django.jwt — JWT authentication
Unified JWT provider (singleton). Supports HS256 and RS256.
from stapel_core.django.jwt.provider import jwt_provider
access, refresh = jwt_provider.create_tokens(user)
payload = jwt_provider.validate_token(access_token)
Settings:
JWT_ALGORITHM = 'HS256' # or 'RS256'
JWT_SECRET_KEY = 'your-secret' # HS256
JWT_PRIVATE_KEY = '...' # RS256
JWT_PUBLIC_KEY = '...' # RS256
JWT_ISSUER = 'https://yourapp.com'
JWT_AUDIENCE = None
JWT_ACCESS_TOKEN_LIFETIME = 900 # seconds
JWT_REFRESH_TOKEN_LIFETIME = 604800 # seconds
stapel_core.django.jwt.authentication — JWT cookie auth
JWTCookieAuthentication reads JWT from access_token cookie or
Authorization: Bearer <token> header.
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'stapel_core.django.jwt.authentication.JWTCookieAuthentication',
],
}
stapel_core.django.api — DRF utilities
| Symbol | Purpose |
|---|---|
StapelDataclassSerializer |
Serializer that maps @dataclass fields |
StapelResponse(serializer) |
Wraps .data automatically |
StapelErrorResponse(status, ERR_KEY) |
Structured error response |
StapelValidationError(ERR_KEY) |
Raises DRF validation error with error key |
register_service_errors(dict) |
Registers error messages for a service |
AnchorPagination / CreatedAtAnchorPagination |
Cursor-style paginators |
stapel_core.bus — Event bus
Transport-agnostic event bus: in-memory backend for tests/dev, Kafka, NATS
JetStream, or Redis Streams for production — pick one via
STAPEL_BUS_BACKEND (or bring your own BusBackend subclass).
Publish (sync, fire-and-forget):
from stapel_core.bus import publish, Event
publish('user.created', Event(
event_type='user.created',
service='auth',
payload={'user_id': '...'},
))
Consume by subclassing the management-command base:
from stapel_core.bus import BaseBusConsumerCommand, Event
class ConsumeUsers(BaseBusConsumerCommand):
topics = ['user.created']
consumer_group = 'notifications'
def handle_event(self, event: Event) -> None:
...
Backend is selected via the STAPEL_BUS_BACKEND env var or Django setting
(shorthand memory / kafka / nats / redis_streams, or any dotted
path). Default is memory (stapel_core.bus.backends.memory.MemoryBus);
production picks one of stapel_core.bus.backends.kafka.KafkaBus,
stapel_core.bus.backends.nats.NatsJetStreamBus, or
stapel_core.bus.backends.redis_streams.RedisStreamsBus (needs
pip install 'stapel-core[kafka]' / [nats] / [redis-bus] respectively —
see MODULE.md for connection settings and delivery semantics).
stapel_core.notifications — Push notifications
from stapel_core.notifications import request_notification
request_notification(
notification_type='welcome',
user_id=str(user.id),
email=user.email,
variables={'name': user.username},
source_service='auth',
)
stapel_core.oauth — OAuth provider registry
Provider classes (GoogleProvider, GitHubProvider, etc.) and registry for
OAuth consumer flows (when your service accepts OAuth logins from external
providers).
stapel_core.gdpr — GDPR utilities
Account closure requests, data export, re-registration hashes.
Running tests
cd iron-common-python
pip install -e '.[dev]'
pytest stapel_core/tests/ -v
License
MIT — see LICENSE.
This page is assembled by stapel-readme from docs/readme.md plus the contract artifacts in docs/. Edit the prose in docs/readme.md; the badges, facts and links above and below it are generated — do not hand-edit README.md.
Release files for stapel-core 0.55.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| stapel_core-0.55.0.tar.gz | 993.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| stapel_core-0.55.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.8 MB
Release files / stapel_core-0.55.0.tar.gz
| Download URL | stapel_core-0.55.0.tar.gz |
|---|---|
| Size | 993.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4748b9fdc87bdb1868c54d89677bc5d0aa4bb04f8816566563fd68c5c89435e8
|
|
BLAKE2b-256 checksum How to use checksums |
f431fb6619cb3681d663eb5fbb3f5d5dbbb9591797386ac69e95a8ae24059e2c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 2, 2026.
Transparency logRelease files / stapel_core-0.55.0-py3-none-any.whl
| Download URL | stapel_core-0.55.0-py3-none-any.whl |
|---|---|
| Size | 777.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5eaa9530f9348fcb7a24fb45fe5490c36ab1b419af8a94893b55aa0e1bc82f5c
|
|
BLAKE2b-256 checksum How to use checksums |
427d038a1875bde90bbe9e2efcc5521addcabe36af7f843bea305f6f2611e896
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 2, 2026.
Transparency log