Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migration of zuliprc location to a config directory #1503

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 11 additions & 15 deletions tests/cli/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ def test_main_help(capsys: CaptureFixture[str], options: str) -> None:
"--theme THEME, -t THEME",
"-h, --help",
"-d, --debug",
"-n, --new-account",
"--list-accounts",
"--list-themes",
"--profile",
"--config-file CONFIG_FILE, -c CONFIG_FILE",
Expand Down Expand Up @@ -378,32 +380,26 @@ def unreadable_dir(tmp_path: Path) -> Generator[Tuple[Path, Path], None, None]:
unreadable_dir.chmod(0o755)


@pytest.mark.parametrize(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed parametrize as there's only one test case remaining, moved the values inside the test function.
The test function could be renamed to be more specific too.

"path_to_use, expected_exception",
[
("unreadable", "PermissionError"),
("goodnewhome", "FileNotFoundError"),
],
ids=["valid_path_but_cannot_be_written_to", "path_does_not_exist"],
)
def test_main_cannot_write_zuliprc_given_good_credentials(
monkeypatch: pytest.MonkeyPatch,
capsys: CaptureFixture[str],
mocker: MockerFixture,
unreadable_dir: Tuple[Path, Path],
path_to_use: str,
expected_exception: str,
path_to_use: str = "unreadable",
expected_exception: str = "PermissionError",
) -> None:
tmp_path, unusable_path = unreadable_dir

# This is default base path to use
zuliprc_path = os.path.join(str(tmp_path), path_to_use)
monkeypatch.setenv("HOME", zuliprc_path)
zuliprc_path = os.path.join(str(tmp_path), path_to_use, "zuliprc")
mocker.patch(MODULE + ".CONFIG_PATH", zuliprc_path)
mocker.patch(MODULE + ".HOME_PATH_ZULIPRC", os.path.join(str(tmp_path), "home"))
account_alias = "my_account_alias"

# Give some arbitrary input and fake that it's always valid
mocker.patch.object(builtins, "input", lambda _: "text\n")
mocker.patch(
MODULE + ".get_api_key", return_value=("my_site", "my_login", "my_api_key")
MODULE + ".get_api_key",
return_value=("my_site", "my_login", "my_api_key", account_alias),
)

with pytest.raises(SystemExit):
Expand All @@ -415,7 +411,7 @@ def test_main_cannot_write_zuliprc_given_good_credentials(
expected_line = (
"\x1b[91m"
f"{expected_exception}: zuliprc could not be created "
f"at {os.path.join(zuliprc_path, 'zuliprc')}"
f"at {os.path.join(zuliprc_path, account_alias, 'zuliprc')}"
"\x1b[0m"
)
assert lines[-1] == expected_line
Expand Down
136 changes: 122 additions & 14 deletions zulipterminal/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import traceback
from enum import Enum
from os import path, remove
from pathlib import Path
from typing import Dict, List, NamedTuple, Optional, Tuple

import requests
Expand All @@ -26,10 +27,18 @@
)
from zulipterminal.core import Controller
from zulipterminal.model import ServerConnectionFailure
from zulipterminal.platform_code import detected_platform, detected_python_in_full
from zulipterminal.platform_code import (
detected_platform,
detected_python_in_full,
xdg_config_home,
)
from zulipterminal.version import ZT_VERSION


HOME_PATH_ZULIPRC = str(Path.home() / "zuliprc")
CONFIG_PATH = str(xdg_config_home() / "zulip-terminal")


class ConfigSource(Enum):
DEFAULT = "from default config"
ZULIPRC = "in zuliprc file"
Expand Down Expand Up @@ -124,6 +133,25 @@ def parse_args(argv: List[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=description, formatter_class=formatter_class
)
parser.add_argument(
"account-alias",
nargs="?",
action="store",
default="",
help="specify the chosen alias of your zulip account "
"to fetch its configuration",
)
parser.add_argument(
"-n",
"--new-account",
action="store_true",
help="login to a new account",
)
parser.add_argument(
"--list-accounts",
action="store_true",
help="list the aliases of all your zulip accounts, and exit",
)
parser.add_argument(
"-v",
"--version",
Expand All @@ -136,7 +164,7 @@ def parse_args(argv: List[str]) -> argparse.Namespace:
"-c",
action="store",
help="config file downloaded from your zulip "
"organization (default: ~/zuliprc)",
f"organization (default: {CONFIG_PATH}/account_alias/zuliprc)",
)
parser.add_argument(
"--theme",
Expand Down Expand Up @@ -257,7 +285,7 @@ def get_server_settings(realm_url: str) -> ServerSettings:
return response.json()


def get_api_key(realm_url: str) -> Optional[Tuple[str, str, str]]:
def get_api_key(realm_url: str) -> Optional[Tuple[str, str, str, str]]:
from getpass import getpass

try:
Expand All @@ -272,6 +300,12 @@ def get_api_key(realm_url: str) -> Optional[Tuple[str, str, str]]:
login_id_label = get_login_label(server_properties)
login_id = styled_input(login_id_label)
password = getpass(in_color("blue", "Password: "))
account_alias = styled_input(
"Please choose a simple and unique name for this account,"
" which represents your user profile and its server."
" Use only letters, numbers, and underscores.\n"
"Account alias: "
)

response = requests.post(
url=f"{preferred_realm_url}/api/v1/fetch_api_key",
Expand All @@ -281,13 +315,20 @@ def get_api_key(realm_url: str) -> Optional[Tuple[str, str, str]]:
},
)
if response.status_code == requests.codes.OK:
return preferred_realm_url, login_id, str(response.json()["api_key"])
return (
preferred_realm_url,
login_id,
str(response.json()["api_key"]),
account_alias,
)
return None


def fetch_zuliprc(zuliprc_path: str) -> None:
def login_and_save() -> str:
"""
Prompts the user for their login credentials and saves them to a zuliprc file.
"""
print(
f"{in_color('red', f'zuliprc file was not found at {zuliprc_path}')}"
f"\nPlease enter your credentials to login into your Zulip organization."
f"\n"
f"\nNOTE: The {in_color('blue', 'Zulip server URL')}"
Expand All @@ -311,7 +352,8 @@ def fetch_zuliprc(zuliprc_path: str) -> None:
print(in_color("red", "\nIncorrect Email(or Username) or Password!\n"))
login_data = get_api_key(realm_url)

preferred_realm_url, login_id, api_key = login_data
preferred_realm_url, login_id, api_key, account_alias = login_data
zuliprc_path = os.path.join(CONFIG_PATH, account_alias, "zuliprc")
save_zuliprc_failure = _write_zuliprc(
zuliprc_path,
login_id=login_id,
Expand All @@ -322,6 +364,7 @@ def fetch_zuliprc(zuliprc_path: str) -> None:
print(f"Generated API key saved at {zuliprc_path}")
else:
exit_with_error(save_zuliprc_failure)
return zuliprc_path


def _write_zuliprc(
Expand All @@ -332,6 +375,7 @@ def _write_zuliprc(
Only creates new private files; errors if file already exists
"""
try:
Path(to_path).parent.mkdir(parents=True, exist_ok=True, mode=0o700)
with open(
os.open(to_path, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o600), "w"
) as f:
Expand All @@ -343,21 +387,61 @@ def _write_zuliprc(
return f"{ex.__class__.__name__}: zuliprc could not be created at {to_path}"


def parse_zuliprc(zuliprc_str: str) -> Dict[str, SettingData]:
zuliprc_path = path.expanduser(zuliprc_str)
while not path.exists(zuliprc_path):
def resolve_to_valid_path(zuliprc_str: Optional[str], is_new_account: bool) -> str:
"""
Returns the path to a valid zuliprc file.
If a path is not provided by the user, searches the default locations.
If none are found or the path provided is invalid, prompts the user to login
and returns the path to the created zuliprc file.
"""
zuliprc_path = (
check_for_default_zuliprc()
if zuliprc_str is None
else path.expanduser(zuliprc_str)
)
while zuliprc_path is None or not path.exists(zuliprc_path):
if not is_new_account:
print(
f"{in_color('red', 'zuliprc file was not found')}"
f"{in_color('red', f' at {zuliprc_path}')}"
if zuliprc_path
else "."
)
try:
fetch_zuliprc(zuliprc_path)
zuliprc_path = login_and_save()
# Invalid user inputs (e.g. pressing arrow keys) may cause ValueError
except (OSError, ValueError):
# Remove zuliprc file if created.
if path.exists(zuliprc_path):
if zuliprc_path is not None and path.exists(zuliprc_path):
remove(zuliprc_path)
print(in_color("red", "\nInvalid Credentials, Please try again!\n"))
except EOFError:
# Assume that the user pressed Ctrl+D and continue the loop
print("\n")
return zuliprc_path


def check_for_default_zuliprc() -> Optional[str]:
zuliprc_count_in_config = (
sum(1 for _ in Path(CONFIG_PATH).glob("*/zuliprc"))
if path.exists(CONFIG_PATH)
else 0
)
home_path_count = path.exists(HOME_PATH_ZULIPRC)
total_count = zuliprc_count_in_config + home_path_count

if total_count == 1:
return HOME_PATH_ZULIPRC if home_path_count else CONFIG_PATH
if total_count > 1:
exit_with_error(
"Found multiple zuliprc configuration files. Please retry by specifying"
" the path to your target zuliprc file by running\n"
"`zulip-term --config-file path/to/your/zuliprc`"
)
return None


def parse_zuliprc(zuliprc_path: str) -> Dict[str, SettingData]:
mode = os.stat(zuliprc_path).st_mode
is_readable_by_group_or_others = mode & (stat.S_IRWXG | stat.S_IRWXO)

Expand Down Expand Up @@ -413,6 +497,17 @@ def list_themes() -> str:
)


def list_accounts() -> str:
if not path.exists(CONFIG_PATH):
exit_with_error(f"Config folder not found at {CONFIG_PATH}.")
valid_accounts = [file.parent.name for file in Path(CONFIG_PATH).glob("*/zuliprc")]
if len(valid_accounts) == 0:
exit_with_error("No accounts found.")
return "Configurations for the following accounts are available:\n " "\n ".join(
valid_accounts
)


def main(options: Optional[List[str]] = None) -> None:
"""
Launch Zulip Terminal.
Expand Down Expand Up @@ -451,10 +546,23 @@ def main(options: Optional[List[str]] = None) -> None:
print(list_themes())
sys.exit(0)

if args.list_accounts:
print(list_accounts())
sys.exit(0)

zuliprc_path = None
account_alias = getattr(args, "account-alias")
if account_alias:
if args.config_file:
exit_with_error("Cannot use account-alias and --config-file together")
zuliprc_path = os.path.join(CONFIG_PATH, account_alias, "zuliprc")
if not path.exists(zuliprc_path):
exit_with_error(
f"Account alias {account_alias} not found\n" f"{list_accounts()}"
)
if args.config_file:
zuliprc_path = args.config_file
else:
zuliprc_path = "~/zuliprc"
zuliprc_path = resolve_to_valid_path(zuliprc_path, args.new_account)

print(
"Detected:"
Expand Down
10 changes: 10 additions & 0 deletions zulipterminal/platform_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
Detection of supported platforms & platform-specific functions
"""

import os
import platform
import subprocess
from pathlib import Path
from typing import Tuple

from typing_extensions import Literal
Expand Down Expand Up @@ -89,6 +91,14 @@ def notify(title: str, text: str) -> str:
return ""


def xdg_config_home() -> Path:
"""Return a Path corresponding to XDG_CONFIG_HOME."""
config_home = os.environ.get("XDG_CONFIG_HOME")
if config_home and Path(config_home).is_absolute():
return Path(config_home)
return Path.home() / ".config"


def successful_GUI_return_code() -> int: # noqa: N802 (allow upper case)
"""
Returns success return code for GUI commands, which are OS specific.
Expand Down
Loading