This is an automated email from the ASF dual-hosted git repository.

Lee-W 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 61f028989c0 [AIP-94] airflowctl jobs: add check command (#68534)
61f028989c0 is described below

commit 61f028989c027a240a7a10c47a81f6cdce9cdd8a
Author: PoAn Yang <[email protected]>
AuthorDate: Fri Aug 7 15:54:32 2026 +0900

    [AIP-94] airflowctl jobs: add check command (#68534)
    
    Signed-off-by: PoAn Yang <[email protected]>
---
 .../src/airflow/cli/commands/jobs_command.py       |   2 +
 .../unit/cli/commands/test_command_deprecations.py |   2 +
 airflow-ctl/docs/images/command_hashes.txt         |   2 +-
 airflow-ctl/docs/images/output_jobs.svg            |  66 ++++++------
 airflow-ctl/src/airflowctl/ctl/cli_config.py       |  49 +++++++++
 .../src/airflowctl/ctl}/commands/jobs_command.py   |  43 +++-----
 .../airflow_ctl/ctl/commands/test_jobs_command.py  | 115 +++++++++++++++++++++
 7 files changed, 220 insertions(+), 59 deletions(-)

diff --git a/airflow-core/src/airflow/cli/commands/jobs_command.py 
b/airflow-core/src/airflow/cli/commands/jobs_command.py
index 194d8720db2..57ab4a1df7b 100644
--- a/airflow-core/src/airflow/cli/commands/jobs_command.py
+++ b/airflow-core/src/airflow/cli/commands/jobs_command.py
@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING
 
 from sqlalchemy import select
 
+from airflow.cli.utils import deprecated_for_airflowctl
 from airflow.jobs.job import Job, JobState
 from airflow.utils.net import get_hostname
 from airflow.utils.providers_configuration_loader import 
providers_configuration_loaded
@@ -29,6 +30,7 @@ if TYPE_CHECKING:
     from sqlalchemy.orm import Session
 
 
+@deprecated_for_airflowctl("airflowctl jobs check")
 @providers_configuration_loaded
 @provide_session
 def check(args, *, session: Session = NEW_SESSION) -> None:
diff --git a/airflow-core/tests/unit/cli/commands/test_command_deprecations.py 
b/airflow-core/tests/unit/cli/commands/test_command_deprecations.py
index 667a2dc7ff5..27aa75e4938 100644
--- a/airflow-core/tests/unit/cli/commands/test_command_deprecations.py
+++ b/airflow-core/tests/unit/cli/commands/test_command_deprecations.py
@@ -36,6 +36,7 @@ from airflow.cli.commands import (
     config_command,
     connection_command,
     dag_command,
+    jobs_command,
     pool_command,
     provider_command,
     task_command,
@@ -86,6 +87,7 @@ MIGRATED_CLI_COMMANDS = [
     (task_command.task_states_for_dag_run, "airflowctl tasks 
states-for-dag-run"),
     (task_command.task_clear, "airflowctl tasks clear"),
     (task_command.task_failed_deps, "airflowctl tasks failed-deps"),
+    (jobs_command.check, "airflowctl jobs check"),
 ]
 
 
diff --git a/airflow-ctl/docs/images/command_hashes.txt 
b/airflow-ctl/docs/images/command_hashes.txt
index 65c22154bc3..a6fd590fcf6 100644
--- a/airflow-ctl/docs/images/command_hashes.txt
+++ b/airflow-ctl/docs/images/command_hashes.txt
@@ -6,7 +6,7 @@ config:a3d936cb15fe3b547bf6c82cf93d923f
 connections:942f9f88cb908c28bf5c19159fc5065b
 dags:ea477b70a1c3bcf8ed603cdbd11e5ab6
 dagrun:07035226eaaff0a557d3ad9aab4b41b5
-jobs:a5b644c5da8889443bb40ee10b599270
+jobs:d4af478f28dae48ee18d43b998edc345
 pools:19efe105b9515ab1926ebcaf0e028d71
 providers:34502fe09dc0b8b0a13e7e46efdffda6
 taskinstances:bea84117114c2438eb7e7026f6bb7042
diff --git a/airflow-ctl/docs/images/output_jobs.svg 
b/airflow-ctl/docs/images/output_jobs.svg
index 13b31d2caed..9ff6f8753c1 100644
--- a/airflow-ctl/docs/images/output_jobs.svg
+++ b/airflow-ctl/docs/images/output_jobs.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 933 294.0" 
xmlns="http://www.w3.org/2000/svg";>
+<svg class="rich-terminal" viewBox="0 0 933 318.4" 
xmlns="http://www.w3.org/2000/svg";>
     <!-- Generated with Rich https://www.textualize.io -->
     <style>
 
@@ -19,78 +19,82 @@
         font-weight: 700;
     }
 
-    .terminal-656532750-matrix {
+    .terminal-1867393987-matrix {
         font-family: Fira Code, monospace;
         font-size: 20px;
         line-height: 24.4px;
         font-variant-east-asian: full-width;
     }
 
-    .terminal-656532750-title {
+    .terminal-1867393987-title {
         font-size: 18px;
         font-weight: bold;
         font-family: arial;
     }
 
-    .terminal-656532750-r1 { fill: #ff8700 }
-.terminal-656532750-r2 { fill: #c5c8c6 }
-.terminal-656532750-r3 { fill: #808080 }
-.terminal-656532750-r4 { fill: #68a0b3 }
+    .terminal-1867393987-r1 { fill: #ff8700 }
+.terminal-1867393987-r2 { fill: #c5c8c6 }
+.terminal-1867393987-r3 { fill: #808080 }
+.terminal-1867393987-r4 { fill: #68a0b3 }
     </style>
 
     <defs>
-    <clipPath id="terminal-656532750-clip-terminal">
-      <rect x="0" y="0" width="914.0" height="243.0" />
+    <clipPath id="terminal-1867393987-clip-terminal">
+      <rect x="0" y="0" width="914.0" height="267.4" />
     </clipPath>
-    <clipPath id="terminal-656532750-line-0">
+    <clipPath id="terminal-1867393987-line-0">
     <rect x="0" y="1.5" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-656532750-line-1">
+<clipPath id="terminal-1867393987-line-1">
     <rect x="0" y="25.9" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-656532750-line-2">
+<clipPath id="terminal-1867393987-line-2">
     <rect x="0" y="50.3" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-656532750-line-3">
+<clipPath id="terminal-1867393987-line-3">
     <rect x="0" y="74.7" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-656532750-line-4">
+<clipPath id="terminal-1867393987-line-4">
     <rect x="0" y="99.1" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-656532750-line-5">
+<clipPath id="terminal-1867393987-line-5">
     <rect x="0" y="123.5" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-656532750-line-6">
+<clipPath id="terminal-1867393987-line-6">
     <rect x="0" y="147.9" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-656532750-line-7">
+<clipPath id="terminal-1867393987-line-7">
     <rect x="0" y="172.3" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-656532750-line-8">
+<clipPath id="terminal-1867393987-line-8">
     <rect x="0" y="196.7" width="915" height="24.65"/>
             </clipPath>
+<clipPath id="terminal-1867393987-line-9">
+    <rect x="0" y="221.1" width="915" height="24.65"/>
+            </clipPath>
     </defs>
 
-    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="931" height="292" rx="8"/>
+    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="931" height="316.4" rx="8"/>
             <g transform="translate(26,22)">
             <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
             <circle cx="22" cy="0" r="7" fill="#febc2e"/>
             <circle cx="44" cy="0" r="7" fill="#28c840"/>
             </g>
         
-    <g transform="translate(9, 41)" 
clip-path="url(#terminal-656532750-clip-terminal)">
+    <g transform="translate(9, 41)" 
clip-path="url(#terminal-1867393987-clip-terminal)">
     
-    <g class="terminal-656532750-matrix">
-    <text class="terminal-656532750-r1" x="0" y="20" textLength="73.2" 
clip-path="url(#terminal-656532750-line-0)">Usage:</text><text 
class="terminal-656532750-r3" x="85.4" y="20" textLength="183" 
clip-path="url(#terminal-656532750-line-0)">airflowctl&#160;jobs</text><text 
class="terminal-656532750-r2" x="268.4" y="20" textLength="24.4" 
clip-path="url(#terminal-656532750-line-0)">&#160;[</text><text 
class="terminal-656532750-r4" x="292.8" y="20" textLength="24.4" 
clip-path="url(#terminal [...]
-</text><text class="terminal-656532750-r2" x="915" y="44.4" textLength="12.2" 
clip-path="url(#terminal-656532750-line-1)">
-</text><text class="terminal-656532750-r2" x="0" y="68.8" textLength="280.6" 
clip-path="url(#terminal-656532750-line-2)">Perform&#160;Jobs&#160;operations</text><text
 class="terminal-656532750-r2" x="915" y="68.8" textLength="12.2" 
clip-path="url(#terminal-656532750-line-2)">
-</text><text class="terminal-656532750-r2" x="915" y="93.2" textLength="12.2" 
clip-path="url(#terminal-656532750-line-3)">
-</text><text class="terminal-656532750-r1" x="0" y="117.6" textLength="256.2" 
clip-path="url(#terminal-656532750-line-4)">Positional&#160;Arguments:</text><text
 class="terminal-656532750-r2" x="915" y="117.6" textLength="12.2" 
clip-path="url(#terminal-656532750-line-4)">
-</text><text class="terminal-656532750-r4" x="24.4" y="142" textLength="85.4" 
clip-path="url(#terminal-656532750-line-5)">COMMAND</text><text 
class="terminal-656532750-r2" x="915" y="142" textLength="12.2" 
clip-path="url(#terminal-656532750-line-5)">
-</text><text class="terminal-656532750-r4" x="48.8" y="166.4" 
textLength="48.8" clip-path="url(#terminal-656532750-line-6)">list</text><text 
class="terminal-656532750-r2" x="170.8" y="166.4" textLength="597.8" 
clip-path="url(#terminal-656532750-line-6)">List&#160;scheduler,&#160;triggerer,&#160;and&#160;other&#160;Airflow&#160;jobs</text><text
 class="terminal-656532750-r2" x="915" y="166.4" textLength="12.2" 
clip-path="url(#terminal-656532750-line-6)">
-</text><text class="terminal-656532750-r2" x="915" y="190.8" textLength="12.2" 
clip-path="url(#terminal-656532750-line-7)">
-</text><text class="terminal-656532750-r1" x="0" y="215.2" textLength="97.6" 
clip-path="url(#terminal-656532750-line-8)">Options:</text><text 
class="terminal-656532750-r2" x="915" y="215.2" textLength="12.2" 
clip-path="url(#terminal-656532750-line-8)">
-</text><text class="terminal-656532750-r4" x="24.4" y="239.6" 
textLength="24.4" clip-path="url(#terminal-656532750-line-9)">-h</text><text 
class="terminal-656532750-r2" x="48.8" y="239.6" textLength="24.4" 
clip-path="url(#terminal-656532750-line-9)">,&#160;</text><text 
class="terminal-656532750-r4" x="73.2" y="239.6" textLength="73.2" 
clip-path="url(#terminal-656532750-line-9)">--help</text><text 
class="terminal-656532750-r2" x="170.8" y="239.6" textLength="378.2" 
clip-path="url(#termina [...]
+    <g class="terminal-1867393987-matrix">
+    <text class="terminal-1867393987-r1" x="0" y="20" textLength="73.2" 
clip-path="url(#terminal-1867393987-line-0)">Usage:</text><text 
class="terminal-1867393987-r3" x="85.4" y="20" textLength="183" 
clip-path="url(#terminal-1867393987-line-0)">airflowctl&#160;jobs</text><text 
class="terminal-1867393987-r2" x="268.4" y="20" textLength="24.4" 
clip-path="url(#terminal-1867393987-line-0)">&#160;[</text><text 
class="terminal-1867393987-r4" x="292.8" y="20" textLength="24.4" 
clip-path="url(#t [...]
+</text><text class="terminal-1867393987-r2" x="915" y="44.4" textLength="12.2" 
clip-path="url(#terminal-1867393987-line-1)">
+</text><text class="terminal-1867393987-r2" x="0" y="68.8" textLength="280.6" 
clip-path="url(#terminal-1867393987-line-2)">Perform&#160;Jobs&#160;operations</text><text
 class="terminal-1867393987-r2" x="915" y="68.8" textLength="12.2" 
clip-path="url(#terminal-1867393987-line-2)">
+</text><text class="terminal-1867393987-r2" x="915" y="93.2" textLength="12.2" 
clip-path="url(#terminal-1867393987-line-3)">
+</text><text class="terminal-1867393987-r1" x="0" y="117.6" textLength="256.2" 
clip-path="url(#terminal-1867393987-line-4)">Positional&#160;Arguments:</text><text
 class="terminal-1867393987-r2" x="915" y="117.6" textLength="12.2" 
clip-path="url(#terminal-1867393987-line-4)">
+</text><text class="terminal-1867393987-r4" x="24.4" y="142" textLength="85.4" 
clip-path="url(#terminal-1867393987-line-5)">COMMAND</text><text 
class="terminal-1867393987-r2" x="915" y="142" textLength="12.2" 
clip-path="url(#terminal-1867393987-line-5)">
+</text><text class="terminal-1867393987-r4" x="48.8" y="166.4" textLength="61" 
clip-path="url(#terminal-1867393987-line-6)">check</text><text 
class="terminal-1867393987-r2" x="170.8" y="166.4" textLength="390.4" 
clip-path="url(#terminal-1867393987-line-6)">Check&#160;if&#160;job(s)&#160;are&#160;still&#160;alive.</text><text
 class="terminal-1867393987-r2" x="915" y="166.4" textLength="12.2" 
clip-path="url(#terminal-1867393987-line-6)">
+</text><text class="terminal-1867393987-r4" x="48.8" y="190.8" 
textLength="48.8" clip-path="url(#terminal-1867393987-line-7)">list</text><text 
class="terminal-1867393987-r2" x="170.8" y="190.8" textLength="597.8" 
clip-path="url(#terminal-1867393987-line-7)">List&#160;scheduler,&#160;triggerer,&#160;and&#160;other&#160;Airflow&#160;jobs</text><text
 class="terminal-1867393987-r2" x="915" y="190.8" textLength="12.2" 
clip-path="url(#terminal-1867393987-line-7)">
+</text><text class="terminal-1867393987-r2" x="915" y="215.2" 
textLength="12.2" clip-path="url(#terminal-1867393987-line-8)">
+</text><text class="terminal-1867393987-r1" x="0" y="239.6" textLength="97.6" 
clip-path="url(#terminal-1867393987-line-9)">Options:</text><text 
class="terminal-1867393987-r2" x="915" y="239.6" textLength="12.2" 
clip-path="url(#terminal-1867393987-line-9)">
+</text><text class="terminal-1867393987-r4" x="24.4" y="264" textLength="24.4" 
clip-path="url(#terminal-1867393987-line-10)">-h</text><text 
class="terminal-1867393987-r2" x="48.8" y="264" textLength="24.4" 
clip-path="url(#terminal-1867393987-line-10)">,&#160;</text><text 
class="terminal-1867393987-r4" x="73.2" y="264" textLength="73.2" 
clip-path="url(#terminal-1867393987-line-10)">--help</text><text 
class="terminal-1867393987-r2" x="170.8" y="264" textLength="378.2" 
clip-path="url(#termi [...]
 </text>
     </g>
     </g>
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py 
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index 787a53ce074..bbf5e59f8e9 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -354,6 +354,35 @@ ARG_ACTION_ON_EXISTING_KEY = Arg(
     choices=("overwrite", "fail", "skip"),
 )
 
+# Jobs command args
+ARG_JOB_TYPE_FILTER = Arg(
+    flags=("--job-type",),
+    choices=("SchedulerJob", "TriggererJob", "DagProcessorJob"),
+    help="The type of job(s) that will be checked.",
+)
+ARG_JOB_HOSTNAME_FILTER = Arg(
+    flags=("--hostname",),
+    type=str,
+    default=None,
+    help="The hostname of job(s) that will be checked.",
+)
+ARG_JOB_LOCAL_FILTER = Arg(
+    flags=("--local",),
+    action="store_true",
+    help="If passed, this command will only show jobs from the local host.",
+)
+ARG_JOB_LIMIT = Arg(
+    flags=("--limit",),
+    type=positive_int(allow_zero=True),
+    default=1,
+    help="The number of recent jobs that will be checked. To disable limit, 
set 0.",
+)
+ARG_JOB_ALLOW_MULTIPLE = Arg(
+    flags=("--allow-multiple",),
+    action="store_true",
+    help="If passed, this command will be successful even if multiple matching 
alive jobs are found.",
+)
+
 # Config arguments
 ARG_CONFIG_SECTION = Arg(
     flags=("--section",),
@@ -1185,6 +1214,21 @@ VARIABLE_COMMANDS = (
     ),
 )
 
+JOB_COMMANDS = (
+    ActionCommand(
+        name="check",
+        help="Check if job(s) are still alive.",
+        func=lazy_load_command("airflowctl.ctl.commands.jobs_command.check"),
+        args=(
+            ARG_JOB_TYPE_FILTER,
+            ARG_JOB_HOSTNAME_FILTER,
+            ARG_JOB_LOCAL_FILTER,
+            ARG_JOB_LIMIT,
+            ARG_JOB_ALLOW_MULTIPLE,
+        ),
+    ),
+)
+
 core_commands: list[CLICommand] = [
     GroupCommand(
         name="auth",
@@ -1207,6 +1251,11 @@ core_commands: list[CLICommand] = [
         help="Manage Airflow Dags",
         subcommands=DAG_COMMANDS,
     ),
+    GroupCommand(
+        name="jobs",
+        help="Manage Airflow jobs",
+        subcommands=JOB_COMMANDS,
+    ),
     GroupCommand(
         name="pools",
         help="Manage Airflow pools",
diff --git a/airflow-core/src/airflow/cli/commands/jobs_command.py 
b/airflow-ctl/src/airflowctl/ctl/commands/jobs_command.py
similarity index 51%
copy from airflow-core/src/airflow/cli/commands/jobs_command.py
copy to airflow-ctl/src/airflowctl/ctl/commands/jobs_command.py
index 194d8720db2..0eb9a47a62a 100644
--- a/airflow-core/src/airflow/cli/commands/jobs_command.py
+++ b/airflow-ctl/src/airflowctl/ctl/commands/jobs_command.py
@@ -16,39 +16,28 @@
 # under the License.
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
+import socket
 
-from sqlalchemy import select
+import rich
 
-from airflow.jobs.job import Job, JobState
-from airflow.utils.net import get_hostname
-from airflow.utils.providers_configuration_loader import 
providers_configuration_loaded
-from airflow.utils.session import NEW_SESSION, provide_session
+from airflowctl.api.client import NEW_API_CLIENT, ClientKind, 
provide_api_client
 
-if TYPE_CHECKING:
-    from sqlalchemy.orm import Session
 
-
-@providers_configuration_loaded
-@provide_session
-def check(args, *, session: Session = NEW_SESSION) -> None:
+@provide_api_client(kind=ClientKind.CLI)
+def check(args, api_client=NEW_API_CLIENT) -> None:
     """Check if job(s) are still alive."""
-    if args.allow_multiple and args.limit <= 1:
-        raise SystemExit("To use option --allow-multiple, you must set the 
limit to a value greater than 1.")
+    if args.allow_multiple and args.limit == 1:
+        raise SystemExit(
+            "To use option --allow-multiple, you must set the limit to a value 
greater than 1 "
+            "or 0 to disable it."
+        )
     if args.hostname and args.local:
-        raise SystemExit("You can't use --hostname and --local at the same 
time")
+        raise SystemExit("You can't use --hostname and --local at the same 
time.")
 
-    query = select(Job).where(Job.state == 
JobState.RUNNING).order_by(Job.latest_heartbeat.desc())
-    if args.job_type:
-        query = query.where(Job.job_type == args.job_type)
-    if args.hostname:
-        query = query.where(Job.hostname == args.hostname)
-    if args.local:
-        query = query.where(Job.hostname == get_hostname())
+    hostname = socket.getfqdn() if args.local else args.hostname
+    alive_jobs = api_client.jobs.list(job_type=args.job_type, 
hostname=hostname, is_alive=True).jobs
     if args.limit > 0:
-        query = query.limit(args.limit)
-
-    alive_jobs: list[Job] = [job for job in session.scalars(query) if 
job.is_alive()]
+        alive_jobs = alive_jobs[: args.limit]
 
     count_alive_jobs = len(alive_jobs)
     if count_alive_jobs == 0:
@@ -56,6 +45,6 @@ def check(args, *, session: Session = NEW_SESSION) -> None:
     if count_alive_jobs > 1 and not args.allow_multiple:
         raise SystemExit(f"Found {count_alive_jobs} alive jobs. Expected only 
one.")
     if count_alive_jobs == 1:
-        print("Found one alive job.")
+        rich.print("Found one alive job.")
     else:
-        print(f"Found {count_alive_jobs} alive jobs.")
+        rich.print(f"Found {count_alive_jobs} alive jobs.")
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_jobs_command.py 
b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_jobs_command.py
new file mode 100644
index 00000000000..697faa682c4
--- /dev/null
+++ b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_jobs_command.py
@@ -0,0 +1,115 @@
+# 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 types import SimpleNamespace
+from unittest import mock
+
+import pytest
+
+from airflowctl.ctl import cli_parser
+from airflowctl.ctl.commands import jobs_command
+
+
+def _make_jobs(count: int) -> SimpleNamespace:
+    """Build a stand-in for the ``JobCollectionResponse`` of ``count`` 
already-alive jobs."""
+    return SimpleNamespace(jobs=[SimpleNamespace(id=i) for i in range(count)])
+
+
+class TestCliJobsCheck:
+    parser = cli_parser.get_parser()
+
+    @pytest.mark.parametrize(
+        ("argv", "alive_count", "expected_output"),
+        [
+            (["jobs", "check", "--job-type", "SchedulerJob"], 1, "Found one 
alive job."),
+            (["jobs", "check", "--limit", "100", "--allow-multiple"], 3, 
"Found 3 alive jobs."),
+            (["jobs", "check", "--limit", "0", "--allow-multiple"], 3, "Found 
3 alive jobs."),
+            (["jobs", "check", "--limit", "1"], 5, "Found one alive job."),
+        ],
+    )
+    def test_reports_alive_jobs(self, capsys, argv, alive_count, 
expected_output):
+        api_client = mock.MagicMock()
+        api_client.jobs.list.return_value = _make_jobs(alive_count)
+
+        jobs_command.check(self.parser.parse_args(argv), api_client=api_client)
+
+        assert expected_output in capsys.readouterr().out
+
+    @pytest.mark.parametrize(
+        ("argv", "local_hostname", "expected_kwargs"),
+        [
+            (
+                ["jobs", "check", "--job-type", "SchedulerJob"],
+                None,
+                {"job_type": "SchedulerJob", "hostname": None, "is_alive": 
True},
+            ),
+            (
+                ["jobs", "check", "--hostname", "HOSTNAME"],
+                None,
+                {"job_type": None, "hostname": "HOSTNAME", "is_alive": True},
+            ),
+            (
+                ["jobs", "check", "--local"],
+                "local-host",
+                {"job_type": None, "hostname": "local-host", "is_alive": True},
+            ),
+        ],
+    )
+    def test_forwards_filters_to_jobs_list(self, argv, local_hostname, 
expected_kwargs):
+        api_client = mock.MagicMock()
+        api_client.jobs.list.return_value = _make_jobs(1)
+
+        with mock.patch("airflowctl.ctl.commands.jobs_command.socket.getfqdn", 
return_value=local_hostname):
+            jobs_command.check(self.parser.parse_args(argv), 
api_client=api_client)
+
+        api_client.jobs.list.assert_called_once_with(**expected_kwargs)
+
+    @pytest.mark.parametrize(
+        ("argv", "alive_count", "expected_error"),
+        [
+            (["jobs", "check"], 0, r"No alive jobs found."),
+            (["jobs", "check", "--limit", "100"], 3, r"Found 3 alive jobs. 
Expected only one."),
+        ],
+    )
+    def test_exits_when_alive_count_unexpected(self, argv, alive_count, 
expected_error):
+        api_client = mock.MagicMock()
+        api_client.jobs.list.return_value = _make_jobs(alive_count)
+
+        with pytest.raises(SystemExit, match=expected_error):
+            jobs_command.check(self.parser.parse_args(argv), 
api_client=api_client)
+
+    @pytest.mark.parametrize(
+        ("argv", "expected_error"),
+        [
+            (
+                ["jobs", "check", "--allow-multiple"],
+                r"To use option --allow-multiple, you must set the limit to a 
value greater than 1 "
+                r"or 0 to disable it.",
+            ),
+            (
+                ["jobs", "check", "--hostname", "h", "--local"],
+                r"You can't use --hostname and --local at the same time",
+            ),
+        ],
+    )
+    def test_rejects_invalid_argument_combinations(self, argv, expected_error):
+        api_client = mock.MagicMock()
+
+        with pytest.raises(SystemExit, match=expected_error):
+            jobs_command.check(self.parser.parse_args(argv), 
api_client=api_client)
+        api_client.jobs.list.assert_not_called()

Reply via email to