blob: 70282cc0e2c1bf1506acb5b533cfbaff202950f7 [file]
#
# 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.
"""Base configuration parser with pure parsing logic."""
from __future__ import annotations
import contextlib
import datetime
import functools
import itertools
import json
import logging
import os
import shlex
import subprocess
import sys
import warnings
from collections.abc import Callable, Generator, Iterable
from configparser import ConfigParser, NoOptionError, NoSectionError
from contextlib import contextmanager
from copy import deepcopy
from enum import Enum
from json.decoder import JSONDecodeError
from re import Pattern
from typing import IO, TYPE_CHECKING, Any, TypeVar, overload
from .exceptions import AirflowConfigException
log = logging.getLogger(__name__)
def _build_kwarg_env_prefix(section: str, kwargs_key: str) -> str:
"""
Build env prefix for per-key backend kwargs.
("secrets", "backend_kwargs") -> "AIRFLOW__SECRETS__BACKEND_KWARG__"
("workers", "secrets_backend_kwargs") -> "AIRFLOW__WORKERS__SECRETS_BACKEND_KWARG__"
"""
singular_key = kwargs_key.replace("_kwargs", "_kwarg")
return f"{ENV_VAR_PREFIX}{section.upper()}__{singular_key.upper()}__"
def _collect_kwarg_env_vars(prefix: str) -> dict[str, str]:
"""
Scan os.environ for per-key secrets backend kwargs.
AIRFLOW__SECRETS__BACKEND_KWARG__ROLE_ID -> {"role_id": value}
Values are raw strings (not JSON-parsed).
Empty keys (trailing __ with no suffix) are ignored.
"""
overrides: dict[str, str] = {}
for env_var, value in os.environ.items():
if env_var.startswith(prefix):
kwarg_key = env_var[len(prefix) :].lower()
if kwarg_key:
overrides[kwarg_key] = value
return overrides
ConfigType = str | int | float | bool
ConfigOptionsDictType = dict[str, ConfigType]
ConfigSectionSourcesType = dict[str, str | tuple[str, str]]
ConfigSourcesType = dict[str, ConfigSectionSourcesType]
ENV_VAR_PREFIX = "AIRFLOW__"
# Separates the team name from the base section name in a team scoped config file section.
TEAM_SECTION_SEPARATOR = "="
# (section, key) pairs that may hold a custom secrets backend class. _get_custom_secret_backend()
# reads this to pick the pair for the current mode; callers that only need to know whether *some*
# backend is configured (e.g. a CLI warning), without instantiating one, read all of the values.
# Named without "secret" so static analysis (e.g. CodeQL's clear-text-logging query) doesn't treat
# the (non-secret) section/key strings sourced from here as sensitive data.
CUSTOM_BACKEND_CONFIG_KEYS = {
"general": ("secrets", "backend"),
"worker": ("workers", "secrets_backend"),
}
def team_section_name(team_name: str, section: str) -> str:
"""
Build the config file section name that holds the team scoped overrides of ``section``.
:param team_name: name of the team the overrides belong to
:param section: base section name that is being overridden
:return: the team scoped section name, e.g. ``team_a=celery``
"""
return f"{team_name}{TEAM_SECTION_SEPARATOR}{section}"
def base_section_name(section: str) -> str:
"""
Return the base section name of a possibly team scoped config file section.
Team scoped sections are built by :func:`team_section_name`. Base section names never contain
the separator, so the name is split on the last one - that way the base section is recovered
even for a team name that contains the separator itself.
:param section: section name, either a base one or a team scoped one
:return: the base section name, which is ``section`` itself when it is not team scoped
"""
_, separator, base_section = section.rpartition(TEAM_SECTION_SEPARATOR)
return base_section if separator else section
if TYPE_CHECKING:
from airflow.providers_manager import ProvidersManager
from airflow.sdk.providers_manager_runtime import ProvidersManagerTaskRuntime
class ValueNotFound:
"""Object of this is raised when a configuration value cannot be found."""
pass
VALUE_NOT_FOUND_SENTINEL = ValueNotFound()
@overload
def expand_env_var(env_var: None) -> None: ...
@overload
def expand_env_var(env_var: str) -> str: ...
def expand_env_var(env_var: str | None) -> str | None:
"""
Expand (potentially nested) env vars.
Repeat and apply `expandvars` and `expanduser` until
interpolation stops having any effect.
"""
if not env_var or not isinstance(env_var, str):
return env_var
while True:
interpolated = os.path.expanduser(os.path.expandvars(str(env_var)))
if interpolated == env_var:
return interpolated
env_var = interpolated
def run_command(command: str) -> str:
"""Run command and returns stdout."""
process = subprocess.Popen(
shlex.split(command), stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True
)
output, stderr = (stream.decode(sys.getdefaultencoding(), "ignore") for stream in process.communicate())
if process.returncode != 0:
raise AirflowConfigException(
f"Cannot execute {command}. Error code is: {process.returncode}. "
f"Output: {output}, Stderr: {stderr}"
)
return output
def _is_template(configuration_description: dict[str, dict[str, Any]], section: str, key: str) -> bool:
"""
Check if the config is a template.
:param configuration_description: description of configuration
:param section: section
:param key: key
:return: True if the config is a template
"""
return configuration_description.get(section, {}).get(key, {}).get("is_template", False)
def configure_parser_from_configuration_description(
parser: ConfigParser,
configuration_description: dict[str, dict[str, Any]],
all_vars: dict[str, Any],
) -> None:
"""
Configure a ConfigParser based on configuration description.
:param parser: ConfigParser to configure
:param configuration_description: configuration description from config.yml
"""
for section, section_desc in configuration_description.items():
parser.add_section(section)
options = section_desc["options"]
for key in options:
default_value = options[key]["default"]
is_template = options[key].get("is_template", False)
if (default_value is not None) and not (
options[key].get("version_deprecated") or options[key].get("deprecation_reason")
):
if is_template or not isinstance(default_value, str):
parser.set(section, key, str(default_value))
else:
try:
parser.set(section, key, default_value.format(**all_vars))
except (KeyError, ValueError):
parser.set(section, key, default_value)
def create_provider_cfg_config_fallback_defaults(
provider_config_fallback_defaults_cfg_path: str,
) -> ConfigParser:
"""
Create fallback defaults for configuration.
This parser contains provider defaults for Airflow configuration, containing fallback default values
that might be needed when provider classes are being imported - before provider's configuration
is loaded.
Unfortunately airflow currently performs a lot of stuff during importing and some of that might lead
to retrieving provider configuration before the defaults for the provider are loaded.
Those are only defaults, so if you have "real" values configured in your configuration (.cfg file or
environment variables) those will be used as usual.
NOTE!! Do NOT attempt to remove those default fallbacks thinking that they are unnecessary duplication,
at least not until we fix the way how airflow imports "do stuff". This is unlikely to succeed.
You've been warned!
:param provider_config_fallback_defaults_cfg_path: path to the provider config fallback defaults .cfg file
"""
config_parser = ConfigParser()
config_parser.read(provider_config_fallback_defaults_cfg_path)
return config_parser
class AirflowConfigParser(ConfigParser):
"""
Base configuration parser with pure parsing logic.
This class provides the core parsing methods that work with:
- configuration_description: dict describing config options (required in __init__)
- _default_values: ConfigParser with default values (required in __init__)
- deprecated_options: class attribute mapping new -> old options
- deprecated_sections: class attribute mapping new -> old sections
"""
# A mapping of section -> setting -> { old, replace } for deprecated default values.
# Subclasses can override this to define deprecated values that should be upgraded.
deprecated_values: dict[str, dict[str, tuple[Pattern, str]]] = {}
# A mapping of (new section, new option) -> (old section, old option, since_version).
# When reading new option, the old option will be checked to see if it exists. If it does a
# DeprecationWarning will be issued and the old option will be used instead
deprecated_options: dict[tuple[str, str], tuple[str, str, str]] = {
("dag_processor", "dag_file_processor_timeout"): ("core", "dag_file_processor_timeout", "3.0"),
("dag_processor", "refresh_interval"): ("scheduler", "dag_dir_list_interval", "3.0"),
("api", "base_url"): ("webserver", "base_url", "3.0"),
("api", "host"): ("webserver", "web_server_host", "3.0"),
("api", "port"): ("webserver", "web_server_port", "3.0"),
("api", "workers"): ("webserver", "workers", "3.0"),
("api", "worker_timeout"): ("webserver", "web_server_worker_timeout", "3.0"),
("api", "ssl_cert"): ("webserver", "web_server_ssl_cert", "3.0"),
("api", "ssl_key"): ("webserver", "web_server_ssl_key", "3.0"),
("api", "access_logfile"): ("webserver", "access_logfile", "3.0"),
("triggerer", "capacity"): ("triggerer", "default_capacity", "3.0"),
("api", "expose_config"): ("webserver", "expose_config", "3.0.1"),
("fab", "access_denied_message"): ("webserver", "access_denied_message", "3.0.2"),
("fab", "expose_hostname"): ("webserver", "expose_hostname", "3.0.2"),
("fab", "navbar_color"): ("webserver", "navbar_color", "3.0.2"),
("fab", "navbar_text_color"): ("webserver", "navbar_text_color", "3.0.2"),
("fab", "navbar_hover_color"): ("webserver", "navbar_hover_color", "3.0.2"),
("fab", "navbar_text_hover_color"): ("webserver", "navbar_text_hover_color", "3.0.2"),
("api", "secret_key"): ("webserver", "secret_key", "3.0.2"),
("api", "enable_swagger_ui"): ("webserver", "enable_swagger_ui", "3.0.2"),
("dag_processor", "parsing_pre_import_modules"): ("scheduler", "parsing_pre_import_modules", "3.0.4"),
("api", "grid_view_sorting_order"): ("webserver", "grid_view_sorting_order", "3.1.0"),
("api", "log_fetch_timeout_sec"): ("webserver", "log_fetch_timeout_sec", "3.1.0"),
("api", "hide_paused_dags_by_default"): ("webserver", "hide_paused_dags_by_default", "3.1.0"),
("core", "num_dag_runs_to_retain_rendered_fields"): (
"core",
"max_num_rendered_ti_fields_per_task",
"3.2.0",
),
("api", "page_size"): ("webserver", "page_size", "3.1.0"),
("api", "default_wrap"): ("webserver", "default_wrap", "3.1.0"),
("api", "auto_refresh_interval"): ("webserver", "auto_refresh_interval", "3.1.0"),
("api", "require_confirmation_dag_change"): ("webserver", "require_confirmation_dag_change", "3.1.0"),
("api", "instance_name"): ("webserver", "instance_name", "3.1.0"),
("api", "log_config"): ("api", "access_logfile", "3.1.0"),
("scheduler", "ti_metrics_interval"): ("scheduler", "running_metrics_interval", "3.2.0"),
("api", "fallback_page_limit"): ("api", "page_size", "3.2.0"),
("workers", "missing_dag_retries"): ("workers", "missing_dag_retires", "3.1.8"),
("core", "execution_api_server_url"): ("workers", "execution_api_server_url", "3.0"),
("database", "sql_alchemy_conn"): ("core", "sql_alchemy_conn", "3.0"),
}
# A mapping of new section -> (old section, since_version).
deprecated_sections: dict[str, tuple[str, str]] = {}
@property
def _lookup_sequence(self) -> list[Callable]:
"""
Define the sequence of lookup methods for get(). The definition here does not have provider lookup.
Subclasses can override this to customise lookup order.
"""
lookup_methods = [
self._get_environment_variables,
self._get_option_from_config_file,
self._get_option_from_commands,
self._get_option_from_secrets,
self._get_option_from_defaults,
]
if self._use_providers_configuration:
# Provider fallback lookups are last so they have the lowest priority in the lookup sequence.
lookup_methods += [
self._get_option_from_provider_metadata_config_fallbacks,
self._get_option_from_provider_cfg_config_fallbacks,
]
return lookup_methods
@functools.cached_property
def configuration_description(self) -> dict[str, dict[str, Any]]:
"""
Return configuration description from multiple sources.
Respects the ``_use_providers_configuration`` flag to decide whether to include
provider configuration.
The merged description is built as follows:
1. Start from the base configuration description provided in ``__init__``, usually
loaded from ``config.yml`` in core. Values defined here are never overridden.
2. Merge provider metadata from ``_provider_metadata_configuration_description``,
loaded from provider packages' ``get_provider_info`` method. Only adds missing
sections/options; does not overwrite existing entries from the base configuration.
3. Merge default values from ``_provider_cfg_config_fallback_default_values``,
loaded from ``provider_config_fallback_defaults.cfg``. Only sets ``"default"``
(and heuristically ``"sensitive"``) for options that do not already define them.
Base configuration takes precedence, then provider metadata fills in missing
descriptions/options, and finally cfg-based fallbacks provide defaults only where
none are defined.
We use ``cached_property`` to cache the merged result; clear this cache (via
``invalidate_cache``) when toggling ``_use_providers_configuration``.
"""
if not self._use_providers_configuration:
return self._configuration_description
merged_description: dict[str, dict[str, Any]] = deepcopy(self._configuration_description)
# Merge full provider config descriptions (with metadata like sensitive, description, etc.)
# from provider packages' get_provider_info method, reusing the cached raw dict.
for section, section_content in self._provider_metadata_configuration_description.items():
if section not in merged_description:
merged_description[section] = deepcopy(section_content)
else:
existing_options = merged_description[section].setdefault("options", {})
for option, option_content in section_content.get("options", {}).items():
if option not in existing_options:
existing_options[option] = deepcopy(option_content)
# Merge default values from cfg-based fallbacks (key=value only, no metadata).
# Uses setdefault so provider metadata values above take priority.
cfg = self._provider_cfg_config_fallback_default_values
for section in cfg.sections():
section_options = merged_description.setdefault(section, {"options": {}}).setdefault(
"options", {}
)
for option in cfg.options(section):
opt_dict = section_options.setdefault(option, {})
opt_dict.setdefault("default", cfg.get(section, option))
# For cfg-only options with no provider metadata, infer sensitivity from name.
if "sensitive" not in opt_dict and option.endswith(("password", "secret")):
opt_dict["sensitive"] = True
return merged_description
@property
def _config_sources_for_as_dict(self) -> list[tuple[str, ConfigParser]]:
"""Override the base method to add provider fallbacks when providers are loaded."""
sources: list[tuple[str, ConfigParser]] = []
if self._use_providers_configuration:
# Provider fallback defaults are listed first so they have the lowest priority
# in as_dict()'s "last source wins" semantics.
sources += [
("provider-cfg-fallback-defaults", self._provider_cfg_config_fallback_default_values),
(
"provider-metadata-fallback-defaults",
self._provider_metadata_config_fallback_default_values,
),
]
sources += [
("default", self._default_values),
("airflow.cfg", self),
]
return sources
def _get_option_from_provider_cfg_config_fallbacks(
self,
deprecated_key: str | None,
deprecated_section: str | None,
key: str,
section: str,
issue_warning: bool = True,
extra_stacklevel: int = 0,
**kwargs,
) -> str | ValueNotFound:
"""Get config option from provider fallback defaults."""
value = self.get_from_provider_cfg_config_fallback_defaults(section, key, **kwargs)
if value is not VALUE_NOT_FOUND_SENTINEL:
return value
return VALUE_NOT_FOUND_SENTINEL
def _get_option_from_provider_metadata_config_fallbacks(
self,
deprecated_key: str | None,
deprecated_section: str | None,
key: str,
section: str,
issue_warning: bool = True,
extra_stacklevel: int = 0,
**kwargs,
) -> str | ValueNotFound:
"""Get config option from provider metadata fallback defaults."""
value = self.get_from_provider_metadata_config_fallback_defaults(section, key, **kwargs)
if value is not VALUE_NOT_FOUND_SENTINEL:
return value
return VALUE_NOT_FOUND_SENTINEL
def get_from_provider_cfg_config_fallback_defaults(self, section: str, key: str, **kwargs) -> Any:
"""Get provider config fallback default values."""
raw = kwargs.get("raw", False)
vars_ = kwargs.get("vars")
return self._provider_cfg_config_fallback_default_values.get(
section, key, fallback=VALUE_NOT_FOUND_SENTINEL, raw=raw, vars=vars_
)
@functools.cached_property
def _provider_metadata_configuration_description(self) -> dict[str, dict[str, Any]]:
"""Raw provider configuration descriptions with full metadata (sensitive, description, etc.)."""
result: dict[str, dict[str, Any]] = {}
for _, config in self._provider_manager_type().provider_configs:
result.update(config)
return result
@functools.cached_property
def _provider_metadata_config_fallback_default_values(self) -> ConfigParser:
"""Return Provider metadata config fallback default values."""
return self._create_default_config_parser_callable(self._provider_metadata_configuration_description)
def get_from_provider_metadata_config_fallback_defaults(self, section: str, key: str, **kwargs) -> Any:
"""Get provider metadata config fallback default values."""
raw = kwargs.get("raw", False)
vars_ = kwargs.get("vars")
return self._provider_metadata_config_fallback_default_values.get(
section, key, fallback=VALUE_NOT_FOUND_SENTINEL, raw=raw, vars=vars_
)
@property
def _validators(self) -> list[Callable[[], None]]:
"""
Return list of validators defined on a config parser class. Base class will return an empty list.
Subclasses can override this to customize the validators that are run during validation on the
config parser instance.
"""
return []
def validate(self) -> None:
"""Run all registered validators."""
for validator in self._validators:
validator()
self.is_validated = True
def _validate_deprecated_values(self) -> None:
"""Validate and upgrade deprecated default values."""
for section, replacement in self.deprecated_values.items():
for name, info in replacement.items():
old, new = info
current_value = self.get(section, name, fallback="")
if self._using_old_value(old, current_value):
self.upgraded_values[(section, name)] = current_value
new_value = old.sub(new, current_value)
self._update_env_var(section=section, name=name, new_value=new_value)
self._create_future_warning(
name=name,
section=section,
current_value=current_value,
new_value=new_value,
)
def _using_old_value(self, old: Pattern, current_value: str) -> bool:
"""Check if current_value matches the old pattern."""
return old.search(current_value) is not None
def _update_env_var(self, section: str, name: str, new_value: str) -> None:
"""Update environment variable with new value."""
env_var = self._env_var_name(section, name)
# Set it as an env var so that any subprocesses keep the same override!
os.environ[env_var] = new_value
@staticmethod
def _create_future_warning(name: str, section: str, current_value: Any, new_value: Any) -> None:
"""Create a FutureWarning for deprecated default values."""
warnings.warn(
f"The {name!r} setting in [{section}] has the old default value of {current_value!r}. "
f"This value has been changed to {new_value!r} in the running config, but please update your config.",
FutureWarning,
stacklevel=3,
)
def __init__(
self,
configuration_description: dict[str, dict[str, Any]],
_default_values: ConfigParser,
provider_manager_type: type[ProvidersManager] | type[ProvidersManagerTaskRuntime],
create_default_config_parser_callable: Callable[[dict[str, dict[str, Any]]], ConfigParser],
provider_config_fallback_defaults_cfg_path: str,
*args,
**kwargs,
):
"""
Initialize the parser.
:param configuration_description: Description of configuration options
:param _default_values: ConfigParser with default values
:param provider_manager_type: Either ProvidersManager or ProvidersManagerTaskRuntime, depending on the context of the caller.
:param create_default_config_parser_callable: The `create_default_config_parser` function from core or SDK, depending on the context of the caller.
:param provider_config_fallback_defaults_cfg_path: Path to the `provider_config_fallback_defaults.cfg` file.
"""
super().__init__(*args, **kwargs)
self._configuration_description = configuration_description
self._default_values = _default_values
self._provider_manager_type = provider_manager_type
self._create_default_config_parser_callable = create_default_config_parser_callable
self._provider_cfg_config_fallback_default_values = create_provider_cfg_config_fallback_defaults(
provider_config_fallback_defaults_cfg_path
)
self._suppress_future_warnings = False
self.upgraded_values: dict[tuple[str, str], str] = {}
# The _use_providers_configuration flag will always be True unless we call `write(include_providers=False)` or `with self.make_sure_configuration_loaded(with_providers=False)`.
# Even when we call those methods, the flag will be set back to True after the method is done, so it only affects the current call to `as_dict()` and does not have any effect on subsequent calls.
self._use_providers_configuration = True
def invalidate_cache(self) -> None:
"""
Clear all ``functools.cached_property`` entries on this instance.
Call this after mutating class-level attributes (e.g. ``deprecated_options``)
so that derived cached properties are recomputed on next access.
"""
for attr_name in (
name
for name in dir(type(self))
if isinstance(getattr(type(self), name, None), functools.cached_property)
):
self.__dict__.pop(attr_name, None)
def _invalidate_provider_flag_caches(self) -> None:
"""Invalidate caches related to provider configuration flags."""
self.__dict__.pop("configuration_description", None)
self.__dict__.pop("sensitive_config_values", None)
@functools.cached_property
def inversed_deprecated_options(self):
"""Build inverse mapping from old options to new options."""
return {(sec, name): key for key, (sec, name, ver) in self.deprecated_options.items()}
@functools.cached_property
def inversed_deprecated_sections(self):
"""Build inverse mapping from old sections to new sections."""
return {
old_section: new_section for new_section, (old_section, ver) in self.deprecated_sections.items()
}
@functools.cached_property
def sensitive_config_values(self) -> set[tuple[str, str]]:
"""Get set of sensitive config values that should be masked."""
flattened = {
(s, k): item
for s, s_c in self.configuration_description.items()
for k, item in s_c.get("options", {}).items()
}
sensitive = {
(section.lower(), key.lower())
for (section, key), v in flattened.items()
if v.get("sensitive") is True
}
depr_option = {self.deprecated_options[x][:-1] for x in sensitive if x in self.deprecated_options}
depr_section = {
(self.deprecated_sections[s][0], k) for s, k in sensitive if s in self.deprecated_sections
}
sensitive.update(depr_section, depr_option)
return sensitive
def _names_sensitive_team_env_var(self, env_var: str) -> bool:
"""
Check whether an environment variable name is a team scoped override of a sensitive option.
Team scoped variables are named ``AIRFLOW__<TEAM>___<SECTION>__<KEY>`` (see
:meth:`_env_var_name`). A team name may contain underscores itself, so the team name is not
parsed out of the variable name; the name is matched against the tail that each option
registered as sensitive contributes instead. The ``_CMD`` / ``_SECRET`` fallbacks are
matched too, because - unlike for a base section - they are never resolved into their value.
:param env_var: environment variable name
:return: True if the variable holds a team scoped value of an option registered as sensitive
"""
env_var = env_var.upper()
if not env_var.startswith(ENV_VAR_PREFIX):
return False
# Every tail matched below starts with the ``___`` separating the team name from the
# section, so a name not containing it cannot be a team scoped one.
if "___" not in env_var:
return False
for section, key in self.sensitive_config_values:
option_tail = self._env_var_name(section, key).removeprefix(ENV_VAR_PREFIX)
for tail in (f"___{option_tail}", f"___{option_tail}_CMD", f"___{option_tail}_SECRET"):
# The team name sits between the prefix and the tail, so it must not be empty.
if env_var.endswith(tail) and len(env_var) > len(ENV_VAR_PREFIX) + len(tail):
return True
return False
def is_sensitive_option(self, section: str, key: str) -> bool:
"""
Check whether the value of ``key`` in ``section`` is registered as sensitive.
Options are registered as sensitive under their base section name, while a team scoped
override of the very same option is held by a ``<team name>=<section>`` config file section
or by an ``AIRFLOW__<TEAM>___<SECTION>__<KEY>`` environment variable. Both of those
spellings are resolved back to the base option here, so that a team scoped value is treated
exactly like the base one. A name that does not resolve to a registered option is not
sensitive - so this only ever recognises more options as sensitive, never fewer.
:param section: section name, either a base one or a team scoped one
:param key: option name
:return: True if the value of the option should be treated as sensitive
"""
section = section.lower()
key = key.lower()
if (section, key) in self.sensitive_config_values:
return True
base_section = base_section_name(section)
if base_section != section:
if (base_section, key) in self.sensitive_config_values:
return True
# A team scoped ``_cmd`` / ``_secret`` fallback is not resolved into its value, so it
# stays in the output as configured and has to be recognised on its own.
for fallback_suffix in ("_cmd", "_secret"):
if not key.endswith(fallback_suffix):
continue
if (base_section, key.removesuffix(fallback_suffix)) in self.sensitive_config_values:
return True
# A team scoped environment variable is reported under the section and key its name splits
# into, which is neither the base nor the team scoped section name.
return self._names_sensitive_team_env_var(self._env_var_name(section, key))
def _update_defaults_from_string(self, config_string: str) -> None:
"""
Update the defaults in _default_values based on values in config_string ("ini" format).
Override shared parser's method to add validation for template variables.
Note that those values are not validated and cannot contain variables because we are using
regular config parser to load them. This method is used to test the config parser in unit tests.
:param config_string: ini-formatted config string
"""
parser = ConfigParser()
parser.read_string(config_string)
for section in parser.sections():
if section not in self._default_values.sections():
self._default_values.add_section(section)
errors = False
for key, value in parser.items(section):
if not self.is_template(section, key) and "{" in value:
errors = True
log.error(
"The %s.%s value %s read from string contains variable. This is not supported",
section,
key,
value,
)
self._default_values.set(section, key, value)
if errors:
raise AirflowConfigException(
f"The string config passed as default contains variables. "
f"This is not supported. String config: {config_string}"
)
def get_default_value(self, section: str, key: str, fallback: Any = None, raw=False, **kwargs) -> Any:
"""
Retrieve default value from default config parser, including provider fallbacks.
This will retrieve the default value from the core default config parser first. If not found
and providers configuration is loaded, it also checks provider fallback defaults.
Optionally a raw, stored value can be retrieved by setting skip_interpolation to True.
This is useful for example when we want to write the default value to a file, and we don't
want the interpolation to happen as it is going to be done later when the config is read.
:param section: section of the config
:param key: key to use
:param fallback: fallback value to use
:param raw: if raw, then interpolation will be reversed
:param kwargs: other args
:return:
"""
value = self._default_values.get(section, key, fallback=VALUE_NOT_FOUND_SENTINEL, **kwargs)
# Provider metadata has higher priority than cfg fallback — check it first.
if value is VALUE_NOT_FOUND_SENTINEL and self._use_providers_configuration:
value = self._provider_metadata_config_fallback_default_values.get(
section, key, fallback=VALUE_NOT_FOUND_SENTINEL, **kwargs
)
if value is VALUE_NOT_FOUND_SENTINEL and self._use_providers_configuration:
value = self._provider_cfg_config_fallback_default_values.get(
section, key, fallback=VALUE_NOT_FOUND_SENTINEL, **kwargs
)
if value is VALUE_NOT_FOUND_SENTINEL:
value = fallback
if raw and isinstance(value, str):
return value.replace("%", "%%")
return value
def _get_custom_secret_backend(self, worker_mode: bool = False) -> Any | None:
"""
Get Secret Backend if defined in airflow.cfg.
Conditionally selects the section, key and kwargs key based on whether it is called from worker or not.
"""
section, key = CUSTOM_BACKEND_CONFIG_KEYS["worker" if worker_mode else "general"]
kwargs_key = "secrets_backend_kwargs" if worker_mode else "backend_kwargs"
secrets_backend_cls = self.getimport(section=section, key=key)
if not secrets_backend_cls:
if worker_mode:
# if we find no secrets backend for worker, return that of secrets backend
section, key = CUSTOM_BACKEND_CONFIG_KEYS["general"]
secrets_backend_cls = self.getimport(section=section, key=key)
if not secrets_backend_cls:
return None
# When falling back to secrets backend, use its kwargs
kwargs_key = "backend_kwargs"
else:
return None
try:
backend_kwargs = self.getjson(section=section, key=kwargs_key)
if not backend_kwargs:
backend_kwargs = {}
elif not isinstance(backend_kwargs, dict):
raise ValueError("not a dict")
except AirflowConfigException:
log.warning("Failed to parse [%s] %s as JSON, defaulting to no kwargs.", section, kwargs_key)
backend_kwargs = {}
except ValueError:
log.warning("Failed to parse [%s] %s into a dict, defaulting to no kwargs.", section, kwargs_key)
backend_kwargs = {}
# Collect per-key overrides; they take precedence over the JSON blob.
env_prefix = _build_kwarg_env_prefix(section, kwargs_key)
backend_kwargs.update(_collect_kwarg_env_vars(env_prefix))
return secrets_backend_cls(**backend_kwargs)
def _get_config_value_from_secret_backend(self, config_key: str) -> str | None:
"""
Get Config option values from Secret Backend.
Called by the shared parser's _get_secret_option() method as part of the lookup chain.
Uses _get_custom_secret_backend() to get the backend instance.
:param config_key: the config key to retrieve
:return: config value or None
"""
try:
secrets_client = self._get_custom_secret_backend()
if not secrets_client:
return None
return secrets_client.get_config(config_key)
except Exception as e:
raise AirflowConfigException(
"Cannot retrieve config from alternative secrets backend. "
"Make sure it is configured properly and that the Backend "
"is accessible.\n"
f"{e}"
)
def _get_cmd_option_from_config_sources(
self, config_sources: ConfigSourcesType, section: str, key: str
) -> str | None:
fallback_key = key + "_cmd"
if (section, key) in self.sensitive_config_values:
section_dict = config_sources.get(section)
if section_dict is not None:
command_value = section_dict.get(fallback_key)
if command_value is not None:
if isinstance(command_value, str):
command = command_value
else:
command = command_value[0]
return run_command(command)
return None
def _get_secret_option_from_config_sources(
self, config_sources: ConfigSourcesType, section: str, key: str
) -> str | None:
fallback_key = key + "_secret"
if (section, key) in self.sensitive_config_values:
section_dict = config_sources.get(section)
if section_dict is not None:
secrets_path_value = section_dict.get(fallback_key)
if secrets_path_value is not None:
if isinstance(secrets_path_value, str):
secrets_path = secrets_path_value
else:
secrets_path = secrets_path_value[0]
return self._get_config_value_from_secret_backend(secrets_path)
return None
def _include_secrets(
self,
config_sources: ConfigSourcesType,
display_sensitive: bool,
display_source: bool,
raw: bool,
):
for section, key in self.sensitive_config_values:
value: str | None = self._get_secret_option_from_config_sources(config_sources, section, key)
if value:
if not display_sensitive:
value = "< hidden >"
if display_source:
opt: str | tuple[str, str] = (value, "secret")
elif raw:
opt = value.replace("%", "%%")
else:
opt = value
config_sources.setdefault(section, {}).update({key: opt})
del config_sources[section][key + "_secret"]
def _include_commands(
self,
config_sources: ConfigSourcesType,
display_sensitive: bool,
display_source: bool,
raw: bool,
):
for section, key in self.sensitive_config_values:
opt = self._get_cmd_option_from_config_sources(config_sources, section, key)
if not opt:
continue
opt_to_set: str | tuple[str, str] | None = opt
if not display_sensitive:
opt_to_set = "< hidden >"
if display_source:
opt_to_set = (str(opt_to_set), "cmd")
elif raw:
opt_to_set = str(opt_to_set).replace("%", "%%")
if opt_to_set is not None:
dict_to_update: dict[str, str | tuple[str, str]] = {key: opt_to_set}
config_sources.setdefault(section, {}).update(dict_to_update)
del config_sources[section][key + "_cmd"]
def _include_envs(
self,
config_sources: ConfigSourcesType,
display_sensitive: bool,
display_source: bool,
raw: bool,
):
for env_var in [
os_environment for os_environment in os.environ if os_environment.startswith(ENV_VAR_PREFIX)
]:
try:
_, section, key = env_var.split("__", 2)
opt = self._get_env_var_option(section, key)
except ValueError:
continue
if opt is None:
log.warning("Ignoring unknown env var '%s'", env_var)
continue
if not display_sensitive and env_var != self._env_var_name("core", "unit_test_mode"):
if self._names_sensitive_team_env_var(env_var):
# Covers the cmd/secret variants too; see is_sensitive_option.
opt = "< hidden >"
# Don't hide cmd/secret values here
elif not env_var.lower().endswith(("cmd", "secret")):
if (section, key) in self.sensitive_config_values:
opt = "< hidden >"
elif raw:
opt = opt.replace("%", "%%")
if display_source:
opt = (opt, "env var")
section = section.lower()
key = key.lower()
config_sources.setdefault(section, {}).update({key: opt})
def _filter_by_source(
self,
config_sources: ConfigSourcesType,
display_source: bool,
getter_func,
):
"""
Delete default configs from current configuration.
An OrderedDict of OrderedDicts, if it would conflict with special sensitive_config_values.
This is necessary because bare configs take precedence over the command
or secret key equivalents so if the current running config is
materialized with Airflow defaults they in turn override user set
command or secret key configs.
:param config_sources: The current configuration to operate on
:param display_source: If False, configuration options contain raw
values. If True, options are a tuple of (option_value, source).
Source is either 'airflow.cfg', 'default', 'env var', or 'cmd'.
:param getter_func: A callback function that gets the user configured
override value for a particular sensitive_config_values config.
:return: None, the given config_sources is filtered if necessary,
otherwise untouched.
"""
for section, key in self.sensitive_config_values:
# Don't bother if we don't have section / key
if section not in config_sources or key not in config_sources[section]:
continue
# Check that there is something to override defaults
try:
getter_opt = getter_func(section, key)
except ValueError:
continue
if not getter_opt:
continue
# Check to see that there is a default value
if self.get_default_value(section, key) is None:
continue
# Check to see if bare setting is the same as defaults
if display_source:
# when display_source = true, we know that the config_sources contains tuple
opt, source = config_sources[section][key] # type: ignore
else:
opt = config_sources[section][key] # type: ignore[assignment]
if opt == self.get_default_value(section, key):
del config_sources[section][key]
@staticmethod
def _deprecated_value_is_set_in_config(
deprecated_section: str,
deprecated_key: str,
configs: Iterable[tuple[str, ConfigParser]],
) -> bool:
for config_type, config in configs:
if config_type != "default":
with contextlib.suppress(NoSectionError):
deprecated_section_array = config.items(section=deprecated_section, raw=True)
if any(key == deprecated_key for key, _ in deprecated_section_array):
return True
return False
@staticmethod
def _deprecated_variable_is_set(deprecated_section: str, deprecated_key: str) -> bool:
return (
os.environ.get(f"{ENV_VAR_PREFIX}{deprecated_section.upper()}__{deprecated_key.upper()}")
is not None
)
@staticmethod
def _deprecated_command_is_set_in_config(
deprecated_section: str,
deprecated_key: str,
configs: Iterable[tuple[str, ConfigParser]],
) -> bool:
return AirflowConfigParser._deprecated_value_is_set_in_config(
deprecated_section=deprecated_section, deprecated_key=deprecated_key + "_cmd", configs=configs
)
@staticmethod
def _deprecated_variable_command_is_set(deprecated_section: str, deprecated_key: str) -> bool:
return (
os.environ.get(f"{ENV_VAR_PREFIX}{deprecated_section.upper()}__{deprecated_key.upper()}_CMD")
is not None
)
@staticmethod
def _deprecated_secret_is_set_in_config(
deprecated_section: str,
deprecated_key: str,
configs: Iterable[tuple[str, ConfigParser]],
) -> bool:
return AirflowConfigParser._deprecated_value_is_set_in_config(
deprecated_section=deprecated_section, deprecated_key=deprecated_key + "_secret", configs=configs
)
@staticmethod
def _deprecated_variable_secret_is_set(deprecated_section: str, deprecated_key: str) -> bool:
return (
os.environ.get(f"{ENV_VAR_PREFIX}{deprecated_section.upper()}__{deprecated_key.upper()}_SECRET")
is not None
)
@staticmethod
def _replace_config_with_display_sources(
config_sources: ConfigSourcesType,
configs: Iterable[tuple[str, ConfigParser]],
configuration_description: dict[str, dict[str, Any]],
display_source: bool,
raw: bool,
deprecated_options: dict[tuple[str, str], tuple[str, str, str]],
include_env: bool,
include_cmds: bool,
include_secret: bool,
):
for source_name, config in configs:
sections = config.sections()
for section in sections:
AirflowConfigParser._replace_section_config_with_display_sources(
config,
config_sources,
configuration_description,
display_source,
raw,
section,
source_name,
deprecated_options,
configs,
include_env=include_env,
include_cmds=include_cmds,
include_secret=include_secret,
)
@staticmethod
def _replace_section_config_with_display_sources(
config: ConfigParser,
config_sources: ConfigSourcesType,
configuration_description: dict[str, dict[str, Any]],
display_source: bool,
raw: bool,
section: str,
source_name: str,
deprecated_options: dict[tuple[str, str], tuple[str, str, str]],
configs: Iterable[tuple[str, ConfigParser]],
include_env: bool,
include_cmds: bool,
include_secret: bool,
):
sect = config_sources.setdefault(section, {})
if isinstance(config, AirflowConfigParser):
with config.suppress_future_warnings():
items: Iterable[tuple[str, Any]] = config.items(section=section, raw=raw)
else:
items = config.items(section=section, raw=raw)
for k, val in items:
deprecated_section, deprecated_key, _ = deprecated_options.get((section, k), (None, None, None))
if deprecated_section and deprecated_key:
if source_name == "default":
# If deprecated entry has some non-default value set for any of the sources requested,
# We should NOT set default for the new entry (because it will override anything
# coming from the deprecated ones)
if AirflowConfigParser._deprecated_value_is_set_in_config(
deprecated_section, deprecated_key, configs
):
continue
if include_env and AirflowConfigParser._deprecated_variable_is_set(
deprecated_section, deprecated_key
):
continue
if include_cmds and (
AirflowConfigParser._deprecated_variable_command_is_set(
deprecated_section, deprecated_key
)
or AirflowConfigParser._deprecated_command_is_set_in_config(
deprecated_section, deprecated_key, configs
)
):
continue
if include_secret and (
AirflowConfigParser._deprecated_variable_secret_is_set(
deprecated_section, deprecated_key
)
or AirflowConfigParser._deprecated_secret_is_set_in_config(
deprecated_section, deprecated_key, configs
)
):
continue
if display_source:
updated_source_name = source_name
if source_name == "default":
# defaults can come from other sources (default-<PROVIDER>) that should be used here
source_description_section = configuration_description.get(section, {})
source_description_key = source_description_section.get("options", {}).get(k, {})
if source_description_key is not None:
updated_source_name = source_description_key.get("source", source_name)
sect[k] = (val, updated_source_name)
else:
sect[k] = val
def _warn_deprecate(
self, section: str, key: str, deprecated_section: str, deprecated_name: str, extra_stacklevel: int
):
"""Warn about deprecated config option usage."""
if section == deprecated_section:
warnings.warn(
f"The {deprecated_name} option in [{section}] has been renamed to {key} - "
f"the old setting has been used, but please update your config.",
DeprecationWarning,
stacklevel=4 + extra_stacklevel,
)
else:
warnings.warn(
f"The {deprecated_name} option in [{deprecated_section}] has been moved to the {key} option "
f"in [{section}] - the old setting has been used, but please update your config.",
DeprecationWarning,
stacklevel=4 + extra_stacklevel,
)
@contextmanager
def suppress_future_warnings(self):
"""
Context manager to temporarily suppress future warnings.
This is a stub used by the shared parser's lookup methods when checking deprecated options.
Subclasses can override this to customize warning suppression behavior.
:return: context manager that suppresses future warnings
"""
suppress_future_warnings = self._suppress_future_warnings
self._suppress_future_warnings = True
yield self
self._suppress_future_warnings = suppress_future_warnings
def _env_var_name(self, section: str, key: str, team_name: str | None = None) -> str:
"""Generate environment variable name for a config option."""
team_component: str = f"{team_name.upper()}___" if team_name else ""
return f"{ENV_VAR_PREFIX}{team_component}{section.replace('.', '_').upper()}__{key.upper()}"
def _get_env_var_option(self, section: str, key: str, team_name: str | None = None):
"""Get config option from environment variable."""
env_var: str = self._env_var_name(section, key, team_name=team_name)
if env_var in os.environ:
return expand_env_var(os.environ[env_var])
# alternatively AIRFLOW__{SECTION}__{KEY}_CMD (for a command)
env_var_cmd = env_var + "_CMD"
if env_var_cmd in os.environ:
# if this is a valid command key...
if (section, key) in self.sensitive_config_values:
return run_command(os.environ[env_var_cmd])
# alternatively AIRFLOW__{SECTION}__{KEY}_SECRET (to get from Secrets Backend)
env_var_secret_path = env_var + "_SECRET"
if env_var_secret_path in os.environ:
# if this is a valid secret path...
if (section, key) in self.sensitive_config_values:
return self._get_config_value_from_secret_backend(os.environ[env_var_secret_path])
return None
def _get_cmd_option(self, section: str, key: str):
"""Get config option from command execution."""
fallback_key = key + "_cmd"
if (section, key) in self.sensitive_config_values:
if super().has_option(section, fallback_key):
command = super().get(section, fallback_key)
try:
cmd_output = run_command(command)
except AirflowConfigException as e:
raise e
except Exception as e:
raise AirflowConfigException(
f"Cannot run the command for the config section [{section}]{fallback_key}_cmd."
f" Please check the {fallback_key} value."
) from e
return cmd_output
return None
def _get_secret_option(self, section: str, key: str) -> str | None:
"""Get Config option values from Secret Backend."""
fallback_key = key + "_secret"
if (section, key) in self.sensitive_config_values:
if super().has_option(section, fallback_key):
secrets_path = super().get(section, fallback_key)
return self._get_config_value_from_secret_backend(secrets_path)
return None
def _get_environment_variables(
self,
deprecated_key: str | None,
deprecated_section: str | None,
key: str,
section: str,
issue_warning: bool = True,
extra_stacklevel: int = 0,
**kwargs,
) -> str | ValueNotFound:
"""Get config option from environment variables."""
team_name = kwargs.get("team_name", None)
option = self._get_env_var_option(section, key, team_name=team_name)
if option is not None:
return option
if deprecated_section and deprecated_key:
with self.suppress_future_warnings():
option = self._get_env_var_option(deprecated_section, deprecated_key, team_name=team_name)
if option is not None:
if issue_warning:
self._warn_deprecate(section, key, deprecated_section, deprecated_key, extra_stacklevel)
return option
return VALUE_NOT_FOUND_SENTINEL
def _get_option_from_config_file(
self,
deprecated_key: str | None,
deprecated_section: str | None,
key: str,
section: str,
issue_warning: bool = True,
extra_stacklevel: int = 0,
**kwargs,
) -> str | ValueNotFound:
"""Get config option from config file."""
if team_name := kwargs.get("team_name", None):
section = team_section_name(team_name, section)
# since this is the last lookup that supports team_name, pop it
kwargs.pop("team_name")
if super().has_option(section, key):
return expand_env_var(super().get(section, key, **kwargs))
if deprecated_section and deprecated_key:
if super().has_option(deprecated_section, deprecated_key):
if issue_warning:
self._warn_deprecate(section, key, deprecated_section, deprecated_key, extra_stacklevel)
with self.suppress_future_warnings():
return expand_env_var(super().get(deprecated_section, deprecated_key, **kwargs))
return VALUE_NOT_FOUND_SENTINEL
def _get_option_from_commands(
self,
deprecated_key: str | None,
deprecated_section: str | None,
key: str,
section: str,
issue_warning: bool = True,
extra_stacklevel: int = 0,
**kwargs,
) -> str | ValueNotFound:
"""Get config option from command execution."""
if kwargs.get("team_name", None):
# Commands based team config fetching is not currently supported
return VALUE_NOT_FOUND_SENTINEL
option = self._get_cmd_option(section, key)
if option:
return option
if deprecated_section and deprecated_key:
with self.suppress_future_warnings():
option = self._get_cmd_option(deprecated_section, deprecated_key)
if option:
if issue_warning:
self._warn_deprecate(section, key, deprecated_section, deprecated_key, extra_stacklevel)
return option
return VALUE_NOT_FOUND_SENTINEL
def _get_option_from_secrets(
self,
deprecated_key: str | None,
deprecated_section: str | None,
key: str,
section: str,
issue_warning: bool = True,
extra_stacklevel: int = 0,
**kwargs,
) -> str | ValueNotFound:
"""Get config option from secrets backend."""
if kwargs.get("team_name", None):
# Secrets based team config fetching is not currently supported
return VALUE_NOT_FOUND_SENTINEL
option = self._get_secret_option(section, key)
if option:
return option
if deprecated_section and deprecated_key:
with self.suppress_future_warnings():
option = self._get_secret_option(deprecated_section, deprecated_key)
if option:
if issue_warning:
self._warn_deprecate(section, key, deprecated_section, deprecated_key, extra_stacklevel)
return option
return VALUE_NOT_FOUND_SENTINEL
def _get_option_from_defaults(
self,
deprecated_key: str | None,
deprecated_section: str | None,
key: str,
section: str,
issue_warning: bool = True,
extra_stacklevel: int = 0,
team_name: str | None = None,
**kwargs,
) -> str | ValueNotFound:
"""Get config option from default values."""
if self.get_default_value(section, key) is not None or "fallback" in kwargs:
return expand_env_var(self.get_default_value(section, key, **kwargs))
return VALUE_NOT_FOUND_SENTINEL
def _resolve_deprecated_lookup(
self,
section: str,
key: str,
lookup_from_deprecated: bool,
extra_stacklevel: int = 0,
) -> tuple[str, str, str | None, str | None, bool]:
"""
Resolve deprecated section/key mappings and determine deprecated values.
:param section: Section name (will be lowercased)
:param key: Key name (will be lowercased)
:param lookup_from_deprecated: Whether to lookup from deprecated options
:param extra_stacklevel: Extra stack level for warnings
:return: Tuple of (resolved_section, resolved_key, deprecated_section, deprecated_key, warning_emitted)
"""
section = section.lower()
key = key.lower()
warning_emitted = False
deprecated_section: str | None = None
deprecated_key: str | None = None
if not lookup_from_deprecated:
return section, key, deprecated_section, deprecated_key, warning_emitted
option_description = self.configuration_description.get(section, {}).get("options", {}).get(key, {})
if option_description.get("deprecated"):
deprecation_reason = option_description.get("deprecation_reason", "")
warnings.warn(
f"The '{key}' option in section {section} is deprecated. {deprecation_reason}",
DeprecationWarning,
stacklevel=2 + extra_stacklevel,
)
# For the cases in which we rename whole sections
if section in self.inversed_deprecated_sections:
deprecated_section, deprecated_key = (section, key)
section = self.inversed_deprecated_sections[section]
if not self._suppress_future_warnings:
warnings.warn(
f"The config section [{deprecated_section}] has been renamed to "
f"[{section}]. Please update your `conf.get*` call to use the new name",
FutureWarning,
stacklevel=2 + extra_stacklevel,
)
# Don't warn about individual rename if the whole section is renamed
warning_emitted = True
elif (section, key) in self.inversed_deprecated_options:
# Handle using deprecated section/key instead of the new section/key
new_section, new_key = self.inversed_deprecated_options[(section, key)]
if not self._suppress_future_warnings and not warning_emitted:
warnings.warn(
f"section/key [{section}/{key}] has been deprecated, you should use"
f"[{new_section}/{new_key}] instead. Please update your `conf.get*` call to use the "
"new name",
FutureWarning,
stacklevel=2 + extra_stacklevel,
)
warning_emitted = True
deprecated_section, deprecated_key = section, key
section, key = (new_section, new_key)
elif section in self.deprecated_sections:
# When accessing the new section name, make sure we check under the old config name
deprecated_key = key
deprecated_section = self.deprecated_sections[section][0]
else:
deprecated_section, deprecated_key, _ = self.deprecated_options.get(
(section, key), (None, None, None)
)
return section, key, deprecated_section, deprecated_key, warning_emitted
def load_providers_configuration(self) -> None:
"""
Load configuration for providers.
.. deprecated:: 3.2.0
Provider configuration is now loaded lazily via the ``configuration_description``
cached property. This method is kept for backwards compatibility and will be
removed in a future version.
"""
warnings.warn(
"load_providers_configuration() is deprecated. "
"Provider configuration is now loaded lazily via the "
"`configuration_description` cached property.",
DeprecationWarning,
stacklevel=2,
)
self._use_providers_configuration = True
self._invalidate_provider_flag_caches()
def restore_core_default_configuration(self) -> None:
"""
Restore the parser state before provider-contributed sections were loaded.
.. deprecated:: 3.2.0
Use ``make_sure_configuration_loaded(with_providers=False)`` context manager
instead. This method is kept for backwards compatibility and will be removed
in a future version.
"""
warnings.warn(
"restore_core_default_configuration() is deprecated. "
"Use `make_sure_configuration_loaded(with_providers=False)` instead.",
DeprecationWarning,
stacklevel=2,
)
self._use_providers_configuration = False
self._invalidate_provider_flag_caches()
@overload # type: ignore[override]
def get(self, section: str, key: str, fallback: str = ..., **kwargs) -> str: ...
@overload # type: ignore[override]
def get(self, section: str, key: str, **kwargs) -> str | None: ...
def get( # type: ignore[misc, override]
self,
section: str,
key: str,
suppress_warnings: bool = False,
lookup_from_deprecated: bool = True,
_extra_stacklevel: int = 0,
team_name: str | None = None,
**kwargs,
) -> str | None:
"""
Get config value by iterating through lookup sequence.
Priority order is defined by _lookup_sequence property.
"""
section, key, deprecated_section, deprecated_key, warning_emitted = self._resolve_deprecated_lookup(
section=section,
key=key,
lookup_from_deprecated=lookup_from_deprecated,
extra_stacklevel=_extra_stacklevel,
)
if team_name is not None:
kwargs["team_name"] = team_name
for lookup_method in self._lookup_sequence:
value = lookup_method(
deprecated_key=deprecated_key,
deprecated_section=deprecated_section,
key=key,
section=section,
issue_warning=not warning_emitted,
extra_stacklevel=_extra_stacklevel,
**kwargs,
)
if value is not VALUE_NOT_FOUND_SENTINEL:
return value
# Check if fallback was explicitly provided (even if None)
if "fallback" in kwargs:
return kwargs["fallback"]
if not suppress_warnings:
log.warning("section/key [%s/%s] not found in config", section, key)
raise AirflowConfigException(f"section/key [{section}/{key}] not found in config")
def getboolean(self, section: str, key: str, **kwargs) -> bool: # type: ignore[override]
"""Get config value as boolean."""
val = str(self.get(section, key, _extra_stacklevel=1, **kwargs)).lower().strip()
if "#" in val:
val = val.split("#")[0].strip()
if val in ("t", "true", "1"):
return True
if val in ("f", "false", "0"):
return False
raise AirflowConfigException(
f'Failed to convert value to bool. Please check "{key}" key in "{section}" section. '
f'Current value: "{val}".'
)
def getint(self, section: str, key: str, **kwargs) -> int: # type: ignore[override]
"""Get config value as integer."""
val = self.get(section, key, _extra_stacklevel=1, **kwargs)
if val is None:
raise AirflowConfigException(
f"Failed to convert value None to int. "
f'Please check "{key}" key in "{section}" section is set.'
)
try:
return int(val)
except ValueError:
try:
if (float_val := float(val)) != (int_val := int(float_val)):
raise ValueError
return int_val
except (ValueError, OverflowError):
raise AirflowConfigException(
f'Failed to convert value to int. Please check "{key}" key in "{section}" section. '
f'Current value: "{val}".'
)
def getfloat(self, section: str, key: str, **kwargs) -> float: # type: ignore[override]
"""Get config value as float."""
val = self.get(section, key, _extra_stacklevel=1, **kwargs)
if val is None:
raise AirflowConfigException(
f"Failed to convert value None to float. "
f'Please check "{key}" key in "{section}" section is set.'
)
try:
return float(val)
except ValueError:
raise AirflowConfigException(
f'Failed to convert value to float. Please check "{key}" key in "{section}" section. '
f'Current value: "{val}".'
)
def getlist(self, section: str, key: str, delimiter=",", **kwargs):
"""Get config value as list."""
val = self.get(section, key, **kwargs)
if isinstance(val, list) or val is None:
# `get` will always return a (possibly-empty) string, so the only way we can
# have these types is with `fallback=` was specified. So just return it.
return val
if val == "":
return []
try:
return [item.strip() for item in val.split(delimiter)]
except Exception:
raise AirflowConfigException(
f'Failed to parse value to a list. Please check "{key}" key in "{section}" section. '
f'Current value: "{val}".'
)
E = TypeVar("E", bound=Enum)
def getenum(self, section: str, key: str, enum_class: type[E], **kwargs) -> E:
"""Get config value as enum."""
val = self.get(section, key, **kwargs)
enum_names = [enum_item.name for enum_item in enum_class]
if val is None:
raise AirflowConfigException(
f'Failed to convert value. Please check "{key}" key in "{section}" section. '
f'Current value: "{val}" and it must be one of {", ".join(enum_names)}'
)
try:
return enum_class[val]
except KeyError:
if "fallback" in kwargs and kwargs["fallback"] in enum_names:
return enum_class[kwargs["fallback"]]
raise AirflowConfigException(
f'Failed to convert value. Please check "{key}" key in "{section}" section. '
f"the value must be one of {', '.join(enum_names)}"
)
def getenumlist(self, section: str, key: str, enum_class: type[E], delimiter=",", **kwargs) -> list[E]:
"""Get config value as list of enums."""
kwargs.setdefault("fallback", [])
string_list = self.getlist(section, key, delimiter, **kwargs)
enum_names = [enum_item.name for enum_item in enum_class]
enum_list = []
for val in string_list:
try:
enum_list.append(enum_class[val])
except KeyError:
log.warning(
"Failed to convert value %r. Please check %s key in %s section. "
"it must be one of %s, if not the value is ignored",
val,
key,
section,
", ".join(enum_names),
)
return enum_list
def getimport(self, section: str, key: str, **kwargs) -> Any:
"""
Read options, import the full qualified name, and return the object.
In case of failure, it throws an exception with the key and section names
:return: The object or None, if the option is empty
"""
# Fixed: use self.get() instead of conf.get()
full_qualified_path = self.get(section=section, key=key, **kwargs)
if not full_qualified_path:
return None
try:
# Import here to avoid circular dependency
from ..module_loading import import_string
return import_string(full_qualified_path)
except ImportError as e:
log.warning(e)
raise AirflowConfigException(
f'The object could not be loaded. Please check "{key}" key in "{section}" section. '
f'Current value: "{full_qualified_path}".'
)
def getjson(
self, section: str, key: str, fallback=None, **kwargs
) -> dict | list | str | int | float | None:
"""
Return a config value parsed from a JSON string.
``fallback`` is *not* JSON parsed but used verbatim when no config value is given.
"""
try:
data = self.get(section=section, key=key, fallback=None, _extra_stacklevel=1, **kwargs)
except (NoSectionError, NoOptionError):
data = None
if data is None or data == "":
return fallback
try:
return json.loads(data)
except JSONDecodeError as e:
raise AirflowConfigException(f"Unable to parse [{section}] {key!r} as valid json") from e
def gettimedelta(
self, section: str, key: str, fallback: Any = None, **kwargs
) -> datetime.timedelta | None:
"""
Get the config value for the given section and key, and convert it into datetime.timedelta object.
If the key is missing, then it is considered as `None`.
:param section: the section from the config
:param key: the key defined in the given section
:param fallback: fallback value when no config value is given, defaults to None
:raises AirflowConfigException: raised because ValueError or OverflowError
:return: datetime.timedelta(seconds=<config_value>) or None
"""
val = self.get(section, key, fallback=fallback, _extra_stacklevel=1, **kwargs)
if val:
# the given value must be convertible to integer
try:
int_val = int(val)
except ValueError:
raise AirflowConfigException(
f'Failed to convert value to int. Please check "{key}" key in "{section}" section. '
f'Current value: "{val}".'
)
try:
return datetime.timedelta(seconds=int_val)
except OverflowError as err:
raise AirflowConfigException(
f"Failed to convert value to timedelta in `seconds`. "
f"{err}. "
f'Please check "{key}" key in "{section}" section. Current value: "{val}".'
)
return fallback
def get_mandatory_value(self, section: str, key: str, **kwargs) -> str:
"""Get mandatory config value, raising ValueError if not found."""
value = self.get(section, key, _extra_stacklevel=1, **kwargs)
if value is None:
raise ValueError(f"The value {section}/{key} should be set!")
return value
def get_mandatory_list_value(self, section: str, key: str, **kwargs) -> list[str]:
"""Get mandatory config value as list, raising ValueError if not found."""
value = self.getlist(section, key, **kwargs)
if value is None:
raise ValueError(f"The value {section}/{key} should be set!")
return value
def read( # type: ignore[override]
self,
filenames: str | bytes | os.PathLike | Iterable[str | bytes | os.PathLike],
encoding: str | None = None,
) -> list[str]:
return super().read(filenames=filenames, encoding=encoding) # type: ignore[arg-type,return-value]
def read_dict( # type: ignore[override]
self, dictionary: dict[str, dict[str, Any]], source: str = "<dict>"
) -> None:
"""
We define a different signature here to add better type hints and checking.
:param dictionary: dictionary to read from
:param source: source to be used to store the configuration
:return:
"""
super().read_dict(dictionary=dictionary, source=source)
def _has_section_in_any_defaults(self, section: str) -> bool:
"""Check if section exists in core defaults or provider fallback defaults."""
if self._default_values.has_section(section):
return True
if self._use_providers_configuration:
if self._provider_cfg_config_fallback_default_values.has_section(section):
return True
if self._provider_metadata_config_fallback_default_values.has_section(section):
return True
return False
def get_sections_including_defaults(self) -> list[str]:
"""
Retrieve all sections from the configuration parser, including sections defined by built-in defaults.
:return: list of section names
"""
sections_from_config = self.sections()
sections_from_description = list(self.configuration_description.keys())
return list(dict.fromkeys(itertools.chain(sections_from_description, sections_from_config)))
def get_options_including_defaults(self, section: str) -> list[str]:
"""
Retrieve all possible options from the configuration parser for the section given.
Includes options defined by built-in defaults.
:param section: section name
:return: list of option names for the section given
"""
my_own_options = self.options(section) if self.has_section(section) else []
all_options_from_defaults = list(
self.configuration_description.get(section, {}).get("options", {}).keys()
)
return list(dict.fromkeys(itertools.chain(all_options_from_defaults, my_own_options)))
def has_option( # type: ignore[override]
self, section: str, option: str, lookup_from_deprecated: bool = True, **kwargs
) -> bool:
"""
Check if option is defined.
Uses self.get() to avoid reimplementing the priority order of config variables
(env, config, cmd, defaults).
:param section: section to get option from
:param option: option to get
:param lookup_from_deprecated: If True, check if the option is defined in deprecated sections
:param kwargs: additional keyword arguments to pass to get(), such as team_name
:return:
"""
try:
value = self.get(
section,
option,
fallback=VALUE_NOT_FOUND_SENTINEL,
_extra_stacklevel=1,
suppress_warnings=True,
lookup_from_deprecated=lookup_from_deprecated,
**kwargs,
)
if value is VALUE_NOT_FOUND_SENTINEL:
return False
return True
except (NoOptionError, NoSectionError, AirflowConfigException):
return False
def set(self, section: str, option: str, value: str | None = None) -> None: # type: ignore[override]
"""
Set an option to the given value.
This override just makes sure the section and option are lower case, to match what we do in `get`.
"""
section = section.lower()
option = option.lower()
defaults = self.configuration_description or {}
if not self.has_section(section) and section in defaults:
# Trying to set a key in a section that exists in default, but not in the user config;
# automatically create it
self.add_section(section)
super().set(section, option, value)
def remove_option(self, section: str, option: str, remove_default: bool = True): # type: ignore[override]
"""
Remove an option if it exists in config from a file or default config.
If both of config have the same option, this removes the option
in both configs unless remove_default=False.
"""
section = section.lower()
option = option.lower()
if super().has_option(section, option):
super().remove_option(section, option)
if remove_default and self._default_values.has_option(section, option):
self._default_values.remove_option(section, option)
def optionxform(self, optionstr: str) -> str:
"""
Transform option names on every read, get, or set operation.
This changes from the default behaviour of ConfigParser from lower-casing
to instead be case-preserving.
:param optionstr:
:return:
"""
return optionstr
def as_dict(
self,
display_source: bool = False,
display_sensitive: bool = False,
raw: bool = False,
include_env: bool = True,
include_cmds: bool = True,
include_secret: bool = True,
) -> ConfigSourcesType:
"""
Return the current configuration as an OrderedDict of OrderedDicts.
When materializing current configuration Airflow defaults are
materialized along with user set configs. If any of the `include_*`
options are False then the result of calling command or secret key
configs do not override Airflow defaults and instead are passed through.
In order to then avoid Airflow defaults from overwriting user set
command or secret key configs we filter out bare sensitive_config_values
that are set to Airflow defaults when command or secret key configs
produce different values.
:param display_source: If False, the option value is returned. If True,
a tuple of (option_value, source) is returned. Source is either
'airflow.cfg', 'default', 'env var', or 'cmd'.
:param display_sensitive: If True, the values of options set by env
vars and bash commands will be displayed. If False, those options
are shown as '< hidden >'
:param raw: Should the values be output as interpolated values, or the
"raw" form that can be fed back in to ConfigParser
:param include_env: Should the value of configuration from AIRFLOW__
environment variables be included or not
:param include_cmds: Should the result of calling any ``*_cmd`` config be
set (True, default), or should the _cmd options be left as the
command to run (False)
:param include_secret: Should the result of calling any ``*_secret`` config be
set (True, default), or should the _secret options be left as the
path to get the secret from (False)
:return: Dictionary, where the key is the name of the section and the content is
the dictionary with the name of the parameter and its value.
"""
if not display_sensitive:
# We want to hide the sensitive values at the appropriate methods
# since envs from cmds, secrets can be read at _include_envs method
if not all([include_env, include_cmds, include_secret]):
raise ValueError(
"If display_sensitive is false, then include_env, "
"include_cmds, include_secret must all be set as True"
)
config_sources: ConfigSourcesType = {}
# We check sequentially all those sources and the last one we saw it in will "win"
configs = self._config_sources_for_as_dict
self._replace_config_with_display_sources(
config_sources,
configs,
self.configuration_description,
display_source,
raw,
self.deprecated_options,
include_cmds=include_cmds,
include_env=include_env,
include_secret=include_secret,
)
# add env vars and overwrite because they have priority
if include_env:
self._include_envs(config_sources, display_sensitive, display_source, raw)
else:
self._filter_by_source(config_sources, display_source, self._get_env_var_option)
# add bash commands
if include_cmds:
self._include_commands(config_sources, display_sensitive, display_source, raw)
else:
self._filter_by_source(config_sources, display_source, self._get_cmd_option)
# add config from secret backends
if include_secret:
self._include_secrets(config_sources, display_sensitive, display_source, raw)
else:
self._filter_by_source(config_sources, display_source, self._get_secret_option)
if not display_sensitive:
# This ensures the ones from config file is hidden too
# if they are not provided through env, cmd and secret
# The collected options are walked (rather than the registered sensitive ones) so that
# team scoped sections are covered as well - they are named after the team, not after
# the base section the option is registered under.
hidden = "< hidden >"
for section, options in config_sources.items():
for key, value in list(options.items()):
if not value or not self.is_sensitive_option(section, key):
continue
if display_source:
source = value[1]
options[key] = (hidden, source)
else:
options[key] = hidden
return config_sources
def _write_option_header(
self,
file: IO[str],
option: str,
extra_spacing: bool,
include_descriptions: bool,
include_env_vars: bool,
include_examples: bool,
include_sources: bool,
section_config_description: dict[str, dict[str, Any]],
section_to_write: str,
sources_dict: ConfigSourcesType,
) -> tuple[bool, bool]:
"""
Write header for configuration option.
Returns tuple of (should_continue, needs_separation) where needs_separation should be
set if the option needs additional separation to visually separate it from the next option.
"""
option_config_description = (
section_config_description.get("options", {}).get(option, {})
if section_config_description
else {}
)
description = option_config_description.get("description")
needs_separation = False
if description and include_descriptions:
for line in description.splitlines():
file.write(f"# {line}\n")
needs_separation = True
example = option_config_description.get("example")
if example is not None and include_examples:
if extra_spacing:
file.write("#\n")
example_lines = example.splitlines()
example = "\n# ".join(example_lines)
file.write(f"# Example: {option} = {example}\n")
needs_separation = True
if include_sources and sources_dict:
sources_section = sources_dict.get(section_to_write)
value_with_source = sources_section.get(option) if sources_section else None
if value_with_source is None:
file.write("#\n# Source: not defined\n")
else:
file.write(f"#\n# Source: {value_with_source[1]}\n")
needs_separation = True
if include_env_vars:
file.write(f"#\n# Variable: AIRFLOW__{section_to_write.upper()}__{option.upper()}\n")
if extra_spacing:
file.write("#\n")
needs_separation = True
return True, needs_separation
def is_template(self, section: str, key) -> bool:
"""
Return whether the value is templated.
:param section: section of the config
:param key: key in the section
:return: True if the value is templated
"""
return _is_template(self.configuration_description, section, key)
def getsection(self, section: str, team_name: str | None = None) -> ConfigOptionsDictType | None:
"""
Return the section as a dict.
Values are converted to int, float, bool as required.
:param section: section from the config
:param team_name: optional team name for team-specific configuration lookup
"""
# Handle team-specific section lookup for config file
config_section = team_section_name(team_name, section) if team_name else section
if not self.has_section(config_section) and not self._has_section_in_any_defaults(config_section):
return None
if self._default_values.has_section(config_section):
_section: ConfigOptionsDictType = dict(self._default_values.items(config_section))
else:
_section = {}
if self.has_section(config_section):
_section.update(self.items(config_section))
# Use section (not config_section) for env var lookup - team_name is handled by _env_var_name
section_prefix = self._env_var_name(section, "", team_name=team_name)
for env_var in sorted(os.environ.keys()):
if env_var.startswith(section_prefix):
key = env_var.replace(section_prefix, "")
if key.endswith("_CMD"):
key = key[:-4]
key = key.lower()
_section[key] = self._get_env_var_option(section, key, team_name=team_name)
for key, val in _section.items():
if val is None:
raise AirflowConfigException(
f"Failed to convert value automatically. "
f'Please check "{key}" key in "{section}" section is set.'
)
try:
_section[key] = int(val)
except ValueError:
try:
_section[key] = float(val)
except ValueError:
if isinstance(val, str) and val.lower() in ("t", "true"):
_section[key] = True
elif isinstance(val, str) and val.lower() in ("f", "false"):
_section[key] = False
return _section
@staticmethod
def _write_section_header(
file: IO[str],
include_descriptions: bool,
section_config_description: dict[str, str],
section_to_write: str,
) -> None:
"""Write header for configuration section."""
file.write(f"[{section_to_write}]\n")
section_description = section_config_description.get("description")
if section_description and include_descriptions:
for line in section_description.splitlines():
file.write(f"# {line}\n")
file.write("\n")
def _write_value(
self,
file: IO[str],
option: str,
comment_out_everything: bool,
needs_separation: bool,
only_defaults: bool,
section_to_write: str,
hide_sensitive: bool,
is_sensitive: bool,
show_values: bool = False,
):
default_value = self.get_default_value(section_to_write, option, raw=True)
if only_defaults:
value = default_value
else:
value = self.get(section_to_write, option, fallback=default_value, raw=True)
if not show_values:
file.write(f"# {option} = \n")
else:
if hide_sensitive and is_sensitive:
value = "< hidden >"
else:
pass
if value is None:
file.write(f"# {option} = \n")
else:
if comment_out_everything:
value_lines = value.splitlines()
value = "\n# ".join(value_lines)
file.write(f"# {option} = {value}\n")
else:
if "\n" in value:
try:
value = json.dumps(json.loads(value), indent=4)
value = value.replace(
"\n", "\n "
) # indent multi-line JSON to satisfy configparser format
except JSONDecodeError:
pass
file.write(f"{option} = {value}\n")
if needs_separation:
file.write("\n")
def write( # type: ignore[override]
self,
file: IO[str],
section: str | None = None,
include_examples: bool = True,
include_descriptions: bool = True,
include_sources: bool = True,
include_env_vars: bool = True,
include_providers: bool = True,
comment_out_everything: bool = False,
hide_sensitive: bool = False,
extra_spacing: bool = True,
only_defaults: bool = False,
show_values: bool = False,
**kwargs: Any,
) -> None:
"""
Write configuration with comments and examples to a file.
:param file: file to write to
:param section: section of the config to write, defaults to all sections
:param include_examples: Include examples in the output
:param include_descriptions: Include descriptions in the output
:param include_sources: Include the source of each config option
:param include_env_vars: Include environment variables corresponding to each config option
:param include_providers: Include providers configuration
:param comment_out_everything: Comment out all values
:param hide_sensitive: Replace sensitive values in the output with "< hidden >"
:param extra_spacing: Add extra spacing before examples and after variables
:param only_defaults: Only include default values when writing the config, not the actual values
"""
with self.make_sure_configuration_loaded(with_providers=include_providers):
sources_dict = {}
if include_sources:
sources_dict = self.as_dict(display_source=True)
for section_to_write in self.get_sections_including_defaults():
section_config_description = self.configuration_description.get(section_to_write, {})
if section_to_write != section and section is not None:
continue
if self._has_section_in_any_defaults(section_to_write) or self.has_section(section_to_write):
self._write_section_header(
file, include_descriptions, section_config_description, section_to_write
)
for option in self.get_options_including_defaults(section_to_write):
should_continue, needs_separation = self._write_option_header(
file=file,
option=option,
extra_spacing=extra_spacing,
include_descriptions=include_descriptions,
include_env_vars=include_env_vars,
include_examples=include_examples,
include_sources=include_sources,
section_config_description=section_config_description,
section_to_write=section_to_write,
sources_dict=sources_dict,
)
is_sensitive = self.is_sensitive_option(section_to_write, option)
self._write_value(
file=file,
option=option,
comment_out_everything=comment_out_everything,
needs_separation=needs_separation,
only_defaults=only_defaults,
section_to_write=section_to_write,
hide_sensitive=hide_sensitive,
is_sensitive=is_sensitive,
show_values=show_values,
)
if include_descriptions and not needs_separation:
# extra separation between sections in case last option did not need it
file.write("\n")
@contextmanager
def make_sure_configuration_loaded(self, with_providers: bool) -> Generator[None, None, None]:
"""
Make sure configuration is loaded with or without providers.
The context manager will only toggle the `self._use_providers_configuration` flag if `with_providers` is False, and will reset `self._use_providers_configuration` to True after the context block.
Nop for `with_providers=True` as the configuration already loads providers configuration by default.
:param with_providers: whether providers should be loaded
"""
if not with_providers:
self._use_providers_configuration = False
# Only invalidate cached properties that depend on _use_providers_configuration.
# Do NOT use invalidate_cache() here — it would also evict expensive provider-discovery
# caches (_provider_metadata_configuration_description, _provider_metadata_config_fallback_default_values)
# that don't depend on this flag.
self._invalidate_provider_flag_caches()
try:
yield
finally:
if not with_providers:
self._use_providers_configuration = True
self._invalidate_provider_flag_caches()