blob: df9f344ab0001420ada13996a0fa3b7b46d62ec8 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Airflow Registry Parameter & Module Extractor
Discovers provider modules (operators, hooks, sensors, triggers, etc.) at runtime
and extracts constructor/function parameters via MRO or signature inspection.
Produces both modules.json (the full module catalog) and per-provider
parameters.json files.
Must be run inside breeze where all providers are installed.
Usage:
breeze run python dev/registry/extract_parameters.py
Output:
- dev/registry/modules.json (+ registry/src/_data/modules.json on host)
- dev/registry/output/versions/{provider_id}/{version}/parameters.json
- registry/src/_data/versions/{provider_id}/{version}/parameters.json
- dev/registry/runtime_modules.json (debug stats)
"""
from __future__ import annotations
import argparse
import ast
import concurrent.futures
import importlib
import inspect
import json
import logging
import re
import sys
import textwrap
import typing
from collections import defaultdict
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
import yaml
from extract_metadata import fetch_provider_inventory, read_inventory
from extract_versions import detect_layout, git_tag_exists, read_guide_docs as read_guide_docs_at_tag
from registry_contract_models import validate_modules_catalog, validate_provider_parameters
from registry_tools.docs_guides import attach_guide_urls, collect_guide_anchors, is_guide_page
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
PROVIDERS_DIR = AIRFLOW_ROOT / "providers"
PROVIDERS_JSON_CANDIDATES = [
SCRIPT_DIR / "providers.json",
AIRFLOW_ROOT / "registry" / "src" / "_data" / "providers.json",
]
# Inside breeze, write to dev/registry/output/ (mounted).
# On host, also write to the registry data directory.
OUTPUT_DIRS = [
SCRIPT_DIR / "output",
AIRFLOW_ROOT / "registry" / "src" / "_data",
]
@dataclass
class Module:
"""A discovered provider module (operator, hook, sensor, etc.)."""
id: str
name: str # Class name (e.g., SnowflakeOperator)
type: str # operator, hook, sensor, trigger, transfer, etc.
import_path: str # Full import path to the class
module_path: str # Module file path
short_description: str
docs_url: str
source_url: str
category: str
provider_id: str
provider_name: str
supports_durable_execution: bool
supports_deferrable: bool
guide_url: str | None = None
def get_category(integration_name: str) -> str:
"""Slugify an integration name into a category ID."""
cat_id = integration_name.lower().replace(" ", "-").replace("(", "").replace(")", "")
return re.sub(r"[^a-z0-9-]", "", cat_id)
def format_annotation(annotation: type, _depth: int = 0) -> str | None:
"""Convert a type annotation to a human-readable string."""
if _depth > 5:
return str(annotation)
if annotation is inspect.Parameter.empty:
return None
if annotation is type(None):
return "None"
origin = getattr(annotation, "__origin__", None)
# typing.Union (includes X | Y on 3.10+)
if origin is typing.Union:
args = typing.get_args(annotation)
parts = [format_annotation(a, _depth + 1) for a in args]
return " | ".join(p for p in parts if p)
if origin is not None:
args = typing.get_args(annotation)
origin_name = getattr(origin, "__name__", str(origin))
if args:
arg_strs = [format_annotation(a, _depth + 1) or "Any" for a in args]
return f"{origin_name}[{', '.join(arg_strs)}]"
return origin_name
if hasattr(annotation, "__name__"):
return annotation.__name__
s = str(annotation)
s = re.sub(r"\btyping\.", "", s)
s = re.sub(r"\bcollections\.abc\.", "", s)
return s
def format_default(default: object) -> object:
"""Convert a default value to a JSON-serializable representation."""
if default is inspect.Parameter.empty:
return None
if default is None:
return None
if isinstance(default, (str, int, float, bool)):
return default
if isinstance(default, (list, tuple, dict)):
try:
json.dumps(default)
return default
except (TypeError, ValueError):
pass
try:
return str(default)
except Exception:
return repr(default)
def get_params_from_signature(sig: inspect.Signature, qualified_origin: str) -> dict[str, dict]:
"""Convert an inspect.Signature into parameter metadata."""
params: dict[str, dict] = {}
for name, param in sig.parameters.items():
if name in ("self", "cls"):
continue
if name.startswith("_"):
continue
if param.kind in (param.VAR_KEYWORD, param.VAR_POSITIONAL):
continue
params[name] = {
"name": name,
"type": format_annotation(param.annotation),
"default": format_default(param.default),
"required": param.default is inspect.Parameter.empty,
"origin": qualified_origin,
}
return params
def get_params_from_class(cls: type) -> dict[str, dict]:
"""
Extract all __init__ parameters by walking the MRO in reverse
so child class overrides parent for the same parameter name.
Records the full qualified origin (module.ClassName) for each param.
"""
params: dict[str, dict] = {}
for klass in reversed(cls.__mro__):
if klass is object:
continue
init = klass.__dict__.get("__init__")
if init is None:
continue
try:
sig = inspect.signature(init)
except (TypeError, ValueError):
continue
qualified_origin = f"{klass.__module__}.{klass.__qualname__}"
params.update(get_params_from_signature(sig, qualified_origin))
return params
def get_mro_chain(cls: type) -> list[str]:
"""Return the full MRO as a list of qualified class names."""
return [f"{k.__module__}.{k.__qualname__}" for k in cls.__mro__ if k is not object]
def parse_param_descriptions(doc: str) -> dict[str, str]:
"""Parse ``:param name: description`` entries from a docstring."""
descriptions: dict[str, str] = {}
if not doc:
return descriptions
for match in re.finditer(
r":param\s+(\w+):\s*(.+?)(?=\n\s*:\w|\n\s*\.\.|$)",
doc,
re.DOTALL,
):
name = match.group(1)
desc = match.group(2).strip()
desc = re.sub(r"\s+", " ", desc)
if name not in descriptions:
descriptions[name] = desc
return descriptions
def parse_docstring_params(cls: type) -> dict[str, str]:
"""
Parse :param name: description from class and ancestor docstrings.
Child class descriptions take priority.
"""
descriptions: dict[str, str] = {}
for klass in cls.__mro__:
if klass is object:
continue
for name, desc in parse_param_descriptions(getattr(klass, "__doc__", None) or "").items():
if name not in descriptions:
descriptions[name] = desc
return descriptions
def extract_class_params(cls: type) -> tuple[list[str], list[dict]]:
"""
Extract parameter list for a class, merging signature + docstrings.
Only includes params originating from provider classes (airflow.providers.*).
Returns (mro_chain, filtered_params).
"""
params = get_params_from_class(cls)
descriptions = parse_docstring_params(cls)
for name, param in params.items():
if name in descriptions:
param["description"] = descriptions[name]
else:
param["description"] = None
provider_params = [p for p in params.values() if p["origin"].startswith("airflow.providers.")]
mro = get_mro_chain(cls)
return mro, provider_params
def extract_callable_params(func: typing.Callable[..., typing.Any]) -> tuple[list[str], list[dict]]:
"""Extract parameter metadata from a callable signature and docstring."""
try:
sig = inspect.signature(func)
except (TypeError, ValueError) as e:
raise TypeError(f"Could not inspect callable signature: {e}") from e
qualified_origin = f"{func.__module__}.{func.__qualname__}"
params = get_params_from_signature(sig, qualified_origin)
descriptions = parse_param_descriptions(getattr(func, "__doc__", None) or "")
for name, param in params.items():
param["description"] = descriptions.get(name)
provider_params = [p for p in params.values() if p["origin"].startswith("airflow.providers.")]
return [], provider_params
def extract_params(obj: object) -> tuple[list[str], list[dict]]:
"""Extract parameter metadata from either a class or a callable."""
if inspect.isclass(obj):
return extract_class_params(obj)
if callable(obj):
return extract_callable_params(typing.cast("typing.Callable[..., typing.Any]", obj))
raise TypeError(f"Unsupported import type: {type(obj)!r}")
def import_symbol(import_path: str) -> object | None:
"""Import a symbol from its full dotted path."""
parts = import_path.rsplit(".", 1)
if len(parts) != 2:
return None
module_path, symbol_name = parts
try:
module = importlib.import_module(module_path)
return getattr(module, symbol_name, None)
except Exception as e:
print(f" WARN failed to import {import_path}: {e}")
return None
def find_json(candidates: list[Path], name: str) -> Path:
"""Find first existing JSON file from candidates list."""
for candidate in candidates:
if candidate.exists():
return candidate
print(f"ERROR: {name} not found. Searched:")
for c in candidates:
print(f" - {c}")
print(f"\nCopy {name} to dev/registry/ or run extract_metadata.py first.")
sys.exit(1)
log = logging.getLogger(__name__)
def load_base_classes() -> dict[str, type]:
"""Import base classes for issubclass checks.
Returns a mapping of type name -> base class (e.g. "sensor" -> BaseSensorOperator).
"""
base_classes: dict[str, type] = {}
for type_name, import_path in BASE_CLASS_IMPORTS:
module_path, class_name = import_path.rsplit(".", 1)
try:
mod = importlib.import_module(module_path)
base_classes[type_name] = getattr(mod, class_name)
except Exception:
log.warning("Could not import base class %s", import_path)
return base_classes
def _should_skip_class(name: str) -> bool:
"""Return True if a class name should be excluded from discovery."""
if name.startswith("_"):
return True
if name.startswith("Base"):
return True
if "Abstract" in name or "Mixin" in name:
return True
return False
def _get_first_docstring_line(obj: object) -> str | None:
"""Return the first non-empty line of a class docstring, or None."""
doc = getattr(obj, "__doc__", None)
if not doc:
return None
for line in doc.strip().splitlines():
stripped = line.strip()
if stripped:
return stripped
return None
def _get_source_line(cls: type) -> int | None:
"""Return the source line number for a class, or None if unavailable."""
try:
return inspect.getsourcelines(cls)[1]
except (OSError, TypeError):
return None
def load_resumable_job_mixin() -> type | None:
"""Import ResumableJobMixin for durable-execution capability checks, or None if unavailable."""
try:
from airflow.sdk import ResumableJobMixin
return ResumableJobMixin
except ImportError:
log.warning("Could not import ResumableJobMixin")
return None
# Matches an actual self.defer() call or self.deferrable attribute read, but not
# self.defer_for_approval(). `raise TaskDeferred` catches operators that raise it directly
# instead of calling self.defer() (e.g. VespaIngestOperator); requiring the `raise` keeps an
# `except TaskDeferred` handler or a `:raises TaskDeferred:` docstring line from matching.
_DEFERRAL_TOKEN_RE = re.compile(r"self\.defer\(|self\.deferrable\b|raise TaskDeferred")
_SELF_CALL_RE = re.compile(r"self\.([A-Za-z_][A-Za-z0-9_]*)\(")
_SUPER_EXECUTE_RE = re.compile(r"super\(\)\.execute\(")
# Matches the @task.* decorator idiom of naming the parent class directly instead of using
# super() (e.g. `AgentOperator.execute(self, context)` in common.ai's @task.agent).
_EXPLICIT_EXECUTE_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\.execute\(")
# To prevent infinite looping, most cases in the repo are 1-2 hops away.
_MAX_DEFERRAL_WALK_DEPTH = 6
def _get_method_source(cls: type, name: str) -> str | None:
method = getattr(cls, name, None)
if method is None:
return None
try:
return inspect.getsource(method)
except (OSError, TypeError):
return None
def _find_owner_of_execute(mro: tuple[type, ...], start_idx: int) -> type | None:
"""Return the first class in `mro[start_idx:]` whose own `__dict__` defines `execute`."""
for cls in mro[start_idx:]:
if "execute" in cls.__dict__:
return cls
return None
def _next_execute_via_super(origin: type, current: type) -> type | None:
"""Find what `super().execute()` resolves to from `current`, per `origin`'s MRO.
Must use `origin`'s MRO, not `current`'s own: with multiple inheritance (e.g. a
`@task.kubernetes`-built class) they diverge, and a mixin's own MRO may have no
relationship to the class the chain actually needs to reach.
"""
try:
idx = origin.__mro__.index(current)
except ValueError:
return None
return _find_owner_of_execute(origin.__mro__, idx + 1)
def _strip_comment_lines(source: str) -> str:
"""Drop whole-line comments so they can't be mistaken for real delegation code.
A comment can say the opposite of what the code does (e.g. "overrides execute rather
than calling super().execute()"), and a raw-text search can't tell the two apart.
"""
return "\n".join(line for line in source.splitlines() if not line.strip().startswith("#"))
def _next_execute_hop(origin: type, current: type, source: str) -> type | None:
"""Find the next class in `source`'s delegation chain, via `super().execute()` or an
explicit `ParentClass.execute(...)` call.
Every explicit match is tried in order, since an unrelated earlier call (e.g.
`cursor.execute(...)`) can otherwise shadow the real delegation. The matched class is
resolved to whoever actually owns `execute` (it may only inherit one, e.g.
`GKEStartPodOperator.execute(self, context)`), the same way a `super()` hop is.
"""
source = _strip_comment_lines(source)
if _SUPER_EXECUTE_RE.search(source):
return _next_execute_via_super(origin, current)
mro_by_name = {base.__name__: base for base in origin.__mro__}
for match in _EXPLICIT_EXECUTE_RE.finditer(source):
named_cls = mro_by_name.get(match.group(1))
if named_cls is None:
continue
owner = _find_owner_of_execute(origin.__mro__, origin.__mro__.index(named_cls))
if owner is not None:
return owner
return None
def _find_marker_declaring_class(cls: type) -> type | None:
"""Return the class in `cls`'s MRO whose own body sets `__supports_durable_execution = True`.
The lookup is per-class (`_{base.__name__}__supports_durable_execution`), not a fixed
string, and only matches a class whose own `__dict__` carries the (mangled) name;
inheriting the attribute value from a base doesn't count, only writing it yourself does.
Leading underscores in the class name are stripped first, matching Python's own name
mangling rule (`_Foo` mangles to `_Foo__x`, not `__Foo__x`).
"""
for base in cls.__mro__:
mangled = f"_{base.__name__.lstrip('_')}__supports_durable_execution"
if base.__dict__.get(mangled) is True:
return base
return None
def _delegates_execute_to(cls: type, target: type, depth: int) -> bool:
"""Return True if `cls`'s resolved `execute()` chain reaches `target.execute`.
Covers a class that never overrides `execute` (inherits `target.execute` directly, e.g.
GKEStartPodOperator), one whose override ends in `super().execute(context)` (e.g.
EksPodOperator), and one that names the parent class directly instead (e.g.
`AgentOperator.execute(self, context)` in `@task.agent`).
The `super().execute(` check is a text search with no view of control flow, so a class
that returns before reaching that call still matches. SparkKubernetesOperator is the
live example: its deferrable path returns out of `execute_async(context)` and never
reaches the `super().execute(context)` below it. The durable verdict is still right
there, but for a reason this function does not test, since KubernetesPodOperator's
`execute_sync` and `execute_async` both reach the pod reattach independently. The gap
this leaves: a subclass whose `execute` neither reaches the marker-declaring class nor
goes through that reattach would inherit the durable claim without earning it.
"""
owner = _find_owner_of_execute(cls.__mro__, 0)
remaining = depth
while owner is not None:
if owner is target:
return True
if remaining <= 0:
return False
source = _get_method_source(owner, "execute")
if source is None:
return False
owner = _next_execute_hop(cls, owner, source)
remaining -= 1
return False
def _execute_chain_calls_resumable(cls: type, depth: int) -> bool:
"""Return True if some class along `cls`'s resolved `execute()` chain calls execute_resumable().
Same walk as `_delegates_execute_to`: a delegating override's own source may not mention
`execute_resumable` even though the class it hands off to does.
"""
owner = _find_owner_of_execute(cls.__mro__, 0)
remaining = depth
while owner is not None:
source = _get_method_source(owner, "execute")
if source is None:
return False
if "execute_resumable" in source:
return True
if remaining <= 0:
return False
owner = _next_execute_hop(cls, owner, source)
remaining -= 1
return False
def is_durable_capable(cls: type, resumable_mixin: type | None) -> bool:
"""Return True if a class implements durable/crash-safe execution.
Two ways to qualify:
1. A class-level `__supports_durable_execution = True` declaration (for operators like
KubernetesPodOperator/AgentOperator that implement this directly against
task_state_store, without ResumableJobMixin). Inherited by a subclass that hasn't
replaced the declaring class's `execute()`, whether by not overriding it at all, or by
delegating back via `super().execute()`.
2. Genuinely implementing ResumableJobMixin's contract, where `execute()` (or a class it
delegates to) calls `execute_resumable()`.
"""
declaring_cls = _find_marker_declaring_class(cls)
if declaring_cls is not None and _delegates_execute_to(cls, declaring_cls, _MAX_DEFERRAL_WALK_DEPTH):
return True
if resumable_mixin is None or resumable_mixin not in cls.__mro__:
return False
if inspect.isabstract(cls):
return False
return _execute_chain_calls_resumable(cls, _MAX_DEFERRAL_WALK_DEPTH)
def _is_terminal_block(body: list[ast.stmt]) -> bool:
"""Return True if `body`'s last statement always exits the function (raise or return)."""
return bool(body) and isinstance(body[-1], (ast.Raise, ast.Return))
def _strip_dead_version_guard_branches(source: str, resolve_global: typing.Callable[[str], object]) -> str:
"""Blank out code after a terminal `if <flag>: raise ...` whose flag resolves True here.
Some operators write `if AIRFLOW_V_3_3_PLUS: raise ...` with no `else`, followed by a
pre-3.3 `self.defer(...)` fallback that never runs on the core being imported.
`resolve_global` looks up the flag by name rather than a fixed list.
"""
try:
tree = ast.parse(textwrap.dedent(source))
except SyntaxError:
return source
if not tree.body or not isinstance(tree.body[0], (ast.FunctionDef, ast.AsyncFunctionDef)):
return source
dead_from: int | None = None
for stmt in tree.body[0].body:
if (
isinstance(stmt, ast.If)
and not stmt.orelse
and isinstance(stmt.test, ast.Name)
and _is_terminal_block(stmt.body)
and resolve_global(stmt.test.id) is True
):
dead_from = stmt.end_lineno
break
if dead_from is None:
return source
return "".join(source.splitlines(keepends=True)[:dead_from])
def _get_reachable_method_source(cls: type, name: str) -> str | None:
"""Like `_get_method_source`, but with comments and dead version-guard branches stripped."""
method = getattr(cls, name, None)
if method is None:
return None
try:
source = _strip_comment_lines(inspect.getsource(method))
except (OSError, TypeError):
return None
# unwrap() undoes a functools.wraps() decorator, whose __globals__ would otherwise
# point at the decorator's own module instead of the method's. BaseOperatorMeta wraps
# execute on nearly every registered operator, so this is the common case, not an edge
# one: without it the version flag resolves to nothing and no dead branch is stripped.
func = inspect.unwrap(getattr(method, "__func__", method))
module_globals = getattr(func, "__globals__", None)
if module_globals is None:
return source
return _strip_dead_version_guard_branches(source, module_globals.get)
def _references_deferral(
origin: type, current: type, source: str, visited: set[tuple[int, str, int]], depth: int
) -> bool:
"""Return True if `source` (the resolved `execute()` of `current`, called on `origin`) references deferral.
`origin` stays fixed across recursion so `super().execute()` hops resolve against its
real MRO, while `current` walks forward through the chain. `self.<name>()` helpers are
resolved against `origin` rather than `current`, matching how they actually dispatch at
runtime: past the first hop `current` is an ancestor, whose copy of a helper a more
derived class may have overridden. `visited` keys include the remaining depth, so a node
first reached with little budget left isn't skipped when re-reached with more.
"""
if _DEFERRAL_TOKEN_RE.search(source):
return True
if depth <= 0:
return False
next_cls = _next_execute_hop(origin, current, source)
if next_cls is not None:
key = (id(next_cls), "execute", depth)
if key not in visited:
visited.add(key)
next_source = _get_reachable_method_source(next_cls, "execute")
if next_source is not None and _references_deferral(
origin, next_cls, next_source, visited, depth - 1
):
return True
for name in _SELF_CALL_RE.findall(source):
key = (id(origin), name, depth)
if key in visited:
continue
visited.add(key)
helper_source = _get_reachable_method_source(origin, name)
if helper_source is not None and _references_deferral(
origin, current, helper_source, visited, depth - 1
):
return True
return False
def supports_deferrable(cls: type) -> bool:
"""Return True if the class's resolved execute() actually references deferral.
Checking for a `deferrable` constructor parameter isn't enough: a subclass can
inherit the parameter while overriding execute() with code that never reads it,
and an operator that always defers unconditionally has no parameter to find at
all. This walks outward from execute() into helper methods it calls via
`self.<name>(...)`, and up the MRO through `super().execute(...)` looking for
an actual `self.defer(...)` call, a `self.deferrable` read, or a `TaskDeferred`
raise, rather than only checking execute()'s own source text. A `defer()` fallback
behind a version guard that's False on this core (e.g. `if AIRFLOW_V_3_3_PLUS: raise
...`) is excluded, since it never actually runs here.
"""
source = _get_reachable_method_source(cls, "execute")
if source is None:
return False
seed = {(id(cls), "execute", _MAX_DEFERRAL_WALK_DEPTH)}
return _references_deferral(cls, cls, source, seed, _MAX_DEFERRAL_WALK_DEPTH)
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 _resolve_decorated_operator_class(decorator_fn: object) -> type | None:
"""Return the operator class a `@task.*` decorator builds, or None.
A task decorator's registered `class-name` points at the factory function, not at an
operator, so capability detection has nothing to inspect without this hop. Every
decorator in the tree passes its operator to `task_decorator_factory` as
`decorated_operator_class=<Name>`, which is resolved here against the factory's own
module. The class itself is private (`_AgentDecoratedOperator`), so the decorator entry
is the only place it surfaces in the catalog.
"""
try:
source = inspect.getsource(decorator_fn) # type: ignore[arg-type]
except (OSError, TypeError):
return None
match = re.search(r"decorated_operator_class\s*=\s*([A-Za-z_][A-Za-z0-9_]*)", source)
if match is None:
return None
func = inspect.unwrap(getattr(decorator_fn, "__func__", decorator_fn)) # type: ignore[arg-type]
module_globals = getattr(func, "__globals__", None)
if module_globals is None:
return None
candidate = module_globals.get(match.group(1))
return candidate if inspect.isclass(candidate) else None
def read_guide_docs(docs_dir: Path) -> dict[str, str]:
"""Read a provider's authored reST docs from the working tree, keyed by path relative to ``docs_dir``."""
if not docs_dir.is_dir():
return {}
docs = {}
for path in sorted(docs_dir.rglob("*.rst")):
relative = path.relative_to(docs_dir).as_posix()
if not is_guide_page(relative):
continue
docs[relative] = path.read_text(encoding="utf-8")
return docs
def read_released_guide_docs(
provider_id: str, version: str, provider_rel_path: Path
) -> dict[str, str] | None:
"""Read a provider's guide docs at its release tag, or None when there is no tag to read.
The guide links point at ``/stable``, which serves the released docs, so the
anchors must come from the same content; the working tree may be ahead of it.
A tag from the old flat layout yields an empty dict, meaning no guide links
rather than a fallback to the working tree.
"""
if not version:
return None
tag = f"providers-{provider_id}/{version}"
if not git_tag_exists(tag):
return None
dir_path = provider_rel_path.as_posix()
layout = detect_layout(tag, dir_path)
if layout is None:
return None
return read_guide_docs_at_tag(tag, layout, dir_path)
def discover_classes_from_provider(
provider_yaml_path: Path,
base_classes: dict[str, type],
resumable_mixin: type | None = None,
inventory: dict[str, str] | None = None,
version: str = "",
) -> list[dict]:
"""Discover classes from a single provider by importing its modules at runtime.
Reads the provider.yaml to find which modules/classes to inspect, imports them,
and returns metadata for each discovered class with every required `Module`
dataclass field, plus ``guide_url`` when a how-to guide documents the class.
"""
with open(provider_yaml_path) as f:
provider_yaml = yaml.safe_load(f)
provider_id = provider_yaml.get("package-name", "").replace("apache-airflow-providers-", "")
if not provider_id:
return []
provider_name = provider_yaml.get("name", provider_id.replace("-", " ").title())
provider_rel_path = provider_yaml_path.parent.relative_to(PROVIDERS_DIR)
tag = f"providers-{provider_id}/{version}" if version else "main"
base_docs_url = f"https://airflow.apache.org/docs/apache-airflow-providers-{provider_id}/stable"
base_source_url = f"https://github.com/apache/airflow/blob/{tag}/providers/{provider_rel_path}/src"
# Build integration-name lookup for module-level sections
# Maps (section_name, module_path) -> integration_name
integration_by_module: dict[tuple[str, str], str] = {}
for section_name in list(MODULE_LEVEL_SECTIONS) + ["bundles"]:
for group in provider_yaml.get(section_name, []):
integration = group.get("integration-name", "")
for mp in group.get("python-modules", []):
integration_by_module[(section_name, mp)] = integration
def resolve_docs_url(full_class_path: str, module_path: str) -> str:
"""Look up docs URL from inventory, falling back to manual construction."""
if inventory and full_class_path in inventory:
return f"{base_docs_url}/{inventory[full_class_path]}"
api_ref_path = module_path.replace(".", "/")
return f"{base_docs_url}/_api/{api_ref_path}/index.html#{full_class_path}"
def make_source_url(cls: type, module_path: str) -> str:
"""Construct a GitHub source URL with line number when available."""
url = f"{base_source_url}/{module_path.replace('.', '/')}.py"
line = _get_source_line(cls)
if line:
url += f"#L{line}"
return url
def make_entry(
cls_or_obj: type,
name: str,
module_type: str,
import_path: str,
module_path: str,
integration: str = "",
category: str = "",
transfer_desc: str | None = None,
) -> dict:
"""Build a full module entry dict with all fields."""
module_name = module_path.split(".")[-1]
docstring = _get_first_docstring_line(cls_or_obj)
short_desc = docstring or transfer_desc or f"{integration} {module_type}".strip()
return {
"id": f"{provider_id}-{module_name}-{name}",
"name": name,
"type": module_type,
"import_path": import_path,
"module_path": module_path,
"short_description": short_desc,
"docs_url": resolve_docs_url(import_path, module_path),
"source_url": make_source_url(cls_or_obj, module_path),
"category": category or get_category(integration),
"provider_id": provider_id,
"provider_name": provider_name,
"supports_durable_execution": is_durable_capable(cls_or_obj, resumable_mixin),
"supports_deferrable": supports_deferrable(cls_or_obj),
}
discovered: list[dict] = []
# --- Module-level sections (operators, hooks, sensors, triggers, bundles) ---
for section_name, module_type in MODULE_LEVEL_SECTIONS.items():
expected_base = base_classes.get(module_type)
for group in provider_yaml.get(section_name, []):
integration = group.get("integration-name", "")
category = get_category(integration)
for module_path in group.get("python-modules", []):
try:
mod = importlib.import_module(module_path)
except Exception:
log.warning("Could not import module %s", module_path)
continue
for name, cls in inspect.getmembers(mod, inspect.isclass):
if cls.__module__ != mod.__name__:
continue
if _should_skip_class(name):
continue
if expected_base and not issubclass(cls, expected_base):
continue
discovered.append(
make_entry(
cls,
name,
module_type,
f"{module_path}.{name}",
module_path,
integration,
category,
)
)
# --- Transfers (module-level, singular python-module key) ---
transfer_base = base_classes.get("operator")
for transfer in provider_yaml.get("transfers", []):
module_path = transfer.get("python-module", "")
if not module_path:
continue
source = transfer.get("source-integration-name", "")
target = transfer.get("target-integration-name", "")
transfer_desc = f"Transfer from {source} to {target}" if source and target else None
category = get_category(source) if source else ""
try:
mod = importlib.import_module(module_path)
except Exception:
log.warning("Could not import module %s", module_path)
continue
for name, cls in inspect.getmembers(mod, inspect.isclass):
if cls.__module__ != mod.__name__:
continue
if _should_skip_class(name):
continue
if transfer_base and not issubclass(cls, transfer_base):
continue
discovered.append(
make_entry(
cls,
name,
"transfer",
f"{module_path}.{name}",
module_path,
source,
category,
transfer_desc,
)
)
# --- Class-level sections (notifications, secrets-backends, logging, executors) ---
for section_name, module_type in CLASS_LEVEL_SECTIONS.items():
for class_path in provider_yaml.get(section_name, []):
if not class_path or not isinstance(class_path, str):
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
cls = candidate
discovered.append(
make_entry(
cls,
class_name,
module_type,
class_path,
module_path,
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", []):
if not (class_path := decorator.get("class-name", "")):
continue
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}"
# Capabilities belong to the operator the decorator builds, not to the factory
# function the provider registers, so they're read off the resolved class.
decorated_cls = _resolve_decorated_operator_class(obj)
discovered.append(
{
"id": f"{provider_id}-decorator-{decorator_name or func_name}",
"name": display_name,
"type": "decorator",
"import_path": class_path,
"module_path": module_path,
"short_description": short_desc,
"docs_url": resolve_docs_url(class_path, module_path),
"source_url": f"{base_source_url}/{module_path.replace('.', '/')}.py",
"category": "decorators",
"provider_id": provider_id,
"provider_name": provider_name,
"supports_durable_execution": (
is_durable_capable(decorated_cls, resumable_mixin) if decorated_cls else False
),
"supports_deferrable": supports_deferrable(decorated_cls) if decorated_cls else False,
}
)
guide_docs = read_released_guide_docs(provider_id, version, provider_rel_path)
if guide_docs is None:
guide_docs = read_guide_docs(provider_yaml_path.parent / "docs")
attach_guide_urls(discovered, collect_guide_anchors(guide_docs), base_docs_url)
return discovered
def compare_with_ast(
runtime_classes: list[dict],
modules_json_path: Path,
) -> dict:
"""Compare runtime-discovered classes against AST-produced modules.json.
Returns a stats dict with counts of phantoms, misses, and type mismatches.
"""
with open(modules_json_path) as f:
ast_data = json.load(f)
ast_modules = ast_data.get("modules", [])
ast_by_path: dict[str, dict] = {}
for m in ast_modules:
path = m.get("import_path", "")
if path:
ast_by_path[path] = m
runtime_by_path: dict[str, dict] = {}
for r in runtime_classes:
path = r.get("import_path", "")
if path:
runtime_by_path[path] = r
ast_paths = set(ast_by_path)
runtime_paths = set(runtime_by_path)
phantoms = sorted(ast_paths - runtime_paths)
misses = sorted(runtime_paths - ast_paths)
common = ast_paths & runtime_paths
type_mismatches = []
for path in sorted(common):
ast_type = ast_by_path[path].get("type", "")
runtime_type = runtime_by_path[path].get("type", "")
if ast_type != runtime_type:
type_mismatches.append(
{
"import_path": path,
"ast_type": ast_type,
"runtime_type": runtime_type,
}
)
# Print comparison table
print("\n" + "=" * 60)
print("Runtime vs AST Comparison")
print("=" * 60)
print(f" AST classes: {len(ast_paths)}")
print(f" Runtime classes: {len(runtime_paths)}")
print(f" In common: {len(common)}")
print(f" AST phantoms: {len(phantoms)} (in AST, not runtime)")
print(f" AST misses: {len(misses)} (in runtime, not AST)")
print(f" Type mismatches: {len(type_mismatches)}")
if phantoms:
print(f"\nAST Phantoms ({len(phantoms)}):")
for p in phantoms[:20]:
ast_type = ast_by_path[p].get("type", "?")
print(f" [{ast_type}] {p}")
if len(phantoms) > 20:
print(f" ... and {len(phantoms) - 20} more")
if misses:
print(f"\nAST Misses ({len(misses)}):")
for p in misses[:20]:
rt_type = runtime_by_path[p].get("type", "?")
print(f" [{rt_type}] {p}")
if len(misses) > 20:
print(f" ... and {len(misses) - 20} more")
if type_mismatches:
print(f"\nType Mismatches ({len(type_mismatches)}):")
for m in type_mismatches[:20]:
print(f" {m['import_path']}: AST={m['ast_type']} Runtime={m['runtime_type']}")
if len(type_mismatches) > 20:
print(f" ... and {len(type_mismatches) - 20} more")
print("=" * 60)
return {
"ast_phantoms": len(phantoms),
"ast_misses": len(misses),
"type_mismatches": len(type_mismatches),
"phantom_paths": phantoms,
"miss_paths": misses,
"mismatch_details": type_mismatches,
}
def _extract_params_from_modules(
modules: list[dict],
) -> tuple[dict[str, dict[str, dict]], dict[str, str], int, int, int]:
"""Extract parameters from a list of module dicts.
Returns (provider_classes, provider_names, total_processed, total_failed, total_params).
"""
provider_classes: dict[str, dict[str, dict]] = defaultdict(dict)
provider_names: dict[str, str] = {}
total_processed = 0
total_failed = 0
total_params = 0
for i, module in enumerate(modules, 1):
import_path = module.get("import_path", "")
provider_id = module.get("provider_id", "")
provider_name = module.get("provider_name", module.get("provider_id", ""))
module_type = module.get("type", "")
class_name = module.get("name", "")
if not import_path or not provider_id:
continue
provider_names[provider_id] = provider_name
obj = import_symbol(import_path)
if obj is None:
total_failed += 1
continue
try:
mro, params = extract_params(obj)
except Exception as e:
print(f" ERROR extracting params for {import_path}: {e}")
total_failed += 1
continue
provider_classes[provider_id][import_path] = {
"name": class_name,
"type": module_type,
"mro": mro,
"parameters": params,
}
total_processed += 1
total_params += len(params)
if i % 100 == 0:
print(f" Processed {i}/{len(modules)} modules...")
return provider_classes, provider_names, total_processed, total_failed, total_params
def _write_parameter_files(
provider_classes: dict[str, dict[str, dict]],
provider_names: dict[str, str],
provider_versions: dict[str, str],
generated_at: str,
) -> None:
"""Write per-provider parameter JSON files."""
for output_dir in OUTPUT_DIRS:
if not output_dir.parent.exists():
continue
written = 0
for pid, classes in provider_classes.items():
version = provider_versions.get(pid)
if not version:
print(f" WARN: no version found for {pid}, skipping")
continue
version_dir = output_dir / "versions" / pid / version
version_dir.mkdir(parents=True, exist_ok=True)
provider_data = validate_provider_parameters(
{
"provider_id": pid,
"provider_name": provider_names.get(pid, pid),
"version": version,
"generated_at": generated_at,
"classes": classes,
}
)
with open(version_dir / "parameters.json", "w") as f:
json.dump(provider_data, f, separators=(",", ":"))
written += 1
print(f"Wrote {written} provider parameter files to {output_dir}/versions/")
def _fetch_inventories(
provider_ids: set[str],
provider_yamls: dict[str, dict],
) -> dict[str, dict[str, str]]:
"""Fetch Sphinx inventory files in parallel for all providers."""
package_names: dict[str, str] = {}
for pid in provider_ids:
py = provider_yamls.get(pid, {})
package_names[pid] = py.get("package-name", f"apache-airflow-providers-{pid}")
def _fetch_and_parse(pid: str) -> tuple[str, dict[str, str] | None]:
inv_path = fetch_provider_inventory(package_names[pid])
if inv_path:
try:
return pid, read_inventory(inv_path)
except Exception as e:
print(f" Warning: Could not parse inventory for {pid}: {e}")
return pid, None
return pid, None
inventories: dict[str, dict[str, str]] = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
futures = {executor.submit(_fetch_and_parse, pid): pid for pid in provider_ids}
for future in concurrent.futures.as_completed(futures):
pid, inv = future.result()
if inv:
inventories[pid] = inv
return inventories
def main():
parser = argparse.ArgumentParser(description="Extract provider parameters and modules")
parser.add_argument(
"--provider",
default=None,
help=(
"Only process these provider ID(s) (space-separated, e.g. 'amazon google'). "
"Writes a partial modules.json containing only the requested providers; "
"merge_registry_data.py replaces those providers' entries in the global catalog "
"while preserving everyone else."
),
)
parser.add_argument(
"--providers-json",
default=None,
help="Path to providers.json (overrides default search paths).",
)
args = parser.parse_args()
print("Airflow Registry Parameter & Module Extractor")
print("=" * 50)
if args.providers_json:
providers_json_path = Path(args.providers_json)
else:
providers_json_path = find_json(PROVIDERS_JSON_CANDIDATES, "providers.json")
with open(providers_json_path) as f:
providers_data = json.load(f)
provider_versions: dict[str, str] = {}
for p in providers_data.get("providers", []):
provider_versions[p["id"]] = p["version"]
generated_at = datetime.now(UTC).isoformat()
_main_discover(
provider_versions,
generated_at,
requested_providers=_parse_requested_providers(args.provider),
)
print("\nDone!")
def _parse_requested_providers(provider_arg: str | None) -> set[str] | None:
"""Parse --provider argument into a set of provider IDs.
Accepts a space-separated string (matching extract_metadata.py and
extract_connections.py). Returns None when the argument is empty so
callers can distinguish "all providers" from "explicit empty set".
"""
if not provider_arg:
return None
return {pid.strip() for pid in provider_arg.split() if pid.strip()}
def _main_discover(
provider_versions: dict[str, str],
generated_at: str,
requested_providers: set[str] | None = None,
) -> None:
"""Runtime discovery: find classes from provider.yaml files, produce modules.json and parameters.
When ``requested_providers`` is set, only those providers are scanned and the resulting
modules.json is partial (covers only the requested providers). ``merge_registry_data.py``
handles incremental merges by replacing entries for provider IDs present in the new
modules.json while preserving all others, so partial output is safe.
"""
provider_yaml_paths = sorted(PROVIDERS_DIR.rglob("provider.yaml"))
print(f"Found {len(provider_yaml_paths)} provider.yaml files")
base_classes = load_base_classes()
print(f"Loaded {len(base_classes)} base classes: {', '.join(sorted(base_classes))}")
resumable_mixin = load_resumable_job_mixin()
# Load all provider.yaml data and map provider_id -> yaml dict / path
provider_yamls_by_id: dict[str, dict] = {}
provider_paths_by_id: dict[str, Path] = {}
for yaml_path in provider_yaml_paths:
with open(yaml_path) as f:
py = yaml.safe_load(f)
pid = py.get("package-name", "").replace("apache-airflow-providers-", "")
if pid:
provider_yamls_by_id[pid] = py
provider_paths_by_id[pid] = yaml_path
# Filter to requested provider(s) if specified
if requested_providers:
missing = requested_providers - set(provider_paths_by_id)
if missing:
print(f"ERROR: provider(s) not found in provider.yaml files: {sorted(missing)}")
sys.exit(1)
provider_paths_by_id = {pid: provider_paths_by_id[pid] for pid in requested_providers}
provider_yamls_by_id = {pid: provider_yamls_by_id[pid] for pid in requested_providers}
print(f"Filtering to provider(s): {', '.join(sorted(requested_providers))}")
# Fetch Sphinx inventories in parallel
print("Fetching Sphinx inventory files ...")
inventories = _fetch_inventories(set(provider_yamls_by_id), provider_yamls_by_id)
print(f" {len(inventories)}/{len(provider_yamls_by_id)} inventories loaded")
all_discovered: list[dict] = []
providers_seen: set[str] = set()
for pid, yaml_path in sorted(provider_paths_by_id.items()):
version = provider_versions.get(pid, "")
discovered = discover_classes_from_provider(
yaml_path,
base_classes,
resumable_mixin,
inventory=inventories.get(pid),
version=version,
)
all_discovered.extend(discovered)
for d in discovered:
providers_seen.add(d["provider_id"])
print(f"\nDiscovered {len(all_discovered)} classes from {len(providers_seen)} providers")
# Deduplicate by ID
seen_ids: set[str] = set()
unique_modules: list[dict] = []
for m in all_discovered:
mid = m["id"]
if mid not in seen_ids:
seen_ids.add(mid)
unique_modules.append(m)
all_discovered = unique_modules
print(f"Deduplicated to {len(all_discovered)} unique modules")
# Write modules.json. In --provider mode this is partial (covers only the
# requested providers); merge_registry_data.py drives module replacement
# off the provider IDs present in this file, so non-requested providers'
# entries are preserved untouched in the global catalog.
modules_json = validate_modules_catalog({"modules": all_discovered})
scope_label = (
f"partial, providers: {', '.join(sorted(requested_providers))}" if requested_providers else "full"
)
output_dirs = [SCRIPT_DIR, AIRFLOW_ROOT / "registry" / "src" / "_data"]
for out_dir in output_dirs:
if not out_dir.parent.exists():
continue
out_dir.mkdir(parents=True, exist_ok=True)
with open(out_dir / "modules.json", "w") as f:
json.dump(modules_json, f, indent=2)
print(f"Wrote {len(all_discovered)} modules ({scope_label}) to {out_dir / 'modules.json'}")
# Write runtime_modules.json (debug/stats file). Only meaningful for full
# builds; skip in --provider mode since it would only show partial stats.
if not requested_providers:
runtime_output = {
"generated_at": generated_at,
"discovery_method": "runtime",
"stats": {
"total_classes": len(all_discovered),
"total_providers": len(providers_seen),
},
"classes": all_discovered,
}
runtime_json_path = SCRIPT_DIR / "runtime_modules.json"
with open(runtime_json_path, "w") as f:
json.dump(runtime_output, f, indent=2)
print(f"Wrote {runtime_json_path}")
# Extract parameters
print("\nExtracting parameters from runtime-discovered classes...")
provider_classes, provider_names, total_processed, total_failed, total_params = (
_extract_params_from_modules(all_discovered)
)
print(f"\nProcessed {total_processed} classes, {total_failed} failed imports")
print(f"Extracted {total_params} total parameters")
print(f"Across {len(provider_classes)} providers")
_write_parameter_files(provider_classes, provider_names, provider_versions, generated_at)
if __name__ == "__main__":
sys.exit(main() or 0)