| #!/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. |
| """ |
| Extract feature flag metadata from superset/config.py. |
| |
| This script parses the annotated feature flags in config.py and outputs |
| a JSON file that can be consumed by the documentation site to generate |
| dynamic feature flag tables. |
| |
| Usage: |
| python scripts/extract_feature_flags.py > docs/static/feature-flags.json |
| |
| Annotations supported: |
| @lifecycle: development | testing | stable | deprecated |
| @docs: URL to documentation |
| @category: runtime_config | path_to_deprecation | internal (for stable flags) |
| """ |
| |
| import json |
| import re |
| import sys |
| from pathlib import Path |
| from typing import TypedDict |
| |
| |
| class FeatureFlag(TypedDict, total=False): |
| name: str |
| default: bool |
| lifecycle: str |
| description: str |
| docs: str | None |
| category: str | None |
| |
| |
| def extract_feature_flags(config_path: Path) -> list[FeatureFlag]: |
| """ |
| Parse config.py and extract feature flag metadata from comments. |
| |
| Each flag should have a comment block above it with: |
| - Description (first line(s) before @annotations) |
| - @lifecycle: development | testing | stable | deprecated |
| - @docs: URL (optional) |
| - @category: runtime_config | path_to_deprecation | internal (optional) |
| """ |
| content = config_path.read_text() |
| |
| # Find the DEFAULT_FEATURE_FLAGS dict (type annotation is optional) |
| match = re.search( |
| r"DEFAULT_FEATURE_FLAGS(?:\s*:\s*[^=]+)?\s*=\s*\{(.+?)\n\}", |
| content, |
| re.DOTALL, |
| ) |
| if not match: |
| print( |
| "ERROR: Could not find DEFAULT_FEATURE_FLAGS in config.py", file=sys.stderr |
| ) |
| sys.exit(1) |
| |
| flags_content = match.group(1) |
| flags: list[FeatureFlag] = [] |
| |
| # Split content into lines for easier processing |
| lines = flags_content.split("\n") |
| |
| current_comments: list[str] = [] |
| |
| for line in lines: |
| stripped = line.strip() |
| |
| # Skip section headers and dividers |
| if "====" in stripped or "----" in stripped: |
| current_comments = [] |
| continue |
| |
| # Collect comment lines |
| if stripped.startswith("#"): |
| comment_text = stripped[1:].strip() |
| # Skip section description comments |
| if comment_text.startswith("These features") or comment_text.startswith( |
| "These flags" |
| ): |
| current_comments = [] |
| continue |
| current_comments.append(comment_text) |
| continue |
| |
| # Check for flag definition |
| flag_match = re.match(r'"([A-Z0-9_]+)":\s*(True|False),?', stripped) |
| if flag_match: |
| if current_comments: |
| flag_name = flag_match.group(1) |
| default_value = flag_match.group(2) == "True" |
| |
| flag = parse_comment_lines(current_comments, flag_name, default_value) |
| if flag: |
| flags.append(flag) |
| |
| current_comments = [] # Always reset after a flag definition |
| |
| return flags |
| |
| |
| def parse_comment_lines( |
| comment_lines: list[str], flag_name: str, default: bool |
| ) -> FeatureFlag | None: |
| """Parse comment lines to extract flag metadata.""" |
| if not comment_lines: |
| return None |
| |
| lifecycle = None |
| docs = None |
| category = None |
| description_lines = [] |
| |
| for line in comment_lines: |
| if line.startswith("@lifecycle:"): |
| lifecycle = line.split(":", 1)[1].strip() |
| elif line.startswith("@docs:"): |
| docs = line.split(":", 1)[1].strip() |
| elif line.startswith("@category:"): |
| category = line.split(":", 1)[1].strip() |
| elif line and not line.startswith("@"): |
| description_lines.append(line) |
| |
| if not lifecycle: |
| # Skip flags without lifecycle annotation |
| return None |
| |
| description = " ".join(description_lines) |
| |
| flag: FeatureFlag = { |
| "name": flag_name, |
| "default": default, |
| "lifecycle": lifecycle, |
| "description": description, |
| } |
| |
| if docs: |
| flag["docs"] = docs |
| if category: |
| flag["category"] = category |
| |
| return flag |
| |
| |
| def main() -> None: |
| # Find config.py relative to this script |
| script_dir = Path(__file__).parent |
| repo_root = script_dir.parent |
| config_path = repo_root / "superset" / "config.py" |
| |
| if not config_path.exists(): |
| print(f"ERROR: Could not find {config_path}", file=sys.stderr) |
| sys.exit(1) |
| |
| flags = extract_feature_flags(config_path) |
| |
| # Group by lifecycle |
| grouped: dict[str, list[FeatureFlag]] = { |
| "development": [], |
| "testing": [], |
| "stable": [], |
| "deprecated": [], |
| } |
| |
| for flag in flags: |
| lifecycle = flag.get("lifecycle", "stable") |
| if lifecycle in grouped: |
| grouped[lifecycle].append(flag) |
| |
| # Sort each group alphabetically by name |
| for lifecycle in grouped: |
| grouped[lifecycle].sort(key=lambda f: f["name"]) |
| |
| output = { |
| "generated": True, |
| "source": "superset/config.py", |
| "flags": grouped, |
| } |
| |
| print(json.dumps(output, indent=2)) |
| |
| |
| if __name__ == "__main__": |
| main() |