Skip to content
Merged
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
32 changes: 25 additions & 7 deletions phabfive/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
76 changes: 3 additions & 73 deletions phabfive/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)


Expand Down Expand Up @@ -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]")
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ classifiers = [
dependencies = [
"anyconfig>=0.10.0",
"appdirs",
"InquirerPy>=0.3.4",
"jinja2",
"mkdocs>=1.6.1",
"phabricator",
Expand Down
80 changes: 76 additions & 4 deletions tests/test_arcrc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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({})

Expand All @@ -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"
Expand Down Expand Up @@ -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": {
Expand All @@ -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({})

Expand Down
24 changes: 24 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.