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 a7b79ca1cf9 Add auth list-envs command to list CLI environments and 
their auth status (#61426)
a7b79ca1cf9 is described below

commit a7b79ca1cf96cc240f90eec124c6467d4b833052
Author: Dheeraj Turaga <[email protected]>
AuthorDate: Sat Feb 14 18:05:18 2026 -0600

    Add auth list-envs command to list CLI environments and their auth status 
(#61426)
    
    - Adds a new 'airflowctl auth list-envs' command that scans AIRFLOW_HOME 
for environment config files and reports each environment's
      name, API URL, and authentication status (authenticated / not 
authenticated).
      - Filters out internal files (debug_creds_*.json, *_generated.json) so 
only real environments are listed.
---
 airflow-ctl/docs/images/command_hashes.txt         |   2 +-
 airflow-ctl/docs/images/output_auth.svg            | 100 ++++-----
 airflow-ctl/src/airflowctl/ctl/cli_config.py       |   7 +
 .../src/airflowctl/ctl/commands/auth_command.py    |  89 ++++++++
 .../airflow_ctl/ctl/commands/test_auth_command.py  | 223 +++++++++++++++++++++
 5 files changed, 374 insertions(+), 47 deletions(-)

diff --git a/airflow-ctl/docs/images/command_hashes.txt 
b/airflow-ctl/docs/images/command_hashes.txt
index 8a450901218..7f990f14b1b 100644
--- a/airflow-ctl/docs/images/command_hashes.txt
+++ b/airflow-ctl/docs/images/command_hashes.txt
@@ -1,6 +1,6 @@
 main:65249416abad6ad24c276fb44326ae15
 assets:b3ae2b933e54528bf486ff28e887804d
-auth:f396d4bce90215599dde6ad0a8f30f29
+auth:82bc73405e153df5112f05c4811ab92b
 backfill:bbce9859a2d1ce054ad22db92dea8c05
 config:cb175bedf29e8a2c2c6a2ebd13d770a7
 connections:e34b6b93f64714986139958c1f370428
diff --git a/airflow-ctl/docs/images/output_auth.svg 
b/airflow-ctl/docs/images/output_auth.svg
index fc7f38acd3f..9f0f482e186 100644
--- a/airflow-ctl/docs/images/output_auth.svg
+++ b/airflow-ctl/docs/images/output_auth.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 811 342.79999999999995" 
xmlns="http://www.w3.org/2000/svg";>
+<svg class="rich-terminal" viewBox="0 0 933 391.59999999999997" 
xmlns="http://www.w3.org/2000/svg";>
     <!-- Generated with Rich https://www.textualize.io -->
     <style>
 
@@ -19,86 +19,94 @@
         font-weight: 700;
     }
 
-    .terminal-1537035387-matrix {
+    .terminal-1800249333-matrix {
         font-family: Fira Code, monospace;
         font-size: 20px;
         line-height: 24.4px;
         font-variant-east-asian: full-width;
     }
 
-    .terminal-1537035387-title {
+    .terminal-1800249333-title {
         font-size: 18px;
         font-weight: bold;
         font-family: arial;
     }
 
-    .terminal-1537035387-r1 { fill: #ff8700 }
-.terminal-1537035387-r2 { fill: #c5c8c6 }
-.terminal-1537035387-r3 { fill: #808080 }
-.terminal-1537035387-r4 { fill: #68a0b3 }
+    .terminal-1800249333-r1 { fill: #ff8700 }
+.terminal-1800249333-r2 { fill: #c5c8c6 }
+.terminal-1800249333-r3 { fill: #808080 }
+.terminal-1800249333-r4 { fill: #68a0b3 }
     </style>
 
     <defs>
-    <clipPath id="terminal-1537035387-clip-terminal">
-      <rect x="0" y="0" width="792.0" height="291.79999999999995" />
+    <clipPath id="terminal-1800249333-clip-terminal">
+      <rect x="0" y="0" width="914.0" height="340.59999999999997" />
     </clipPath>
-    <clipPath id="terminal-1537035387-line-0">
-    <rect x="0" y="1.5" width="793" height="24.65"/>
+    <clipPath id="terminal-1800249333-line-0">
+    <rect x="0" y="1.5" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-1537035387-line-1">
-    <rect x="0" y="25.9" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-1">
+    <rect x="0" y="25.9" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-1537035387-line-2">
-    <rect x="0" y="50.3" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-2">
+    <rect x="0" y="50.3" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-1537035387-line-3">
-    <rect x="0" y="74.7" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-3">
+    <rect x="0" y="74.7" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-1537035387-line-4">
-    <rect x="0" y="99.1" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-4">
+    <rect x="0" y="99.1" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-1537035387-line-5">
-    <rect x="0" y="123.5" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-5">
+    <rect x="0" y="123.5" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-1537035387-line-6">
-    <rect x="0" y="147.9" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-6">
+    <rect x="0" y="147.9" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-1537035387-line-7">
-    <rect x="0" y="172.3" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-7">
+    <rect x="0" y="172.3" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-1537035387-line-8">
-    <rect x="0" y="196.7" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-8">
+    <rect x="0" y="196.7" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-1537035387-line-9">
-    <rect x="0" y="221.1" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-9">
+    <rect x="0" y="221.1" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-1537035387-line-10">
-    <rect x="0" y="245.5" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-10">
+    <rect x="0" y="245.5" width="915" height="24.65"/>
+            </clipPath>
+<clipPath id="terminal-1800249333-line-11">
+    <rect x="0" y="269.9" width="915" height="24.65"/>
+            </clipPath>
+<clipPath id="terminal-1800249333-line-12">
+    <rect x="0" y="294.3" 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="809" height="340.8" rx="8"/>
+    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="931" height="389.6" 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-1537035387-clip-terminal)">
+    <g transform="translate(9, 41)" 
clip-path="url(#terminal-1800249333-clip-terminal)">
     
-    <g class="terminal-1537035387-matrix">
-    <text class="terminal-1537035387-r1" x="0" y="20" textLength="73.2" 
clip-path="url(#terminal-1537035387-line-0)">Usage:</text><text 
class="terminal-1537035387-r3" x="85.4" y="20" textLength="183" 
clip-path="url(#terminal-1537035387-line-0)">airflowctl&#160;auth</text><text 
class="terminal-1537035387-r2" x="268.4" y="20" textLength="24.4" 
clip-path="url(#terminal-1537035387-line-0)">&#160;[</text><text 
class="terminal-1537035387-r4" x="292.8" y="20" textLength="24.4" 
clip-path="url(#t [...]
-</text><text class="terminal-1537035387-r2" x="793" y="44.4" textLength="12.2" 
clip-path="url(#terminal-1537035387-line-1)">
-</text><text class="terminal-1537035387-r2" x="0" y="68.8" textLength="793" 
clip-path="url(#terminal-1537035387-line-2)">Manage&#160;authentication&#160;for&#160;CLI.&#160;Either&#160;pass&#160;token&#160;from&#160;environment</text><text
 class="terminal-1537035387-r2" x="793" y="68.8" textLength="12.2" 
clip-path="url(#terminal-1537035387-line-2)">
-</text><text class="terminal-1537035387-r2" x="0" y="93.2" textLength="597.8" 
clip-path="url(#terminal-1537035387-line-3)">variable/parameter&#160;or&#160;pass&#160;username&#160;and&#160;password.</text><text
 class="terminal-1537035387-r2" x="793" y="93.2" textLength="12.2" 
clip-path="url(#terminal-1537035387-line-3)">
-</text><text class="terminal-1537035387-r2" x="793" y="117.6" 
textLength="12.2" clip-path="url(#terminal-1537035387-line-4)">
-</text><text class="terminal-1537035387-r1" x="0" y="142" textLength="256.2" 
clip-path="url(#terminal-1537035387-line-5)">Positional&#160;Arguments:</text><text
 class="terminal-1537035387-r2" x="793" y="142" textLength="12.2" 
clip-path="url(#terminal-1537035387-line-5)">
-</text><text class="terminal-1537035387-r4" x="24.4" y="166.4" 
textLength="85.4" 
clip-path="url(#terminal-1537035387-line-6)">COMMAND</text><text 
class="terminal-1537035387-r2" x="793" y="166.4" textLength="12.2" 
clip-path="url(#terminal-1537035387-line-6)">
-</text><text class="terminal-1537035387-r4" x="48.8" y="190.8" textLength="61" 
clip-path="url(#terminal-1537035387-line-7)">login</text><text 
class="terminal-1537035387-r2" x="170.8" y="190.8" textLength="622.2" 
clip-path="url(#terminal-1537035387-line-7)">Login&#160;to&#160;the&#160;metadata&#160;database&#160;for&#160;personal&#160;usage.&#160;</text><text
 class="terminal-1537035387-r2" x="793" y="190.8" textLength="12.2" 
clip-path="url(#terminal-1537035387-line-7)">
-</text><text class="terminal-1537035387-r2" x="0" y="215.2" textLength="500.2" 
clip-path="url(#terminal-1537035387-line-8)">JWT&#160;Token&#160;must&#160;be&#160;provided&#160;via&#160;parameter.</text><text
 class="terminal-1537035387-r2" x="793" y="215.2" textLength="12.2" 
clip-path="url(#terminal-1537035387-line-8)">
-</text><text class="terminal-1537035387-r2" x="793" y="239.6" 
textLength="12.2" clip-path="url(#terminal-1537035387-line-9)">
-</text><text class="terminal-1537035387-r1" x="0" y="264" textLength="97.6" 
clip-path="url(#terminal-1537035387-line-10)">Options:</text><text 
class="terminal-1537035387-r2" x="793" y="264" textLength="12.2" 
clip-path="url(#terminal-1537035387-line-10)">
-</text><text class="terminal-1537035387-r4" x="24.4" y="288.4" 
textLength="24.4" clip-path="url(#terminal-1537035387-line-11)">-h</text><text 
class="terminal-1537035387-r2" x="48.8" y="288.4" textLength="24.4" 
clip-path="url(#terminal-1537035387-line-11)">,&#160;</text><text 
class="terminal-1537035387-r4" x="73.2" y="288.4" textLength="73.2" 
clip-path="url(#terminal-1537035387-line-11)">--help</text><text 
class="terminal-1537035387-r2" x="170.8" y="288.4" textLength="378.2" 
clip-path="ur [...]
+    <g class="terminal-1800249333-matrix">
+    <text class="terminal-1800249333-r1" x="0" y="20" textLength="73.2" 
clip-path="url(#terminal-1800249333-line-0)">Usage:</text><text 
class="terminal-1800249333-r3" x="85.4" y="20" textLength="183" 
clip-path="url(#terminal-1800249333-line-0)">airflowctl&#160;auth</text><text 
class="terminal-1800249333-r2" x="268.4" y="20" textLength="24.4" 
clip-path="url(#terminal-1800249333-line-0)">&#160;[</text><text 
class="terminal-1800249333-r4" x="292.8" y="20" textLength="24.4" 
clip-path="url(#t [...]
+</text><text class="terminal-1800249333-r2" x="915" y="44.4" textLength="12.2" 
clip-path="url(#terminal-1800249333-line-1)">
+</text><text class="terminal-1800249333-r2" x="0" y="68.8" textLength="805.2" 
clip-path="url(#terminal-1800249333-line-2)">Manage&#160;authentication&#160;for&#160;CLI.&#160;Either&#160;pass&#160;token&#160;from&#160;environment&#160;</text><text
 class="terminal-1800249333-r2" x="915" y="68.8" textLength="12.2" 
clip-path="url(#terminal-1800249333-line-2)">
+</text><text class="terminal-1800249333-r2" x="0" y="93.2" textLength="597.8" 
clip-path="url(#terminal-1800249333-line-3)">variable/parameter&#160;or&#160;pass&#160;username&#160;and&#160;password.</text><text
 class="terminal-1800249333-r2" x="915" y="93.2" textLength="12.2" 
clip-path="url(#terminal-1800249333-line-3)">
+</text><text class="terminal-1800249333-r2" x="915" y="117.6" 
textLength="12.2" clip-path="url(#terminal-1800249333-line-4)">
+</text><text class="terminal-1800249333-r1" x="0" y="142" textLength="256.2" 
clip-path="url(#terminal-1800249333-line-5)">Positional&#160;Arguments:</text><text
 class="terminal-1800249333-r2" x="915" y="142" textLength="12.2" 
clip-path="url(#terminal-1800249333-line-5)">
+</text><text class="terminal-1800249333-r4" x="24.4" y="166.4" 
textLength="85.4" 
clip-path="url(#terminal-1800249333-line-6)">COMMAND</text><text 
class="terminal-1800249333-r2" x="915" y="166.4" textLength="12.2" 
clip-path="url(#terminal-1800249333-line-6)">
+</text><text class="terminal-1800249333-r4" x="48.8" y="190.8" 
textLength="109.8" 
clip-path="url(#terminal-1800249333-line-7)">list-envs</text><text 
class="terminal-1800249333-r2" x="915" y="190.8" textLength="12.2" 
clip-path="url(#terminal-1800249333-line-7)">
+</text><text class="terminal-1800249333-r2" x="170.8" y="215.2" 
textLength="671" 
clip-path="url(#terminal-1800249333-line-8)">List&#160;all&#160;CLI&#160;environments&#160;that&#160;the&#160;user&#160;has&#160;logged&#160;into</text><text
 class="terminal-1800249333-r2" x="915" y="215.2" textLength="12.2" 
clip-path="url(#terminal-1800249333-line-8)">
+</text><text class="terminal-1800249333-r4" x="48.8" y="239.6" textLength="61" 
clip-path="url(#terminal-1800249333-line-9)">login</text><text 
class="terminal-1800249333-r2" x="170.8" y="239.6" textLength="744.2" 
clip-path="url(#terminal-1800249333-line-9)">Login&#160;to&#160;the&#160;metadata&#160;database&#160;for&#160;personal&#160;usage.&#160;JWT&#160;Token&#160;</text><text
 class="terminal-1800249333-r2" x="915" y="239.6" textLength="12.2" 
clip-path="url(#terminal-1800249333-line-9)">
+</text><text class="terminal-1800249333-r2" x="0" y="264" textLength="378.2" 
clip-path="url(#terminal-1800249333-line-10)">must&#160;be&#160;provided&#160;via&#160;parameter.</text><text
 class="terminal-1800249333-r2" x="915" y="264" textLength="12.2" 
clip-path="url(#terminal-1800249333-line-10)">
+</text><text class="terminal-1800249333-r2" x="915" y="288.4" 
textLength="12.2" clip-path="url(#terminal-1800249333-line-11)">
+</text><text class="terminal-1800249333-r1" x="0" y="312.8" textLength="97.6" 
clip-path="url(#terminal-1800249333-line-12)">Options:</text><text 
class="terminal-1800249333-r2" x="915" y="312.8" textLength="12.2" 
clip-path="url(#terminal-1800249333-line-12)">
+</text><text class="terminal-1800249333-r4" x="24.4" y="337.2" 
textLength="24.4" clip-path="url(#terminal-1800249333-line-13)">-h</text><text 
class="terminal-1800249333-r2" x="48.8" y="337.2" textLength="24.4" 
clip-path="url(#terminal-1800249333-line-13)">,&#160;</text><text 
class="terminal-1800249333-r4" x="73.2" y="337.2" textLength="73.2" 
clip-path="url(#terminal-1800249333-line-13)">--help</text><text 
class="terminal-1800249333-r2" x="170.8" y="337.2" textLength="378.2" 
clip-path="ur [...]
 </text>
     </g>
     </g>
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py 
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index 76b7bec3ba6..2b8e6d8bfd7 100644
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -811,6 +811,13 @@ AUTH_COMMANDS = (
         func=lazy_load_command("airflowctl.ctl.commands.auth_command.login"),
         args=(ARG_AUTH_URL, ARG_AUTH_TOKEN, ARG_AUTH_ENVIRONMENT, 
ARG_AUTH_USERNAME, ARG_AUTH_PASSWORD),
     ),
+    ActionCommand(
+        name="list-envs",
+        help="List all CLI environments that the user has logged into",
+        description="List all CLI environments with their authentication 
status",
+        
func=lazy_load_command("airflowctl.ctl.commands.auth_command.list_envs"),
+        args=(ARG_OUTPUT,),
+    ),
 )
 
 CONFIG_COMMANDS = (
diff --git a/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py 
b/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
index e66a1d835d4..8cc55ff69e8 100644
--- a/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
+++ b/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
@@ -18,13 +18,18 @@
 
 from __future__ import annotations
 
+import glob
+import json
 import os
 import sys
 
+import keyring
 import rich
+from keyring.errors import NoKeyringError
 
 from airflowctl.api.client import NEW_API_CLIENT, ClientKind, Credentials, 
provide_api_client
 from airflowctl.api.datamodels.auth_generated import LoginBody
+from airflowctl.ctl.console_formatting import AirflowConsole
 
 
 @provide_api_client(kind=ClientKind.AUTH)
@@ -75,3 +80,87 @@ def login(args, api_client=NEW_API_CLIENT) -> None:
         api_environment=args.env,
     ).save()
     rich.print(success_message)
+
+
+def list_envs(args) -> None:
+    """List all CLI environments that the user has logged into."""
+    # Get AIRFLOW_HOME
+    airflow_home = os.environ.get("AIRFLOW_HOME", 
os.path.expanduser("~/airflow"))
+
+    # Check if directory exists
+    if not os.path.isdir(airflow_home):
+        rich.print(f"[yellow]No AIRFLOW_HOME directory found at 
{airflow_home}[/yellow]")
+        AirflowConsole().print_as(data=[], output=args.output)
+        return
+
+    # Find all .json files
+    config_files = glob.glob(os.path.join(airflow_home, "*.json"))
+
+    environments = []
+
+    for config_path in config_files:
+        filename = os.path.basename(config_path)
+
+        # Skip non-environment config files
+        if filename.startswith("debug_creds_") or 
filename.endswith("_generated.json"):
+            continue
+
+        env_name = filename.replace(".json", "")
+
+        # Try to read config file
+        api_url = None
+        config_status = "ok"
+
+        try:
+            with open(config_path) as f:
+                config = json.load(f)
+                api_url = config.get("api_url", "unknown")
+        except (OSError, json.JSONDecodeError, KeyError) as e:
+            config_status = f"config error: {str(e)[:50]}"
+            api_url = "error reading config"
+
+        # Try to get token from keyring
+        token_status = "not authenticated"
+
+        try:
+            if os.getenv("AIRFLOW_CLI_DEBUG_MODE") == "true":
+                # Check debug credentials file
+                debug_path = os.path.join(airflow_home, 
f"debug_creds_{env_name}.json")
+                if os.path.exists(debug_path):
+                    with open(debug_path) as f:
+                        debug_creds = json.load(f)
+                        if f"api_token_{env_name}" in debug_creds:
+                            token_status = "authenticated"
+            else:
+                # Check keyring
+                token = keyring.get_password("airflowctl", 
f"api_token_{env_name}")
+                if token:
+                    token_status = "authenticated"
+        except NoKeyringError:
+            token_status = "keyring unavailable"
+        except ValueError:
+            # Incorrect keyring password
+            token_status = "keyring error"
+        except Exception as e:
+            token_status = f"error: {str(e)[:30]}"
+
+        # If config is corrupted, override token status
+        if config_status != "ok":
+            token_status = config_status
+
+        environments.append(
+            {
+                "environment": env_name,
+                "api_url": api_url,
+                "status": token_status,
+            }
+        )
+
+    # Sort by environment name
+    environments.sort(key=lambda x: x.get("environment", ""))
+
+    # Display results
+    if not environments:
+        rich.print(f"[yellow]No environments found in {airflow_home}[/yellow]")
+
+    AirflowConsole().print_as(data=environments, output=args.output)
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_auth_command.py 
b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_auth_command.py
index b0faa0965ca..54fb83901ee 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_auth_command.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_auth_command.py
@@ -139,3 +139,226 @@ class TestCliAuthCommands:
                 ),
                 api_client=api_client,
             )
+
+
+class TestListEnvs:
+    parser = cli_parser.get_parser()
+
+    def test_list_envs_empty_airflow_home(self, monkeypatch):
+        """Test list-envs with no AIRFLOW_HOME directory."""
+        with (
+            tempfile.TemporaryDirectory() as temp_dir,
+            patch("keyring.get_password"),
+        ):
+            non_existent_dir = os.path.join(temp_dir, "non_existent")
+            monkeypatch.setenv("AIRFLOW_HOME", non_existent_dir)
+
+            args = self.parser.parse_args(["auth", "list-envs"])
+            auth_command.list_envs(args)
+
+    def test_list_envs_no_environments(self, monkeypatch):
+        """Test list-envs with empty AIRFLOW_HOME."""
+        with (
+            tempfile.TemporaryDirectory() as temp_airflow_home,
+            patch("keyring.get_password"),
+        ):
+            monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+            args = self.parser.parse_args(["auth", "list-envs"])
+            auth_command.list_envs(args)
+
+    def test_list_envs_single_authenticated(self, monkeypatch):
+        """Test list-envs with a single authenticated environment."""
+        with (
+            tempfile.TemporaryDirectory() as temp_airflow_home,
+            patch("keyring.get_password") as mock_get_password,
+        ):
+            monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+            # Create a config file
+            config_path = os.path.join(temp_airflow_home, "production.json")
+            with open(config_path, "w") as f:
+                json.dump({"api_url": "http://localhost:8080"}, f)
+
+            # Mock keyring to return a token
+            mock_get_password.return_value = "test_token"
+
+            args = self.parser.parse_args(["auth", "list-envs"])
+            auth_command.list_envs(args)
+
+            mock_get_password.assert_called_once_with("airflowctl", 
"api_token_production")
+
+    def test_list_envs_multiple_mixed_status(self, monkeypatch):
+        """Test list-envs with multiple environments with different 
statuses."""
+        with (
+            tempfile.TemporaryDirectory() as temp_airflow_home,
+            patch("keyring.get_password") as mock_get_password,
+        ):
+            monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+            # Create authenticated environment
+            with open(os.path.join(temp_airflow_home, "production.json"), "w") 
as f:
+                json.dump({"api_url": "http://localhost:8080"}, f)
+
+            # Create not authenticated environment
+            with open(os.path.join(temp_airflow_home, "staging.json"), "w") as 
f:
+                json.dump({"api_url": "http://localhost:8081"}, f)
+
+            # Mock keyring to return token only for production
+            def mock_get_password_func(service, key):
+                if key == "api_token_production":
+                    return "prod_token"
+                return None
+
+            mock_get_password.side_effect = mock_get_password_func
+
+            args = self.parser.parse_args(["auth", "list-envs"])
+            auth_command.list_envs(args)
+
+    def test_list_envs_json_output(self, monkeypatch):
+        """Test list-envs with JSON output format."""
+        with (
+            tempfile.TemporaryDirectory() as temp_airflow_home,
+            patch("keyring.get_password") as mock_get_password,
+        ):
+            monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+            # Create a config file
+            with open(os.path.join(temp_airflow_home, "production.json"), "w") 
as f:
+                json.dump({"api_url": "http://localhost:8080"}, f)
+
+            mock_get_password.return_value = "test_token"
+
+            args = self.parser.parse_args(["auth", "list-envs", "--output", 
"json"])
+            auth_command.list_envs(args)
+
+    def test_list_envs_yaml_output(self, monkeypatch):
+        """Test list-envs with YAML output format."""
+        with (
+            tempfile.TemporaryDirectory() as temp_airflow_home,
+            patch("keyring.get_password") as mock_get_password,
+        ):
+            monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+            # Create a config file
+            with open(os.path.join(temp_airflow_home, "production.json"), "w") 
as f:
+                json.dump({"api_url": "http://localhost:8080"}, f)
+
+            mock_get_password.return_value = "test_token"
+
+            args = self.parser.parse_args(["auth", "list-envs", "--output", 
"yaml"])
+            auth_command.list_envs(args)
+
+    def test_list_envs_plain_output(self, monkeypatch):
+        """Test list-envs with plain output format."""
+        with (
+            tempfile.TemporaryDirectory() as temp_airflow_home,
+            patch("keyring.get_password") as mock_get_password,
+        ):
+            monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+            # Create a config file
+            with open(os.path.join(temp_airflow_home, "production.json"), "w") 
as f:
+                json.dump({"api_url": "http://localhost:8080"}, f)
+
+            mock_get_password.return_value = "test_token"
+
+            args = self.parser.parse_args(["auth", "list-envs", "--output", 
"plain"])
+            auth_command.list_envs(args)
+
+    def test_list_envs_keyring_unavailable(self, monkeypatch):
+        """Test list-envs when keyring is unavailable."""
+        from keyring.errors import NoKeyringError
+
+        with (
+            tempfile.TemporaryDirectory() as temp_airflow_home,
+            patch("keyring.get_password") as mock_get_password,
+        ):
+            monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+            # Create a config file
+            with open(os.path.join(temp_airflow_home, "production.json"), "w") 
as f:
+                json.dump({"api_url": "http://localhost:8080"}, f)
+
+            mock_get_password.side_effect = NoKeyringError("no backend")
+
+            args = self.parser.parse_args(["auth", "list-envs"])
+            auth_command.list_envs(args)
+
+    def test_list_envs_keyring_error(self, monkeypatch):
+        """Test list-envs when keyring has an error."""
+        with (
+            tempfile.TemporaryDirectory() as temp_airflow_home,
+            patch("keyring.get_password") as mock_get_password,
+        ):
+            monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+            # Create a config file
+            with open(os.path.join(temp_airflow_home, "production.json"), "w") 
as f:
+                json.dump({"api_url": "http://localhost:8080"}, f)
+
+            mock_get_password.side_effect = ValueError("incorrect password")
+
+            args = self.parser.parse_args(["auth", "list-envs"])
+            auth_command.list_envs(args)
+
+    def test_list_envs_corrupted_config(self, monkeypatch):
+        """Test list-envs with corrupted config file."""
+        with (
+            tempfile.TemporaryDirectory() as temp_airflow_home,
+            patch("keyring.get_password"),
+        ):
+            monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+            # Create a corrupted config file
+            config_path = os.path.join(temp_airflow_home, "production.json")
+            with open(config_path, "w") as f:
+                f.write("invalid json content {{{")
+
+            args = self.parser.parse_args(["auth", "list-envs"])
+            auth_command.list_envs(args)
+
+    def test_list_envs_debug_mode(self, monkeypatch):
+        """Test list-envs in debug mode."""
+        with tempfile.TemporaryDirectory() as temp_airflow_home:
+            monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+            monkeypatch.setenv("AIRFLOW_CLI_DEBUG_MODE", "true")
+
+            # Create a config file
+            with open(os.path.join(temp_airflow_home, "production.json"), "w") 
as f:
+                json.dump({"api_url": "http://localhost:8080"}, f)
+
+            # Create debug credentials file
+            debug_creds_path = os.path.join(temp_airflow_home, 
"debug_creds_production.json")
+            with open(debug_creds_path, "w") as f:
+                json.dump({"api_token_production": "debug_token"}, f)
+
+            args = self.parser.parse_args(["auth", "list-envs"])
+            auth_command.list_envs(args)
+
+    def test_list_envs_filters_special_files(self, monkeypatch):
+        """Test list-envs filters out special files."""
+        with (
+            tempfile.TemporaryDirectory() as temp_airflow_home,
+            patch("keyring.get_password") as mock_get_password,
+        ):
+            monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+            # Create regular config
+            with open(os.path.join(temp_airflow_home, "production.json"), "w") 
as f:
+                json.dump({"api_url": "http://localhost:8080"}, f)
+
+            # Create files that should be filtered out
+            with open(os.path.join(temp_airflow_home, 
"debug_creds_production.json"), "w") as f:
+                json.dump({"api_token_production": "token"}, f)
+
+            with open(os.path.join(temp_airflow_home, "some_generated.json"), 
"w") as f:
+                json.dump({"data": "generated"}, f)
+
+            mock_get_password.return_value = "test_token"
+
+            args = self.parser.parse_args(["auth", "list-envs"])
+            auth_command.list_envs(args)
+
+            # Only production environment should be checked, not the special 
files
+            mock_get_password.assert_called_once_with("airflowctl", 
"api_token_production")

Reply via email to