sinpapel-webhooks
Event-driven HTTP communication for sinpapel — outbound webhooks (signal-driven) + inbound receiver framework. HMAC-SHA256 signing Stripe-compatible, pluggable delivery backends (inline / outbox / celery), idempotency dedup. Production-ready sin broker dependency.
Status: v0.2.1 — event catalog expansion + Admin REST API. See CHANGELOG · README en español.
1. Installation
# Core (incluye outbox backend default — DB-backed queue, no broker required)
pip install sinpapel-webhooks
# Con Celery distributed delivery (gated por extra)
pip install "sinpapel-webhooks[celery]"
# Con Admin REST API (Subscriptions CRUD + Deliveries/Events read — v0.2.0)
pip install "sinpapel-webhooks[admin]"
Requirements: Python ≥3.10, Django ≥5.0, sinpapel ≥0.7.0.
2. Quick Start (5 min adoption)
Step 1 — settings.py
INSTALLED_APPS = [
# ... django.contrib + simple_history + sinpapel + sinpapel_drf ...
"sinpapel_webhooks", # auto-discovers <app>/webhooks.py
]
# Outbound (default outbox = DB-backed queue, no broker)
SINPAPEL_WEBHOOKS_BACKEND = "outbox"
# Inbound (per-source secrets)
SINPAPEL_WEBHOOKS_INBOUND_SECRETS = {
"baz": "<32-byte-hex-secret-from-baz-config>",
}
Step 2 — urls.py
from django.urls import include, path
urlpatterns = [
# ... existing patterns ...
path("sinpapel/api/webhooks/", include("sinpapel_webhooks.urls")),
]
Step 3 — myapp/webhooks.py (auto-discovered)
from sinpapel_webhooks import webhook_receiver
@webhook_receiver(source="baz", event="payment.confirmed")
def handle_baz_payment(payload, request):
# Handle inbound webhook from BAZ
return {"acked": True, "pago_id": payload["data"]["pago_id"]}
Step 4 — Migrate + run worker
python manage.py migrate sinpapel_webhooks
python manage.py sinpapel_webhooks_worker # daemon mode
Step 5 — Create outbound subscription (Django admin or shell)
from sinpapel_webhooks.models import WebhookSubscription
WebhookSubscription.objects.create(
name="my-consumer",
url="https://consumer.example.com/webhook",
events=["workflow.transition.completed"],
secret="<32-byte-hex>",
active=True,
)
That's it. Outbound POSTs trigger automáticamente desde sinpapel signals
(workflow transitions, signatures, document changes). Inbound POSTs a
/sinpapel/api/webhooks/in/baz/ invocan tu handler.
3. Settings Reference
| Setting | Default | Purpose |
|---|---|---|
SINPAPEL_WEBHOOKS_BACKEND |
"outbox" |
Delivery backend: "inline" / "outbox" / "celery" / dotted-path |
SINPAPEL_WEBHOOKS_REQUEST_TIMEOUT |
10 |
Seconds for HTTP POST timeout |
SINPAPEL_WEBHOOKS_TIMESTAMP_TOLERANCE |
300 |
Replay protection window (seconds) |
SINPAPEL_WEBHOOKS_MAX_ATTEMPTS |
5 |
Max retry attempts antes de dead_letter |
SINPAPEL_WEBHOOKS_RETRY_BACKOFF |
[60, 300, 1800, 7200, 43200] |
Backoff seconds (1m/5m/30m/2h/12h) |
SINPAPEL_WEBHOOKS_DEAD_LETTER_AFTER_ATTEMPTS |
True |
True → status="dead_letter"; False → "failed" |
SINPAPEL_WEBHOOKS_INBOUND_SECRETS |
{} |
Dict per-source secret: {"source_name": "32-byte-hex"} |
4. Outbound
Sinpapel-webhooks listens a 3 sinpapel domain events automatically:
| Event Type | Trigger |
|---|---|
workflow.transition.completed |
SeguimientoWorkflow created (transition exitosa) |
signature.completed |
RegistroFirma created |
document.uploaded |
InstanciaDocumento created |
Subscription model
WebhookSubscription:
name: human-readable labelurl: target POST URLevents: JSON list of event types subscribedsecret: HMAC-SHA256 secret (32-byte hex recommended)active: enable/disablecreated_by: User FK (audit)history: HistoricalRecords (django-simple-history audit trail)
Payload envelope
{
"event_id": "01HXX5K8N3...",
"event_type": "workflow.transition.completed",
"occurred_at": "2026-05-01T14:22:33.123Z",
"data": {
"solicitud_id": 142,
"estado_anterior": "EN_REVISION",
"estado_nuevo": "FIRMADO",
"user_id": 88,
"comentarios": "Aprobado"
},
"metadata": {
"sinpapel_version": "0.1.0",
"webhooks_version": "0.1.0",
"subscription_id": 5,
"api_version": "2026-04-30"
}
}
Outbound headers
POST /your-webhook HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: sinpapel-webhooks/0.1.0
X-Sinpapel-Signature: t=1714492800,v1=<sha256-hex>
X-Sinpapel-Event-Id: 01HXX5K8N3...
X-Sinpapel-Event-Type: workflow.transition.completed
X-Sinpapel-Webhook-Id: 5
Custom events
from sinpapel_webhooks.emit import emit_event
emit_event(
event_type="custom.invoice.paid",
payload={"invoice_id": 142, "amount": 5000.0},
source=invoice_instance, # optional GFK
)
Event catalog (v0.2.0)
10 canonical event types. Subscribe to any of them via the events JSON list on WebhookSubscription.
| event_type | Trigger | Requires |
|---|---|---|
workflow.transition.completed |
SeguimientoWorkflow create |
sinpapel ≥ 0.1.0 |
signature.completed |
RegistroFirma create |
sinpapel ≥ 0.1.0 |
document.uploaded |
InstanciaDocumento create |
sinpapel ≥ 0.1.0 |
workflow.predicate.configured |
CondicionTransicion create/update |
sinpapel ≥ 0.4.0 |
workflow.predicate.failed |
WorkflowEngine rejects a transition due to a CondicionTransicion |
sinpapel ≥ 0.5.0 (custom Signal) |
workflow.transition.preview |
WorkflowEngine.preview_transition (opt-in via SINPAPEL_EMIT_PREVIEW_EVENTS=True) |
sinpapel ≥ 0.5.0 |
sla.configured |
SLAConfiguracion create/update |
sinpapel ≥ 0.4.0 |
sla.breached |
SLAEngine detects instance exceeded dias_maximos |
sinpapel ≥ 0.5.0 |
sla.action.executed |
SLAEngine dispatched a _accion_* handler (notificar / escalar / rechazar / alertar) |
sinpapel ≥ 0.5.0 |
workflow.metadata.captured |
Consumer-emit (call emit_event(...) from your own post_save on models using MetadatosCapturables) |
— |
Loose coupling note: events that need ≥ 0.5.0 use custom Django Signals declared in sinpapel.signals (the receivers are auto-connected at apps.ready()). Older sinpapel still works — the defensive try: from sinpapel.signals import ... falls back, so only post_save-driven events fire.
Admin REST API (v0.2.0)
Install the extra:
pip install sinpapel-webhooks[admin]
Mount the URLs (same include as the inbound endpoint):
# myproject/urls.py
urlpatterns = [
path("sinpapel/api/webhooks/", include("sinpapel_webhooks.urls")),
]
Routes (all under /sinpapel/api/webhooks/admin/):
| Verb + Path | Purpose |
|---|---|
GET /admin/subscriptions/ |
List subscriptions (paginated) |
POST /admin/subscriptions/ |
Create — response returns secret ONCE in plaintext |
GET /admin/subscriptions/{id}/ |
Retrieve — secret masked (*** + last 4 chars) |
PATCH /admin/subscriptions/{id}/ |
Update — secret is read-only here |
DELETE /admin/subscriptions/{id}/ |
Delete |
POST /admin/subscriptions/{id}/rotate-secret/ |
Rotate secret — response returns new value once |
POST /admin/subscriptions/{id}/test/ |
Send synthetic delivery via InlineBackend |
GET /admin/deliveries/ |
List deliveries — filters: ?status=, ?subscription=, ?since= |
GET /admin/deliveries/{id}/ |
Retrieve delivery |
POST /admin/deliveries/{id}/retry/ |
Re-enqueue a delivery (resets to pending) |
POST /admin/deliveries/requeue-dead-letter/ |
Body {ids: [...]} or {all: true} |
GET /admin/events/ |
List events with delivery_count |
GET /admin/events/{id}/ |
Retrieve event with embedded deliveries |
GET /admin/inbound-events/ |
List inbound dedup log — filters: ?source=, ?handler_status=, ?since= |
GET /admin/inbound-events/{id}/ |
Retrieve inbound event |
Authentication
Default permission: rest_framework.permissions.IsAdminUser. Override with the setting:
SINPAPEL_WEBHOOKS_ADMIN_PERMISSION = "myapp.permissions.IsOpsTeam"
Resolved via import_string at module import; invalid dotted paths raise ImproperlyConfigured.
Examples
# Create a subscription (note: response body is the ONLY time the full secret is visible)
curl -X POST https://api.example.com/sinpapel/api/webhooks/admin/subscriptions/ \
-H "Authorization: Token <admin-token>" \
-H "Content-Type: application/json" \
-d '{"name":"ops","url":"https://ops.example.com/hook","events":["workflow.transition.completed"],"secret":"<32-byte-hex>"}'
# Rotate the secret (server generates a new 32-byte hex value)
curl -X POST https://api.example.com/sinpapel/api/webhooks/admin/subscriptions/5/rotate-secret/ \
-H "Authorization: Token <admin-token>"
# Re-enqueue all dead-lettered deliveries
curl -X POST https://api.example.com/sinpapel/api/webhooks/admin/deliveries/requeue-dead-letter/ \
-H "Authorization: Token <admin-token>" \
-H "Content-Type: application/json" \
-d '{"all": true}'
5. Inbound
Decorator-based handler registration
# myapp/webhooks.py — auto-discovered en startup vía autodiscover_modules
from sinpapel_webhooks import webhook_receiver
@webhook_receiver(source="baz", event="payment.confirmed")
def handle_baz_payment(payload, request):
"""payload es el envelope completo (event_id, event_type, occurred_at, data, metadata)."""
pago_id = payload["data"]["pago_id"]
monto = payload["data"]["monto"]
# ... your logic
return {"acked": True, "pago_id": pago_id}
@webhook_receiver(source="moneygram", event="dispersion.completed")
def handle_moneygram_dispersion(payload, request):
"""Returns None → defaults to {"acked": True}."""
# ... your logic
Inbound URL routing
POST /sinpapel/api/webhooks/in/<source>/
<source> matches SINPAPEL_WEBHOOKS_INBOUND_SECRETS key.
Inbound status codes
| Code | Reason |
|---|---|
| 200 | Success (handler invoked) o duplicate (idempotency) |
| 400 | Missing required headers o invalid JSON body |
| 401 | HMAC signature invalid o unknown source |
| 404 | No handler registered para (source, event_type) |
| 500 | Handler raised exception |
Idempotency
InboundWebhookEvent.unique_together(source, event_id) provee atomic dedup.
Si external service POSTea mismo event_id 2 veces, segunda recibe 200 {"acked": true, "duplicate": true} sin re-invocar handler.
6. Backends — Choosing a delivery backend
| Backend | Use case | Pros | Cons |
|---|---|---|---|
inline |
Dev / smoke tests / very low volume | No infra; immediate delivery; debugging fácil | Bloquea Django request; no retry; pierde events on consumer downtime |
outbox ⭐ default |
Production default | No broker; at-least-once con retry; backpressure natural | Lag worker cycle (1-30s); requires worker process |
celery |
High volume / distributed | Distributed retry; horizontal scale; concurrency control | Requires Celery + broker (Redis/RabbitMQ) |
Configuration
# Inline (dev only)
SINPAPEL_WEBHOOKS_BACKEND = "inline"
# Outbox (default prod)
SINPAPEL_WEBHOOKS_BACKEND = "outbox"
# Run: python manage.py sinpapel_webhooks_worker
# Celery (high volume)
SINPAPEL_WEBHOOKS_BACKEND = "celery"
# Plus your existing Celery app config (broker, etc.)
Custom backends
# my_project/webhooks_backends.py
class MyBackend:
name = "my_backend"
def enqueue(self, delivery_id): ...
def deliver_now(self, delivery_id): ...
# settings.py
SINPAPEL_WEBHOOKS_BACKEND = "my_project.webhooks_backends.MyBackend"
7. HMAC Verify (consumer-side)
Header format: X-Sinpapel-Signature: t=<unix-ts>,v1=<sha256-hex> (Stripe-compatible).
Algorithm: HMAC-SHA256(secret, f"{timestamp}.".encode() + raw_body_bytes).hexdigest().
Critical: Use raw body bytes (NOT re-serialized JSON). Some frameworks parse + re-serialize body, which changes bytes and breaks HMAC.
Test vector
payload: b'{"event":"test","data":{"x":1}}'
secret: "demo-secret"
timestamp: 1714492800
expected: t=1714492800,v1=42740cbdf2a28e4c8c81742f20936d35a6895352d2395818f04a28d4e2030e11
Python
import hmac, hashlib, time
def verify_sinpapel_signature(payload: bytes, header: str, secret: str, *, tolerance: int = 300) -> bool:
"""Returns True if HMAC valid + timestamp within tolerance."""
parts = dict(p.split("=", 1) for p in header.split(","))
ts = int(parts["t"])
if abs(time.time() - ts) > tolerance:
return False # Replay protection
expected = hmac.new(
secret.encode(),
f"{ts}.".encode() + payload,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
JavaScript (Node.js)
const crypto = require("crypto");
function verifySinpapelSignature(payload, header, secret, { tolerance = 300 } = {}) {
// payload: Buffer | string (raw body, NOT re-serialized JSON)
// header: "t=<ts>,v1=<hex>"
const parts = Object.fromEntries(
header.split(",").map(p => p.split("=", 2))
);
const ts = parseInt(parts.t, 10);
if (Math.abs(Date.now() / 1000 - ts) > tolerance) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.`)
.update(payload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1),
);
}
Ruby
require "openssl"
def verify_sinpapel_signature(payload, header, secret, tolerance: 300)
parts = Hash[header.split(",").map { |p| p.split("=", 2) }]
ts = parts["t"].to_i
return false if (Time.now.to_i - ts).abs > tolerance
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{payload}")
Rack::Utils.secure_compare(expected, parts["v1"])
end
Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func VerifySinpapelSignature(payload []byte, header, secret string, tolerance int64) bool {
parts := map[string]string{}
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
ts, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil {
return false
}
if abs(time.Now().Unix()-ts) > tolerance {
return false
}
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(strconv.FormatInt(ts, 10) + "."))
h.Write(payload)
expected := hex.EncodeToString(h.Sum(nil))
return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}
func abs(x int64) int64 {
if x < 0 { return -x }
return x
}
8. Workers — Deployment
Long-running daemon (production)
python manage.py sinpapel_webhooks_worker
Cron / one-shot
python manage.py sinpapel_webhooks_worker --once
Custom batch size + poll interval
python manage.py sinpapel_webhooks_worker --batch-size 100 --poll-interval 10
systemd unit example
# /etc/systemd/system/sinpapel-webhooks-worker.service
[Unit]
Description=sinpapel-webhooks outbox worker
After=postgresql.service
[Service]
Type=simple
User=django
WorkingDirectory=/opt/myproject
Environment=DJANGO_SETTINGS_MODULE=myproject.settings
ExecStart=/opt/myproject/venv/bin/python manage.py sinpapel_webhooks_worker
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Operational commands
# Re-queue dead letter deliveries
python manage.py sinpapel_webhooks_requeue_dead_letter --all
python manage.py sinpapel_webhooks_requeue_dead_letter --id 142
# Test subscription URL + secret
python manage.py sinpapel_webhooks_test_subscription 5
9. Migrations
python manage.py migrate sinpapel_webhooks
Creates 4 tables:
sinpapel_webhooks_webhooksubscription(+ history table for audit)sinpapel_webhooks_webhookeventsinpapel_webhooks_webhookdeliverysinpapel_webhooks_inboundwebhookevent
3-package coexistence: sinpapel + sinpapel_drf + sinpapel_webhooks correr en mismo Django process sin migration conflicts (verified vía 1200+ test suite).
PostgreSQL recommended para outbox backend multi-worker (SELECT FOR UPDATE SKIP LOCKED). SQLite fallback es single-worker only.
10. Troubleshooting
Celery test config NOT propagated via Django settings
# WRONG — Django override_settings NO propaga a Celery config
@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
def test_celery_backend(): ...
# CORRECT — set Celery config directly
@pytest.fixture(autouse=True)
def _eager_celery():
from celery import current_app
current_app.conf.task_always_eager = True
current_app.conf.task_eager_propagates = True
yield
HMAC byte-exact requirement
# WRONG — request.POST parses form-encoded; modifies bytes
body = request.POST.get("payload")
# WRONG — json.loads + re-serialize cambia bytes
body = json.dumps(json.loads(request.body))
# CORRECT — request.body returns raw bytes
body = request.body # bytes
autodiscover_modules edge cases
- Apps SIN
webhooks.py→ silently skipped (Django stdlib documented behavior) - Apps con
webhooks.pysyntax error → ImportError raised at startup (loud failure) webhooks.pyimports modules requiring DB → ensure migrations applied before test runner
bulk_create NO dispara signals
# WRONG — bulk_create bypasses post_save signals
SeguimientoWorkflow.objects.bulk_create([...]) # NO webhooks emitted
# CORRECT — use .save() iterativo if signals required
for s in seguimientos:
s.save() # signals fire normally
Outbox queue grows unbounded
If consumer URL is permanently down, deliveries accumulate as pending → failed → retry → eventually dead_letter. Monitor:
WebhookDelivery.objects.filter(status="dead_letter").count()
WebhookDelivery.objects.filter(status="failed").count()
Operational response: python manage.py sinpapel_webhooks_requeue_dead_letter after fixing consumer.
11. Reference
- Architecture:
work/epics/e14-sinpapel-webhooks/design.md - ADR-013 (delivery port pluggable):
dev/decisions/adr-013-webhook-delivery-pluggable-backend.md - ADR-014 (HMAC signing scheme):
dev/decisions/adr-014-webhook-signing-hmac-sha256.md
Public API
from sinpapel_webhooks import webhook_receiver, __version__
from sinpapel_webhooks.signing import compute_signature, verify_signature
from sinpapel_webhooks.emit import emit_event
from sinpapel_webhooks.delivery.ports import WebhookDeliveryBackend, DeliveryResult
from sinpapel_webhooks.exceptions import (
SinpapelWebhooksError,
WebhookSignatureError,
WebhookDeliveryError,
WebhookReceiverNotFound,
)
Models
from sinpapel_webhooks.models import (
WebhookSubscription, # outbound config
WebhookEvent, # canonical event log
WebhookDelivery, # delivery attempt log
InboundWebhookEvent, # idempotency dedup
)
12. Versioning
- v0.2.0 (2026-05-16) — Event catalog expansion (7 new event types) + Admin REST API (Subscriptions CRUD + Deliveries read/retry + Events/InboundEvents read). Requires
sinpapel >=0.5.0for the 4 signal-driven events; older sinpapel still loads (defensive import) with 2post_save-driven events functional. - v0.1.0 (2026-05-01) — Initial release. Epic E14 close.
- Lockstep:
sinpapel_webhooks 0.2.xrequiressinpapel >=0.5.0,<0.6for full feature set (degrades gracefully on older sinpapel). - Future: v0.3 may add rate limiting, drf-spectacular OpenAPI schema, multi-tenancy, mTLS, Kafka backend.
See CHANGELOG.md for full change history.
13. FAQ
¿Por qué outbox es default en lugar de inline?
Outbox no requiere broker (DB-backed queue) y provee at-least-once delivery con retry automático. Inline pierde events si consumer URL está down. Outbox = production-ready out-of-box.
¿Cómo migro de inline a outbox?
Single setting change + run worker:
SINPAPEL_WEBHOOKS_BACKEND = "outbox"
python manage.py sinpapel_webhooks_worker
¿Cómo testeo Celery backend sin Redis broker?
from celery import current_app
current_app.conf.task_always_eager = True # NOT via Django settings
current_app.conf.task_eager_propagates = True
¿Por qué no DRF en inbound view?
Inbound es server-to-server JSON only — DRF parser/renderer overhead innecesario. Plain Django view + JsonResponse sufficient. Mantiene loose coupling con sinpapel_drf.
¿Cómo agregar custom event types?
emit_event(event_type="custom.X", payload={...}, source=instance) desde tu código. Subscriptions matching events__contains="custom.X" reciben.
¿Sinpapel-webhooks soporta WebSocket / SSE?
No. Para push real-time usar Channels separado. Webhooks son HTTP POST callbacks event-driven.
¿Hay admin REST endpoints (subscription CRUD)?
Sí, desde v0.2.0. Install con pip install sinpapel-webhooks[admin] y monta el include — automáticamente expone /sinpapel/api/webhooks/admin/subscriptions/ (CRUD + rotate-secret + test), /admin/deliveries/ (read + retry + requeue-dead-letter), /admin/events/, /admin/inbound-events/. Default permission IsAdminUser; override via SINPAPEL_WEBHOOKS_ADMIN_PERMISSION setting.
¿Cómo rotar secrets?
Outbound (v0.2.0+): POST /sinpapel/api/webhooks/admin/subscriptions/{id}/rotate-secret/ — server genera 32-byte hex nuevo y la response devuelve el valor en plaintext (única vez). El campo secret en GET/PATCH viene enmascarado (*** + last 4 chars).
Inbound: update SINPAPEL_WEBHOOKS_INBOUND_SECRETS dict y restart Django process. Dual-secret overlap window queda en backlog.
License: GPL-3.0-or-later — see LICENSE. Source: https://github.com/aprendomx/sinpapel-webhooks Issues: https://github.com/aprendomx/sinpapel-webhooks/issues
Metadata
Release files for sinpapel-webhooks 0.2.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| sinpapel_webhooks-0.2.3.tar.gz | 59.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sinpapel_webhooks-0.2.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 127.3 kB
Release files / sinpapel_webhooks-0.2.3.tar.gz
| Download URL | sinpapel_webhooks-0.2.3.tar.gz |
|---|---|
| Size | 59.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
36d5e163ed5b58eb613e4ec2c707fe8156bdd0dc9907d494f4087b98a0474c5a
|
|
BLAKE2b-256 checksum How to use checksums |
e715757fcc447e9d9f671427b1b0ea48ff2b691508c671db9c431cbb8814af0a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.12
|
Release files / sinpapel_webhooks-0.2.3-py3-none-any.whl
| Download URL | sinpapel_webhooks-0.2.3-py3-none-any.whl |
|---|---|
| Size | 67.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b6060c7c871ef7cb0a3ad33d1a1a7fbf8b916cc325ee1092b6a98e6e04a42129
|
|
BLAKE2b-256 checksum How to use checksums |
0916d715cb7ed55adbc20fdf5b8dc541716111580dfe492a8b235305b1dad95c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.12
|