diff --git a/.github/workflows/finalize-tests.yml b/.github/workflows/finalize-tests.yml index 5a3398d6a08fd..66871392b4e88 100644 --- a/.github/workflows/finalize-tests.yml +++ b/.github/workflows/finalize-tests.yml @@ -191,9 +191,20 @@ jobs: uses: actions/download-artifact@v4 with: path: ./artifacts + - name: "Setup python" + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.default-python-version }} - name: "Summarize all warnings" run: | - ls -R ./artifacts/ - cat ./artifacts/test-warnings*/* | sort | uniq - echo - echo Total number of unique warnings $(cat ./artifacts/test-warnings*/* | sort | uniq | wc -l) + ./scripts/ci/testing/summarize_captured_warnings.py ./artifacts \ + --pattern "**/warnings-*.txt" \ + --output ./files + - name: "Upload artifact for summarized warnings" + uses: actions/upload-artifact@v4 + with: + name: test-summarized-warnings + path: ./files/warn-summary-*.txt + retention-days: 7 + if-no-files-found: ignore + overwrite: true diff --git a/.gitignore b/.gitignore index e9165576f9964..6176a0e929c2f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ airflow/www/*.log airflow-webserver.pid standalone_admin_password.txt warnings.txt +warn-summary-*.txt # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/.rat-excludes b/.rat-excludes index 344fbc24e8d96..be0a11a905ad4 100644 --- a/.rat-excludes +++ b/.rat-excludes @@ -127,6 +127,7 @@ newsfragments/* # Warning file generated warnings.txt +warn-summary-*.txt # Dev stuff tests/* diff --git a/contributing-docs/testing/unit_tests.rst b/contributing-docs/testing/unit_tests.rst index 00c9114c64922..9d1dac3ba143f 100644 --- a/contributing-docs/testing/unit_tests.rst +++ b/contributing-docs/testing/unit_tests.rst @@ -1140,6 +1140,34 @@ for prevent to run on unsupported platform. - ``breeze``: Run test only inside of Breeze container, it might be useful in case of run some potential dangerous things in tests or if it expects to use common Breeze things. +Warnings capture system +....................... + +By default, all warnings captured during the test runs are saved into the ``tests/warnings.txt``. + +If required, you could change the path by providing ``--warning-output-path`` as pytest CLI arguments +or by setting the environment variable ``CAPTURE_WARNINGS_OUTPUT``. + +.. code-block:: console + + root@3f98e75b1ebe:/opt/airflow# pytest tests/core/ --warning-output-path=/foo/bar/spam.egg + ... + ========================= Warning summary. Total: 34, Unique: 16 ========================== + airflow: total 11, unique 1 + other: total 12, unique 4 + tests: total 11, unique 11 + Warnings saved into /foo/bar/spam.egg file. + + ================================= short test summary info ================================= + +You might also disable capture warnings by providing ``--disable-capture-warnings`` as pytest CLI arguments +or by setting `global warnings filter `__ +to **ignore**, e.g. set ``PYTHONWARNINGS`` environment variable to ``ignore``. + +.. code-block:: bash + + pytest tests/core/ --disable-capture-warnings + Code Coverage ------------- diff --git a/dev/breeze/src/airflow_breeze/utils/run_tests.py b/dev/breeze/src/airflow_breeze/utils/run_tests.py index 3abd58aec74c5..f9606cdc70e6a 100644 --- a/dev/breeze/src/airflow_breeze/utils/run_tests.py +++ b/dev/breeze/src/airflow_breeze/utils/run_tests.py @@ -191,6 +191,7 @@ def get_excluded_provider_args(python_version: str) -> list[str]: # Those directories are already ignored vu pyproject.toml. We want to exclude them here as well. NO_RECURSE_DIRS = [ + "tests/_internals", "tests/dags_with_system_exit", "tests/test_utils", "tests/dags_corrupted", diff --git a/pyproject.toml b/pyproject.toml index 1bc89bbea80eb..7dcd936e6be4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -486,6 +486,7 @@ addopts = [ norecursedirs = [ ".eggs", "airflow", + "tests/_internals", "tests/dags_with_system_exit", "tests/test_utils", "tests/dags_corrupted", diff --git a/scripts/ci/pre_commit/check_tests_in_right_folders.py b/scripts/ci/pre_commit/check_tests_in_right_folders.py index 314076fafd947..1d556fa748cd5 100755 --- a/scripts/ci/pre_commit/check_tests_in_right_folders.py +++ b/scripts/ci/pre_commit/check_tests_in_right_folders.py @@ -27,6 +27,7 @@ initialize_breeze_precommit(__name__, __file__) POSSIBLE_TEST_FOLDERS = [ + "_internals", "always", "api", "api_connexion", diff --git a/scripts/ci/testing/summarize_captured_warnings.py b/scripts/ci/testing/summarize_captured_warnings.py new file mode 100755 index 0000000000000..44c294648901d --- /dev/null +++ b/scripts/ci/testing/summarize_captured_warnings.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python +# 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. + +from __future__ import annotations + +import argparse +import functools +import json +import os +import shutil +from collections.abc import Iterator +from dataclasses import asdict, dataclass, fields +from itertools import groupby +from pathlib import Path +from typing import Any, Callable, Iterable +from uuid import NAMESPACE_OID, uuid5 + +if __name__ not in ("__main__", "__mp_main__"): + raise SystemExit( + "This file is intended to be executed as an executable program. You cannot use it as a module." + f"To run this script, run the ./{__file__} command [FILE] ..." + ) + + +REQUIRED_FIELDS = ("category", "message", "node_id", "filename", "lineno", "group", "count") +CONSOLE_SIZE = shutil.get_terminal_size((80, 20)).columns +# Use as prefix/suffix in report output +IMPORTANT_WARNING_SIGN = { + "sqlalchemy.exc.MovedIn20Warning": "!!!", + "sqlalchemy.exc.SAWarning": "!!", + "pydantic.warnings.PydanticDeprecatedSince20": "!!", + "celery.exceptions.CPendingDeprecationWarning": "!!", + "pytest.PytestWarning": "!!", + "airflow.exceptions.RemovedInAirflow3Warning": "!", + "airflow.exceptions.AirflowProviderDeprecationWarning": "!", + "airflow.utils.context.AirflowContextDeprecationWarning": "!", +} +# Always print messages for these warning categories +ALWAYS_SHOW_WARNINGS = { + "sqlalchemy.exc.MovedIn20Warning", + "sqlalchemy.exc.SAWarning", + "pytest.PytestWarning", +} + + +def warnings_filename(suffix: str) -> str: + return f"warn-summary-{suffix}.txt" + + +WARNINGS_ALL = warnings_filename("all") +WARNINGS_BAD = warnings_filename("bad") + + +@functools.lru_cache(maxsize=None) +def _unique_key(*args: str) -> str: + return str(uuid5(NAMESPACE_OID, "-".join(args))) + + +def sorted_groupby(it, grouping_key: Callable): + """Helper for sort and group by.""" + for group, grouped_data in groupby(sorted(it, key=grouping_key), key=grouping_key): + yield group, list(grouped_data) + + +def count_groups( + records: Iterable, grouping_key: Callable, *, reverse=True, top: int = 0 +) -> Iterator[tuple[Any, int]]: + it = sorted_groupby(records, grouping_key) + for ix, (group, r) in enumerate(sorted(it, key=lambda k: len(k[1]), reverse=reverse), start=1): + if top and top < ix: + return + yield group, len(r) + + +@dataclass +class CapturedWarnings: + category: str + message: str + node_id: str + filename: str + lineno: int + + @property + def unique_warning(self) -> str: + return _unique_key(self.category, self.message, self.filename, str(self.lineno)) + + @property + def unique_key(self) -> str: + return _unique_key(self.node_id, self.category, self.message, self.filename, str(self.lineno)) + + @classmethod + def from_dict(cls, d: dict) -> CapturedWarnings: + fields_names = [f.name for f in fields(CapturedWarnings)] + return cls(**{k: v for k, v in d.items() if k in fields_names}) + + def output(self) -> str: + return json.dumps(asdict(self)) + + +def find_files(directory: Path, glob_pattern: str) -> Iterator[tuple[Path, str]]: + print(f" Process directory {directory} with pattern {glob_pattern!r} ".center(CONSOLE_SIZE, "=")) + directory = Path(directory) + for filepath in directory.rglob(glob_pattern): + yield from resolve_file(filepath, directory) + + +def resolve_file(filepath: Path, directory: Path | None = None) -> Iterator[tuple[Path, str]]: + if not filepath.is_file(): + raise SystemExit("Provided path {filepath} is not a file.") + if directory: + source_path = filepath.relative_to(directory).as_posix() + else: + source_path = filepath.as_posix() + yield filepath, source_path + + +def merge_files(files: Iterator[tuple[Path, str]], output_directory: Path) -> Path: + output_file = output_directory.joinpath(WARNINGS_ALL) + output_bad = output_directory.joinpath(WARNINGS_BAD) + + records = bad_records = 0 + processed_files = 0 + with output_file.open(mode="w") as wfp, output_bad.open(mode="w") as badwfp: + for filepath, source_filename in files: + print(f"Process file: {filepath.as_posix()}") + with open(filepath) as fp: + for lineno, line in enumerate(fp, start=1): + if not (line := line.strip()): + continue + try: + record = json.loads(line) + if not isinstance(record, dict): + raise TypeError + elif not all(field in record for field in REQUIRED_FIELDS): + raise ValueError + except Exception: + bad_records += 1 + dump = json.dumps({"source": source_filename, "lineno": lineno, "record": line}) + badwfp.write(f"{dump}\n") + else: + records += 1 + record["source"] = source_filename + wfp.write(f"{json.dumps(record)}\n") + processed_files += 1 + + print() + print( + f" Total processed lines {records + bad_records:,} in {processed_files:,} file(s) ".center( + CONSOLE_SIZE, "-" + ) + ) + print(f"Good Records: {records:,}. Saved into file {output_file.as_posix()}") + if bad_records: + print(f"Bad Records: {bad_records:,}. Saved into file {output_file.as_posix()}") + else: + output_bad.unlink() + return output_file + + +def group_report_warnings(group, group_records, output_directory: Path) -> None: + output_filepath = output_directory / warnings_filename(f"group-{group}") + + group_warnings: dict[str, CapturedWarnings] = {} + unique_group_warnings: dict[str, CapturedWarnings] = {} + for record in group_records: + cw = CapturedWarnings.from_dict(record) + if cw.unique_key not in group_warnings: + group_warnings[cw.unique_key] = cw + if cw.unique_warning not in unique_group_warnings: + unique_group_warnings[cw.unique_warning] = cw + + print(f" Group {group!r} ".center(CONSOLE_SIZE, "=")) + with output_filepath.open(mode="w") as fp: + for cw in group_warnings.values(): + fp.write(f"{cw.output()}\n") + print(f"Saved into file: {output_filepath.as_posix()}\n") + + print(f"Unique warnings within the test cases: {len(group_warnings):,}\n") + print("Top 10 Tests Cases:") + it = count_groups( + group_warnings.values(), + grouping_key=lambda cw: ( + cw.category, + cw.node_id, + ), + top=10, + ) + for (category, node_id), count in it: + if suffix := IMPORTANT_WARNING_SIGN.get(category, ""): + suffix = f" ({suffix})" + print(f" {category} {node_id} - {count:,}{suffix}") + print() + + print(f"Unique warnings: {len(unique_group_warnings):,}\n") + print("Warnings grouped by category:") + for category, count in count_groups(unique_group_warnings.values(), grouping_key=lambda cw: cw.category): + if suffix := IMPORTANT_WARNING_SIGN.get(category, ""): + suffix = f" ({suffix})" + print(f" {category} - {count:,}{suffix}") + print() + + print("Top 10 Warnings:") + it = count_groups( + unique_group_warnings.values(), grouping_key=lambda cw: (cw.category, cw.filename, cw.lineno), top=10 + ) + for (category, filename, lineno), count in it: + if suffix := IMPORTANT_WARNING_SIGN.get(category, ""): + suffix = f" ({suffix})" + print(f" {filename}:{lineno}:{category} - {count:,}{suffix}") + print() + + always = list(filter(lambda w: w.category in ALWAYS_SHOW_WARNINGS, unique_group_warnings.values())) + if always: + print(f" Always reported warnings {len(always):,}".center(CONSOLE_SIZE, "-")) + for cw in always: + if prefix := IMPORTANT_WARNING_SIGN.get(cw.category, ""): + prefix = f" ({prefix})" + print(f"{cw.filename}:{cw.lineno}") + print(f" {cw.category} - {cw.message}") + print() + + +def split_by_groups(output_file: Path, output_directory: Path) -> None: + records: list[dict] = [] + with output_file.open() as fp: + records.extend(map(json.loads, fp)) + for group, group_records in sorted_groupby(records, grouping_key=lambda record: record["group"]): + group_report_warnings(group, group_records, output_directory) + + +def main(_input: str, _output: str | None, pattern: str | None) -> int | str: + cwd = Path(".").resolve() + print(f"Current Working Directory: {cwd.as_posix()}") + + try: + input_path = Path(os.path.expanduser(os.path.expandvars(_input))).resolve(strict=True) + except OSError as ex: + return f"Unable to resolve {_input!r} path. {type(ex).__name__}: {ex}" + + if not pattern: + print(f" Process file {input_path} ".center(CONSOLE_SIZE, "=")) + if not input_path.is_file(): + return f"{input_path} is not a file." + files = resolve_file(input_path, cwd) + else: + if not input_path.is_dir(): + return f"{input_path} is not a file." + files = find_files(input_path, pattern) + + output_directory = Path(_output or cwd).resolve() + output_directory.mkdir(parents=True, exist_ok=True) + + output_file = merge_files(files, output_directory) + split_by_groups(output_file, output_directory) + return 0 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Capture Warnings Summarizer") + parser.add_argument("input", help="Input file/or directory path") + parser.add_argument("-g", "--pattern", help="Glob pattern to filter warnings files") + parser.add_argument("-o", "--output", help="Output directory") + args = parser.parse_args() + raise SystemExit(main(args.input, args.output, args.pattern)) diff --git a/tests/_internals/__init__.py b/tests/_internals/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/tests/_internals/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/tests/_internals/capture_warnings.py b/tests/_internals/capture_warnings.py new file mode 100644 index 0000000000000..9380fdfe5ae8a --- /dev/null +++ b/tests/_internals/capture_warnings.py @@ -0,0 +1,212 @@ +# 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. + +from __future__ import annotations + +import functools +import itertools +import json +import os +import site +import sys +import warnings +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Callable + +import pytest + +TESTS_DIR = Path(__file__).parents[1].resolve() + + +@functools.lru_cache(maxsize=None) +def _sites_locations() -> tuple[str, ...]: + return tuple([*site.getsitepackages(), site.getusersitepackages()]) + + +@functools.lru_cache(maxsize=None) +def _resolve_warning_filepath(path: str, rootpath: str): + if path.startswith(_sites_locations()): + for site_loc in _sites_locations(): + if path.startswith(site_loc): + return path[len(site_loc) :].lstrip(os.sep) + elif path.startswith(rootpath): + return path[len(rootpath) :].lstrip(os.sep) + return path + + +@dataclass(frozen=True, unsafe_hash=True) +class CapturedWarning: + category: str + message: str + node_id: str + filename: str + lineno: int + + @classmethod + def from_record( + cls, warning_message: warnings.WarningMessage, node_id: str, root_path: Path + ) -> CapturedWarning: + category = warning_message.category.__name__ + if (category_module := warning_message.category.__module__) != "builtins": + category = f"{category_module}.{category}" + node_id, *_ = node_id.partition("[") + return cls( + category=category, + message=str(warning_message.message), + node_id=node_id, + filename=_resolve_warning_filepath(warning_message.filename, os.fspath(root_path)), + lineno=warning_message.lineno, + ) + + @property + def uniq_key(self): + return self.category, self.message, self.lineno, self.lineno + + @property + def group(self) -> str: + """ + Determine in which type of files warning raises. + + It depends on ``stacklevel`` in ``warnings.warn``, and if it is not set correctly, + it might refer to another file. + There is an assumption that airflow and all dependencies set it correct eventually. + But we should not use it to filter it out, only for show in different groups. + """ + if self.filename.startswith("airflow/"): + if self.filename.startswith("airflow/providers/"): + return "providers" + return "airflow" + elif self.filename.startswith("tests/"): + return "tests" + return "other" + + def dumps(self) -> str: + return json.dumps(asdict(self)) + + @classmethod + def loads(cls, obj: str) -> CapturedWarning: + return cls(**json.loads(obj)) + + def output(self, count: int) -> str: + return json.dumps({**asdict(self), "group": self.group, "count": count}) + + +class CaptureWarningsPlugin: + """Internal plugin for capture warnings during the tests run.""" + + node_key: str = "capture_warnings_node" + + def __init__(self, config: pytest.Config, output_path: str | None = None): + output_path = output_path or os.environ.get("CAPTURE_WARNINGS_OUTPUT") or "warnings.txt" + warning_output_path = Path(os.path.expandvars(os.path.expandvars(output_path))) + if not warning_output_path.is_absolute(): + warning_output_path = TESTS_DIR.joinpath(output_path) + + self.warning_output_path = warning_output_path + self.config = config + self.root_path = config.rootpath + self.is_worker_node = hasattr(config, "workerinput") + self.captured_warnings: dict[CapturedWarning, int] = {} + + @pytest.hookimpl(hookwrapper=True) + def pytest_runtest_call(self, item: pytest.Item): + with warnings.catch_warnings(record=True) as records: + if not sys.warnoptions: + warnings.filterwarnings("always", category=DeprecationWarning) + warnings.filterwarnings("always", category=PendingDeprecationWarning) + yield + + for record in records: + cap_warning = CapturedWarning.from_record(record, item.nodeid, root_path=self.root_path) + if cap_warning not in self.captured_warnings: + self.captured_warnings[cap_warning] = 1 + else: + self.captured_warnings[cap_warning] += 1 + + @pytest.hookimpl(hookwrapper=True, trylast=True) + def pytest_sessionfinish(self, session: pytest.Session, exitstatus: int): + """Save warning captures in the session finish on xdist worker node""" + yield + if self.is_worker_node and self.captured_warnings and hasattr(self.config, "workeroutput"): + self.config.workeroutput[self.node_key] = tuple( + [(cw.dumps(), count) for cw, count in self.captured_warnings.items()] + ) + + @pytest.hookimpl(optionalhook=True) + def pytest_testnodedown(self, node, error): + """Get warning captures from the xdist worker node.""" + if not (workeroutput := getattr(node, "workeroutput", {})): + return + + node_captured_warnings: tuple[tuple[str, int]] = workeroutput.get(self.node_key) + if not node_captured_warnings: + return + + for cw_ser, count in node_captured_warnings: + if (cw := CapturedWarning.loads(cw_ser)) in self.captured_warnings: + self.captured_warnings[cw] += count + else: + self.captured_warnings[cw] = count + + @staticmethod + def sorted_groupby(it, grouping_key: Callable): + """Helper for sort and group by.""" + for group, grouped_data in itertools.groupby(sorted(it, key=grouping_key), key=grouping_key): + yield group, list(grouped_data) + + @pytest.hookimpl(hookwrapper=True) + def pytest_terminal_summary(self, terminalreporter, exitstatus: int, config: pytest.Config): + yield + if self.is_worker_node: # No need to print/write file on worker node + return + + if self.warning_output_path.exists(): # Cleanup file. + self.warning_output_path.open("w").close() + + if not self.captured_warnings: + return + + if not self.warning_output_path.parent.exists(): + self.warning_output_path.parent.mkdir(parents=True, exist_ok=True) + + terminalreporter.section( + f"Warning summary. Total: {sum(self.captured_warnings.values()):,}, " + f"Unique: {len({cw.uniq_key for cw in self.captured_warnings}):,}", + yellow=True, + bold=True, + ) + for group, grouped_data in self.sorted_groupby(self.captured_warnings.items(), lambda x: x[0].group): + color = {} + if group in ("airflow", "providers"): + color["red"] = True + elif group == "tests": + color["yellow"] = True + else: + color["white"] = True + terminalreporter.write(group, bold=True, **color) + terminalreporter.write( + f": total {sum(item[1] for item in grouped_data):,}, " + f"unique {len({item[0].uniq_key for item in grouped_data}):,}\n" + ) + + with self.warning_output_path.open("w") as fp: + for cw, count in self.captured_warnings.items(): + fp.write(f"{cw.output(count)}\n") + terminalreporter.write("Warnings saved into ") + terminalreporter.write(os.fspath(self.warning_output_path), yellow=True) + terminalreporter.write(" file.\n") diff --git a/tests/conftest.py b/tests/conftest.py index 45ca9aaea5523..cc1a81a998ecd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,11 +32,13 @@ import pytest import time_machine import yaml +from itsdangerous import URLSafeSerializer + +if TYPE_CHECKING: + from tests._internals.capture_warnings import CaptureWarningsPlugin # noqa: F401 # We should set these before loading _any_ of the rest of airflow so that the # unit test mode config is set as early as possible. -from itsdangerous import URLSafeSerializer - assert "airflow" not in sys.modules, "No airflow module can be imported before these lines" # Clear all Environment Variables that might have side effect, @@ -68,8 +70,6 @@ if not (ko := _KEEP_CONFIGS.get(section)) or not ("*" in ko or option in ko): del os.environ[env_key] -DEFAULT_WARNING_OUTPUT_PATH = Path("warnings.txt") -warning_output_path = DEFAULT_WARNING_OUTPUT_PATH SUPPORTED_DB_BACKENDS = ("sqlite", "postgres", "mysql") # A bit of a Hack - but we need to check args before they are parsed by pytest in order to @@ -120,6 +120,9 @@ "tests/test_utils/perf/dags/elastic_dag.py", ] +# https://docs.pytest.org/en/stable/reference/reference.html#stash +capture_warnings_key = pytest.StashKey["CaptureWarningsPlugin"]() + @pytest.fixture def reset_environment(): @@ -295,11 +298,22 @@ def pytest_addoption(parser): dest="db_cleanup", help="Disable DB clear before each test module.", ) + group.addoption( + "--disable-capture-warnings", + action="store_true", + dest="disable_capture_warnings", + help="Disable internal capture warnings.", + ) group.addoption( "--warning-output-path", action="store", dest="warning_output_path", - default=DEFAULT_WARNING_OUTPUT_PATH.resolve().as_posix(), + metavar="PATH", + help=( + "Path for resulting captured warnings. Absolute or relative to the `tests` directory. " + "If not provided or environment variable `CAPTURE_WARNINGS_OUTPUT` not set " + "then 'warnings.txt' will be used." + ), ) @@ -415,11 +429,26 @@ def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line("markers", "enable_redact: do not mock redact secret masker") os.environ["_AIRFLOW__SKIP_DATABASE_EXECUTOR_COMPATIBILITY_CHECK"] = "1" - configure_warning_output(config) + + # Setup capture warnings + if "ignore" in sys.warnoptions: + config.option.disable_capture_warnings = True + if not config.option.disable_capture_warnings: + from tests._internals.capture_warnings import CaptureWarningsPlugin + + plugin = CaptureWarningsPlugin( + config=config, output_path=config.getoption("warning_output_path", default=None) + ) + config.pluginmanager.register(plugin) + config.stash[capture_warnings_key] = plugin -def pytest_unconfigure(config): +def pytest_unconfigure(config: pytest.Config) -> None: os.environ.pop("_AIRFLOW__SKIP_DATABASE_EXECUTOR_COMPATIBILITY_CHECK", None) + capture_warnings = config.stash.get(capture_warnings_key, None) + if capture_warnings: + del config.stash[capture_warnings_key] + config.pluginmanager.unregister(capture_warnings) def skip_if_not_marked_with_integration(selected_integrations, item): @@ -1275,140 +1304,6 @@ def _disable_redact(request: pytest.FixtureRequest, mocker): return -# The code below is a modified version of capture-warning code from -# https://github.com/athinkingape/pytest-capture-warnings - -# MIT License -# -# Portions Copyright (c) 2022 A Thinking Ape Entertainment Ltd. -# Portions Copyright (c) 2022 Pyschojoker (Github) -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -captured_warnings: dict[tuple[str, int, type[Warning], str], warnings.WarningMessage] = {} -captured_warnings_count: dict[tuple[str, int, type[Warning], str], int] = {} -# By set ``_ispytest=True`` in WarningsRecorder we suppress annoying warnings: -# PytestDeprecationWarning: A private pytest class or function was used. -warnings_recorder = pytest.WarningsRecorder(_ispytest=True) -default_formatwarning = warnings_recorder._module.formatwarning # type: ignore[attr-defined] -default_showwarning = warnings_recorder._module.showwarning # type: ignore[attr-defined] - - -@pytest.hookimpl(hookwrapper=True) -def pytest_runtest_call(item): - """ - Needed to grab the item.location information - """ - if os.environ.get("PYTHONWARNINGS") == "ignore": - yield - return - - with warnings.catch_warnings(record=True) as records: - yield - for record in records: - quadruplet: tuple[str, int, type[Warning], str] = ( - record.filename, - record.lineno, - record.category, - str(record.message), - ) - if quadruplet in captured_warnings: - captured_warnings_count[quadruplet] += 1 - continue - else: - captured_warnings[quadruplet] = record - captured_warnings_count[quadruplet] = 1 - - -@pytest.hookimpl(hookwrapper=True) -def pytest_terminal_summary(terminalreporter, exitstatus, config=None): - pwd = os.path.realpath(os.curdir) - - def cut_path(path): - if path.startswith(pwd): - path = path[len(pwd) + 1 :] - if "/site-packages/" in path: - path = path.split("/site-packages/")[1] - return path - - def format_test_function_location(item): - return f"{item.location[0]}::{item.location[2]}:{item.location[1]}" - - yield - - if captured_warnings: - terminalreporter.section( - f"Warning summary. Total: {sum(captured_warnings_count.values())}, " - f"Unique: {len(captured_warnings.values())}", - yellow=True, - bold=True, - ) - warnings_as_json = [] - - for warning in captured_warnings.values(): - serialized_warning = { - x: str(getattr(warning.message, x)) for x in dir(warning.message) if not x.startswith("__") - } - - serialized_warning.update( - { - "path": cut_path(warning.filename), - "lineno": warning.lineno, - "count": 1, - "warning_message": str(warning.message), - } - ) - - # How we format the warnings: pylint parseable format - # {path}:{line}: [{msg_id}({symbol}), {obj}] {msg} - # Always: - # {path}:{line}: [W0513(warning), ] {msg} - - if "with_traceback" in serialized_warning: - del serialized_warning["with_traceback"] - warnings_as_json.append(serialized_warning) - - with warning_output_path.open("w") as f: - for i in warnings_as_json: - f.write(f'{i["path"]}:{i["lineno"]}: [W0513(warning), ] {i["warning_message"]}') - f.write("\n") - terminalreporter.write("Warnings saved into ") - terminalreporter.write(os.fspath(warning_output_path), yellow=True) - terminalreporter.write(" file.\n") - else: - # nothing, clear file - with warning_output_path.open("w") as f: - pass - - -def configure_warning_output(config): - global warning_output_path - warning_output_path = Path(config.getoption("warning_output_path")) - if ( - "CAPTURE_WARNINGS_OUTPUT" in os.environ - and warning_output_path.resolve() != DEFAULT_WARNING_OUTPUT_PATH.resolve() - ): - warning_output_path = os.environ["CAPTURE_WARNINGS_OUTPUT"] - - -# End of modified code from https://github.com/athinkingape/pytest-capture-warnings - if TYPE_CHECKING: # Static checkers do not know about pytest fixtures' types and return, # In case if them distributed through third party packages.