This is an automated email from the ASF dual-hosted git repository.
Lee-W pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new b2a195a4a2a Add more provider module categories to the provider page
(#70190)
b2a195a4a2a is described below
commit b2a195a4a2a8419802b51c0bf599c4f72e0136ba
Author: Wei Lee <[email protected]>
AuthorDate: Thu Aug 6 16:22:02 2026 +0800
Add more provider module categories to the provider page (#70190)
---
dev/registry/extract_parameters.py | 101 ++++++++-----
dev/registry/extract_versions.py | 73 +++++++--
dev/registry/registry_tools/types.py | 69 +++++++++
dev/registry/tests/test_extract_parameters.py | 99 +++++++++++++
dev/registry/tests/test_extract_versions.py | 121 +++++++++++++++
dev/registry/tests/test_types.py | 26 ++++
registry/.eleventy.js | 22 +++
registry/src/_data/types.json | 30 ++++
registry/src/css/main.css | 205 ++++++++++++++++++++++++++
registry/src/css/tokens.css | 6 +
registry/src/js/provider-detail.js | 47 +++++-
registry/src/js/search.js | 8 +-
registry/src/provider-version.njk | 31 +++-
13 files changed, 785 insertions(+), 53 deletions(-)
diff --git a/dev/registry/extract_parameters.py
b/dev/registry/extract_parameters.py
index e32b06bae7a..7a44c4614ca 100644
--- a/dev/registry/extract_parameters.py
+++ b/dev/registry/extract_parameters.py
@@ -54,7 +54,13 @@ from pathlib import Path
import yaml
from extract_metadata import fetch_provider_inventory, read_inventory
from registry_contract_models import validate_modules_catalog,
validate_provider_parameters
-from registry_tools.types import BASE_CLASS_IMPORTS, CLASS_LEVEL_SECTIONS,
MODULE_LEVEL_SECTIONS
+from registry_tools.types import (
+ BASE_CLASS_IMPORTS,
+ CLASS_LEVEL_CATEGORY_OVERRIDES,
+ CLASS_LEVEL_SECTIONS,
+ DICT_SHAPED_CLASS_LEVEL_SECTIONS,
+ MODULE_LEVEL_SECTIONS,
+)
AIRFLOW_ROOT = Path(__file__).parent.parent.parent
SCRIPT_DIR = Path(__file__).parent
@@ -354,9 +360,9 @@ def _should_skip_class(name: str) -> bool:
return False
-def _get_first_docstring_line(cls: type) -> str | None:
+def _get_first_docstring_line(obj: object) -> str | None:
"""Return the first non-empty line of a class docstring, or None."""
- doc = getattr(cls, "__doc__", None)
+ doc = getattr(obj, "__doc__", None)
if not doc:
return None
for line in doc.strip().splitlines():
@@ -426,6 +432,27 @@ def is_durable_capable(cls: type, resumable_mixin: type |
None) -> bool:
return "execute_resumable" in source
+def _resolve_dotted_path(class_path: str) -> tuple[str, str, object] | None:
+ """Split a dotted ``module.name`` path and import ``name`` from that
module.
+
+ Returns ``None`` (without logging) if ``class_path`` has no dot, or
``None``
+ (after logging a warning) if the module import fails. Otherwise returns
+ ``(module_path, name, obj)``, where ``obj`` may be ``None`` if the module
+ has no such attribute — callers decide how to treat a missing attribute.
+ """
+ parts = class_path.rsplit(".", 1)
+ if len(parts) != 2:
+ return None
+ module_path, name = parts
+ try:
+ mod = importlib.import_module(module_path)
+ obj = getattr(mod, name, None)
+ except Exception:
+ log.warning("Could not import %s", class_path)
+ return None
+ return module_path, name, obj
+
+
def discover_classes_from_provider(
provider_yaml_path: Path,
base_classes: dict[str, type],
@@ -583,29 +610,14 @@ def discover_classes_from_provider(
for class_path in provider_yaml.get(section_name, []):
if not class_path or not isinstance(class_path, str):
continue
- parts = class_path.rsplit(".", 1)
- if len(parts) != 2:
- continue
- module_path, class_name = parts
- try:
- mod = importlib.import_module(module_path)
- candidate = getattr(mod, class_name, None)
- except Exception:
- log.warning("Could not import %s", class_path)
+ if (resolved := _resolve_dotted_path(class_path)) is None:
continue
+ module_path, class_name, candidate = resolved
if candidate is None or not inspect.isclass(candidate):
log.warning("%s is not a class", class_path)
continue
cls = candidate
- # Use section name as category for class-level entries
- category_map = {
- "notifications": "notifications",
- "secrets-backends": "secrets",
- "logging": "logging",
- "executors": "executors",
- }
-
discovered.append(
make_entry(
cls,
@@ -613,29 +625,52 @@ def discover_classes_from_provider(
module_type,
class_path,
module_path,
- category=category_map.get(section_name, section_name),
+ category=CLASS_LEVEL_CATEGORY_OVERRIDES.get(section_name,
section_name),
+ )
+ )
+
+ # --- Dict-shaped class-level sections (plugins, dialects; each entry is a
+ # dict carrying an integration-name field plus a class-path field, see
types.py) ---
+ for section_name, (
+ module_type,
+ class_field,
+ integration_field,
+ ) in DICT_SHAPED_CLASS_LEVEL_SECTIONS.items():
+ for entry in provider_yaml.get(section_name, []):
+ if not isinstance(entry, dict):
+ continue
+ if not (class_path := entry.get(class_field, "")):
+ continue
+ if (resolved := _resolve_dotted_path(class_path)) is None:
+ continue
+ module_path, class_name, candidate = resolved
+ if candidate is None or not inspect.isclass(candidate):
+ log.warning("%s is not a class", class_path)
+ continue
+
+ discovered.append(
+ make_entry(
+ candidate,
+ class_name,
+ module_type,
+ class_path,
+ module_path,
+ integration=entry.get(integration_field, ""),
+ category=CLASS_LEVEL_CATEGORY_OVERRIDES.get(section_name,
section_name),
)
)
# --- Task decorators (class-name key in each entry) ---
for decorator in provider_yaml.get("task-decorators", []):
- class_path = decorator.get("class-name", "")
- decorator_name = decorator.get("name", "")
- if not class_path:
- continue
- parts = class_path.rsplit(".", 1)
- if len(parts) != 2:
+ if not (class_path := decorator.get("class-name", "")):
continue
- module_path, func_name = parts
- try:
- mod = importlib.import_module(module_path)
- obj = getattr(mod, func_name, None)
- except Exception:
- log.warning("Could not import %s", class_path)
+ if (resolved := _resolve_dotted_path(class_path)) is None:
continue
+ module_path, func_name, obj = resolved
if obj is None:
continue
+ decorator_name = decorator.get("name", "")
display_name = f"@task.{decorator_name}" if decorator_name else
func_name
docstring = _get_first_docstring_line(obj) if hasattr(obj, "__doc__")
else None
short_desc = docstring or f"Task decorator for {decorator_name or
func_name}"
diff --git a/dev/registry/extract_versions.py b/dev/registry/extract_versions.py
index cc5f094c79f..c47d91a7043 100644
--- a/dev/registry/extract_versions.py
+++ b/dev/registry/extract_versions.py
@@ -59,7 +59,13 @@ except ImportError:
sys.exit(1)
from extract_metadata import fetch_provider_inventory, read_connection_urls,
resolve_connection_docs_url
-from registry_tools.types import MODULE_LEVEL_SECTIONS, TYPE_SUFFIXES
+from registry_tools.types import (
+ CLASS_LEVEL_CATEGORY_OVERRIDES,
+ CLASS_LEVEL_SECTIONS,
+ DICT_SHAPED_CLASS_LEVEL_SECTIONS,
+ MODULE_LEVEL_SECTIONS,
+ TYPE_SUFFIXES,
+)
SCRIPT_DIR = Path(__file__).parent
AIRFLOW_ROOT = Path(__file__).parent.parent.parent
@@ -76,6 +82,23 @@ PROVIDERS_JSON_CANDIDATES = [
REGISTRY_DIR / "src" / "_data" / "providers.json",
]
+# Description suffix for class-level (FQCN) sections, keyed by module type id.
+# Kept local to this file (unlike category, which is shared via
+# CLASS_LEVEL_CATEGORY_OVERRIDES) because extract_parameters.py has a real
+# docstring to use instead and doesn't need a description suffix at all.
+FQCN_DESC_SUFFIXES: dict[str, str] = {
+ "notifier": "notifier",
+ "secret": "secrets backend",
+ "logging": "log handler",
+ "executor": "executor",
+ "extra_link": "extra link",
+ "queue": "queue",
+ "auth_manager": "auth manager",
+ "db_manager": "db manager",
+ "plugin": "plugin",
+ "dialect": "dialect",
+}
+
def build_provider_id_to_path_map() -> dict[str, str]:
"""Scan providers/ for provider.yaml to build provider_id ->
directory_path mapping."""
@@ -300,15 +323,13 @@ def extract_modules_from_yaml(
if mp:
process_module(mp, "transfer", source, get_category(source))
- # Class-level sections (full class paths, no source file parsing needed)
- FQCN_SECTIONS: dict[str, tuple[str, str, str]] = {
- # yaml_key: (module_type, category, description_suffix)
- "notifications": ("notifier", "notifications", "notifier"),
- "secrets-backends": ("secret", "secrets", "secrets backend"),
- "logging": ("logging", "logging", "log handler"),
- "executors": ("executor", "executors", "executor"),
- }
- for yaml_key, (mod_type, category, desc_suffix) in FQCN_SECTIONS.items():
+ # Class-level sections (full class paths, no source file parsing needed).
+ # Iterating CLASS_LEVEL_SECTIONS keeps this in sync with the single
+ # source of truth in types.py; a section added there without a matching
+ # FQCN_DESC_SUFFIXES entry raises KeyError instead of silently skipping.
+ for yaml_key, mod_type in CLASS_LEVEL_SECTIONS.items():
+ category = CLASS_LEVEL_CATEGORY_OVERRIDES.get(yaml_key, yaml_key)
+ desc_suffix = FQCN_DESC_SUFFIXES[mod_type]
for class_path in provider_yaml.get(yaml_key, []):
if not class_path:
continue
@@ -329,6 +350,38 @@ def extract_modules_from_yaml(
}
)
+ # Dict-shaped class-level sections (each entry is a dict carrying the
+ # class path under a section-specific field name, see types.py).
+ for yaml_key, (
+ mod_type,
+ class_path_field,
+ _integration_field,
+ ) in DICT_SHAPED_CLASS_LEVEL_SECTIONS.items():
+ category = CLASS_LEVEL_CATEGORY_OVERRIDES.get(yaml_key, yaml_key)
+ desc_suffix = FQCN_DESC_SUFFIXES[mod_type]
+ for entry in provider_yaml.get(yaml_key, []):
+ if not isinstance(entry, dict):
+ continue
+ class_path = entry.get(class_path_field, "")
+ if not class_path:
+ continue
+ parts = class_path.rsplit(".", 1)
+ if len(parts) != 2:
+ continue
+ mod_path, class_name = parts
+ api_ref = mod_path.replace(".", "/")
+ modules.append(
+ {
+ "name": class_name,
+ "type": mod_type,
+ "import_path": class_path,
+ "short_description": f"{class_name} {desc_suffix}",
+ "docs_url":
f"{base_docs_url}/_api/{api_ref}/index.html#{class_path}",
+ "source_url": f"{base_source_url}/{api_ref}.py",
+ "category": category,
+ }
+ )
+
return modules
diff --git a/dev/registry/registry_tools/types.py
b/dev/registry/registry_tools/types.py
index cd91b70ed65..8f88ffe6e97 100644
--- a/dev/registry/registry_tools/types.py
+++ b/dev/registry/registry_tools/types.py
@@ -116,6 +116,48 @@ MODULE_TYPES: dict[str, dict] = {
"label": "Retry Policies",
"icon": "R",
},
+ "extra_link": {
+ "yaml_key": "extra-links",
+ "level": "flat",
+ "suffixes": [],
+ "label": "Extra Links",
+ "icon": "I",
+ },
+ "queue": {
+ "yaml_key": "queues",
+ "level": "flat",
+ "suffixes": [],
+ "label": "Message Queues",
+ "icon": "Q",
+ },
+ "plugin": {
+ "yaml_key": "plugins",
+ "level": "flat",
+ "suffixes": [],
+ "label": "Plugins",
+ "icon": "P",
+ },
+ "auth_manager": {
+ "yaml_key": "auth-managers",
+ "level": "flat",
+ "suffixes": [],
+ "label": "Auth Managers",
+ "icon": "A",
+ },
+ "db_manager": {
+ "yaml_key": "db-managers",
+ "level": "flat",
+ "suffixes": [],
+ "label": "DB Managers",
+ "icon": "M",
+ },
+ "dialect": {
+ "yaml_key": "dialects",
+ "level": "flat",
+ "suffixes": [],
+ "label": "Dialects",
+ "icon": "D",
+ },
}
# Runtime base class imports for issubclass checks (extract_parameters.py).
@@ -147,11 +189,38 @@ TYPE_SUFFIXES: dict[str, list[str]] = {type_id:
info["suffixes"] for type_id, in
# Class-level sections used by extract_parameters.py (subset of flat that
# list full class paths rather than simple entries).
+#
+# "plugins" and "dialects" are also flat/class-path sections, but each entry
is a
+# dict (plugin-class / dialect-class-name) rather than a bare string, so they
are
+# handled by the generic dict-shaped loop driven by
DICT_SHAPED_CLASS_LEVEL_SECTIONS
+# below instead of this table.
CLASS_LEVEL_SECTIONS: dict[str, str] = {
"notifications": "notifier",
"secrets-backends": "secret",
"logging": "logging",
"executors": "executor",
+ "extra-links": "extra_link",
+ "queues": "queue",
+ "auth-managers": "auth_manager",
+ "db-managers": "db_manager",
+}
+
+# Maps yaml section key -> (type id, class-path field name, integration-name
+# field name) for dict-shaped class-level sections (each yaml entry is a dict,
+# not a bare class-path string). Shared by extract_versions.py and
+# extract_parameters.py so both walk these sections with a single generic loop
+# instead of one per section, and so the field names are defined exactly once.
+DICT_SHAPED_CLASS_LEVEL_SECTIONS: dict[str, tuple[str, str, str]] = {
+ "plugins": ("plugin", "plugin-class", "name"),
+ "dialects": ("dialect", "dialect-class-name", "dialect-type"),
+}
+
+# Maps yaml section key -> category string for class-level (FQCN) sections.
+# Only lists yaml keys whose category differs from the key itself. Callers
+# fall back to the yaml key via .get(section_name, section_name) for every
+# other key, including ones like "notifications" where key == category.
+CLASS_LEVEL_CATEGORY_OVERRIDES: dict[str, str] = {
+ "secrets-backends": "secrets",
}
# All type ids, ordered consistently.
diff --git a/dev/registry/tests/test_extract_parameters.py
b/dev/registry/tests/test_extract_parameters.py
index 2cdb0953229..3ebdf2004a7 100644
--- a/dev/registry/tests/test_extract_parameters.py
+++ b/dev/registry/tests/test_extract_parameters.py
@@ -30,6 +30,7 @@ from extract_parameters import (
Module,
_get_source_line,
_parse_requested_providers,
+ _resolve_dotted_path,
_should_skip_class,
compare_with_ast,
discover_classes_from_provider,
@@ -370,6 +371,18 @@ class FakeSecretBackend:
__module__ = "airflow.providers.amazon.aws.secrets.secrets_manager"
+class FakePlugin:
+ """S3 plugin."""
+
+ __module__ = "airflow.providers.amazon.aws.plugins.s3"
+
+
+class FakeDialect:
+ """Redshift dialect."""
+
+ __module__ = "airflow.providers.amazon.aws.dialects.redshift"
+
+
def _make_module(name: str, members: dict) -> types.ModuleType:
"""Create a fake module with given members."""
mod = types.ModuleType(name)
@@ -411,6 +424,20 @@ FAKE_PROVIDER_YAML = {
"executors": [
"airflow.providers.amazon.aws.executors.ecs.FakeExecutor",
],
+ "plugins": [
+ {
+ "name": "AmazonS3Plugin",
+ "plugin-class":
"airflow.providers.amazon.aws.plugins.s3.FakePlugin",
+ },
+ "not-a-dict-entry",
+ ],
+ "dialects": [
+ {
+ "dialect-type": "redshift",
+ "dialect-class-name":
"airflow.providers.amazon.aws.dialects.redshift.FakeDialect",
+ },
+ "not-a-dict-entry",
+ ],
"task-decorators": [],
}
@@ -473,6 +500,14 @@ class TestDiscoverClassesFromProvider:
"airflow.providers.amazon.aws.executors.ecs",
{"FakeExecutor": FakeExecutor},
),
+ "airflow.providers.amazon.aws.plugins.s3": _make_module(
+ "airflow.providers.amazon.aws.plugins.s3",
+ {"FakePlugin": FakePlugin},
+ ),
+ "airflow.providers.amazon.aws.dialects.redshift": _make_module(
+ "airflow.providers.amazon.aws.dialects.redshift",
+ {"FakeDialect": FakeDialect},
+ ),
}
if module_name in modules:
return modules[module_name]
@@ -513,6 +548,42 @@ class TestDiscoverClassesFromProvider:
assert len(hooks) == 1
assert hooks[0]["name"] == "FakeHook"
+ def test_discovers_plugin(self, provider_yaml_path, base_classes):
+ with (
+ patch("extract_parameters.PROVIDERS_DIR",
provider_yaml_path.parent.parent),
+ patch("extract_parameters.importlib.import_module",
side_effect=self._mock_import),
+ ):
+ result = discover_classes_from_provider(provider_yaml_path,
base_classes)
+
+ plugins = [r for r in result if r["type"] == "plugin"]
+ assert len(plugins) == 1
+ assert plugins[0]["name"] == "FakePlugin"
+ assert plugins[0]["category"] == "plugins"
+
+ def test_discovers_dialect(self, provider_yaml_path, base_classes):
+ with (
+ patch("extract_parameters.PROVIDERS_DIR",
provider_yaml_path.parent.parent),
+ patch("extract_parameters.importlib.import_module",
side_effect=self._mock_import),
+ ):
+ result = discover_classes_from_provider(provider_yaml_path,
base_classes)
+
+ dialects = [r for r in result if r["type"] == "dialect"]
+ assert len(dialects) == 1
+ assert dialects[0]["name"] == "FakeDialect"
+ assert dialects[0]["category"] == "dialects"
+
+ def test_skips_non_dict_plugin_and_dialect_entries(self,
provider_yaml_path, base_classes):
+ """FAKE_PROVIDER_YAML's plugins/dialects each carry a bare-string entry
+ alongside the valid dict entry; it must be skipped, not raise."""
+ with (
+ patch("extract_parameters.PROVIDERS_DIR",
provider_yaml_path.parent.parent),
+ patch("extract_parameters.importlib.import_module",
side_effect=self._mock_import),
+ ):
+ result = discover_classes_from_provider(provider_yaml_path,
base_classes)
+
+ assert len([r for r in result if r["type"] == "plugin"]) == 1
+ assert len([r for r in result if r["type"] == "dialect"]) == 1
+
def test_filters_reexported_classes(self, provider_yaml_path,
base_classes):
"""Classes where cls.__module__ != the module being scanned should be
excluded."""
with (
@@ -1086,3 +1157,31 @@ class TestParseRequestedProviders:
def test_duplicate_providers_collapsed(self):
assert _parse_requested_providers("amazon amazon google") ==
{"amazon", "google"}
+
+
+class TestResolveDottedPath:
+ @pytest.mark.parametrize(
+ "class_path",
+ (
+ "no_dot_here",
+ "this.module.does.not.exist.at.all.Foo",
+ ),
+ )
+ def test__resolve_dotted_path_returns_none(self, class_path: str):
+ assert _resolve_dotted_path(class_path) is None
+
+ @pytest.mark.parametrize(
+ ("class_path", "expected"),
+ (
+ (
+ "extract_parameters.ThisAttrDoesNotExist",
+ ("extract_parameters", "ThisAttrDoesNotExist", None),
+ ),
+ (
+ "extract_parameters.get_category",
+ ("extract_parameters", "get_category", get_category),
+ ),
+ ),
+ )
+ def test__resolve_dotted_path(self, class_path: str, expected: tuple[str,
str, object]):
+ assert _resolve_dotted_path(class_path) == expected
diff --git a/dev/registry/tests/test_extract_versions.py
b/dev/registry/tests/test_extract_versions.py
index 45a2e26a2ea..a2c8ce33575 100644
--- a/dev/registry/tests/test_extract_versions.py
+++ b/dev/registry/tests/test_extract_versions.py
@@ -18,11 +18,14 @@
from __future__ import annotations
+import pytest
from extract_versions import (
AIRFLOW_ROOT,
PROVIDERS_JSON_CANDIDATES,
SCRIPT_DIR,
+ extract_modules_from_yaml,
)
+from registry_tools.types import CLASS_LEVEL_SECTIONS,
DICT_SHAPED_CLASS_LEVEL_SECTIONS
class TestProvidersJsonCandidates:
@@ -49,3 +52,121 @@ class TestProvidersJsonCandidates:
# should be caught and fixed at the source. Match siblings (extract_
# parameters.py, extract_metadata.py) which use exactly these two.
assert len(PROVIDERS_JSON_CANDIDATES) == 2
+
+
+# Expected (category, description_suffix) per class-level section, entered by
+# hand from the PR #70190 spec rather than derived from production code, so a
+# regression in extract_versions.py's mapping logic is actually caught.
+EXPECTED_CLASS_LEVEL_CATEGORIES = {
+ "notifications": "notifications",
+ "secrets-backends": "secrets",
+ "logging": "logging",
+ "executors": "executors",
+ "extra-links": "extra-links",
+ "queues": "queues",
+ "auth-managers": "auth-managers",
+ "db-managers": "db-managers",
+}
+
+EXPECTED_CLASS_LEVEL_DESC_SUFFIXES = {
+ "notifier": "notifier",
+ "secret": "secrets backend",
+ "logging": "log handler",
+ "executor": "executor",
+ "extra_link": "extra link",
+ "queue": "queue",
+ "auth_manager": "auth manager",
+ "db_manager": "db manager",
+}
+
+
+def _extract_class_level_modules(provider_yaml: dict) -> list[dict]:
+ return extract_modules_from_yaml(
+ provider_yaml,
+ tag="providers-test/1.0.0",
+ layout="new",
+ dir_path="test",
+ provider_id="test",
+ version="1.0.0",
+ )
+
+
+class TestExtractModulesFromYamlClassLevelSections:
+ @pytest.mark.parametrize(("yaml_key", "mod_type"),
list(CLASS_LEVEL_SECTIONS.items()))
+ def test_class_level_section_produces_module(self, yaml_key, mod_type):
+ class_path = f"airflow.providers.test.{yaml_key.replace('-',
'_')}.example.ExampleClass"
+ provider_yaml = {yaml_key: [class_path]}
+
+ modules = _extract_class_level_modules(provider_yaml)
+
+ assert len(modules) == 1
+ module = modules[0]
+ assert module["type"] == mod_type
+ assert module["category"] == EXPECTED_CLASS_LEVEL_CATEGORIES[yaml_key]
+ expected_desc_suffix = EXPECTED_CLASS_LEVEL_DESC_SUFFIXES[mod_type]
+ assert module["short_description"] == f"ExampleClass
{expected_desc_suffix}"
+
+ def test_all_class_level_sections_produce_covered_types(self):
+ """Type coverage must track CLASS_LEVEL_SECTIONS; a key added there
+ without matching handling in extract_versions.py fails here."""
+ provider_yaml = {
+ yaml_key: [f"airflow.providers.test.{yaml_key.replace('-',
'_')}.example.ExampleClass"]
+ for yaml_key in CLASS_LEVEL_SECTIONS
+ }
+
+ modules = _extract_class_level_modules(provider_yaml)
+
+ assert {m["type"] for m in modules} ==
set(CLASS_LEVEL_SECTIONS.values())
+
+
+# plugins/dialects aren't in CLASS_LEVEL_CATEGORY_OVERRIDES, so category ==
+# yaml_key itself (same fallback as EXPECTED_CLASS_LEVEL_CATEGORIES above).
+EXPECTED_DICT_SHAPED_CATEGORIES = {
+ "plugins": "plugins",
+ "dialects": "dialects",
+}
+
+EXPECTED_DICT_SHAPED_DESC_SUFFIXES = {
+ "plugin": "plugin",
+ "dialect": "dialect",
+}
+
+
+class TestExtractModulesFromYamlDictShapedSections:
+ @pytest.mark.parametrize(
+ ("yaml_key", "type_and_field"),
+ list(DICT_SHAPED_CLASS_LEVEL_SECTIONS.items()),
+ )
+ def test_dict_shaped_section_produces_module(self, yaml_key,
type_and_field):
+ mod_type, class_path_field, _integration_field = type_and_field
+ class_path = f"airflow.providers.test.{yaml_key.replace('-',
'_')}.example.ExampleClass"
+ provider_yaml = {yaml_key: [{class_path_field: class_path}]}
+
+ modules = _extract_class_level_modules(provider_yaml)
+
+ assert len(modules) == 1
+ module = modules[0]
+ assert module["type"] == mod_type
+ assert module["category"] == EXPECTED_DICT_SHAPED_CATEGORIES[yaml_key]
+ expected_desc_suffix = EXPECTED_DICT_SHAPED_DESC_SUFFIXES[mod_type]
+ assert module["short_description"] == f"ExampleClass
{expected_desc_suffix}"
+
+ def test_all_dict_shaped_sections_produce_covered_types(self):
+ """Type coverage must track DICT_SHAPED_CLASS_LEVEL_SECTIONS; a key
+ added there without matching handling in extract_versions.py fails
here."""
+ provider_yaml = {
+ yaml_key: [
+ {
+ class_path_field:
f"airflow.providers.test.{yaml_key.replace('-', '_')}.example.ExampleClass"
+ }
+ ]
+ for yaml_key, (
+ _,
+ class_path_field,
+ _integration_field,
+ ) in DICT_SHAPED_CLASS_LEVEL_SECTIONS.items()
+ }
+
+ modules = _extract_class_level_modules(provider_yaml)
+
+ assert {m["type"] for m in modules} == {t for t, _, _ in
DICT_SHAPED_CLASS_LEVEL_SECTIONS.values()}
diff --git a/dev/registry/tests/test_types.py b/dev/registry/tests/test_types.py
index d5d2dfbf218..85bb578745d 100644
--- a/dev/registry/tests/test_types.py
+++ b/dev/registry/tests/test_types.py
@@ -22,7 +22,9 @@ import pytest
from registry_tools.types import (
ALL_TYPE_IDS,
BASE_CLASS_IMPORTS,
+ CLASS_LEVEL_CATEGORY_OVERRIDES,
CLASS_LEVEL_SECTIONS,
+ DICT_SHAPED_CLASS_LEVEL_SECTIONS,
FLAT_LEVEL_SECTIONS,
MODULE_LEVEL_SECTIONS,
MODULE_TYPES,
@@ -93,6 +95,30 @@ class TestDerivedLookups:
assert yaml_key in FLAT_LEVEL_SECTIONS
assert FLAT_LEVEL_SECTIONS[yaml_key] == type_id
+ def
test_class_level_category_overrides_are_subset_of_class_level_sections(self):
+ assert set(CLASS_LEVEL_CATEGORY_OVERRIDES.keys()) <=
set(CLASS_LEVEL_SECTIONS.keys())
+
+ def test_dict_shaped_class_level_sections_are_subset_of_flat(self):
+ for yaml_key, (type_id, _field_name, _integration_field) in
DICT_SHAPED_CLASS_LEVEL_SECTIONS.items():
+ assert yaml_key in FLAT_LEVEL_SECTIONS
+ assert FLAT_LEVEL_SECTIONS[yaml_key] == type_id
+
+ def test_every_flat_section_is_consumed_by_a_class_level_table(self):
+ # "transfers" and "task-decorators" are deliberately exempt: they are
+ # consumed by their own dedicated extraction paths rather than by
+ # CLASS_LEVEL_SECTIONS or DICT_SHAPED_CLASS_LEVEL_SECTIONS.
+ dedicated_path_exemptions = {"transfers", "task-decorators"}
+ consumed = (
+ set(CLASS_LEVEL_SECTIONS) | set(DICT_SHAPED_CLASS_LEVEL_SECTIONS)
| dedicated_path_exemptions
+ )
+ assert set(FLAT_LEVEL_SECTIONS) == consumed, (
+ f"set(FLAT_LEVEL_SECTIONS) {set(FLAT_LEVEL_SECTIONS)} != consumed
{consumed}. "
+ "Every flat-level section must be consumed by CLASS_LEVEL_SECTIONS
or "
+ "DICT_SHAPED_CLASS_LEVEL_SECTIONS, otherwise both extractors
silently skip it. "
+ "Add the new section to whichever table matches its yaml shape,
or, if it is "
+ "handled by a dedicated path, add it to dedicated_path_exemptions
above."
+ )
+
class TestBaseClassImports:
def test_all_entries_are_tuples(self):
diff --git a/registry/.eleventy.js b/registry/.eleventy.js
index bdd5ce7b20c..6a61ca7d792 100644
--- a/registry/.eleventy.js
+++ b/registry/.eleventy.js
@@ -99,6 +99,28 @@ module.exports = function(eleventyConfig) {
return JSON.stringify(obj);
});
+ // Attaches each type's module count and drops zero-count types, sorted by
+ // count descending -- used to render the provider-page module tabs with the
+ // busiest categories first.
+ eleventyConfig.addFilter("sortTypesByCount", (types, moduleCounts) => {
+ if (!Array.isArray(types)) return [];
+ const counts = moduleCounts || {};
+ return types
+ .filter((t) => (counts[t.id] || 0) > 0)
+ .map((t) => Object.assign({}, t, { count: counts[t.id] || 0 }))
+ .sort((a, b) => b.count - a.count);
+ });
+
+ // Looks up the curated single-letter icon for a type id from types.json,
instead of
+ // deriving it from the id's first character at each render site (which
reintroduces
+ // the letter collisions types.json's `icon` field was curated to avoid).
+ eleventyConfig.addFilter("typeIcon", (types, typeId) => {
+ if (!Array.isArray(types) || !typeId) return "";
+ const match = types.find((t) => t.id === typeId);
+ if (match && match.icon) return match.icon;
+ return typeId.charAt(0).toUpperCase();
+ });
+
eleventyConfig.addShortcode("year", () => `${new Date().getFullYear()}`);
return {
diff --git a/registry/src/_data/types.json b/registry/src/_data/types.json
index 150f986b2e4..cb4d36f0166 100644
--- a/registry/src/_data/types.json
+++ b/registry/src/_data/types.json
@@ -63,5 +63,35 @@
"id": "retry_policy",
"label": "Retry Policies",
"icon": "R"
+ },
+ {
+ "id": "extra_link",
+ "label": "Extra Links",
+ "icon": "I"
+ },
+ {
+ "id": "queue",
+ "label": "Message Queues",
+ "icon": "Q"
+ },
+ {
+ "id": "plugin",
+ "label": "Plugins",
+ "icon": "P"
+ },
+ {
+ "id": "auth_manager",
+ "label": "Auth Managers",
+ "icon": "A"
+ },
+ {
+ "id": "db_manager",
+ "label": "DB Managers",
+ "icon": "M"
+ },
+ {
+ "id": "dialect",
+ "label": "Dialects",
+ "icon": "D"
}
]
diff --git a/registry/src/css/main.css b/registry/src/css/main.css
index 8a262428b55..3e0b077f429 100644
--- a/registry/src/css/main.css
+++ b/registry/src/css/main.css
@@ -898,6 +898,14 @@ footer a:hover {
.provider-card .modules .logging { background: var(--color-logging); }
.provider-card .modules .bundle { background: var(--color-bundle); }
.provider-card .modules .decorator { background: var(--color-decorator); }
+.provider-card .modules .toolset { background: var(--color-toolset); }
+.provider-card .modules .retry_policy { background: var(--color-retry_policy);
}
+.provider-card .modules .extra_link { background: var(--color-extra_link); }
+.provider-card .modules .queue { background: var(--color-queue); }
+.provider-card .modules .plugin { background: var(--color-plugin); }
+.provider-card .modules .auth_manager { background: var(--color-auth_manager);
}
+.provider-card .modules .db_manager { background: var(--color-db_manager); }
+.provider-card .modules .dialect { background: var(--color-dialect); }
.provider-card .meta,
#featured-providers .meta {
@@ -2158,6 +2166,45 @@ main {
color: var(--color-decorator);
}
+.type-icon.toolset {
+ background: rgb(from var(--color-toolset) r g b / 0.2);
+ color: var(--color-toolset);
+}
+
+.type-icon.retry_policy {
+ background: rgb(from var(--color-retry_policy) r g b / 0.2);
+ color: var(--color-retry_policy);
+}
+
+.type-icon.extra_link {
+ background: rgb(from var(--color-extra_link) r g b / 0.2);
+ color: var(--color-extra_link);
+}
+
+.type-icon.queue {
+ background: rgb(from var(--color-queue) r g b / 0.2);
+ color: var(--color-queue);
+}
+
+.type-icon.plugin {
+ background: rgb(from var(--color-plugin) r g b / 0.2);
+ color: var(--color-plugin);
+}
+
+.type-icon.auth_manager {
+ background: rgb(from var(--color-auth_manager) r g b / 0.2);
+ color: var(--color-auth_manager);
+}
+
+.type-icon.db_manager {
+ background: rgb(from var(--color-db_manager) r g b / 0.2);
+ color: var(--color-db_manager);
+}
+
+.type-icon.dialect {
+ background: rgb(from var(--color-dialect) r g b / 0.2);
+ color: var(--color-dialect);
+}
.type-count {
font-size: var(--text-2xl);
@@ -2246,6 +2293,37 @@ main {
--meter-color: var(--color-decorator);
}
+.share-bar.toolset {
+ --meter-color: var(--color-toolset);
+}
+
+.share-bar.retry_policy {
+ --meter-color: var(--color-retry_policy);
+}
+
+.share-bar.extra_link {
+ --meter-color: var(--color-extra_link);
+}
+
+.share-bar.queue {
+ --meter-color: var(--color-queue);
+}
+
+.share-bar.plugin {
+ --meter-color: var(--color-plugin);
+}
+
+.share-bar.auth_manager {
+ --meter-color: var(--color-auth_manager);
+}
+
+.share-bar.db_manager {
+ --meter-color: var(--color-db_manager);
+}
+
+.share-bar.dialect {
+ --meter-color: var(--color-dialect);
+}
.share-label {
font-size: var(--text-xs);
@@ -3067,6 +3145,55 @@ main {
.tab-icon.decorator { background: rgb(from var(--color-decorator) r g b /
0.2); color: var(--color-decorator); }
.tab-icon.toolset { background: rgb(from var(--color-toolset) r g b / 0.2);
color: var(--color-toolset); }
.tab-icon.retry_policy { background: rgb(from var(--color-retry_policy) r g b
/ 0.2); color: var(--color-retry_policy); }
+.tab-icon.extra_link { background: rgb(from var(--color-extra_link) r g b /
0.2); color: var(--color-extra_link); }
+.tab-icon.queue { background: rgb(from var(--color-queue) r g b / 0.2); color:
var(--color-queue); }
+.tab-icon.plugin { background: rgb(from var(--color-plugin) r g b / 0.2);
color: var(--color-plugin); }
+.tab-icon.auth_manager { background: rgb(from var(--color-auth_manager) r g b
/ 0.2); color: var(--color-auth_manager); }
+.tab-icon.db_manager { background: rgb(from var(--color-db_manager) r g b /
0.2); color: var(--color-db_manager); }
+.tab-icon.dialect { background: rgb(from var(--color-dialect) r g b / 0.2);
color: var(--color-dialect); }
+
+/* Module Tabs "More" overflow menu */
+.module-tab-more {
+ position: relative;
+}
+
+.module-tab-more-btn {
+ display: inline-flex;
+}
+
+.module-tab-more-btn svg {
+ width: 1rem;
+ height: 1rem;
+ transition: transform var(--transition-base);
+}
+
+.module-tab-more-btn[aria-expanded="true"] svg {
+ transform: rotate(180deg);
+}
+
+.module-tab-more-menu {
+ position: absolute;
+ top: calc(100% + var(--space-2));
+ left: 0;
+ z-index: 20;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ min-width: 12rem;
+ padding: var(--space-2);
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+}
+
+.module-tab-more-menu[hidden] {
+ display: none;
+}
+
+.module-tab-more-menu .module-tab {
+ justify-content: flex-start;
+}
/* Modules Layout (sidebar + content) */
.modules-layout {
@@ -3242,6 +3369,84 @@ main {
border: 1px solid rgb(from var(--color-bundle) r g b / 0.3);
}
+.provider-detail-page .modules .module .icon.notifier {
+ background: rgb(from var(--color-notifier) r g b / 0.2);
+ color: var(--color-notifier);
+ border: 1px solid rgb(from var(--color-notifier) r g b / 0.3);
+}
+
+.provider-detail-page .modules .module .icon.secret {
+ background: rgb(from var(--color-secret) r g b / 0.2);
+ color: var(--color-secret);
+ border: 1px solid rgb(from var(--color-secret) r g b / 0.3);
+}
+
+.provider-detail-page .modules .module .icon.logging {
+ background: rgb(from var(--color-logging) r g b / 0.2);
+ color: var(--color-logging);
+ border: 1px solid rgb(from var(--color-logging) r g b / 0.3);
+}
+
+.provider-detail-page .modules .module .icon.executor {
+ background: rgb(from var(--color-executor) r g b / 0.2);
+ color: var(--color-executor);
+ border: 1px solid rgb(from var(--color-executor) r g b / 0.3);
+}
+
+.provider-detail-page .modules .module .icon.decorator {
+ background: rgb(from var(--color-decorator) r g b / 0.2);
+ color: var(--color-decorator);
+ border: 1px solid rgb(from var(--color-decorator) r g b / 0.3);
+}
+
+.provider-detail-page .modules .module .icon.toolset {
+ background: rgb(from var(--color-toolset) r g b / 0.2);
+ color: var(--color-toolset);
+ border: 1px solid rgb(from var(--color-toolset) r g b / 0.3);
+}
+
+.provider-detail-page .modules .module .icon.retry_policy {
+ background: rgb(from var(--color-retry_policy) r g b / 0.2);
+ color: var(--color-retry_policy);
+ border: 1px solid rgb(from var(--color-retry_policy) r g b / 0.3);
+}
+
+.provider-detail-page .modules .module .icon.extra_link {
+ background: rgb(from var(--color-extra_link) r g b / 0.2);
+ color: var(--color-extra_link);
+ border: 1px solid rgb(from var(--color-extra_link) r g b / 0.3);
+}
+
+.provider-detail-page .modules .module .icon.queue {
+ background: rgb(from var(--color-queue) r g b / 0.2);
+ color: var(--color-queue);
+ border: 1px solid rgb(from var(--color-queue) r g b / 0.3);
+}
+
+.provider-detail-page .modules .module .icon.plugin {
+ background: rgb(from var(--color-plugin) r g b / 0.2);
+ color: var(--color-plugin);
+ border: 1px solid rgb(from var(--color-plugin) r g b / 0.3);
+}
+
+.provider-detail-page .modules .module .icon.auth_manager {
+ background: rgb(from var(--color-auth_manager) r g b / 0.2);
+ color: var(--color-auth_manager);
+ border: 1px solid rgb(from var(--color-auth_manager) r g b / 0.3);
+}
+
+.provider-detail-page .modules .module .icon.db_manager {
+ background: rgb(from var(--color-db_manager) r g b / 0.2);
+ color: var(--color-db_manager);
+ border: 1px solid rgb(from var(--color-db_manager) r g b / 0.3);
+}
+
+.provider-detail-page .modules .module .icon.dialect {
+ background: rgb(from var(--color-dialect) r g b / 0.2);
+ color: var(--color-dialect);
+ border: 1px solid rgb(from var(--color-dialect) r g b / 0.3);
+}
+
.provider-detail-page .modules .module .content {
flex: 1;
min-width: 0;
diff --git a/registry/src/css/tokens.css b/registry/src/css/tokens.css
index 74a6c039e00..5d8b27897f8 100644
--- a/registry/src/css/tokens.css
+++ b/registry/src/css/tokens.css
@@ -67,6 +67,12 @@
--color-decorator: #d946ef;
--color-toolset: #84cc16;
--color-retry_policy: #10b981;
+ --color-extra_link: #06b6d4;
+ --color-queue: #eab308;
+ --color-plugin: #8b5cf6;
+ --color-auth_manager: #ef4444;
+ --color-db_manager: #64748b;
+ --color-dialect: #f472b6;
/* Additional Colors */
--color-green-400: #4ade80;
diff --git a/registry/src/js/provider-detail.js
b/registry/src/js/provider-detail.js
index df6a81c02e1..2ecb7a52f99 100644
--- a/registry/src/js/provider-detail.js
+++ b/registry/src/js/provider-detail.js
@@ -25,7 +25,9 @@
var extraDepsList = document.getElementById('extra-deps-list');
var extrasDataEl = document.getElementById('extras-data');
var moduleSearch = document.getElementById('module-search');
- var moduleTabs = document.querySelectorAll('.module-tab');
+ // [data-type] excludes the "More" toggle button, which is a .module-tab
+ // for styling purposes only and has no data-type of its own.
+ var moduleTabs = document.querySelectorAll('.module-tab[data-type]');
var categoryBtns = document.querySelectorAll('.category-btn');
var moduleItems = document.querySelectorAll('.modules .module');
var copyImportBtns = document.querySelectorAll('.copy-import');
@@ -152,9 +154,52 @@
tab.classList.add('active');
currentType = tab.dataset.type || 'all';
filterModules();
+ if (moduleTabMoreBtn && moduleTabMoreMenu) {
+ moduleTabMoreBtn.classList.toggle('active',
moduleTabMoreMenu.contains(tab));
+ }
});
});
+ // "More" overflow menu for module tabs that don't fit in the visible row.
+ var moduleTabMoreBtn = document.getElementById('module-tab-more-btn');
+ var moduleTabMoreMenu = document.getElementById('module-tab-more-menu');
+
+ if (moduleTabMoreBtn && moduleTabMoreMenu) {
+ function closeMoreMenu() {
+ var focusWasInMenu = moduleTabMoreMenu.contains(document.activeElement);
+ moduleTabMoreBtn.setAttribute('aria-expanded', 'false');
+ moduleTabMoreMenu.hidden = true;
+ if (focusWasInMenu) { moduleTabMoreBtn.focus(); }
+ }
+
+ moduleTabMoreBtn.addEventListener('click', function(e) {
+ e.stopPropagation();
+ var isOpen = moduleTabMoreBtn.getAttribute('aria-expanded') === 'true';
+ if (isOpen) {
+ closeMoreMenu();
+ } else {
+ moduleTabMoreBtn.setAttribute('aria-expanded', 'true');
+ moduleTabMoreMenu.hidden = false;
+ }
+ });
+
+ document.addEventListener('click', function(e) {
+ if (!moduleTabMoreMenu.contains(e.target) && e.target !==
moduleTabMoreBtn) {
+ closeMoreMenu();
+ }
+ });
+
+ document.addEventListener('keydown', function(e) {
+ if (e.key === 'Escape') closeMoreMenu();
+ });
+
+ // Selecting a tab inside the menu also closes it (filtering itself is
+ // handled by the shared moduleTabs click listener above).
+ moduleTabMoreMenu.querySelectorAll('.module-tab').forEach(function(tab) {
+ tab.addEventListener('click', closeMoreMenu);
+ });
+ }
+
categoryBtns.forEach(function(btn) {
btn.addEventListener('click', function() {
categoryBtns.forEach(function(b) { b.classList.remove('active'); });
diff --git a/registry/src/js/search.js b/registry/src/js/search.js
index d7b13d523d6..1f984183418 100644
--- a/registry/src/js/search.js
+++ b/registry/src/js/search.js
@@ -24,17 +24,19 @@
let currentResults = [];
let searchId = 0;
- // Type labels loaded from types.json (injected via base.njk)
+ // Type labels/icons loaded from types.json (injected via base.njk)
const typeLabels = {};
+ const typeIcons = {};
try {
const typesEl = document.getElementById('types-data');
if (typesEl) {
for (const t of JSON.parse(typesEl.textContent)) {
typeLabels[t.id] = t.label;
+ typeIcons[t.id] = t.icon;
}
}
} catch (_) {
- // Fallback: empty object — badges will show raw type name
+ // Fallback: empty objects — badges show raw type name, icons fall back to
first letter
}
function escapeHtml(str) {
@@ -105,7 +107,7 @@
const providerName = result.meta.providerName || '';
const description = result.meta.description || result.excerpt;
const moduleType = result.meta.moduleType || '';
- const icon = type === 'provider' ? 'P' : (moduleType ?
moduleType[0].toUpperCase() : 'M');
+ const icon = type === 'provider' ? 'P' : (moduleType ?
(typeIcons[moduleType] || moduleType[0].toUpperCase()) : 'M');
const resultType = type === 'provider' ? 'provider' : moduleType;
return `
diff --git a/registry/src/provider-version.njk
b/registry/src/provider-version.njk
index 49356c0796f..c76be316bec 100644
--- a/registry/src/provider-version.njk
+++ b/registry/src/provider-version.njk
@@ -276,16 +276,35 @@ eleventyComputed:
</section>
{% if totalModules > 0 %}
- {# Module Type Tabs #}
+ {# Module Type Tabs: sorted by module count (busiest first), overflow into a
"More" menu #}
+ {% set moduleTabsVisibleCount = 8 %}
+ {% set sortedTypes = types | sortTypesByCount(moduleCounts) %}
+ {% set visibleTypes = sortedTypes | slice(0, moduleTabsVisibleCount) %}
+ {% set overflowTypes = sortedTypes | slice(moduleTabsVisibleCount,
sortedTypes.length) %}
<nav class="module-tabs">
<button class="module-tab active" data-type="all">All ({{ totalModules
}})</button>
- {% for t in types %}
- {% if moduleCounts[t.id] > 0 %}
+ {% for t in visibleTypes %}
<button class="module-tab" data-type="{{ t.id }}">
- <span class="tab-icon {{ t.id }}">{{ t.icon }}</span> {{ t.label }} ({{
moduleCounts[t.id] }})
+ <span class="tab-icon {{ t.id }}">{{ t.icon }}</span> {{ t.label }} ({{
t.count }})
</button>
- {% endif %}
{% endfor %}
+ {% if overflowTypes.length > 0 %}
+ <div class="module-tab-more">
+ <button class="module-tab module-tab-more-btn" type="button"
id="module-tab-more-btn" aria-haspopup="true" aria-expanded="false"
aria-controls="module-tab-more-menu">
+ More
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"
aria-hidden="true">
+ <path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M19 9l-7 7-7-7" />
+ </svg>
+ </button>
+ <div class="module-tab-more-menu" id="module-tab-more-menu" hidden>
+ {% for t in overflowTypes %}
+ <button class="module-tab" data-type="{{ t.id }}">
+ <span class="tab-icon {{ t.id }}">{{ t.icon }}</span> {{ t.label }}
({{ t.count }})
+ </button>
+ {% endfor %}
+ </div>
+ </div>
+ {% endif %}
</nav>
{# Main content with sidebar #}
@@ -352,7 +371,7 @@ eleventyComputed:
{% for module in providerModules %}
<div id="{{ module.id or module.name }}" class="module card"
data-name="{{ module.name | lower }}" data-type="{{ module.type }}"
data-category="{{ module.category }}">
<div class="icon {{ module.type }}">
- <span>{{ module.type[0] | upper }}</span>
+ <span>{{ types | typeIcon(module.type) }}</span>
</div>
<div class="content">
<h3>