From 6751179ce196082c797cbd25d5d1a816929e6fad Mon Sep 17 00:00:00 2001 From: Henrik Holmboe Date: Sun, 8 Mar 2026 22:23:06 +0100 Subject: [PATCH] feat: Add interactive server selector for multi-host .arcrc When ~/.arcrc has multiple hosts and no PHAB_URL is configured, show an interactive arrow-key selector (via InquirerPy) when running in a TTY, with a tip on how to skip the prompt. Non-TTY keeps the existing error. Also replaces the custom 70-line _read_password_with_dots() in setup.py with InquirerPy's inquirer.secret(). Closes #175 Co-Authored-By: Claude Opus 4.6 --- phabfive/core.py | 32 ++++++++++++++---- phabfive/setup.py | 76 ++---------------------------------------- pyproject.toml | 1 + tests/test_arcrc.py | 80 ++++++++++++++++++++++++++++++++++++++++++--- uv.lock | 24 ++++++++++++++ 5 files changed, 129 insertions(+), 84 deletions(-) diff --git a/phabfive/core.py b/phabfive/core.py index 26b8745..236cf6f 100644 --- a/phabfive/core.py +++ b/phabfive/core.py @@ -428,13 +428,31 @@ def _load_arcrc(self, current_conf): ) if not result: - # No default or default didn't match - error with list - host_list = "\n - ".join(hosts.keys()) - raise PhabfiveConfigException( - f"Multiple hosts found in ~/.arcrc but PHAB_URL is not configured. " - f"Please set PHAB_URL to specify which host to use:\n - {host_list}\n\n" - f"Example: export PHAB_URL=https://phorge.example.com/api/" - ) + # No default or default didn't match + if sys.stdin.isatty(): + from InquirerPy import inquirer as inq + + host_urls = list(hosts.keys()) + selected = inq.select( + message="Multiple hosts found in ~/.arcrc. Select server:", + choices=host_urls, + ).execute() + result["PHAB_URL"] = selected + token = hosts[selected].get("token") + if token: + result["PHAB_TOKEN"] = token + print( + f"Tip: to skip this prompt, set PHAB_URL or add " + f'"config":{{"default":"{selected}"}} to ~/.arcrc', + file=sys.stderr, + ) + else: + host_list = "\n - ".join(hosts.keys()) + raise PhabfiveConfigException( + f"Multiple hosts found in ~/.arcrc but PHAB_URL is not configured. " + f"Please set PHAB_URL to specify which host to use:\n - {host_list}\n\n" + f"Example: export PHAB_URL=https://phorge.example.com/api/" + ) return result diff --git a/phabfive/setup.py b/phabfive/setup.py index abcb78f..77cb6f5 100644 --- a/phabfive/setup.py +++ b/phabfive/setup.py @@ -14,78 +14,6 @@ from phabfive.constants import VALIDATORS -def _read_password_with_dots(prompt: str = "") -> str: - """Read password input showing dots for each character typed. - - Args: - prompt: The prompt to display before input - - Returns: - The password string entered by the user - """ - if prompt: - sys.stdout.write(prompt) - sys.stdout.flush() - - password = [] - - if os.name == "nt": - # Windows implementation using msvcrt - import msvcrt - - while True: - char = msvcrt.getwch() - if char in ("\r", "\n"): - sys.stdout.write("\n") - sys.stdout.flush() - break - elif char == "\x03": # Ctrl+C - raise KeyboardInterrupt - elif char == "\x08": # Backspace - if password: - password.pop() - # Erase the dot: move back, write space, move back - sys.stdout.write("\b \b") - sys.stdout.flush() - else: - password.append(char) - sys.stdout.write("\u2022") # bullet dot - sys.stdout.flush() - else: - # Unix implementation using termios - import termios - import tty - - fd = sys.stdin.fileno() - old_settings = termios.tcgetattr(fd) - try: - tty.setraw(fd) - while True: - char = sys.stdin.read(1) - if char in ("\r", "\n"): - sys.stdout.write("\n") - sys.stdout.flush() - break - elif char == "\x03": # Ctrl+C - sys.stdout.write("\n") - sys.stdout.flush() - raise KeyboardInterrupt - elif char == "\x7f" or char == "\x08": # Backspace/Delete - if password: - password.pop() - # Erase the dot: move back, write space, move back - sys.stdout.write("\b \b") - sys.stdout.flush() - elif char >= " ": # Printable characters only - password.append(char) - sys.stdout.write("\u2022") # bullet dot - sys.stdout.flush() - finally: - termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) - - return "".join(password) - - log = logging.getLogger(__name__) @@ -218,8 +146,10 @@ def _prompt_token(self) -> bool: self.console.print(' 2. Click "Generate API Token"') self.console.print(" 3. Copy the token (starts with 'cli-')\n") + from InquirerPy import inquirer as inq + while True: - token = _read_password_with_dots("Enter your API token: ") + token = inq.secret(message="Enter your API token:").execute() if not token: self.console.print("[red]Token cannot be empty[/red]") diff --git a/pyproject.toml b/pyproject.toml index 025530b..74864d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ classifiers = [ dependencies = [ "anyconfig>=0.10.0", "appdirs", + "InquirerPy>=0.3.4", "jinja2", "mkdocs>=1.6.1", "phabricator", diff --git a/tests/test_arcrc.py b/tests/test_arcrc.py index d3c66df..6dce612 100644 --- a/tests/test_arcrc.py +++ b/tests/test_arcrc.py @@ -267,7 +267,7 @@ def setup_method(self): self.phabfive = object.__new__(Phabfive) def test_multiple_hosts_without_phab_url_raises_error(self, tmp_path): - """Test that multiple hosts without PHAB_URL set raises error.""" + """Test that multiple hosts without PHAB_URL set raises error (non-TTY).""" arcrc_path = tmp_path / ".arcrc" arcrc_data = { "hosts": { @@ -282,7 +282,11 @@ def test_multiple_hosts_without_phab_url_raises_error(self, tmp_path): arcrc_path.write_text(json.dumps(arcrc_data)) os.chmod(arcrc_path, 0o600) - with mock.patch.object(os.path, "expanduser", return_value=str(arcrc_path)): + with ( + mock.patch.object(os.path, "expanduser", return_value=str(arcrc_path)), + mock.patch("sys.stdin") as mock_stdin, + ): + mock_stdin.isatty.return_value = False with pytest.raises(PhabfiveConfigException) as exc_info: self.phabfive._load_arcrc({}) @@ -291,6 +295,70 @@ def test_multiple_hosts_without_phab_url_raises_error(self, tmp_path): assert "phorge-a.example.com" in error_msg assert "phorge-b.example.com" in error_msg + def test_multiple_hosts_interactive_selector(self, tmp_path): + """Test that interactive selector picks the selected host.""" + arcrc_path = tmp_path / ".arcrc" + arcrc_data = { + "hosts": { + "https://phorge-a.example.com/api/": { + "token": "cli-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "https://phorge-b.example.com/api/": { + "token": "cli-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + } + } + arcrc_path.write_text(json.dumps(arcrc_data)) + os.chmod(arcrc_path, 0o600) + + mock_prompt = mock.MagicMock() + mock_prompt.execute.return_value = "https://phorge-b.example.com/api/" + + with ( + mock.patch.object(os.path, "expanduser", return_value=str(arcrc_path)), + mock.patch("sys.stdin") as mock_stdin, + mock.patch( + "InquirerPy.inquirer.select", return_value=mock_prompt + ) as mock_select, + ): + mock_stdin.isatty.return_value = True + result = self.phabfive._load_arcrc({}) + + assert result["PHAB_URL"] == "https://phorge-b.example.com/api/" + assert result["PHAB_TOKEN"] == "cli-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + mock_select.assert_called_once() + + def test_multiple_hosts_interactive_prints_tip(self, tmp_path, capsys): + """Test that interactive selector prints tip to stderr.""" + arcrc_path = tmp_path / ".arcrc" + arcrc_data = { + "hosts": { + "https://phorge-a.example.com/api/": { + "token": "cli-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "https://phorge-b.example.com/api/": { + "token": "cli-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + } + } + arcrc_path.write_text(json.dumps(arcrc_data)) + os.chmod(arcrc_path, 0o600) + + mock_prompt = mock.MagicMock() + mock_prompt.execute.return_value = "https://phorge-a.example.com/api/" + + with ( + mock.patch.object(os.path, "expanduser", return_value=str(arcrc_path)), + mock.patch("sys.stdin") as mock_stdin, + mock.patch("InquirerPy.inquirer.select", return_value=mock_prompt), + ): + mock_stdin.isatty.return_value = True + self.phabfive._load_arcrc({}) + + captured = capsys.readouterr() + assert "Tip:" in captured.err + assert "PHAB_URL" in captured.err + def test_multiple_hosts_with_matching_phab_url(self, tmp_path): """Test that multiple hosts with matching PHAB_URL returns token.""" arcrc_path = tmp_path / ".arcrc" @@ -390,7 +458,7 @@ def test_multiple_hosts_with_default_and_phab_url_uses_phab_url(self, tmp_path): assert result["PHAB_TOKEN"] == "cli-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" def test_multiple_hosts_with_invalid_default_raises_error(self, tmp_path): - """Test that invalid default (not matching any host) raises error.""" + """Test that invalid default (not matching any host) raises error (non-TTY).""" arcrc_path = tmp_path / ".arcrc" arcrc_data = { "hosts": { @@ -406,7 +474,11 @@ def test_multiple_hosts_with_invalid_default_raises_error(self, tmp_path): arcrc_path.write_text(json.dumps(arcrc_data)) os.chmod(arcrc_path, 0o600) - with mock.patch.object(os.path, "expanduser", return_value=str(arcrc_path)): + with ( + mock.patch.object(os.path, "expanduser", return_value=str(arcrc_path)), + mock.patch("sys.stdin") as mock_stdin, + ): + mock_stdin.isatty.return_value = False with pytest.raises(PhabfiveConfigException) as exc_info: self.phabfive._load_arcrc({}) diff --git a/uv.lock b/uv.lock index 46e6f4c..d95a26c 100644 --- a/uv.lock +++ b/uv.lock @@ -335,6 +335,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "inquirerpy" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pfzy" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/73/7570847b9da026e07053da3bbe2ac7ea6cde6bb2cbd3c7a5a950fa0ae40b/InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e", size = 44431, upload-time = "2022-06-27T23:11:20.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/ff/3b59672c47c6284e8005b42e84ceba13864aa0f39f067c973d1af02f5d91/InquirerPy-0.3.4-py3-none-any.whl", hash = "sha256:c65fdfbac1fa00e3ee4fb10679f4d3ed7a012abf4833910e63c295827fe2a7d4", size = 67677, upload-time = "2022-06-27T23:11:17.723Z" }, +] + [[package]] name = "jedi" version = "0.19.2" @@ -548,6 +561,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] +[[package]] +name = "pfzy" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/5a/32b50c077c86bfccc7bed4881c5a2b823518f5450a30e639db5d3711952e/pfzy-0.3.4.tar.gz", hash = "sha256:717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1", size = 8396, upload-time = "2022-01-28T02:26:17.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/d7/8ff98376b1acc4503253b685ea09981697385ce344d4e3935c2af49e044d/pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96", size = 8537, upload-time = "2022-01-28T02:26:16.047Z" }, +] + [[package]] name = "phabfive" version = "0.8.0.dev0" @@ -555,6 +577,7 @@ source = { editable = "." } dependencies = [ { name = "anyconfig" }, { name = "appdirs" }, + { name = "inquirerpy" }, { name = "jinja2" }, { name = "mkdocs" }, { name = "phabricator" }, @@ -593,6 +616,7 @@ requires-dist = [ { name = "anyconfig", specifier = ">=0.10.0" }, { name = "appdirs" }, { name = "coverage", marker = "extra == 'test'" }, + { name = "inquirerpy", specifier = ">=0.3.4" }, { name = "jinja2" }, { name = "mkdocs", specifier = ">=1.6.1" }, { name = "mkdocs", marker = "extra == 'docs'" },