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

yzheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git


The following commit(s) were added to refs/heads/main by this push:
     new c6a021b4 Add profile support for cli (#931)
c6a021b4 is described below

commit c6a021b4b7391a961f0ff3667698514ed3fa093d
Author: MonkeyCanCode <[email protected]>
AuthorDate: Sun Feb 16 00:39:19 2025 -0600

    Add profile support for cli (#931)
    
    * Add profile support for cli
    
    * Fix feedback
    
    * Remove fallback locattion in case if SCRIPT_DIR is not set
    
    * Fix test case
---
 .gitignore                                         |   3 +
 polaris                                            |   2 +-
 regtests/client/python/cli/command/__init__.py     |   7 +
 regtests/client/python/cli/command/profiles.py     | 142 +++++++++++++++++++++
 regtests/client/python/cli/constants.py            |   8 ++
 regtests/client/python/cli/options/option_tree.py  |   7 +
 regtests/client/python/cli/options/parser.py       |   1 +
 regtests/client/python/cli/polaris_cli.py          |  51 +++++---
 regtests/polaris-reg-test                          |   2 +-
 .../in-dev/unreleased/command-line-interface.md    | 114 +++++++++++++++++
 10 files changed, 320 insertions(+), 17 deletions(-)

diff --git a/.gitignore b/.gitignore
index 7784833f..e220135f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,9 @@ regtests/client/python/poetry.lock
 /polaris-venv/
 /pyproject.toml
 
+# Polaris CLI profile
+.polaris.json
+
 # Notebook Checkpoints
 **/.ipynb_checkpoints/
 
diff --git a/polaris b/polaris
index 22b8d476..4200e5b7 100755
--- a/polaris
+++ b/polaris
@@ -33,7 +33,7 @@ if [ ! -d ${SCRIPT_DIR}/polaris-venv ]; then
 fi
 
 pushd $SCRIPT_DIR > /dev/null
-PYTHONPATH=regtests/client/python ${SCRIPT_DIR}/polaris-venv/bin/python3 
regtests/client/python/cli/polaris_cli.py "$@"
+PYTHONPATH=regtests/client/python SCRIPT_DIR="$SCRIPT_DIR" 
${SCRIPT_DIR}/polaris-venv/bin/python3 
regtests/client/python/cli/polaris_cli.py "$@"
 status=$?
 popd > /dev/null
 
diff --git a/regtests/client/python/cli/command/__init__.py 
b/regtests/client/python/cli/command/__init__.py
index 76b4a98a..48189dee 100644
--- a/regtests/client/python/cli/command/__init__.py
+++ b/regtests/client/python/cli/command/__init__.py
@@ -124,6 +124,13 @@ class Command(ABC):
                 location=options_get(Arguments.LOCATION),
                 properties=properties
             )
+        elif options.command == Commands.PROFILES:
+            from cli.command.profiles import ProfilesCommand
+            subcommand = options_get(f'{Commands.PROFILES}_subcommand')
+            command = ProfilesCommand(
+                subcommand,
+                profile_name=options_get(Arguments.PROFILE)
+            )
 
         if command is not None:
             command.validate()
diff --git a/regtests/client/python/cli/command/profiles.py 
b/regtests/client/python/cli/command/profiles.py
new file mode 100644
index 00000000..ed0a1028
--- /dev/null
+++ b/regtests/client/python/cli/command/profiles.py
@@ -0,0 +1,142 @@
+#
+# 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.
+#
+import os
+import sys
+import json
+from dataclasses import dataclass
+from typing import Dict, Optional, List
+
+from pydantic import StrictStr
+
+from cli.command import Command
+from cli.constants import Subcommands, DEFAULT_HOSTNAME, DEFAULT_PORT, 
CONFIG_DIR, CONFIG_FILE
+from polaris.management import PolarisDefaultApi
+
+
+@dataclass
+class ProfilesCommand(Command):
+    """
+    A Command implementation to represent `polaris profiles`. The instance 
attributes correspond to parameters
+    that can be provided to various subcommands, except `profiles_subcommand` 
which represents the subcommand
+    itself.
+
+    Example commands:
+        * ./polaris profiles create dev
+        * ./polaris profiles delete dev
+        * ./polaris profiles update dev
+        * ./polaris profiles get dev
+        * ./polaris profiles list
+    """
+
+    profiles_subcommand: str
+    profile_name: str
+
+    def _load_profiles(self) -> Dict[str, Dict[str, str]]:
+        if not os.path.exists(CONFIG_FILE):
+            return {}
+        with open(CONFIG_FILE, "r") as f:
+            return json.load(f)
+
+    def _save_profiles(self, profiles: Dict[str, Dict[str, str]]) -> None:
+        if not os.path.exists(CONFIG_DIR):
+            os.makedirs(CONFIG_DIR)
+        with open(CONFIG_FILE, "w") as f:
+            json.dump(profiles, f, indent=2)
+
+    def _create_profile(self, name: str) -> None:
+        profiles = self._load_profiles()
+        if name not in profiles:
+          client_id = input("Polaris Client ID: ")
+          client_secret = input("Polaris Client Secret: ")
+          host = input(f"Polaris Host [{DEFAULT_HOSTNAME}]: ") or 
DEFAULT_HOSTNAME
+          port = input(f"Polaris Port [{DEFAULT_PORT}]: ") or DEFAULT_PORT
+          profiles[name] = {
+              "client_id": client_id,
+              "client_secret": client_secret,
+              "host": host,
+              "port": port
+          }
+          self._save_profiles(profiles)
+        else:
+          print(f"Profile {name} already exists.")
+          sys.exit(1)
+
+    def _get_profile(self, name: str) -> Optional[Dict[str, str]]:
+        profiles = self._load_profiles()
+        return profiles.get(name)
+
+    def _list_profiles(self) -> List[str]:
+        profiles = self._load_profiles()
+        return list(profiles.keys())
+
+    def _delete_profile(self, name: str) -> None:
+        profiles = self._load_profiles()
+        if name in profiles:
+            del profiles[name]
+            self._save_profiles(profiles)
+
+    def _update_profile(self, name: str) -> None:
+        profiles = self._load_profiles()
+        if name in profiles:
+            current_client_id = profiles[name].get("client_id")
+            current_client_secret = profiles[name].get("client_secret")
+            current_host = profiles[name].get("host")
+            current_port = profiles[name].get("port")
+
+            client_id = input(f"Polaris Client ID [{current_client_id}]: ") or 
current_client_id
+            client_secret = input(f"Polaris Client Secret 
[{current_client_secret}]: ") or current_client_secret
+            host = input(f"Polaris Client ID [{current_host}]: ") or 
current_host
+            port = input(f"Polaris Client Secret [{current_port}]: ") or 
current_port
+            profiles[name] = {
+                "client_id": client_id,
+                "client_secret": client_secret,
+                "host": host,
+                "port": port
+            }
+            self._save_profiles(profiles)
+        else:
+            print(f"Profile {name} does not exist.")
+            sys.exit(1)
+
+    def validate(self):
+        pass
+
+    def execute(self, api: Optional[PolarisDefaultApi] = None) -> None:
+        if self.profiles_subcommand == Subcommands.CREATE:
+            self._create_profile(self.profile_name)
+            print(f"Polaris profile {self.profile_name} created successfully.")
+        elif self.profiles_subcommand == Subcommands.DELETE:
+            self._delete_profile(self.profile_name)
+            print(f"Polaris profile {self.profile_name} deleted successfully.")
+        elif self.profiles_subcommand == Subcommands.UPDATE:
+            self._update_profile(self.profile_name)
+            print(f"Polaris profile {self.profile_name} updated 
successfully.")            
+        elif self.profiles_subcommand == Subcommands.GET:
+            profile = self._get_profile(self.profile_name)
+            if profile:
+                print(f"Polaris profile {self.profile_name}: {profile}")
+            else:
+                print(f"Polaris profile {self.profile_name} not found.")
+        elif self.profiles_subcommand == Subcommands.LIST:
+            profiles = self._list_profiles()
+            print("Polaris profiles:")
+            for profile in profiles:
+                print(f" - {profile}")
+        else:
+            raise Exception(f"{self.profiles_subcommand} is not supported in 
the CLI")
diff --git a/regtests/client/python/cli/constants.py 
b/regtests/client/python/cli/constants.py
index 353d0dc3..6dce2b07 100644
--- a/regtests/client/python/cli/constants.py
+++ b/regtests/client/python/cli/constants.py
@@ -16,6 +16,7 @@
 # specific language governing permissions and limitations
 # under the License.
 #
+import os
 from enum import Enum
 
 
@@ -58,6 +59,7 @@ class Commands:
     CATALOG_ROLES = 'catalog-roles'
     PRIVILEGES = 'privileges'
     NAMESPACES = 'namespaces'
+    PROFILES = 'profiles'
 
 
 class Subcommands:
@@ -132,6 +134,7 @@ class Arguments:
     PARENT = 'parent'
     LOCATION = 'location'
     REGION = 'region'
+    PROFILE = 'profile'
 
 
 class Hints:
@@ -229,5 +232,10 @@ class Hints:
 UNIT_SEPARATOR = chr(0x1F)
 CLIENT_ID_ENV = 'CLIENT_ID'
 CLIENT_SECRET_ENV = 'CLIENT_SECRET'
+CLIENT_PROFILE_ENV = 'CLIENT_PROFILE'
 DEFAULT_HOSTNAME = 'localhost'
 DEFAULT_PORT = 8181
+CONFIG_DIR = os.environ.get('SCRIPT_DIR')
+if CONFIG_DIR is None:
+    raise Exception("The SCRIPT_DIR environment variable is not set. Please 
set it to the Polaris's script directory.")
+CONFIG_FILE = os.path.join(CONFIG_DIR, '.polaris.json')
diff --git a/regtests/client/python/cli/options/option_tree.py 
b/regtests/client/python/cli/options/option_tree.py
index d048b1a5..2510193e 100644
--- a/regtests/client/python/cli/options/option_tree.py
+++ b/regtests/client/python/cli/options/option_tree.py
@@ -230,5 +230,12 @@ class OptionTree:
                 Option(Subcommands.GET, args=[
                     Argument(Arguments.CATALOG, str, 
Hints.CatalogRoles.CATALOG_NAME)
                 ], input_name=Arguments.NAMESPACE),
+            ]),
+            Option(Commands.PROFILES, 'manage profiles', children=[
+                Option(Subcommands.CREATE, input_name=Arguments.PROFILE),
+                Option(Subcommands.DELETE, input_name=Arguments.PROFILE),
+                Option(Subcommands.UPDATE, input_name=Arguments.PROFILE),
+                Option(Subcommands.GET, input_name=Arguments.PROFILE),
+                Option(Subcommands.LIST),
             ])
         ]
diff --git a/regtests/client/python/cli/options/parser.py 
b/regtests/client/python/cli/options/parser.py
index e4a6a6b3..4a26a758 100644
--- a/regtests/client/python/cli/options/parser.py
+++ b/regtests/client/python/cli/options/parser.py
@@ -43,6 +43,7 @@ class Parser(object):
         Argument(Arguments.CLIENT_ID, str, hint='client ID for token-based 
authentication'),
         Argument(Arguments.CLIENT_SECRET, str, hint='client secret for 
token-based authentication'),
         Argument(Arguments.ACCESS_TOKEN, str, hint='access token for 
token-based authentication'),
+        Argument(Arguments.PROFILE, str, hint='profile for token-based 
authentication'),
     ]
 
     @staticmethod
diff --git a/regtests/client/python/cli/polaris_cli.py 
b/regtests/client/python/cli/polaris_cli.py
index 9dd98127..2cc2f4e2 100644
--- a/regtests/client/python/cli/polaris_cli.py
+++ b/regtests/client/python/cli/polaris_cli.py
@@ -22,7 +22,9 @@ import os
 import sys
 from json import JSONDecodeError
 
-from cli.constants import Arguments, CLIENT_ID_ENV, CLIENT_SECRET_ENV, 
DEFAULT_HOSTNAME, DEFAULT_PORT
+from typing import Dict
+
+from cli.constants import Arguments, Commands, CLIENT_ID_ENV, 
CLIENT_SECRET_ENV, CLIENT_PROFILE_ENV, DEFAULT_HOSTNAME, DEFAULT_PORT, 
CONFIG_FILE
 from cli.options.option_tree import Argument
 from cli.options.parser import Parser
 from polaris.management import ApiClient, Configuration
@@ -47,16 +49,21 @@ class PolarisCli:
     @staticmethod
     def execute(args=None):
         options = Parser.parse(args)
-        client_builder = PolarisCli._get_client_builder(options)
-        with client_builder() as api_client:
-            try:
-                from cli.command import Command
-                admin_api = PolarisDefaultApi(api_client)
-                command = Command.from_options(options)
-                command.execute(admin_api)
-            except Exception as e:
-                PolarisCli._try_print_exception(e)
-                sys.exit(1)
+        if options.command == Commands.PROFILES:
+            from cli.command import Command
+            command = Command.from_options(options)
+            command.execute()
+        else:
+            client_builder = PolarisCli._get_client_builder(options)
+            with client_builder() as api_client:
+                try:
+                    from cli.command import Command
+                    admin_api = PolarisDefaultApi(api_client)
+                    command = Command.from_options(options)
+                    command.execute(admin_api)
+                except Exception as e:
+                    PolarisCli._try_print_exception(e)
+                    sys.exit(1)
 
     @staticmethod
     def _try_print_exception(e):
@@ -71,6 +78,13 @@ class PolarisCli:
             sys.stderr.write(f'Exception when communicating with the Polaris 
server.'
                              f' {e}{os.linesep}')
 
+    @staticmethod
+    def _load_profiles() -> Dict[str, Dict[str, str]]:
+        if not os.path.exists(CONFIG_FILE):
+            return {}
+        with open(CONFIG_FILE, "r") as f:
+            return json.load(f)
+
     @staticmethod
     def _get_token(api_client: ApiClient, catalog_url, client_id, 
client_secret) -> str:
         response = api_client.call_api(
@@ -90,9 +104,16 @@ class PolarisCli:
 
     @staticmethod
     def _get_client_builder(options):
+        profile = {}
+        client_profile = options.profile or os.getenv(CLIENT_PROFILE_ENV)
+        if client_profile:
+            profiles = PolarisCli._load_profiles()
+            profile = profiles.get(client_profile)
+            if not profile:
+                raise Exception(f'Polaris profile {client_profile} not found')
         # Determine which credentials to use
-        client_id = options.client_id or os.getenv(CLIENT_ID_ENV)
-        client_secret = options.client_secret or os.getenv(CLIENT_SECRET_ENV)
+        client_id = options.client_id or os.getenv(CLIENT_ID_ENV) or 
profile.get('client_id')
+        client_secret = options.client_secret or os.getenv(CLIENT_SECRET_ENV) 
or profile.get('client_secret')
         
         # Validates
         has_access_token = options.access_token is not None
@@ -117,8 +138,8 @@ class PolarisCli:
             polaris_management_url = f'{options.base_url}/api/management/v1'
             polaris_catalog_url = f'{options.base_url}/api/catalog/v1'
         else:
-            host = options.host or DEFAULT_HOSTNAME
-            port = options.port or DEFAULT_PORT
+            host = options.host or profile.get('host') or DEFAULT_HOSTNAME
+            port = options.port or profile.get('port') or DEFAULT_PORT
             polaris_management_url = f'http://{host}:{port}/api/management/v1'
             polaris_catalog_url = f'http://{host}:{port}/api/catalog/v1'
 
diff --git a/regtests/polaris-reg-test b/regtests/polaris-reg-test
index e8d66822..0b996a4d 100755
--- a/regtests/polaris-reg-test
+++ b/regtests/polaris-reg-test
@@ -53,7 +53,7 @@ fi
 # Save the current directory
 CURRENT_DIR=$(pwd)
 cd $SCRIPT_DIR > /dev/null
-PYTHONPATH=regtests/client/python ${SCRIPT_DIR}/polaris-venv/bin/python3 
regtests/client/python/cli/polaris_cli.py "$@"
+PYTHONPATH=regtests/client/python SCRIPT_DIR="$SCRIPT_DIR" 
${SCRIPT_DIR}/polaris-venv/bin/python3 
regtests/client/python/cli/polaris_cli.py "$@"
 status=$?
 cd $CURRENT_DIR > /dev/null
 
diff --git a/site/content/in-dev/unreleased/command-line-interface.md 
b/site/content/in-dev/unreleased/command-line-interface.md
index b36e5ac4..c9a6045b 100644
--- a/site/content/in-dev/unreleased/command-line-interface.md
+++ b/site/content/in-dev/unreleased/command-line-interface.md
@@ -32,8 +32,11 @@ polaris [options] COMMAND ...
 options:
 --host
 --port
+--base-url
 --client-id
 --client-secret
+--access-token
+--profile
 ```
 
 `COMMAND` must be one of the following:
@@ -43,6 +46,7 @@ options:
 4. catalog-roles
 5. namespaces
 6. privileges
+7. profiles
 
 Each _command_ supports several _subcommands_, and some _subcommands_ have 
_actions_ that come after the subcommand in turn. Finally, _arguments_ follow 
to form a full invocation. Within a set of named arguments at the end of an 
invocation ordering is generally not important. Many invocations also have a 
required positional argument of the type that the _command_ refers to. Again, 
the ordering of this positional argument relative to named arguments is not 
important. 
 
@@ -54,6 +58,7 @@ polaris catalogs delete some_catalog_name
 polaris catalogs update --property foo=bar some_other_catalog
 polaris catalogs update another_catalog --property k=v
 polaris privileges namespace grant --namespace some.schema --catalog 
fourth_catalog --catalog-role some_catalog_role TABLE_READ_DATA
+polaris profiles list
 ```
 
 ### Authentication
@@ -66,8 +71,14 @@ polaris --client-id 4b5ed1ca908c3cc2 --client-secret 
07ea8e4edefb9a9e57c247e8d1a
 
 If `--client-id` and `--client-secret` are not provided, the Polaris CLI will 
try to read the client ID and client secret from environment variables called 
`CLIENT_ID` and `CLIENT_SECRET` respectively. If these flags are not provided 
and the environment variables are not set, the CLI will fail.
 
+Alternatively, the `--access-token` option can be used instead of 
`--client-id` and `--client-secret`, but both authentication methods cannot be 
used simultaneously.
+
+Additionally, the `--profile` option can be used to specify a saved profile 
instead of providing authentication details directly. If `--profile` is not 
provided, the CLI will check the `CLIENT_PROFILE` environment variable. 
Profiles store authentication details and connection settings, simplifying 
repeated CLI usage.
+
 If the `--host` and `--port` options are not provided, the CLI will default to 
communicating with `localhost:8181`.
 
+Alternatively, the `--base-url` option can be used instead of `--host` and 
`--port`, but both options cannot be used simultaneously. This allows 
specifying arbitrary Polaris URLs, including HTTPS ones, that have additional 
base prefixes before the `/api/*/v1` subpaths.
+
 ### PATH
 
 These examples assume the Polaris CLI is on the PATH and so can be invoked 
just by the command `polaris`. You can add the CLI to your PATH environment 
variable with a command like the following:
@@ -86,11 +97,14 @@ Alternatively, you can run the CLI by providing a path to 
it, such as with the f
 
 Each of the commands `catalogs`, `principals`, `principal-roles`, 
`catalog-roles`, and `privileges` is used to manage a different type of entity 
within Polaris.
 
+In addition to these, the `profiles` command is available for managing stored 
authentication profiles, allowing login credentials to be configured for reuse. 
This provides an alternative to passing authentication details with every 
command.
+
 To find details on the options that can be provided to a particular command or 
subcommand ad-hoc, you may wish to use the `--help` flag. For example:
 
 ```
 polaris catalogs --help
 polaris principals create --help
+polaris profiles --help
 ```
 
 ### catalogs
@@ -1025,6 +1039,106 @@ polaris privileges \
   VIEW_FULL_METADATA
 ```
 
+### profiles
+
+The `profiles` command is used to manage stored authentication profiles in 
Polaris. Profiles allow authentication credentials to be saved and reused, 
eliminating the need to pass credentials with every command.
+
+`profiles` supports the following subcommands:
+
+1. create
+2. delete
+3. get
+4. list
+5. update
+
+#### create
+
+The `create` subcommand is used to create a new authentication profile.
+
+```
+input: polaris profiles create --help
+options:
+  create
+    Positional arguments:
+      profile
+```
+
+##### Examples
+
+```
+polaris profiles create dev
+```
+
+#### delete
+
+The `delete` subcommand removes a stored profile.
+
+```
+input: polaris profiles delete --help
+options:
+  delete
+    Positional arguments:
+      profile
+```
+
+##### Examples
+
+```
+polaris profiles delete dev
+```
+
+#### get
+
+The `get` subcommand removes a stored profile.
+
+```
+input: polaris profiles get --help
+options:
+  get
+    Positional arguments:
+      profile
+```
+
+##### Examples
+
+```
+polaris profiles get dev
+```
+
+#### list
+
+The `list` subcommand displays all stored profiles.
+
+```
+input: polaris profiles list --help
+options:
+  list
+```
+
+##### Examples
+
+```
+polaris profiles list
+```
+
+#### update
+
+The `update` subcommand modifies an existing profile.
+
+```
+input: polaris profiles update --help
+options:
+  update
+    Positional arguments:
+      profile
+```
+
+##### Examples
+
+```
+polaris profiles update dev
+```
+
 ## Examples
 
 This section outlines example code for a few common operations as well as for 
some more complex ones. 

Reply via email to