diff --git a/dev/breeze/doc/images/output_sbom.svg b/dev/breeze/doc/images/output_sbom.svg index 02ef64aa6a878..058cb7a00230e 100644 --- a/dev/breeze/doc/images/output_sbom.svg +++ b/dev/breeze/doc/images/output_sbom.svg @@ -104,10 +104,10 @@ --help-hShow this message and exit. ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─ SBOM commands ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ -update-sbom-information                     Update SBOM information in airflow-site project.                       -build-all-airflow-images                    Generate images with airflow versions pre-installed                    -generate-providers-requirements             Generate requirements for selected provider.                           -export-dependency-information               Export dependency information from SBOM.                               +update-sbom-information                  Update SBOM information in airflow-site-archive project.                  +build-all-airflow-images                 Generate images with airflow versions pre-installed                       +generate-providers-requirements          Generate requirements for selected provider.                              +export-dependency-information            Export dependency information from SBOM.                                  ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/dev/breeze/doc/images/output_sbom.txt b/dev/breeze/doc/images/output_sbom.txt index 472454d810718..12cb2627e1612 100644 --- a/dev/breeze/doc/images/output_sbom.txt +++ b/dev/breeze/doc/images/output_sbom.txt @@ -1 +1 @@ -b03d6ab68f41027663d36fe101214323 +a66ca015aaf5cd2f0bf0671db381f9f2 diff --git a/dev/breeze/doc/images/output_sbom_update-sbom-information.svg b/dev/breeze/doc/images/output_sbom_update-sbom-information.svg index 159e57af8981f..714502939fefe 100644 --- a/dev/breeze/doc/images/output_sbom_update-sbom-information.svg +++ b/dev/breeze/doc/images/output_sbom_update-sbom-information.svg @@ -1,4 +1,4 @@ - + - + @@ -161,9 +159,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - Command: sbom update-sbom-information + Command: sbom update-sbom-information @@ -176,42 +204,52 @@ Usage:breeze sbom update-sbom-information[OPTIONS] -Update SBOM information in airflow-site project. +Update SBOM information in airflow-site-archive project. -╭─ Update SBOM information flags ──────────────────────────────────────────────────────────────────────────────────────╮ -*--airflow-site-directoryDirectory where airflow-site directory is located.(DIRECTORY)[required] ---airflow-versionVersion of airflow to update sbom from. (defaulted to all active airflow  -versions)                                                                 -(TEXT)                                                                    ---pythonPython version to update sbom from. (defaults to all historical python    -versions)                                                                 -(3.6 | 3.7 | 3.8 | 3.9 | 3.10 | 3.11 | 3.12)                              ---include-provider-dependenciesWhether to include provider dependencies in SBOM generation. ---include-python/--no-include-pythonWhether to include python dependencies. ---include-npm/--no-include-npmWhether to include npm dependencies. ---all-combinationsProduces all combinations of airflow sbom npm/python(airflow/full).       -Ignores --include flags                                                   ---package-filterFilter(s) to use more than one can be specified. You can use glob pattern -matching the full package name, for example `apache-airflow-providers-*`. -Useful when you want to selectseveral similarly named packages together.  -(apache-airflow-providers | apache-airflow)                               ---forceForce update of sbom even if it already exists. -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Parallel running ───────────────────────────────────────────────────────────────────────────────────────────────────╮ ---run-in-parallelRun the operation in parallel on all or selected subset of parameters. ---parallelismMaximum number of processes to use while running the operation in parallel. -(INTEGER RANGE)                                                             -[default: 4; 1<=x<=8]                                                       ---skip-cleanupSkip cleanup of temporary files created during parallel run. ---debug-resourcesWhether to show resource information while running in parallel. ---include-success-outputsWhether to include outputs of successful runs (not shown by default). -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Common options ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ ---verbose-vPrint verbose information about performed steps. ---dry-run-DIf dry-run is set, commands are only printed, not executed. ---answer-aForce answer to questions.(y | n | q | yes | no | quit) ---help-hShow this message and exit. -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Update SBOM destination flags ──────────────────────────────────────────────────────────────────────────────────────╮ +--airflow-root-pathPath to the root of the airflow repository. Mutually exclusive with                   +--airflow-site-archive-path option. When specified SBOM generated files are placed    +where airflow docs are build (generated/_build/docs/apache-airflow/stable directory). +(DIRECTORY)                                                                           +--airflow-site-archive-pathDirectory where airflow-site-archive directory is located. Mutually exclusive with    +--airflow-root-path option. When specified SBOM generated files are placed in         +airflow-site-archive/docs-archive/directory.                                          +(DIRECTORY)                                                                           +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Update SBOM information flags ──────────────────────────────────────────────────────────────────────────────────────╮ +--airflow-versionVersion of airflow to update sbom from. (defaulted to all active airflow     +versions)                                                                    +(TEXT)                                                                       +--pythonPython version to update sbom from. (defaults to all historical python       +versions)                                                                    +(3.6 | 3.7 | 3.8 | 3.9 | 3.10 | 3.11 | 3.12)                                 +--include-provider-dependenciesWhether to include provider dependencies in SBOM generation. +--include-python/--no-include-pythonWhether to include python dependencies. +--include-npm/--no-include-npmWhether to include npm dependencies. +--all-combinationsProduces all combinations of airflow sbom npm/python(airflow/full). Ignores  +--include flags                                                              +--package-filterFilter(s) to use more than one can be specified. You can use glob pattern    +matching the full package name, for example `apache-airflow-providers-*`.    +Useful when you want to selectseveral similarly named packages together.     +(apache-airflow-providers | apache-airflow)                                  +--forceForce update of sbom even if it already exists. +--github-tokenThe token used to authenticate to GitHub.(TEXT) +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Parallel running ───────────────────────────────────────────────────────────────────────────────────────────────────╮ +--run-in-parallelRun the operation in parallel on all or selected subset of parameters. +--parallelismMaximum number of processes to use while running the operation in parallel. +(INTEGER RANGE)                                                             +[default: 4; 1<=x<=8]                                                       +--skip-cleanupSkip cleanup of temporary files created during parallel run. +--debug-resourcesWhether to show resource information while running in parallel. +--include-success-outputsWhether to include outputs of successful runs (not shown by default). +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Common options ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ +--verbose-vPrint verbose information about performed steps. +--dry-run-DIf dry-run is set, commands are only printed, not executed. +--answer-aForce answer to questions.(y | n | q | yes | no | quit) +--help-hShow this message and exit. +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/dev/breeze/doc/images/output_sbom_update-sbom-information.txt b/dev/breeze/doc/images/output_sbom_update-sbom-information.txt index 6e5ac979b168c..483167d8d8a7a 100644 --- a/dev/breeze/doc/images/output_sbom_update-sbom-information.txt +++ b/dev/breeze/doc/images/output_sbom_update-sbom-information.txt @@ -1 +1 @@ -7cb3c6bd7a08706720035864df941273 +43dadb548e01dd68fd148adf2e62f750 diff --git a/dev/breeze/src/airflow_breeze/commands/sbom_commands.py b/dev/breeze/src/airflow_breeze/commands/sbom_commands.py index f7d7b77a18c5d..8eb29258792c0 100644 --- a/dev/breeze/src/airflow_breeze/commands/sbom_commands.py +++ b/dev/breeze/src/airflow_breeze/commands/sbom_commands.py @@ -121,13 +121,24 @@ def sbom(): """ -@sbom.command(name="update-sbom-information", help="Update SBOM information in airflow-site project.") +@sbom.command(name="update-sbom-information", help="Update SBOM information in airflow-site-archive project.") @click.option( - "--airflow-site-directory", + "--airflow-site-archive-path", type=click.Path(file_okay=False, dir_okay=True, path_type=Path, exists=True), - required=True, - envvar="AIRFLOW_SITE_DIRECTORY", - help="Directory where airflow-site directory is located.", + required=False, + envvar="AIRFLOW_SITE_ARCHIVE_PATH", + help="Directory where airflow-site-archive directory is located. Mutually exclusive with " + "--airflow-root-path option. When specified SBOM generated files are placed in " + "airflow-site-archive/docs-archive/directory.", +) +@click.option( + "--airflow-root-path", + type=click.Path(file_okay=False, dir_okay=True, path_type=Path, exists=True), + required=False, + help="Path to the root of the airflow repository. Mutually exclusive with " + "--airflow-site-archive-path option. When specified SBOM generated files are placed where " + "airflow docs are build (generated/_build/docs/apache-airflow/stable directory).", + envvar="AIRFLOW_ROOT_PATH", ) @click.option( "--airflow-version", @@ -169,6 +180,7 @@ def sbom(): is_flag=True, help="Produces all combinations of airflow sbom npm/python(airflow/full). Ignores --include flags", ) +@option_github_token @option_verbose @option_dry_run @option_answer @@ -182,7 +194,8 @@ def sbom(): default="apache-airflow", ) def update_sbom_information( - airflow_site_directory: Path, + airflow_site_archive_path: Path | None, + airflow_root_path: Path | None, airflow_version: str | None, python: str | None, include_provider_dependencies: bool, @@ -195,6 +208,7 @@ def update_sbom_information( skip_cleanup: bool, force: bool, all_combinations: bool, + github_token: str | None, package_filter: tuple[str, ...], ): import jinja2 @@ -208,8 +222,10 @@ def update_sbom_information( if airflow_version is None: airflow_versions, _ = get_active_airflow_versions() + all_airflow_versions = airflow_versions.copy() else: airflow_versions = [airflow_version] + all_airflow_versions = get_active_airflow_versions(confirm=False) if python is None: python_versions = ALL_HISTORICAL_PYTHON_VERSIONS else: @@ -219,7 +235,19 @@ def update_sbom_information( jobs_to_run: list[SbomApplicationJob] = [] - airflow_site_archive_directory = airflow_site_directory / "docs-archive" + if airflow_root_path and airflow_site_archive_path: + get_console().print( + "[error]You cannot specify both --airflow-site-archive-path and --airflow-root-path. " + "Please specify only one of them." + ) + sys.exit(1) + + if not airflow_root_path and not airflow_site_archive_path: + get_console().print( + "[error]You must specify either --airflow-site-archive-path or --airflow-root-path. " + "Please specify one of them." + ) + sys.exit(1) def _dir_exists_warn_and_should_skip(dir: Path, force: bool) -> bool: if dir.exists(): @@ -230,7 +258,6 @@ def _dir_exists_warn_and_should_skip(dir: Path, force: bool) -> bool: return False return False - apache_airflow_documentation_directory = airflow_site_archive_directory / "apache-airflow" if package_filter == "apache-airflow": if all_combinations: for include_npm, include_python, include_provider_dependencies in [ @@ -244,8 +271,10 @@ def _dir_exists_warn_and_should_skip(dir: Path, force: bool) -> bool: if not include_python: use_python_versions = [None] core_jobs( + all_airflow_versions, _dir_exists_warn_and_should_skip, - apache_airflow_documentation_directory, + airflow_site_archive_path, + airflow_root_path, airflow_versions, application_root_path, force, @@ -260,8 +289,10 @@ def _dir_exists_warn_and_should_skip(dir: Path, force: bool) -> bool: if not include_python: use_python_versions = [None] core_jobs( + all_airflow_versions, _dir_exists_warn_and_should_skip, - apache_airflow_documentation_directory, + airflow_site_archive_path, + airflow_root_path, airflow_versions, application_root_path, force, @@ -284,7 +315,7 @@ def _dir_exists_warn_and_should_skip(dir: Path, force: bool) -> bool: provider_id, provider_version, provider_version_documentation_directory, - ) in list_providers_from_providers_requirements(airflow_site_archive_directory): + ) in list_providers_from_providers_requirements(airflow_site_archive_path): destination_dir = provider_version_documentation_directory / "sbom" destination_dir.mkdir(parents=True, exist_ok=True) @@ -340,6 +371,7 @@ def _dir_exists_warn_and_should_skip(dir: Path, force: bool) -> bool: "job": job, "output": outputs[index], "port_map": port_map, + "github_token": github_token, }, ) for index, job in enumerate(jobs_to_run) @@ -353,7 +385,7 @@ def _dir_exists_warn_and_should_skip(dir: Path, force: bool) -> bool: ) else: for job in jobs_to_run: - produce_sbom_for_application_via_cdxgen_server(job, output=None) + produce_sbom_for_application_via_cdxgen_server(job, output=None, github_token=github_token) html_template = SBOM_INDEX_TEMPLATE @@ -372,6 +404,20 @@ def _generate_index(destination_dir: Path, provider_id: str | None, version: str if package_filter == "apache-airflow": for airflow_v in airflow_versions: + if airflow_site_archive_path: + apache_airflow_documentation_directory = ( + airflow_site_archive_path / "docs-archive" / "apache-airflow" + ) + elif airflow_root_path: + apache_airflow_documentation_directory = ( + airflow_root_path / "generated" / "_build" / "docs" / "apache-airflow" + ) + else: + get_console().print( + "[error]You must specify either --airflow-site-archive-path or --airflow-root-path. " + "Please specify one of them." + ) + sys.exit(1) airflow_version_dir = apache_airflow_documentation_directory / airflow_v destination_dir = airflow_version_dir / "sbom" _generate_index(destination_dir, None, airflow_v) @@ -381,14 +427,16 @@ def _generate_index(destination_dir: Path, provider_id: str | None, version: str provider_id, provider_version, provider_version_documentation_directory, - ) in list_providers_from_providers_requirements(airflow_site_archive_directory): + ) in list_providers_from_providers_requirements(airflow_site_archive_path): destination_dir = provider_version_documentation_directory / "sbom" _generate_index(destination_dir, provider_id, provider_version) def core_jobs( + all_airflow_versions: list[str], _dir_exists_warn_and_should_skip, - apache_airflow_documentation_directory: Path, + airflow_site_archive_path: Path | None, + airflow_root_path: Path | None, airflow_versions: list[str], application_root_path: Path, force: bool, @@ -398,18 +446,48 @@ def core_jobs( jobs_to_run: list[SbomApplicationJob], python_versions: list[str | None], ): + latest_airflow_version = all_airflow_versions[-1] # Create core jobs for airflow_v in airflow_versions: - airflow_version_dir = apache_airflow_documentation_directory / airflow_v - if not airflow_version_dir.exists(): - get_console().print(f"[warning]The {airflow_version_dir} does not exist. Skipping") + if airflow_site_archive_path: + airflow_version_dirs = [ + airflow_site_archive_path / "docs-archive" / "apache-airflow" / airflow_v, + ] + if latest_airflow_version == airflow_v: + airflow_version_dirs.append(airflow_site_archive_path / "apache-airflow" / "stable") + elif airflow_root_path: + airflow_version_dirs = [ + airflow_root_path / "generated" / "_build" / "docs" / "apache-airflow" / "stable" + ] + else: + get_console().print( + "[error]You must specify either --airflow-site-archive-path or --airflow-root-path. " + "Please specify one of them." + ) + sys.exit(1) + exists = True + for airflow_version_dir in airflow_version_dirs: + if not airflow_version_dir.exists(): + get_console().print(f"[warning]The {airflow_version_dir} does not exist. Skipping") + exists = False + break + if not exists: continue - destination_dir = airflow_version_dir / "sbom" - - if _dir_exists_warn_and_should_skip(destination_dir, force): + destination_dirs: list[Path] = [] + for airflow_version_dir in airflow_version_dirs: + destination_dir = airflow_version_dir / "sbom" + if not _dir_exists_warn_and_should_skip(destination_dir, force): + destination_dirs.append(destination_dir) + else: + get_console().print( + f"[warning]The {destination_dir} already exists and generation is not forced. " + f"Skipping for airflow version {airflow_v}" + ) + if not destination_dirs: + get_console().print( + f"[warning]All directories already exist and generation is not forced. Skipping {airflow_v}" + ) continue - - destination_dir.mkdir(parents=True, exist_ok=True) get_console().print(f"[info]Attempting to update sbom for {airflow_v}.") for python_version in python_versions: if include_python and include_npm: @@ -425,10 +503,12 @@ def core_jobs( suffix += "-full" target_sbom_file_name = f"apache-airflow-sbom-{airflow_v}{suffix}.json" - target_sbom_path = destination_dir / target_sbom_file_name - - if _dir_exists_warn_and_should_skip(target_sbom_path, force): - continue + target_sbom_paths: list[Path] = [] + for destination_dir in destination_dirs: + target_sbom_path = destination_dir / target_sbom_file_name + if _dir_exists_warn_and_should_skip(target_sbom_path, force): + continue + target_sbom_paths.append(target_sbom_path) jobs_to_run.append( SbomCoreJob( @@ -436,7 +516,7 @@ def core_jobs( python_version=python_version, application_root_path=application_root_path, include_provider_dependencies=include_provider_dependencies, - target_path=target_sbom_path, + target_paths=target_sbom_paths, include_python=include_python, include_npm=include_npm, ) @@ -745,7 +825,7 @@ def export_dependency_information( import requests - base_url = f"https://airflow.apache.org/docs/apache-airflow/{airflow_version}/sbom" + base_url = f"https://airflow.apache.org/docs/apache-airflow/{airflow_version}" sbom_file_base = f"apache-airflow-sbom-{airflow_version}-python{python}-python-only" sbom_core_url = f"{base_url}/{sbom_file_base}.json" diff --git a/dev/breeze/src/airflow_breeze/commands/sbom_commands_config.py b/dev/breeze/src/airflow_breeze/commands/sbom_commands_config.py index 96cc5cad8852b..a68e8a6c9ac90 100644 --- a/dev/breeze/src/airflow_breeze/commands/sbom_commands_config.py +++ b/dev/breeze/src/airflow_breeze/commands/sbom_commands_config.py @@ -28,10 +28,16 @@ SBOM_PARAMETERS: dict[str, list[dict[str, str | list[str]]]] = { "breeze sbom update-sbom-information": [ + { + "name": "Update SBOM destination flags", + "options": [ + "--airflow-root-path", + "--airflow-site-archive-path", + ], + }, { "name": "Update SBOM information flags", "options": [ - "--airflow-site-directory", "--airflow-version", "--python", "--include-provider-dependencies", @@ -40,6 +46,7 @@ "--all-combinations", "--package-filter", "--force", + "--github-token", ], }, { diff --git a/dev/breeze/src/airflow_breeze/utils/cdxgen.py b/dev/breeze/src/airflow_breeze/utils/cdxgen.py index f8822250dc244..d3b09b459d643 100644 --- a/dev/breeze/src/airflow_breeze/utils/cdxgen.py +++ b/dev/breeze/src/airflow_breeze/utils/cdxgen.py @@ -137,7 +137,7 @@ def get_all_airflow_versions_image_name(python_version: str) -> str: def list_providers_from_providers_requirements( - airflow_site_archive_directory: Path, + airflow_site_archive_path: Path, ) -> Generator[tuple[str, str, str, Path], None, None]: for node_name in os.listdir(PROVIDER_REQUIREMENTS_DIR_PATH): if not node_name.startswith("provider"): @@ -146,7 +146,7 @@ def list_providers_from_providers_requirements( provider_id, provider_version = node_name.rsplit("-", 1) provider_documentation_directory = ( - airflow_site_archive_directory + airflow_site_archive_path / f"apache-airflow-providers-{provider_id.replace('provider-', '').replace('.', '-')}" ) provider_version_documentation_directory = provider_documentation_directory / provider_version @@ -325,10 +325,10 @@ def build_all_airflow_versions_base_image( @dataclass class SbomApplicationJob: python_version: str | None - target_path: Path + target_paths: list[Path] @abstractmethod - def produce(self, output: Output | None, port: int) -> tuple[int, str]: + def produce(self, output: Output | None, port: int, github_token: str | None) -> tuple[int, str]: raise NotImplementedError @abstractmethod @@ -368,7 +368,7 @@ def get_files_directory(self, root_path: Path): source_dir = source_dir / f"python{self.python_version}" return source_dir - def download_dependency_files(self, output: Output | None) -> bool: + def download_dependency_files(self, output: Output | None, github_token: str | None) -> bool: source_dir = self.get_files_directory(self.application_root_path) source_dir.mkdir(parents=True, exist_ok=True) lock_file_relative_path = "airflow/www/yarn.lock" @@ -384,6 +384,7 @@ def download_dependency_files(self, output: Output | None) -> bool: python_version=self.python_version, include_provider_dependencies=self.include_provider_dependencies, output_file=source_dir / "requirements.txt", + github_token=github_token, ): get_console(output=output).print( f"[warning]Failed to download constraints file for " @@ -395,7 +396,7 @@ def download_dependency_files(self, output: Output | None) -> bool: (source_dir / "requirements.txt").unlink(missing_ok=True) return True - def produce(self, output: Output | None, port: int) -> tuple[int, str]: + def produce(self, output: Output | None, port: int, github_token: str | None) -> tuple[int, str]: import requests get_console(output=output).print( @@ -403,7 +404,7 @@ def produce(self, output: Output | None, port: int) -> tuple[int, str]: f"include_provider_dependencies={self.include_provider_dependencies}, " f"python={self.include_python}, npm={self.include_npm}" ) - if not self.download_dependency_files(output): + if not self.download_dependency_files(output, github_token=github_token): return 0, f"SBOM Generate {self.airflow_version}:{self.python_version}" get_console(output=output).print( @@ -437,7 +438,8 @@ def produce(self, output: Output | None, port: int) -> tuple[int, str]: response.status_code, f"SBOM Generate {self.airflow_version}:python{self.python_version}", ) - self.target_path.write_bytes(response.content) + for target_path in self.target_paths: + target_path.write_bytes(response.content) suffix = "" if self.python_version: suffix += f":python{self.python_version}" @@ -462,7 +464,7 @@ class SbomProviderJob(SbomApplicationJob): def get_job_name(self) -> str: return f"{self.provider_id}:{self.provider_version}:python{self.python_version}" - def produce(self, output: Output | None, port: int) -> tuple[int, str]: + def produce(self, output: Output | None, port: int, github_token: str | None) -> tuple[int, str]: import requests get_console(output=output).print( @@ -491,7 +493,8 @@ def produce(self, output: Output | None, port: int) -> tuple[int, str]: response.status_code, f"SBOM Generate {self.provider_id}:{self.provider_version}:{self.python_version}", ) - self.target_path.write_bytes(response.content) + for target_path in self.target_paths: + target_path.write_bytes(response.content) get_console(output=output).print( f"[success]Generated SBOM for {self.provider_id}:{self.provider_version}:" f"{self.python_version}" @@ -501,12 +504,16 @@ def produce(self, output: Output | None, port: int) -> tuple[int, str]: def produce_sbom_for_application_via_cdxgen_server( - job: SbomApplicationJob, output: Output | None, port_map: dict[str, int] | None = None + job: SbomApplicationJob, + output: Output | None, + github_token: str | None, + port_map: dict[str, int] | None = None, ) -> tuple[int, str]: """ Produces SBOM for application using cdxgen server. :param job: Job to run :param output: Output to use + :param github_token: GitHub token to use for downloading files` :param port_map map of process name to port - making sure that one process talks to one server in case parallel processing is used :return: tuple with exit code and output @@ -517,7 +524,7 @@ def produce_sbom_for_application_via_cdxgen_server( else: port = port_map[multiprocessing.current_process().name] get_console(output=output).print(f"[info]Using port {port}") - return job.produce(output, port) + return job.produce(output, port, github_token) def convert_licenses(licenses: list[dict[str, Any]]) -> str: diff --git a/dev/breeze/src/airflow_breeze/utils/github.py b/dev/breeze/src/airflow_breeze/utils/github.py index d1a9c2621045e..2f1f960c4cdbd 100644 --- a/dev/breeze/src/airflow_breeze/utils/github.py +++ b/dev/breeze/src/airflow_breeze/utils/github.py @@ -176,10 +176,10 @@ def get_active_airflow_versions(confirm: bool = True) -> tuple[list[str], dict[s get_console().print("[error]Error fetching tag date for Airflow {version}") sys.exit(1) airflow_release_dates[version] = date - get_console().print("[info]All Airflow 2 versions") - for version in airflow_versions: - get_console().print(f" {version}: [info]{airflow_release_dates[version]}[/]") + get_console().print("[info]All Airflow 2/3 versions") if confirm: + for version in airflow_versions: + get_console().print(f" {version}: [info]{airflow_release_dates[version]}[/]") answer = user_confirm( "Should we continue with those versions?", quit_allowed=False, default_answer=Answer.YES )