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

amount is formatted with two decimals through float. 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:

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