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.