This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 7bbd8a79283 Breeze: Show local reproduction commands in all breeze CI
steps (#63901)
7bbd8a79283 is described below
commit 7bbd8a79283876db4d786291ea830efd38acd193
Author: Aaron Chen <[email protected]>
AuthorDate: Fri Apr 10 02:46:11 2026 -0700
Breeze: Show local reproduction commands in all breeze CI steps (#63901)
* Breeze: Show local reproduction commands for
build-docs/core-tests/providers-tests/task-sdk-tests
* Fix formatting in local reproduction command output
* Refactor CI reproduction commands to derive from Click context
automatically
* Fix handling of excluded parameters and positional arguments in
reproduction command context
* Use long-form flags for flag pairs in reproduction commands
Co-authored-by: Copilot <[email protected]>
* Update reproduction commands to fetch current commit SHA and adjust
comments for clarity
* add ruler on the top and bottom of reproduce command
* Automate CI reproduction commands for all Breeze commands via
command_class
* fix CI error - adjust position of type ignore
---------
Co-authored-by: Copilot <[email protected]>
---
dev/breeze/src/airflow_breeze/utils/click_utils.py | 28 +-
.../src/airflow_breeze/utils/reproduce_ci.py | 239 ++++++++++++
dev/breeze/tests/test_reproduce_ci.py | 432 +++++++++++++++++++++
3 files changed, 697 insertions(+), 2 deletions(-)
diff --git a/dev/breeze/src/airflow_breeze/utils/click_utils.py
b/dev/breeze/src/airflow_breeze/utils/click_utils.py
index 92d8d7b7cea..25a9de38868 100644
--- a/dev/breeze/src/airflow_breeze/utils/click_utils.py
+++ b/dev/breeze/src/airflow_breeze/utils/click_utils.py
@@ -16,7 +16,31 @@
# under the License.
from __future__ import annotations
+from typing import TYPE_CHECKING
+
try:
- from rich_click import RichGroup as BreezeGroup
+ from rich_click import RichCommand as _BaseCommand, RichGroup as _BaseGroup
except ImportError:
- from click import Group as BreezeGroup # type: ignore[assignment] #
noqa: F401
+ from click import ( # type: ignore[assignment]
+ Command as _BaseCommand,
+ Group as _BaseGroup,
+ )
+
+if TYPE_CHECKING:
+ import click
+
+
+class BreezeCommand(_BaseCommand):
+ """Breeze CLI command that automatically prints reproduction instructions
in CI."""
+
+ def invoke(self, ctx: click.Context) -> None:
+ try:
+ return super().invoke(ctx)
+ finally:
+ from airflow_breeze.utils.reproduce_ci import
maybe_print_reproduction
+
+ maybe_print_reproduction(ctx)
+
+
+class BreezeGroup(_BaseGroup):
+ command_class = BreezeCommand
diff --git a/dev/breeze/src/airflow_breeze/utils/reproduce_ci.py
b/dev/breeze/src/airflow_breeze/utils/reproduce_ci.py
new file mode 100644
index 00000000000..4a429f556ab
--- /dev/null
+++ b/dev/breeze/src/airflow_breeze/utils/reproduce_ci.py
@@ -0,0 +1,239 @@
+# 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.
+"""Helpers for printing local reproduction instructions in CI logs."""
+
+from __future__ import annotations
+
+import os
+import shlex
+from dataclasses import dataclass
+
+import click
+from click.core import ParameterSource
+from rich.markup import escape
+
+from airflow_breeze.global_constants import APACHE_AIRFLOW_GITHUB_REPOSITORY
+from airflow_breeze.utils.console import get_console
+from airflow_breeze.utils.run_utils import commit_sha
+
+# Options that are side-effect-only or not meaningful for reproduction (safety
net;
+# expose_value=False options like --verbose/--dry-run/--answer are already
excluded
+# automatically because they don't appear in ctx.params).
+_EXCLUDED_PARAMS: frozenset[str] = frozenset(
+ {
+ "verbose",
+ "dry_run",
+ "answer",
+ "include_success_outputs",
+ "debug_resources",
+ }
+)
+
+# These sources represent values explicitly provided by the user or CI.
+_EXPLICIT_SOURCES: frozenset[ParameterSource] = frozenset(
+ {
+ ParameterSource.COMMANDLINE,
+ ParameterSource.ENVIRONMENT,
+ ParameterSource.PROMPT,
+ }
+)
+
+
+@dataclass
+class ReproductionCommand:
+ argv: list[str]
+ comment: str | None = None
+
+
+def build_reproduction_command_from_context(
+ ctx: click.Context,
+ *,
+ comment: str = "Run the same Breeze command locally",
+) -> ReproductionCommand:
+ """Reconstruct the CLI invocation from the current click Context.
+
+ Iterates over every parameter defined on the command, uses
+ ``ctx.get_parameter_source()`` to identify explicitly-provided values
+ (COMMANDLINE / ENVIRONMENT / PROMPT), and emits only those. DEFAULT
+ and DEFAULT_MAP values are omitted to keep the output concise.
+
+ This removes the need for per-command builder functions.
+ """
+ argv: list[str] = ctx.command_path.split()
+
+ for param in ctx.command.params:
+ if not getattr(param, "expose_value", True):
+ continue
+ if param.name is None or param.name in _EXCLUDED_PARAMS:
+ continue
+
+ value = ctx.params.get(param.name)
+ source = ctx.get_parameter_source(param.name)
+
+ if isinstance(param, click.Argument):
+ continue # collected after options
+
+ if not isinstance(param, click.Option):
+ continue
+
+ # Flag pair (e.g. --force-sa-warnings/--no-force-sa-warnings):
+ # emit the appropriate side only when explicitly provided.
+ if param.is_flag and param.secondary_opts:
+ if source in _EXPLICIT_SOURCES:
+ # Prefer long-form alias for both sides of the flag pair.
+ flag = param.opts[-1] if value else param.secondary_opts[-1]
+ argv.append(flag)
+ continue
+
+ # Simple boolean flag (no secondary_opts)
+ if param.is_flag:
+ if value and source in _EXPLICIT_SOURCES:
+ argv.append(param.opts[-1])
+ continue
+
+ # Non-flag option: only emit explicitly-provided values
+ if source not in _EXPLICIT_SOURCES:
+ continue
+ if value is None:
+ continue
+
+ flag = param.opts[-1] # prefer long form
+
+ # Multiple option (e.g. --package-filter repeated)
+ if param.multiple:
+ for item in value:
+ argv.extend([flag, str(item)])
+ continue
+
+ argv.extend([flag, str(value)])
+
+ # Append positional arguments at the end
+ for param in ctx.command.params:
+ if isinstance(param, click.Argument) and param.name is not None:
+ value = ctx.params.get(param.name)
+ if value is not None:
+ if isinstance(value, (list, tuple)):
+ argv.extend(str(v) for v in value)
+ else:
+ argv.append(str(value))
+
+ return ReproductionCommand(argv=argv, comment=comment)
+
+
+def build_checkout_reproduction_commands(github_repository: str) ->
list[ReproductionCommand]:
+ """Build git commands needed to reproduce the current CI checkout
locally."""
+ current_commit_sha = os.environ.get("GITHUB_SHA") or
os.environ.get("COMMIT_SHA") or commit_sha()
+ github_ref = os.environ.get("GITHUB_REF", "")
+ github_ref_parts = github_ref.split("/")
+ if len(github_ref_parts) == 4 and github_ref_parts[:2] == ["refs", "pull"]:
+ pull_request_number = github_ref_parts[2]
+ pull_request_ref_kind = github_ref_parts[3]
+ return [
+ ReproductionCommand(
+ argv=[
+ "git",
+ "fetch",
+ f"https://github.com/{github_repository}.git",
+ github_ref,
+ ],
+ comment=f"Fetch the same code as CI (pull request
{pull_request_ref_kind} ref)"
+ f" — or: gh pr checkout {pull_request_number}",
+ ),
+ ReproductionCommand(
+ argv=["git", "checkout", current_commit_sha],
+ ),
+ ]
+
+ if not current_commit_sha or current_commit_sha == "COMMIT_SHA_NOT_FOUND":
+ return []
+ return [
+ ReproductionCommand(
+ argv=["git", "checkout", current_commit_sha],
+ comment="Check out the same commit",
+ )
+ ]
+
+
+def build_ci_image_reproduction_command(
+ *,
+ github_repository: str = APACHE_AIRFLOW_GITHUB_REPOSITORY,
+ platform: str = "linux/amd64",
+ python: str = "",
+) -> ReproductionCommand:
+ """Build the CI image preparation command for local reproduction."""
+ if not python:
+ from airflow_breeze.global_constants import
DEFAULT_PYTHON_MAJOR_MINOR_VERSION
+
+ python = DEFAULT_PYTHON_MAJOR_MINOR_VERSION
+ command = ["breeze", "ci-image", "build"]
+ if github_repository != APACHE_AIRFLOW_GITHUB_REPOSITORY:
+ command.extend(["--github-repository", github_repository])
+ command.extend(["--platform", platform, "--python", python])
+ return ReproductionCommand(
+ argv=command,
+ comment="Build the CI image locally",
+ )
+
+
+def should_print_local_reproduction() -> bool:
+ """Return True when local reproduction instructions should be printed."""
+ return (
+ os.environ.get("CI", "").lower() == "true" and
os.environ.get("GITHUB_ACTIONS", "").lower() == "true"
+ )
+
+
+def print_local_reproduction(commands: list[ReproductionCommand]) -> None:
+ """Print local reproduction commands in CI logs."""
+ if not should_print_local_reproduction() or not commands:
+ return
+ lines: list[str] = []
+ step_number = 0
+ for command in commands:
+ if command.comment:
+ if lines:
+ lines.append("")
+ step_number += 1
+ lines.append(f"# {step_number}. {command.comment}")
+ lines.append(shlex.join(command.argv))
+ rendered = "\n".join(lines)
+ ruler = "─" * 80
+ console = get_console()
+ console.print(f"\n[warning]{ruler}[/]")
+ console.print("[warning]HOW TO REPRODUCE LOCALLY[/]\n")
+ console.print(f"[info]{escape(rendered)}[/]\n", soft_wrap=True)
+ console.print(f"[warning]{ruler}[/]\n")
+
+
+def maybe_print_reproduction(ctx: click.Context) -> None:
+ """Called by BreezeCommand.invoke() — prints reproduction instructions in
CI."""
+ if not should_print_local_reproduction():
+ return
+
+ github_repository = ctx.params.get("github_repository",
APACHE_AIRFLOW_GITHUB_REPOSITORY)
+ commands = build_checkout_reproduction_commands(github_repository)
+ # Skip CI image prelude for image build commands themselves — it would be
redundant.
+ command_path = ctx.command_path
+ if not command_path.startswith(("breeze ci-image", "breeze prod-image")):
+ commands.append(
+ build_ci_image_reproduction_command(
+ github_repository=github_repository,
+ python=ctx.params.get("python", ""),
+ platform=ctx.params.get("platform", "linux/amd64"),
+ )
+ )
+ commands.append(build_reproduction_command_from_context(ctx))
+ print_local_reproduction(commands)
diff --git a/dev/breeze/tests/test_reproduce_ci.py
b/dev/breeze/tests/test_reproduce_ci.py
new file mode 100644
index 00000000000..13d9c1b58e6
--- /dev/null
+++ b/dev/breeze/tests/test_reproduce_ci.py
@@ -0,0 +1,432 @@
+# 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
+
+from unittest import mock
+
+import click
+import click.testing
+import pytest
+
+from airflow_breeze.utils.click_utils import BreezeGroup
+from airflow_breeze.utils.reproduce_ci import (
+ ReproductionCommand,
+ build_checkout_reproduction_commands,
+ build_ci_image_reproduction_command,
+ build_reproduction_command_from_context,
+ print_local_reproduction,
+ should_print_local_reproduction,
+)
+
+
[email protected](
+ ("env_vars", "expected"),
+ [
+ ({"CI": "true", "GITHUB_ACTIONS": "true"}, True),
+ ({"CI": "true", "GITHUB_ACTIONS": "false"}, False),
+ ({"CI": "false", "GITHUB_ACTIONS": "true"}, False),
+ ({}, False),
+ ],
+)
+def test_should_print_local_reproduction_only_in_github_actions(env_vars,
expected, monkeypatch):
+ monkeypatch.delenv("CI", raising=False)
+ monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
+ for key, value in env_vars.items():
+ monkeypatch.setenv(key, value)
+ assert should_print_local_reproduction() is expected
+
+
+def test_build_ci_image_reproduction_command_with_custom_repo():
+ result = build_ci_image_reproduction_command(
+ github_repository="someone/airflow",
+ platform="linux/arm64",
+ python="3.11",
+ )
+ assert result.comment == "Build the CI image locally"
+ assert result.argv == [
+ "breeze",
+ "ci-image",
+ "build",
+ "--github-repository",
+ "someone/airflow",
+ "--platform",
+ "linux/arm64",
+ "--python",
+ "3.11",
+ ]
+
+
+def test_build_ci_image_reproduction_command_default_repo():
+ result = build_ci_image_reproduction_command(platform="linux/amd64",
python="3.10")
+ assert result.argv == [
+ "breeze",
+ "ci-image",
+ "build",
+ "--platform",
+ "linux/amd64",
+ "--python",
+ "3.10",
+ ]
+
+
[email protected]("pr_ref_kind", ["merge", "head"])
+def
test_build_checkout_reproduction_commands_fetches_pull_request_ref(pr_ref_kind,
monkeypatch):
+ github_ref = f"refs/pull/42/{pr_ref_kind}"
+ monkeypatch.setenv("GITHUB_REF", github_ref)
+ monkeypatch.setenv("GITHUB_SHA", "merge-sha")
+
+ commands = build_checkout_reproduction_commands("someone/airflow")
+
+ assert [command.comment for command in commands] == [
+ f"Fetch the same code as CI (pull request {pr_ref_kind} ref) — or: gh
pr checkout 42",
+ None,
+ ]
+ assert commands[0].argv == [
+ "git",
+ "fetch",
+ "https://github.com/someone/airflow.git",
+ github_ref,
+ ]
+ assert commands[1].argv == ["git", "checkout", "merge-sha"]
+
+
+def test_build_checkout_reproduction_commands_plain_sha(monkeypatch):
+ monkeypatch.delenv("GITHUB_REF", raising=False)
+ monkeypatch.setenv("GITHUB_SHA", "def456")
+
+ commands = build_checkout_reproduction_commands("apache/airflow")
+
+ assert len(commands) == 1
+ assert commands[0].argv == ["git", "checkout", "def456"]
+
+
[email protected]("airflow_breeze.utils.reproduce_ci.get_console", autospec=True)
+def test_print_local_reproduction_renders_copyable_commands(mock_get_console,
monkeypatch):
+ monkeypatch.setenv("CI", "true")
+ monkeypatch.setenv("GITHUB_ACTIONS", "true")
+
+ print_local_reproduction(
+ [
+ ReproductionCommand(argv=["git", "checkout", "abc123"],
comment="Check out the same commit"),
+ ReproductionCommand(
+ argv=["breeze", "build-docs", "--docs-only"],
+ comment="Run the same Breeze command locally",
+ ),
+ ]
+ )
+
+ assert mock_get_console.return_value.print.call_count == 4
+ ruler_line = mock_get_console.return_value.print.call_args_list[0].args[0]
+ assert "─" * 80 in ruler_line
+ rendered_output =
mock_get_console.return_value.print.call_args_list[2].args[0]
+ assert "# 1. Check out the same commit" in rendered_output
+ assert "git checkout abc123" in rendered_output
+ assert "breeze build-docs --docs-only" in rendered_output
+ bottom_ruler =
mock_get_console.return_value.print.call_args_list[3].args[0]
+ assert "─" * 80 in bottom_ruler
+
+
+def _invoke_and_capture_reproduction(cli, args, monkeypatch, *, env: dict[str,
str] | None = None):
+ captured_commands: list[list[ReproductionCommand]] = []
+
+ monkeypatch.setenv("CI", "true")
+ monkeypatch.setenv("GITHUB_ACTIONS", "true")
+ monkeypatch.setenv("GITHUB_SHA", "abc123")
+ monkeypatch.delenv("GITHUB_REF", raising=False)
+ if env:
+ for key, value in env.items():
+ monkeypatch.setenv(key, value)
+ monkeypatch.setattr(
+ "airflow_breeze.utils.reproduce_ci.print_local_reproduction",
+ lambda commands: captured_commands.append(commands),
+ )
+
+ result = click.testing.CliRunner().invoke(cli, args)
+
+ assert result.exit_code == 0, result.output
+ assert len(captured_commands) == 1
+ return captured_commands[0]
+
+
+def
test_breeze_command_smoke_skip_cleanup_is_included_in_rendered_command(monkeypatch):
+ @click.group(cls=BreezeGroup, name="breeze")
+ def cli():
+ pass
+
+ @cli.command(name="parallel-task")
+ @click.option("--skip-cleanup", is_flag=True)
+ def parallel_task(skip_cleanup: bool):
+ del skip_cleanup
+
+ captured_commands = _invoke_and_capture_reproduction(
+ cli, ["parallel-task", "--skip-cleanup"], monkeypatch
+ )
+
+ assert captured_commands[-1].argv == ["breeze", "parallel-task",
"--skip-cleanup"]
+
+
+# ---------------------------------------------------------------------------
+# Tests for build_reproduction_command_from_context
+# ---------------------------------------------------------------------------
+
+
+def _build_test_command(**options):
+ """Build a simple click command with the given options for testing."""
+
+ @click.command("test-cmd")
+ def cmd(**kwargs):
+ pass
+
+ for _name, opt in options.items():
+ cmd = opt(cmd)
+ return cmd
+
+
+def _invoke_and_get_context(cmd, args, env=None):
+ """Invoke a click command and capture the context."""
+ captured_ctx = {}
+
+ original_invoke = cmd.invoke
+
+ def patched_invoke(ctx):
+ captured_ctx["ctx"] = ctx
+ return original_invoke(ctx)
+
+ cmd.invoke = patched_invoke
+ runner = click.testing.CliRunner(env=env or {})
+ result = runner.invoke(cmd, args, catch_exceptions=False)
+ assert result.exit_code == 0, result.output
+ return captured_ctx["ctx"]
+
+
+class TestBuildReproductionCommandFromContext:
+ """Tests for the generic Click context-based command renderer."""
+
+ def test_simple_bool_flag_emitted_when_true(self):
+ @click.command("my-cmd")
+ @click.option("--verbose-output", is_flag=True, default=False)
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, ["--verbose-output"])
+ result = build_reproduction_command_from_context(ctx)
+ assert result.argv == ["my-cmd", "--verbose-output"]
+
+ def test_simple_bool_flag_omitted_when_default(self):
+ @click.command("my-cmd")
+ @click.option("--verbose-output", is_flag=True, default=False)
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, [])
+ result = build_reproduction_command_from_context(ctx)
+ assert result.argv == ["my-cmd"]
+
+ def test_flag_pair_emits_positive_side(self):
+ @click.command("my-cmd")
+ @click.option("--force/--no-force", default=False)
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, ["--force"])
+ result = build_reproduction_command_from_context(ctx)
+ assert "--force" in result.argv
+ assert "--no-force" not in result.argv
+
+ def test_flag_pair_emits_negative_side(self):
+ @click.command("my-cmd")
+ @click.option("--force/--no-force", default=True)
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, ["--no-force"])
+ result = build_reproduction_command_from_context(ctx)
+ assert "--no-force" in result.argv
+ assert result.argv.count("--force") == 0
+
+ def test_flag_pair_omitted_when_default(self):
+ @click.command("my-cmd")
+ @click.option("--force/--no-force", default=True)
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, [])
+ result = build_reproduction_command_from_context(ctx)
+ assert "--force" not in result.argv
+ assert "--no-force" not in result.argv
+
+ def test_flag_pair_prefers_long_form(self):
+ @click.command("my-cmd")
+ @click.option("-f", "--force/--no-force", default=False)
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, ["-f"])
+ result = build_reproduction_command_from_context(ctx)
+ assert "--force" in result.argv
+ assert "-f" not in result.argv
+
+ def test_string_option_emitted_when_explicit(self):
+ @click.command("my-cmd")
+ @click.option("--backend", default="sqlite")
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, ["--backend", "postgres"])
+ result = build_reproduction_command_from_context(ctx)
+ assert result.argv == ["my-cmd", "--backend", "postgres"]
+
+ def test_string_option_omitted_when_default(self):
+ @click.command("my-cmd")
+ @click.option("--backend", default="sqlite")
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, [])
+ result = build_reproduction_command_from_context(ctx)
+ assert result.argv == ["my-cmd"]
+
+ def test_multiple_option_repeats_flag(self):
+ @click.command("my-cmd")
+ @click.option("--package-filter", multiple=True)
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, ["--package-filter", "foo",
"--package-filter", "bar"])
+ result = build_reproduction_command_from_context(ctx)
+ assert result.argv == ["my-cmd", "--package-filter", "foo",
"--package-filter", "bar"]
+
+ def test_positional_arguments_appended_at_end(self):
+ @click.command("my-cmd")
+ @click.option("--flag", is_flag=True)
+ @click.argument("files", nargs=-1)
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, ["--flag", "file1.py", "file2.py"])
+ result = build_reproduction_command_from_context(ctx)
+ assert result.argv == ["my-cmd", "--flag", "file1.py", "file2.py"]
+
+ def test_expose_value_false_option_excluded(self):
+ @click.command("my-cmd")
+ @click.option("--verbose", is_flag=True, expose_value=False)
+ @click.option("--backend", default="sqlite")
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, ["--verbose", "--backend",
"postgres"])
+ result = build_reproduction_command_from_context(ctx)
+ assert "--verbose" not in result.argv
+ assert "--backend" in result.argv
+
+ def test_excluded_params_filtered_out(self):
+ @click.command("my-cmd")
+ @click.option("--debug-resources", is_flag=True)
+ @click.option("--backend", default="sqlite")
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, ["--debug-resources", "--backend",
"postgres"])
+ result = build_reproduction_command_from_context(ctx)
+ assert "--debug-resources" not in result.argv
+ assert "--backend" in result.argv
+
+ def test_envvar_source_included(self):
+ @click.command("my-cmd")
+ @click.option("--backend", default="sqlite", envvar="BACKEND")
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, [], env={"BACKEND": "postgres"})
+ result = build_reproduction_command_from_context(ctx)
+ assert result.argv == ["my-cmd", "--backend", "postgres"]
+
+ def test_envvar_same_as_default_still_included(self):
+ """When envvar explicitly sets the same value as default, it should
still be emitted."""
+
+ @click.command("my-cmd")
+ @click.option("--backend", default="sqlite", envvar="BACKEND")
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, [], env={"BACKEND": "sqlite"})
+ result = build_reproduction_command_from_context(ctx)
+ assert result.argv == ["my-cmd", "--backend", "sqlite"]
+
+ def test_custom_comment(self):
+ @click.command("my-cmd")
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, [])
+ result = build_reproduction_command_from_context(ctx, comment="Custom
comment")
+ assert result.comment == "Custom comment"
+
+ def test_default_comment(self):
+ @click.command("my-cmd")
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, [])
+ result = build_reproduction_command_from_context(ctx)
+ assert result.comment == "Run the same Breeze command locally"
+
+ def test_subcommand_path(self):
+ @click.group()
+ def grp():
+ pass
+
+ @grp.command("sub-cmd")
+ @click.option("--flag", is_flag=True)
+ def sub_cmd(**kwargs):
+ pass
+
+ captured_ctx = {}
+
+ original_invoke = sub_cmd.invoke
+
+ def patched_invoke(ctx):
+ captured_ctx["ctx"] = ctx
+ return original_invoke(ctx)
+
+ sub_cmd.invoke = patched_invoke
+ runner = click.testing.CliRunner()
+ result = runner.invoke(grp, ["sub-cmd", "--flag"],
catch_exceptions=False)
+ assert result.exit_code == 0
+ ctx = captured_ctx["ctx"]
+ repro = build_reproduction_command_from_context(ctx)
+ assert repro.argv == ["grp", "sub-cmd", "--flag"]
+
+ def test_integer_option_converted_to_string(self):
+ @click.command("my-cmd")
+ @click.option("--timeout", type=int, default=60)
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, ["--timeout", "120"])
+ result = build_reproduction_command_from_context(ctx)
+ assert result.argv == ["my-cmd", "--timeout", "120"]
+
+ def test_prefers_long_option_form(self):
+ @click.command("my-cmd")
+ @click.option("-b", "--backend", default="sqlite")
+ def cmd(**kwargs):
+ pass
+
+ ctx = _invoke_and_get_context(cmd, ["-b", "postgres"])
+ result = build_reproduction_command_from_context(ctx)
+ assert result.argv == ["my-cmd", "--backend", "postgres"]