This document contains rules, patterns, and guidelines for working on the Airflow Registry project.
node_modules/swagger-ui-dist for the API ExplorerFollow the principles from https://css-tricks.com/semantic-class-names/
Class names should answer “what is this?” not “how should it look?”
Good examples:
.provider-card - it's a card displaying provider information.connection-types - a section showing connection types.dependencies - shows dependency information.stats - displays statistical dataBad examples:
.blue-box - describes appearance (what if design changes to green?).flex-row - describes layout implementation.mt-4 - describes spacing value.text-lg - describes typographyWhy this matters:
When an element only makes sense within its parent, use descendant selectors instead of prefixing class names.
Good - leveraging cascade:
<section class="technical"> <div class="python-version">...</div> <div class="connections">...</div> </section>
.technical { /* section styles */ } .technical h3 { /* heading styles */ } .technical .python-version { /* specific section */ } .technical code { /* all code in technical section */ }
Bad - redundant prefixes:
<section class="technical"> <div class="technical-python">...</div> <div class="technical-connections">...</div> </section>
.technical { /* section styles */ } .technical-heading { /* redundant prefix */ } .technical-python { /* redundant prefix */ } .technical-code { /* redundant prefix */ }
Why this matters:
.python-version only appears in .technical, the prefix adds no informationUse the right HTML element for the job. This improves accessibility, SEO, and reduces need for classes.
Semantic elements and their uses:
<header> - Page or section header<footer> - Page or section footer<nav> - Navigation menus<main> - Main content of the page<section> - Thematic grouping of content<article> - Self-contained content<aside> - Side content/sidebars<dl>, <dt>, <dd> - Definition lists (key-value pairs)<details>, <summary> - Collapsible contentExample - Statistics:
<!-- Good: Semantic --> <dl class="stats"> <div> <dt>1,000+</dt> <dd>Providers</dd> </div> </dl>
.stats dt { font-size: var(--text-3xl); } .stats dd { font-size: var(--text-sm); }
Example - Provider detail header:
<!-- Good: Semantic structure --> <header class="card"> <div class="top"> <div class="logo">...</div> <div class="details"> <h1>Amazon</h1> <p class="description">...</p> </div> </div> <div class="stats">...</div> </header>
.provider-detail-page header { /* styles */ } .provider-detail-page header h1 { /* title */ } .provider-detail-page header .stats { /* grid of stats */ }
Use element selectors when:
.provider-detail-page header h1 - there's only one main headingAdd a class when:
.python-version, .connections, .dependencies - all are <div> but serve different purposesPrefer simpler selectors:
section code over section .code-snippetdetails summary over .details-trigger.connections a over .connection-badgeUtility classes describe HOW something looks, not WHAT it is.
Remove these patterns:
.mb-2, .py-12, .gap-4, .px-6, .space-y-4.flex, .grid, .grid-cols-2, .items-center.block, .inline-block, .hidden.text-center, .text-lg, .font-bold, .uppercase.text-blue-500, .bg-gray-100Instead, style contextually:
/* Good */ .technical h3 { font-size: var(--text-sm); font-weight: var(--font-semibold); margin-bottom: var(--space-3); display: flex; align-items: center; } /* Bad */ <h3 class="text-sm font-semibold mb-3 flex items-center">
Components should be styled based on WHERE they appear, not through generic modifier classes.
No generic button classes:
/* Bad */ .btn { } .btn-primary { } .btn-secondary { }
Style contextually instead:
/* Good */ .hero .popular-providers a { padding: var(--space-2) var(--space-4); background: var(--bg-tertiary); border: 1px solid var(--border-primary); border-radius: var(--radius-lg); } .provider-detail-page .actions .btn-primary { background: var(--accent-primary); color: var(--color-navy-900); }
Why this matters:
src/css/
├── tokens.css # Design tokens (colors, spacing, fonts)
├── main.css # Main styles with imports
└── base/
├── reset.css # CSS reset
└── typography.css # Base typography
Each major section follows this pattern:
/* Section container */ .section-name { padding: var(--space-20) 0; background: var(--bg-primary); } /* Nested elements using cascade */ .section-name header { /* Header styles */ } .section-name h2 { /* Title styles */ } .section-name .some-element { /* Element styles */ }
Use CSS custom properties defined in tokens.css:
--color-navy-900, --color-cyan-400, --color-gray-700, etc.--space-4 (1rem), --space-8 (2rem), etc.--text-base, --text-lg, --font-semibold, etc.--bg-primary, --text-primary, --border-primaryIMPORTANT: Always use CSS custom properties instead of hardcoded color values:
border: 1px solid var(--color-gray-700);border: 1px solid #334155;When defining new variables in tokens.css, follow this hierarchy:
Prefer semantic variables (with -light/-dark suffixes) over direct color variables:
--color-input-bg: light-dark(var(--bg-tertiary-light), var(--bg-tertiary-dark));--color-input-bg: light-dark(var(--color-gray-100), var(--color-navy-700));Only use direct color variables when:
--bg-tertiary-light: var(--color-gray-100);)In component CSS (main.css):
background: var(--bg-primary);light-dark() with semantic variables for contextual colorsThis creates a clear hierarchy: base colors → semantic variables → computed variables → usage
The theme system uses the CSS color-scheme property and light-dark() function:
Default theme is dark mode
Theme switching sets document.documentElement.style.colorScheme = 'light' | 'dark'
Theme computed variables defined in tokens.css with progressive enhancement:
/* Progressive enhancement: fallback then light-dark() */ --bg-primary: var(--bg-primary-light); --bg-primary: light-dark(var(--bg-primary-light), var(--bg-primary-dark));
Most elements use computed theme variables:
.element { background: var(--bg-primary); color: var(--text-primary); }
Contextual colors use inline light-dark():
.kbd { border-color: light-dark(var(--border-primary-light), var(--color-navy-600)); }
Progressive Enhancement: Each theme variable is declared twice in tokens.css:
light-dark() supportlight-dark() which overrides in supporting browsersWhen adding or modifying features:
var(--bg-primary) or inline light-dark()-light and -dark variants defined in tokens.cssWhen removing non-semantic CSS:
.grid, .flex, .items-center, etc..mb-*, .py-*, .gap-*, etc..btn, .btn-primary, etc..text-center, .text-lg, etc.Key files in the project:
src/css/main.css - Main stylessrc/css/tokens.css - Design tokenssrc/ - All page templates (index, provider-detail, providers, explore, stats, api-explorer)../dev/registry/registry_contract_models.py - Pydantic contracts for all JSON payloads../dev/registry/export_registry_schemas.py - Generates OpenAPI spec from contractsLocal development server runs at http://localhost:8080
The registry is built in the apache/airflow repo and served at airflow.apache.org/registry/.
registry-build.yml extracts metadata, builds the 11ty site, and syncs to S3. Supports two modes:provider input): extracts all ~99 providers (~12 min)provider=amazon): extracts one provider (~30s), merges with existing data from S3 via merge_registry_data.py, then builds the full site. The S3 sync step excludes the entire api/providers/ subtree for non-target providers to avoid overwriting real data with Eleventy's incomplete/empty stubs (Eleventy 3.x permalink: false does not work with pagination).{live|staging}-docs-airflow-apache-org/registry/ (same bucket as docs, different prefix)airflow.apache.org rewrites /registry/* to CloudFront, which serves from S3publish-docs-to-s3.yml publishes provider docs, its update-registry job calls registry-build.yml with the provider ID for an incremental registry updateREGISTRY_PATH_PREFIX=/registry/ (default)REGISTRY_PATH_PREFIX=/ (set automatically by pnpm dev)| url filter, which prepends the prefixapache/airflow-site repo (separate PR).htaccess rewrite rule - Add a rule to proxy /registry/* through CloudFront:RewriteRule ^registry/(.*)$ https://<cloudfront-distribution>.cloudfront.net/registry/$1 [P,L]
/registry/.These changes mirror the existing /docs/* rewrite pattern.
dev/registry/)The registry's JSON data is produced by four extraction scripts in dev/registry/, which is a Python package (workspace member) with shared code in registry_tools/.
Module type definitions live in dev/registry/registry_tools/types.py — this is the single source of truth for all module types (operator, hook, sensor, trigger, etc.). The three Python extraction scripts and the frontend data file (types.json) all derive from this module. To add a new type, add it to MODULE_TYPES in types.py and run generate_types_json.py.
When modifying these scripts, understand the design decisions below.
| Script | Runs on | Needs providers installed? | What it does |
|---|---|---|---|
extract_metadata.py | Host | No | Provider metadata, class names (AST), PyPI stats, logos |
extract_versions.py | Host | No | Per-version metadata from git tags |
extract_parameters.py | Breeze | Yes | __init__ parameter inspection via MRO |
extract_connections.py | Breeze | Yes | Connection form metadata via ProvidersManager |
extract_parameters.py and extract_connections.py need runtime access to provider classes (to inspect __init__ signatures and call get_connection_form_widgets()). They run inside Breeze where all providers are installed. extract_metadata.py and extract_versions.py only need filesystem access and run on the host. This split means the CI workflow can run the fast scripts (metadata, ~30s per provider) without spinning up Breeze, while parameter/connection extraction is a separate step.
run_provider_yaml_files_check.pyscripts/in_container/run_provider_yaml_files_check.py (run by the check-provider-yaml-valid pre-commit hook inside Breeze) validates that everything listed in provider.yaml is correct: modules exist, classes are importable, and every Python file in operators//hooks//sensors//triggers/ directories is accounted for. This guarantees provider.yaml is the correct and complete manifest.
extract_metadata.py builds on that guarantee but solves a different problem: it needs individual class names within each module. provider.yaml lists entries at two granularities:
airflow.providers.amazon.operators.s3 — a single module can contain many classes (S3CopyObjectOperator, S3DeleteObjectsOperator, S3ListOperator, etc.)airflow.providers.amazon.notifications.chime.ChimeNotifier — full class path already providedFor module-level entries, AST parsing discovers the individual classes and their inheritance chains. For class-level entries, extract_metadata.py uses the paths from provider.yaml directly — no discovery needed. The validation script ensures both categories are correct before extract_metadata.py ever runs.
extract_metadata.py runs on the CI host without installing 100+ provider packages. It reads .py files and extracts class names, base classes, and docstrings from the AST. This means it works with just pyyaml as a dependency.
The trade-off: AST parsing can't resolve dynamic class definitions or runtime-computed attributes. For the 99 providers currently in the repo, AST parsing captures everything because provider classes use standard class Foo(Base): definitions.
An approach using only Sphinx inventory files (without AST) was considered but rejected: inventory files only exist for published providers, and they don't contain inheritance information. The registry needs to know whether a class is an operator, hook, sensor, etc., which requires resolving the inheritance chain back to base classes like BaseOperator or BaseHook. AST parsing provides this.
Classes are discovered by checking if they inherit (transitively) from type-specific base classes defined in get_module_type_base_classes():
BaseOperator, BaseSensorOperator, DecoratedOperator, ...BaseHook, DbApiHook, DiscoverableHookBaseSensorOperator, PokeSensorOperatorBaseTrigger, TriggerEventBaseOperator (transfers are operators)BaseDagBundleInheritance resolution is transitive and cross-file. build_global_inheritance_map() scans all provider src/ directories to build a global class_name → {base_names} map. This handles chains like S3ListOperator → AwsBaseOperator → BaseOperator where the intermediate class lives in a different file.
After inheritance filtering, a post-filter skips private (_-prefixed), Base*, Abstract*, and *Mixin classes to avoid indexing internal helper classes.
There is no suffix-based matching (e.g., checking if a class name ends with “Operator”). The filtering is purely inheritance-based.
Module docs_url values come from Sphinx objects.inv inventory files, not manually constructed paths. The inventory is the canonical mapping of every documented class to its URL, published on S3 alongside the docs.
read_inventory() parses an objects.inv file, returning {qualified_name: url_path} for py:class entriesfetch_provider_inventory() downloads the inventory from S3 with local file caching (12-hour TTL in dev/registry/.inventory_cache/)extract_modules_from_yaml() accepts an inventory dict and uses it for URL lookup, falling back to manual construction when a class isn't in the inventoryThe fallback exists because unpublished providers (new, not yet released to PyPI) won't have inventory files on S3.
If you're adding a new module type or changing how docs_url is constructed, prefer extending the inventory lookup rather than hardcoding URL patterns.
Static site generators produce zero-JS pages by default. The registry works without JavaScript — filtering and search are layered on top progressively. Eleventy has no opinion on frontend frameworks, which keeps the dependency surface small (~30 packages in the lockfile).
The site deploys at /registry/ on airflow.apache.org but runs at / during local dev. Eleventy's pathPrefix config handles this via the REGISTRY_PATH_PREFIX env var. Templates use the | url filter, and client-side JS reads window.__REGISTRY_BASE__ (injected in base.njk).
# Search in templates grep -r "class-name" src/ # Search in CSS grep "\.class-name" src/css/
tokens.css