Plugins
A plugin is an extension of kind plugin: a cross-cutting capability - an
audit trail, pricing rules, a payment gateway - that other extensions use
through events, filters and the internal proxy, and that can be removed
without breaking them. Everything about creating, installing, activating and
isolating one is in extensions.md:
python dev.py make plugin member_pricing
python dev.py extension install member_pricing
python dev.py extension activate member_pricing
# app/plugins/member_pricing/provider.py
def register(context):
context.filter("catalog.price_cents", lambda price_cents, sku: price_cents * 9 // 10)
Official plugins
The framework ships seven capability plugins under plugins/. Each one is
stateless, holds no tenant or business state, and carries no user-facing copy:
a failure comes back as a machine code or a message key, and the application's
translations own the sentence.
| Slug | Container key | Service | What it does |
|---|---|---|---|
brazil_validator |
brazil_validator |
BrazilValidator |
CPF, RG, CNPJ (numeric and alphanumeric), state registration for the 27 states; CEP, phone, CNAE and address normalizers |
document_seal |
document_seal |
DocumentSeal |
Tamper-evident digest of a document (SHA-256, or HMAC with a key) |
mtls |
mtls |
MutualTlsChannel |
Client TLS context from a certificate and key, for peers that authenticate callers by certificate |
pix |
pix |
Pix |
PIX BR Code payload: build a static or dynamic charge, verify a payload's checksum |
privacy |
privacy |
Privacy |
Data portability bundle and the legal-page contract |
qrcode |
qrcode |
QrCode |
Scannable QR codes as SVG, PNG or a data: URI |
seo |
seo |
Seo |
Slugs, canonical URLs, <head> metadata and a content audit, with no cookie |
These use the legacy loader described below: register them once, and every enabled plugin binds its service at boot.
python dev.py plugin sync # discover plugins/*/plugin.py and register new ones, enabled
python dev.py plugin list
python dev.py plugin disable seo
The loader reads plugins/<slug>/plugin.py under the project root and keeps
the enabled state in the plugins table. Both exist in the framework's own
tree; a project generated by craft new has neither. Without the table,
plugin sync registers what it finds in memory only, logs a warning, and the
registration is gone when the process exits - so nothing is bound at the next
boot. The services are plain classes with no dependency on the loader:
wherever the plugins package is importable, importing them directly, as the
examples below do, works without the table.
qr = app.make("qrcode") # `app` is the Application built in bootstrap/app.py
The examples below call the classes directly; the instance resolved from the container exposes the same methods.
QR code
from plugins.qrcode import QrCode
svg = QrCode.svg("https://example.com/orders/42") # SVG markup (str)
png = QrCode.png("https://example.com/orders/42", scale=6) # PNG bytes
src = QrCode.data_uri("https://example.com/orders/42") # data:image/png;base64,...
svg() and png() take scale (default 10), border (4), dark
("#000000"), light ("#ffffff") and error ("m", the error-correction
level); data_uri() forwards the same options to png(). Empty content raises
ValueError("qrcode_content_empty"). The encoder is the segno library, a
runtime dependency of the framework.
PIX BR Code
from plugins.pix import Pix
charge = Pix.brcode("[email protected]", "Example Store", "Sao Paulo", amount=10.5)
charge.payload # the EMV text to render as a QR code or offer as copy-and-paste
Pix.verify(charge.payload) # True; False when the CRC16 does not match the content
brcode(key, merchant_name, merchant_city, amount=None, txid=None, description=None, dynamic=False) returns a PixCharge (payload, key,
merchant_name, merchant_city, amount, txid, dynamic). The merchant
name is sanitized and cut to 25 characters and the city to 15; without a txid
the reference is ***. An empty key raises ValueError("pix_key_empty").
Pix.fields(payload) parses the flat fields of a payload into {id: value}.
amountis formatted with two decimals throughfloat. Pass a value already rounded to the cent.
Document seal
from plugins.document_seal import DocumentSeal
seal = DocumentSeal.seal(contract_bytes) # SHA-256
DocumentSeal.verify(contract_bytes, seal) # True until the content changes
keyed = DocumentSeal.seal(contract_bytes, key=secret) # HMAC-SHA256
DocumentSeal.verify(contract_bytes, keyed, key=secret) # False without the same key
seal() accepts str, bytes or bytearray and returns a Seal
(algorithm, digest, sealed_at, keyed). Store the seal, not the document:
verify() recomputes the digest from the content and compares in constant time.
Mutual TLS channel
from plugins.mtls import ChannelError, ssl_context
try:
context = ssl_context(certificate_pem, private_key_pem, key_password="")
except ChannelError as refused:
refused.code # CHANNEL_MATERIAL_MISSING or CHANNEL_PAIR_REJECTED
refused.message_key # mtls.errors.material_missing / mtls.errors.pair_rejected
ssl_context() returns an ssl.SSLContext carrying the client certificate,
with server verification on. The pair is loaded through a temporary file
created 0600 and removed in the same block, and the ready context is cached
by a fingerprint of the material. ssl_context_from_files(certificate_path, private_key_path, key_password="") reads the pair from disk, and
forget_contexts() discards the cache after a certificate is replaced. From
the container the same functions are context, context_from_files and
forget on app.make("mtls").
Privacy kit
from plugins.privacy import Privacy
bundle = Privacy.portability_bundle(
subject=str(user.id),
sections={"profile": [profile_row], "orders": order_rows},
)
pages = Privacy.legal_pages()
portability_bundle(subject, sections, generated_at=None) returns a
JSON-serializable document: format (craft.privacy.portability/1), subject,
generated_at, total_records and sections sorted by name, each with name,
count and records. The caller collects the rows; the kit only shapes them.
legal_pages() returns the privacy, terms and cookies pages as
{slug, route, title_key} - /privacy with legal.privacy.title, /terms
with legal.terms.title, /cookies with legal.cookies.title. It registers no
route and ships no text: the application renders the pages and seeds the keys.
SEO
from plugins.seo import Seo
Seo.slugify(title) # lowercase, accent-free, hyphenated
Seo.canonical("/blog/first-post", base="https://example.com")
Seo.head(title, body, url="/blog/first-post", base="https://example.com")
report = Seo.audit(title, body)
report.suggestions # message keys, e.g. seo.suggestion.content_thin
head() returns the description, robots, og:title and og:description
tags, plus canonical and og:url when a URL or base is given; pass
index=False for a noindex page. audit() returns a SeoAudit (slug,
meta_title, meta_description, word_count, score, suggestions). The
suggestion keys are seo.suggestion.title_too_short,
seo.suggestion.title_too_long and seo.suggestion.content_thin.
Brazil validator
from plugins.brazil_validator import BrazilValidator
result = BrazilValidator.validate_cpf(request.input("cpf"))
if not result:
result.error_code # a stable code, e.g. INVALID_CHECKSUM
result.message_key # under validation.brazil.<kind>.
result.params
Every check returns a ValidationResult (is_valid, kind, clean,
formatted, version, error_code, message_key, params); it is truthy
when valid, and result.message() resolves the key through the translator.
| Method | Checks or returns |
|---|---|
validate_cpf(value) / validate_rg(value) |
CPF check digits; RG shape |
validate_cnpj(value) |
Numeric and alphanumeric CNPJ; version says which |
validate_document(value) |
Routes to CPF or CNPJ by length |
validate_ie(value, uf) |
State registration for one of the 27 states |
normalize_cep(value) / normalize_phone(value, country_code=True) / normalize_cnae(value) |
A ValidationResult with the normalized value |
normalize_cnae_list(value) / normalize_address(payload) |
A list of codes; a dict of address fields |
clean(value, alphanumeric=False) / format_document(value) / mask_privacy_document(value) |
Storage form; display mask; privacy mask |
validate_batch(rows, field="cpf") |
Totals plus accepted and rejected rows, each rejection with error_code, message_key and params |
validate_batch()writes the cleaned document and two_document_*keys into the accepted rows it was given. Pass copies when the originals matter.
Legacy plugins (deprecated)
Before the extension model, a plugin was a directory plugins/<slug>/ with a
plugin.py exposing a PLUGIN dict (slug, name, version,
description) and register(app), managed with dev.py plugin list|enable|disable|sync. That loader still runs for one release so existing
projects keep working, with its limits:
- a disabled legacy plugin keeps its hooks until the process restarts;
- it has no manifest, version range, dependencies, migrations, views or translations;
- its hooks are isolated only when triggered through
trigger_hook.
Move a legacy plugin by adding an extension.toml (kind = "plugin") under
app/plugins/<slug>/, renaming register(app) to register(context) and
replacing app.make("plugin").add_hook(name, callback) with
context.listen(name, callback).