Skip to main content

stapel-core

CI coverage pypi downloads python license llms.txt

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.81.0
Python >=3.11 (3.11, 3.12, 3.13, 3.14)
Django Django>=5.2,<7.0
Usage surface 57
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

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.81.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for stapel-core 0.81.0
File Size Uploaded
stapel_core-0.81.0.tar.gz 1.2 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for stapel-core 0.81.0
File Interpreter ABI Platform
stapel_core-0.81.0-py3-none-any.whl Python 3 none any Details

Total release size: 2.1 MB

Release files / stapel_core-0.81.0.tar.gz

Download URL stapel_core-0.81.0.tar.gz
Size 1.2 MB
Tags Source
SHA-256 checksum
How to use checksums
aa65cfcfaf8f7183422a8c354fe8d3c4421e3300347f6f79214f7b38c6d0a2be
BLAKE2b-256 checksum
How to use checksums
885ffb0d9765cfe73ac542785029f488b9f4831981b7ae728ddfb1ac697cbef7
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 17, 2026.

Transparency log

Release files / stapel_core-0.81.0-py3-none-any.whl

Download URL stapel_core-0.81.0-py3-none-any.whl
Size 919.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f36f3d5dfa587b689e465358b6a87c40c7bbe6e6e4d3d01ec72918db349d2218
BLAKE2b-256 checksum
How to use checksums
dfe21a9d4b637e79e3d906acc67efbe8ab08a0766aa80b52b5fd3a2521a1be15
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 17, 2026.

Transparency log

Release history Release notifications | RSS feed

0.91.0

2 release files

0.90.0

2 release files

0.89.0

2 release files

0.88.1

2 release files

0.88.0

2 release files

0.87.0

2 release files

0.86.1

2 release files

0.85.1

2 release files

0.85.0

2 release files

0.84.0

2 release files

0.83.1

2 release files

0.83.0

2 release files

0.82.2

2 release files

This release

0.81.0 This release

2 release files

0.80.2

2 release files

0.80.1

2 release files

0.78.0

2 release files

0.77.0

2 release files

0.76.0

2 release files

0.73.0

2 release files

0.70.0

2 release files

0.69.1

2 release files

0.68.1

2 release files

0.67.0

2 release files

0.66.1

2 release files

0.66.0

2 release files

0.65.1

2 release files

0.65.0

2 release files

0.64.0

2 release files

0.63.2

2 release files

0.63.1

2 release files

0.53.0

2 release files

0.52.1

2 release files

0.51.0

2 release files

0.50.0

2 release files

0.49.0

2 release files

0.48.0

2 release files

0.47.0

2 release files

0.46.0

2 release files

0.45.0

2 release files

0.44.2

2 release files

0.44.1

2 release files

0.43.0

2 release files

0.42.0

2 release files

0.41.0

2 release files

0.40.0

2 release files

0.39.0

2 release files

0.38.0

2 release files

0.37.0

2 release files

0.36.0

2 release files

0.35.0

2 release files

0.34.0

2 release files

0.33.2

2 release files

0.33.1

2 release files

0.33.0

2 release files

0.32.0

2 release files

0.31.0

2 release files

0.30.1

2 release files

0.29.0

2 release files

0.28.0

2 release files

0.27.0

2 release files

0.26.0

2 release files

0.25.0

2 release files

0.24.1

2 release files

0.24.0

2 release files

0.23.1

2 release files

0.17.0

2 release files

0.16.1

2 release files

0.16.0

2 release files

0.15.9

2 release files

0.15.8

2 release files

0.15.7

2 release files

0.15.6

2 release files

0.15.5

2 release files

0.15.4

2 release files

0.15.3

2 release files

0.15.2

2 release files

0.15.1

2 release files

0.14.2

2 release files

0.14.1

2 release files

0.14.0

2 release files

0.13.0

2 release files

0.12.4

2 release files

0.12.3

2 release files

0.12.2

2 release files

0.12.1

2 release files

0.12.0

2 release files

0.11.2

2 release files

0.11.1

2 release files

0.11.0

2 release files

0.10.4

2 release files

0.10.3

2 release files

0.10.2

2 release files

0.10.1

2 release files

0.10.0

2 release files

0.8.0

2 release files

0.3.2

2 release files

0.3.1

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page