| # 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 random | |
| import string | |
| from pathlib import Path | |
| from unittest import mock | |
| import pytest | |
| from airflow_breeze.prepare_providers.provider_documentation import ( | |
| NEEDS_LLM_CLASSIFICATION, | |
| VERSION_MAJOR_INDEX, | |
| VERSION_MINOR_INDEX, | |
| VERSION_PATCHLEVEL_INDEX, | |
| Change, | |
| PrepareReleaseDocsChangesOnlyException, | |
| PrepareReleaseDocsNoChangesException, | |
| TypeOfChange, | |
| _convert_git_changes_to_table, | |
| _find_insertion_index_for_version, | |
| _generate_new_changelog, | |
| _get_change_from_line, | |
| _get_changes_classified, | |
| _get_git_log_command, | |
| classification_result, | |
| classify_change_deterministically, | |
| drop_provider_to_doc_only, | |
| format_message_for_classification, | |
| get_most_impactful_change, | |
| get_version_tag, | |
| update_release_notes, | |
| ) | |
| from airflow_breeze.utils.confirm import Answer | |
| from airflow_breeze.utils.packages import HTTPS_REMOTE, ProviderPackageDetails | |
| PROVIDER_DOCUMENTATION = "airflow_breeze.prepare_providers.provider_documentation" | |
| CHANGELOG_CONTENT = """ | |
| Changelog | |
| --------- | |
| 5.0.0 | |
| ..... | |
| Breaking changes | |
| ~~~~~~~~~~~~~~~~ | |
| The ``offset`` parameter has been deprecated from ``list_jobs`` in favor of faster pagination with ``page_token`` similarly to `Databricks API <https://docs.databricks.com/api/workspace/jobs/list>`_. | |
| * ``Remove offset-based pagination from 'list_jobs' function in 'DatabricksHook' (#34926)`` | |
| 4.7.0 | |
| ..... | |
| Features | |
| ~~~~~~~~ | |
| * ``Add operator to create jobs in Databricks (#35156)`` | |
| .. Below changes are excluded from the changelog. Move them to | |
| appropriate section above if needed. Do not delete the lines(!): | |
| * ``Prepare docs 3rd wave of Providers October 2023 (#35187)`` | |
| * ``Pre-upgrade 'ruff==0.0.292' changes in providers (#35053)`` | |
| * ``D401 Support - Providers: DaskExecutor to Github (Inclusive) (#34935)`` | |
| 4.6.0 | |
| ..... | |
| .. note:: | |
| This release of provider is only available for Airflow 2.5+ as explained in the | |
| `Apache Airflow providers support policy <https://github.com/apache/airflow/blob/main/PROVIDERS.rst#minimum-supported-version-of-airflow-for-community-managed-providers>`_. | |
| """ | |
| def test_find_insertion_index_append_to_found_changelog(): | |
| index, append = _find_insertion_index_for_version(CHANGELOG_CONTENT.splitlines(), "5.0.0") | |
| assert append | |
| assert index == 13 | |
| def test_find_insertion_index_insert_new_changelog(): | |
| index, append = _find_insertion_index_for_version(CHANGELOG_CONTENT.splitlines(), "5.0.1") | |
| assert not append | |
| assert index == 3 | |
| def test_generate_new_changelog_recognises_grouped_pr_references(tmp_path): | |
| changelog_path = tmp_path / "changelog.rst" | |
| changelog_path.write_text( | |
| """ | |
| Changelog | |
| --------- | |
| 5.0.0 | |
| ..... | |
| Features | |
| ~~~~~~~~ | |
| * ``Add X (#1001, #1002)`` | |
| * ``Add Y (#1003)`` | |
| * ``Add Z (continuation of #1005) (#1006)`` | |
| 4.7.0 | |
| ..... | |
| * ``Old (#900)`` | |
| """ | |
| ) | |
| provider_details = mock.MagicMock( | |
| spec=ProviderPackageDetails, versions=["5.0.0"], changelog_path=changelog_path | |
| ) | |
| changes = [ | |
| Change("hash", "short", "2024-01-01", "5.0.0", f"Fix (#{pr})", f"Fix (#{pr})", pr) | |
| for pr in ("1001", "1002", "1003", "1004", "1005") | |
| ] | |
| _generate_new_changelog( | |
| package_id="asana", | |
| provider_details=provider_details, | |
| changes=[changes], | |
| context={}, | |
| with_breaking_changes=False, | |
| maybe_with_new_features=False, | |
| ) | |
| new_changelog = changelog_path.read_text() | |
| assert "* ``Fix (#1004)``" in new_changelog | |
| assert "* ``Fix (#1005)``" in new_changelog | |
| for pr in ("1001", "1002", "1003"): | |
| assert new_changelog.count(f"#{pr}") == 1 | |
| @pytest.mark.parametrize( | |
| ("version", "provider_id", "suffix", "tag"), | |
| [ | |
| ("1.0.1", "asana", "", "providers-asana/1.0.1"), | |
| ("1.0.1", "asana", "rc1", "providers-asana/1.0.1rc1"), | |
| ("1.0.1", "apache.hdfs", "beta1", "providers-apache-hdfs/1.0.1beta1"), | |
| ], | |
| ) | |
| def test_get_version_tag(version: str, provider_id: str, suffix: str, tag: str): | |
| assert get_version_tag(version, provider_id, suffix) == tag | |
| @pytest.mark.parametrize( | |
| ("folder_paths", "from_commit", "to_commit", "git_command"), | |
| [ | |
| (None, None, None, ["git", "log", "--pretty=format:%H %h %cd %s", "--date=short", "--", "."]), | |
| ( | |
| None, | |
| "from_tag", | |
| None, | |
| ["git", "log", "--pretty=format:%H %h %cd %s", "--date=short", "from_tag", "--", "."], | |
| ), | |
| ( | |
| None, | |
| "from_tag", | |
| "to_tag", | |
| ["git", "log", "--pretty=format:%H %h %cd %s", "--date=short", "from_tag...to_tag", "--", "."], | |
| ), | |
| ( | |
| [Path("a"), Path("b")], | |
| "from_tag", | |
| "to_tag", | |
| [ | |
| "git", | |
| "log", | |
| "--pretty=format:%H %h %cd %s", | |
| "--date=short", | |
| "from_tag...to_tag", | |
| "--", | |
| "a", | |
| "b", | |
| ], | |
| ), | |
| ], | |
| ) | |
| def test_get_git_log_command( | |
| folder_paths: list[str] | None, from_commit: str | None, to_commit: str | None, git_command: list[str] | |
| ): | |
| assert _get_git_log_command(folder_paths, from_commit, to_commit) == git_command | |
| def test_get_git_log_command_wrong(): | |
| with pytest.raises(ValueError, match=r"to_commit without from_commit"): | |
| _get_git_log_command(None, None, "to_commit") | |
| @pytest.mark.parametrize( | |
| ("line", "version", "change"), | |
| [ | |
| ( | |
| "LONG_HASH_123144 SHORT_HASH 2023-01-01 Description `with` no pr", | |
| "1.0.1", | |
| Change( | |
| full_hash="LONG_HASH_123144", | |
| short_hash="SHORT_HASH", | |
| date="2023-01-01", | |
| version="1.0.1", | |
| message="Description `with` no pr", | |
| message_without_backticks="Description 'with' no pr", | |
| pr=None, | |
| ), | |
| ), | |
| ( | |
| "LONG_HASH_123144 SHORT_HASH 2023-01-01 Description `with` pr (#12345)", | |
| "1.0.1", | |
| Change( | |
| full_hash="LONG_HASH_123144", | |
| short_hash="SHORT_HASH", | |
| date="2023-01-01", | |
| version="1.0.1", | |
| message="Description `with` pr (#12345)", | |
| message_without_backticks="Description 'with' pr (#12345)", | |
| pr="12345", | |
| ), | |
| ), | |
| ], | |
| ) | |
| def test_get_change_from_line(line: str, version: str, change: Change): | |
| assert _get_change_from_line(line, version) == change | |
| @pytest.mark.parametrize( | |
| ("input", "output", "markdown", "changes_len"), | |
| [ | |
| ( | |
| """ | |
| LONG_HASH_123144 SHORT_HASH 2023-01-01 Description `with` no pr | |
| LONG_HASH_123144 SHORT_HASH 2023-01-01 Description `with` pr (#12345) | |
| LONG_HASH_123144 SHORT_HASH 2023-01-01 Description `with` pr (#12346) | |
| """, | |
| """ | |
| 1.0.1 | |
| ..... | |
| Latest change: 2023-01-01 | |
| ============================================= =========== ================================== | |
| Commit Committed Subject | |
| ============================================= =========== ================================== | |
| `SHORT_HASH <https://url/LONG_HASH_123144>`__ 2023-01-01 ``Description 'with' no pr`` | |
| `SHORT_HASH <https://url/LONG_HASH_123144>`__ 2023-01-01 ``Description 'with' pr (#12345)`` | |
| `SHORT_HASH <https://url/LONG_HASH_123144>`__ 2023-01-01 ``Description 'with' pr (#12346)`` | |
| ============================================= =========== ==================================""", | |
| False, | |
| 3, | |
| ), | |
| ( | |
| """ | |
| LONG_HASH_123144 SHORT_HASH 2023-01-01 Description `with` no pr | |
| LONG_HASH_123144 SHORT_HASH 2023-01-01 Description `with` pr (#12345) | |
| LONG_HASH_123144 SHORT_HASH 2023-01-01 Description `with` pr (#12346) | |
| """, | |
| """ | |
| | Commit | Committed | Subject | | |
| |:-------------------------------------------|:-----------:|:---------------------------------| | |
| | [SHORT_HASH](https://url/LONG_HASH_123144) | 2023-01-01 | `Description 'with' no pr` | | |
| | [SHORT_HASH](https://url/LONG_HASH_123144) | 2023-01-01 | `Description 'with' pr (#12345)` | | |
| | [SHORT_HASH](https://url/LONG_HASH_123144) | 2023-01-01 | `Description 'with' pr (#12346)` | | |
| """, | |
| True, | |
| 3, | |
| ), | |
| ], | |
| ) | |
| def test_convert_git_changes_to_table(input: str, output: str, markdown: bool, changes_len): | |
| table, list_of_changes = _convert_git_changes_to_table( | |
| version="1.0.1", changes=input, base_url="https://url/", markdown=markdown | |
| ) | |
| assert table.strip() == output.strip() | |
| assert len(list_of_changes) == changes_len | |
| assert list_of_changes[0].pr is None | |
| assert list_of_changes[1].pr == "12345" | |
| assert list_of_changes[2].pr == "12346" | |
| def generate_random_string(length): | |
| c = string.hexdigits.lower() | |
| return "".join(random.choice(c) for _ in range(length)) | |
| def generate_long_hash(): | |
| return generate_random_string(40) | |
| def generate_short_hash(): | |
| return generate_random_string(10) | |
| @pytest.mark.parametrize( | |
| ( | |
| "descriptions", | |
| "with_breaking_changes", | |
| "maybe_with_new_features", | |
| "breaking_count", | |
| "feature_count", | |
| "bugfix_count", | |
| "other_count", | |
| "misc_count", | |
| "type_of_change", | |
| ), | |
| [ | |
| (["Added feature x"], True, True, 0, 1, 0, 0, 0, [TypeOfChange.FEATURE]), | |
| (["Added feature x"], False, True, 0, 1, 0, 0, 0, [TypeOfChange.FEATURE]), | |
| (["Breaking change in"], True, True, 1, 0, 0, 0, 0, [TypeOfChange.BREAKING_CHANGE]), | |
| (["Misc change in"], False, False, 0, 0, 0, 0, 1, [TypeOfChange.MISC]), | |
| ( | |
| ["Fix change in", "Breaking feature y"], | |
| True, | |
| False, | |
| 1, | |
| 0, | |
| 1, | |
| 0, | |
| 0, | |
| [TypeOfChange.BUGFIX, TypeOfChange.BREAKING_CHANGE], | |
| ), | |
| ], | |
| ) | |
| def test_classify_changes_automatically( | |
| descriptions: list[str], | |
| with_breaking_changes: bool, | |
| maybe_with_new_features: bool, | |
| breaking_count: int, | |
| feature_count: int, | |
| bugfix_count: int, | |
| other_count: int, | |
| misc_count: int, | |
| type_of_change: TypeOfChange, | |
| ): | |
| from airflow_breeze.prepare_providers.provider_documentation import SHORT_HASH_TO_TYPE_DICT | |
| """Test simple automated classification of the changes based on their single-line description.""" | |
| changes = [ | |
| _get_change_from_line( | |
| f"{generate_long_hash()} {generate_short_hash()} 2023-12-01 {description}", version="0.1.0" | |
| ) | |
| for description in descriptions | |
| ] | |
| for i in range(0, len(changes)): | |
| SHORT_HASH_TO_TYPE_DICT[changes[i].short_hash] = type_of_change[i] | |
| classified_changes = _get_changes_classified( | |
| changes, with_breaking_changes=with_breaking_changes, maybe_with_new_features=maybe_with_new_features | |
| ) | |
| assert len(classified_changes.breaking_changes) == breaking_count | |
| assert len(classified_changes.features) == feature_count | |
| assert len(classified_changes.fixes) == bugfix_count | |
| assert len(classified_changes.other) == other_count | |
| assert len(classified_changes.other) == other_count | |
| assert len(classified_changes.misc) == misc_count | |
| @pytest.mark.parametrize( | |
| ("initial_version", "bump_index", "expected_version"), | |
| [ | |
| ("4.2.1", VERSION_MAJOR_INDEX, "5.0.0"), | |
| ("3.5.9", VERSION_MINOR_INDEX, "3.6.0"), | |
| ("2.0.0", VERSION_PATCHLEVEL_INDEX, "2.0.1"), | |
| ], | |
| ) | |
| def test_version_bump_for_provider_documentation(initial_version, bump_index, expected_version): | |
| from airflow_breeze.prepare_providers.provider_documentation import Version, bump_version | |
| result = bump_version(Version(initial_version), bump_index) | |
| assert str(result) == expected_version | |
| @pytest.mark.parametrize( | |
| ("changes", "expected"), | |
| [ | |
| pytest.param([TypeOfChange.SKIP], TypeOfChange.SKIP, id="only-skip"), | |
| pytest.param([TypeOfChange.DOCUMENTATION], TypeOfChange.DOCUMENTATION, id="only-doc"), | |
| pytest.param([TypeOfChange.MISC], TypeOfChange.MISC, id="only-misc"), | |
| pytest.param([TypeOfChange.BUGFIX], TypeOfChange.BUGFIX, id="only-bugfix"), | |
| pytest.param( | |
| [TypeOfChange.MIN_AIRFLOW_VERSION_BUMP], | |
| TypeOfChange.MIN_AIRFLOW_VERSION_BUMP, | |
| id="only-min-airflow-bump", | |
| ), | |
| pytest.param([TypeOfChange.FEATURE], TypeOfChange.FEATURE, id="only-feature"), | |
| pytest.param([TypeOfChange.BREAKING_CHANGE], TypeOfChange.BREAKING_CHANGE, id="only-breaking"), | |
| pytest.param( | |
| [TypeOfChange.SKIP, TypeOfChange.DOCUMENTATION], TypeOfChange.DOCUMENTATION, id="doc-vs-skip" | |
| ), | |
| pytest.param([TypeOfChange.SKIP, TypeOfChange.MISC], TypeOfChange.MISC, id="misc-vs-skip"), | |
| pytest.param([TypeOfChange.DOCUMENTATION, TypeOfChange.MISC], TypeOfChange.MISC, id="misc-vs-doc"), | |
| pytest.param([TypeOfChange.MISC, TypeOfChange.BUGFIX], TypeOfChange.BUGFIX, id="bugfix-vs-misc"), | |
| pytest.param( | |
| [TypeOfChange.BUGFIX, TypeOfChange.MIN_AIRFLOW_VERSION_BUMP], | |
| TypeOfChange.MIN_AIRFLOW_VERSION_BUMP, | |
| id="bump-vs-bugfix", | |
| ), | |
| pytest.param( | |
| [TypeOfChange.MIN_AIRFLOW_VERSION_BUMP, TypeOfChange.FEATURE], | |
| TypeOfChange.FEATURE, | |
| id="feature-vs-bump", | |
| ), | |
| pytest.param( | |
| [TypeOfChange.FEATURE, TypeOfChange.BREAKING_CHANGE], | |
| TypeOfChange.BREAKING_CHANGE, | |
| id="breaking-vs-feature", | |
| ), | |
| # Bigger combos | |
| pytest.param( | |
| [ | |
| TypeOfChange.SKIP, | |
| TypeOfChange.DOCUMENTATION, | |
| TypeOfChange.MISC, | |
| TypeOfChange.BUGFIX, | |
| TypeOfChange.MIN_AIRFLOW_VERSION_BUMP, | |
| TypeOfChange.FEATURE, | |
| TypeOfChange.BREAKING_CHANGE, | |
| ], | |
| TypeOfChange.BREAKING_CHANGE, | |
| id="full-spectrum", | |
| ), | |
| pytest.param( | |
| [ | |
| TypeOfChange.DOCUMENTATION, | |
| TypeOfChange.BUGFIX, | |
| TypeOfChange.MIN_AIRFLOW_VERSION_BUMP, | |
| ], | |
| TypeOfChange.MIN_AIRFLOW_VERSION_BUMP, | |
| id="version-bump-over-bugfix-doc", | |
| ), | |
| pytest.param( | |
| [ | |
| TypeOfChange.DOCUMENTATION, | |
| TypeOfChange.MISC, | |
| TypeOfChange.SKIP, | |
| ], | |
| TypeOfChange.MISC, | |
| id="misc-over-doc-skip", | |
| ), | |
| ], | |
| ) | |
| def test_get_most_impactful_change(changes, expected): | |
| assert get_most_impactful_change(changes) == expected | |
| def test_format_message_for_classification_links_every_reference_to_its_own_number(): | |
| message = "Fix td_format rendering of negative durations (#72694) (#72774)" | |
| assert format_message_for_classification(message) == ( | |
| "Fix td_format rendering of negative durations " | |
| "(https://github.com/apache/airflow/pull/72694) " | |
| "(https://github.com/apache/airflow/pull/72774)" | |
| ) | |
| @pytest.mark.parametrize( | |
| ("provider_id", "changed_files", "expected"), | |
| [ | |
| pytest.param("slack", ["providers/slack/docs/slack.rst"], "documentation", id="only_docs"), | |
| pytest.param( | |
| "apache.flink", | |
| ["providers/apache/flink/docs/slack.rst"], | |
| "documentation", | |
| id="only_docs_longer_path", | |
| ), | |
| pytest.param( | |
| "slack", ["providers/slack/tests/test_slack.py"], "test_or_example_only", id="only_tests" | |
| ), | |
| pytest.param( | |
| "apache.flink", | |
| ["providers/apache/flink/tests/unit/apache/flink/sensors/test_flink_kubernetes.py"], | |
| "test_or_example_only", | |
| id="only_tests_longer_path", | |
| ), | |
| pytest.param( | |
| "slack", | |
| ["providers/slack/src/airflow/providers/slack/example_dags/example_notify.py"], | |
| "test_or_example_only", | |
| id="only_example_dags", | |
| ), | |
| pytest.param( | |
| "slack", | |
| [ | |
| "providers/slack/tests/test_slack.py", | |
| "providers/slack/src/airflow/providers/slack/example_dags/example_notify.py", | |
| ], | |
| "test_or_example_only", | |
| id="tests_and_example_dags", | |
| ), | |
| pytest.param( | |
| "slack", | |
| [ | |
| "providers/slack/tests/test_slack.py", | |
| "providers/slack/docs/slack.rst", | |
| ], | |
| "documentation", | |
| id="docs_and_tests", | |
| ), | |
| pytest.param( | |
| "slack", | |
| [ | |
| "providers/slack/src/airflow/providers/slack/hooks/slack.py", | |
| "providers/slack/tests/test_slack.py", | |
| ], | |
| "other", | |
| id="real_code_and_tests", | |
| ), | |
| pytest.param( | |
| "slack", | |
| [ | |
| "providers/slack/src/airflow/providers/slack/hooks/slack.py", | |
| "providers/slack/tests/test_slack.py", | |
| "providers/slack/docs/slack.rst", | |
| ], | |
| "other", | |
| id="docs_and_real_code", | |
| ), | |
| pytest.param( | |
| "google", | |
| [ | |
| "providers/google/tests/some_test.py", | |
| "providers/amazon/tests/test_something.py", | |
| ], | |
| "test_or_example_only", | |
| id="tests_in_multiple_providers", | |
| ), | |
| pytest.param( | |
| "amazon", | |
| [ | |
| "providers/google/tests/some_test.py", | |
| "providers/amazon/tests/test_something.py", | |
| ], | |
| "test_or_example_only", | |
| id="tests_in_multiple_providers", | |
| ), | |
| pytest.param("slack", ["airflow/utils/db.py"], "other", id="non_provider_file"), | |
| pytest.param("slack", [], "other", id="empty_commit"), | |
| pytest.param( | |
| "informatica", | |
| ["providers/informatica/dev/informatica_simulator/requirements.txt"], | |
| "test_or_example_only", | |
| id="only_dev_tooling", | |
| ), | |
| pytest.param( | |
| "slack", | |
| [ | |
| "providers/slack/tests/test_slack.py", | |
| "providers/slack/dev/some_tool.py", | |
| ], | |
| "test_or_example_only", | |
| id="tests_and_dev_tooling", | |
| ), | |
| pytest.param( | |
| "slack", | |
| [ | |
| "providers/slack/src/airflow/providers/slack/hooks/slack.py", | |
| "providers/slack/dev/some_tool.py", | |
| ], | |
| "other", | |
| id="dev_tooling_and_real_code", | |
| ), | |
| ], | |
| ) | |
| def test_classify_provider_pr_files_logic(provider_id, changed_files, expected): | |
| result = classification_result(provider_id, changed_files) | |
| assert result == expected | |
| def _make_change(subject: str) -> Change: | |
| return Change( | |
| full_hash="deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", | |
| short_hash="deadbee", | |
| date="2026-06-08", | |
| version="1.0.0", | |
| message=subject, | |
| message_without_backticks=subject.replace("`", "'"), | |
| pr="123", | |
| ) | |
| @pytest.mark.parametrize( | |
| ("files_class", "subject", "expected"), | |
| [ | |
| # changed-files verdict wins, regardless of the subject | |
| pytest.param("documentation", "Fix typo in docs", "documentation", id="doc_only"), | |
| pytest.param("documentation", "Bump aiohttp", "documentation", id="doc_only_beats_bump"), | |
| pytest.param("test_or_example_only", "Add a flaky test", "skip", id="test_only"), | |
| # subject-based deterministic rule: release-preparation commits | |
| pytest.param( | |
| "other", "Prepare providers release 2026-07-22 (#70256)", "skip", id="release_prep_skip" | |
| ), | |
| pytest.param( | |
| "other", "prepare providers release 2026-01-01", "skip", id="release_prep_lowercase_skip" | |
| ), | |
| # subject-based deterministic rule: dependency bumps | |
| pytest.param("other", "Bump aiohttp regarding dependabot warning", "misc", id="bump_misc"), | |
| pytest.param("other", "bump the deps group across 1 directory", "misc", id="lowercase_bump_misc"), | |
| # everything else is intentionally left to the LLM (conservative) | |
| pytest.param("other", "Fix the ftp tls", NEEDS_LLM_CLASSIFICATION, id="fix_needs_llm"), | |
| pytest.param("other", "Add DmsModifyTaskOperator", NEEDS_LLM_CLASSIFICATION, id="add_needs_llm"), | |
| pytest.param("other", "Rename resumablemixin file", NEEDS_LLM_CLASSIFICATION, id="rename_needs_llm"), | |
| ], | |
| ) | |
| def test_classify_change_deterministically(files_class, subject, expected): | |
| from unittest import mock | |
| with mock.patch( | |
| "airflow_breeze.prepare_providers.provider_documentation.classify_provider_pr_files", | |
| return_value=files_class, | |
| ): | |
| classification, reason = classify_change_deterministically("amazon", _make_change(subject)) | |
| assert classification == expected | |
| assert reason, "a non-empty reason must always be returned" | |
| @mock.patch(f"{PROVIDER_DOCUMENTATION}.get_provider_details") | |
| @mock.patch(f"{PROVIDER_DOCUMENTATION}.classify_provider_pr_files", return_value="documentation") | |
| @mock.patch(f"{PROVIDER_DOCUMENTATION}.user_confirm", return_value=Answer.YES) | |
| @mock.patch(f"{PROVIDER_DOCUMENTATION}._get_all_changes_for_package") | |
| def test_doc_only_marker_written_when_classification_overrides_user_answer( | |
| mock_get_all_changes, _mock_user_confirm, _mock_classify, mock_get_provider_details, tmp_path | |
| ): | |
| """Every change classifying as documentation makes the provider doc-only, whatever the release | |
| manager answered.""" | |
| (tmp_path / "docs").mkdir() | |
| marker_file = tmp_path / "docs" / ".latest-doc-only-change.txt" | |
| change = _make_change("Fix a typo in the amazon docs") | |
| mock_get_all_changes.return_value = (False, [[change]], "") | |
| provider_details = mock.MagicMock(spec=ProviderPackageDetails) | |
| provider_details.provider_id = "amazon" | |
| provider_details.root_provider_path = tmp_path | |
| mock_get_provider_details.return_value = provider_details | |
| with pytest.raises(PrepareReleaseDocsChangesOnlyException): | |
| update_release_notes( | |
| provider_id="amazon", | |
| reapply_templates_only=False, | |
| base_branch="main", | |
| regenerate_missing_docs=False, | |
| non_interactive=False, | |
| only_min_version_update=False, | |
| ) | |
| assert marker_file.read_text().strip() == change.full_hash | |
| def _make_doc_only_change(full_hash: str = "a" * 40) -> Change: | |
| return Change( | |
| full_hash=full_hash, | |
| short_hash=full_hash[:7], | |
| date="2026-06-08", | |
| version="1.0.0", | |
| message="Fix a typo", | |
| message_without_backticks="Fix a typo", | |
| pr="123", | |
| ) | |
| @mock.patch("airflow_breeze.prepare_providers.provider_documentation.run_command") | |
| @mock.patch("airflow_breeze.prepare_providers.provider_documentation._get_all_changes_for_package") | |
| @mock.patch("airflow_breeze.prepare_providers.provider_documentation.clear_cache_for_provider_metadata") | |
| @mock.patch("airflow_breeze.prepare_providers.provider_documentation.get_provider_yaml") | |
| @mock.patch("airflow_breeze.prepare_providers.provider_documentation.get_provider_details") | |
| def test_drop_provider_to_doc_only_restores_the_release_state_and_records_the_marker( | |
| mock_details, mock_yaml, mock_clear_cache, mock_changes, mock_run, tmp_path | |
| ): | |
| """Correcting the changelog is not enough - the prepared bump would still be released.""" | |
| docs = tmp_path / "docs" | |
| docs.mkdir() | |
| changelog = docs / "changelog.rst" | |
| changelog.write_text("changelog") | |
| provider_yaml = tmp_path / "provider.yaml" | |
| provider_yaml.write_text("versions: [1.0.0]") | |
| mock_details.return_value = mock.MagicMock(root_provider_path=tmp_path) | |
| mock_yaml.return_value = provider_yaml | |
| mock_changes.return_value = (True, [[_make_doc_only_change()]], "table") | |
| with pytest.raises(PrepareReleaseDocsChangesOnlyException): | |
| drop_provider_to_doc_only("amazon", base_branch="main") | |
| restored = mock_run.call_args.args[0] | |
| assert restored[:4] == ["git", "checkout", f"{HTTPS_REMOTE}/main", "--"] | |
| assert str(provider_yaml) in restored | |
| assert str(changelog) in restored | |
| assert (docs / ".latest-doc-only-change.txt").read_text() == "a" * 40 + "\n" | |
| @pytest.mark.parametrize( | |
| ("marked_for_release", "expected_reason"), | |
| [ | |
| pytest.param(True, "has never been released", id="never-released"), | |
| pytest.param(False, "No changes found", id="nothing-since-last-release"), | |
| ], | |
| ) | |
| @mock.patch("airflow_breeze.prepare_providers.provider_documentation.run_command") | |
| @mock.patch("airflow_breeze.prepare_providers.provider_documentation._get_all_changes_for_package") | |
| @mock.patch("airflow_breeze.prepare_providers.provider_documentation.clear_cache_for_provider_metadata") | |
| @mock.patch("airflow_breeze.prepare_providers.provider_documentation.get_provider_yaml") | |
| @mock.patch("airflow_breeze.prepare_providers.provider_documentation.get_provider_details") | |
| def test_drop_provider_to_doc_only_writes_no_marker_when_there_is_nothing_to_mark( | |
| mock_details, | |
| mock_yaml, | |
| mock_clear_cache, | |
| mock_changes, | |
| mock_run, | |
| tmp_path, | |
| capsys, | |
| marked_for_release, | |
| expected_reason, | |
| ): | |
| """An empty marker would silence the provider's next release entirely, and a provider without a | |
| release has nothing for the marker to point at.""" | |
| docs = tmp_path / "docs" | |
| docs.mkdir() | |
| mock_details.return_value = mock.MagicMock(root_provider_path=tmp_path) | |
| mock_yaml.return_value = tmp_path / "provider.yaml" | |
| mock_changes.return_value = (marked_for_release, [], "") | |
| with pytest.raises(PrepareReleaseDocsNoChangesException): | |
| drop_provider_to_doc_only("amazon", base_branch="main") | |
| assert not (docs / ".latest-doc-only-change.txt").exists() | |
| assert expected_reason in capsys.readouterr().out |