From 3c16646dfc4c304fa4eff41d513a56b9e64902cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:51:33 +0000 Subject: [PATCH 1/5] Initial plan From bab9680ab21dd281ec887f1adaf166a80c2116d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:59:25 +0000 Subject: [PATCH 2/5] Add --plain option for parseable CLI output Co-authored-by: sokripon <79755465+sokripon@users.noreply.github.com> --- src/tacomail/cli.py | 249 ++++++++++++++++++++++------------ tests/test_cli.py | 317 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 479 insertions(+), 87 deletions(-) create mode 100644 tests/test_cli.py diff --git a/src/tacomail/cli.py b/src/tacomail/cli.py index ac5349d..4bcd3e8 100644 --- a/src/tacomail/cli.py +++ b/src/tacomail/cli.py @@ -24,8 +24,9 @@ console = Console() -# Global option for async mode +# Global options use_async = False +use_plain = False def get_client(): @@ -61,11 +62,14 @@ def create( else: email_address = client.get_random_address() - console.print(Panel( - f"[bold green]Generated Email:[/bold green]\n{email_address}", - title="✨ Success", - border_style="green" - )) + if use_plain: + print(email_address) + else: + console.print(Panel( + f"[bold green]Generated Email:[/bold green]\n{email_address}", + title="✨ Success", + border_style="green" + )) except Exception as e: console.print(f"[red]Error creating email:[/red] {e}") raise typer.Exit(1) @@ -82,9 +86,13 @@ def list_domains() -> None: try: domains = client.get_domains() - console.print(f"\n[bold]Available Domains:[/bold] ({len(domains)})") - for domain in domains: - console.print(f" • {domain}") + if use_plain: + for domain in domains: + print(domain) + else: + console.print(f"\n[bold]Available Domains:[/bold] ({len(domains)})") + for domain in domains: + console.print(f" • {domain}") except Exception as e: console.print(f"[red]Error fetching domains:[/red] {e}") raise typer.Exit(1) @@ -183,20 +191,26 @@ async def async_operation(): expires_str = expires_dt.strftime("%Y-%m-%d %H:%M:%S") # Display results - console.print(Panel( - f"[bold green]Email Address:[/bold green]\n{email_address}\n\n" - f"[bold green]Session Created[/bold green]\n\n" - f"[bold]Expires:[/bold] {expires_str}\n" - f"[bold]Username:[/bold] {session.username}\n" - f"[bold]Domain:[/bold] {session.domain}\n\n" - f"[dim]You can now receive emails at this address![/dim]", - title="✨ Email & Session Ready", - border_style="green" - )) - - console.print("\n[bold cyan]Next steps:[/bold cyan]") - console.print(" • Monitor inbox: [green]tacomail list {}[/green]".format(email_address)) - console.print(" • Wait for email: [green]tacomail wait {}[/green]".format(email_address)) + if use_plain: + print(f"email={email_address}") + print(f"username={session.username}") + print(f"domain={session.domain}") + print(f"expires={expires_str}") + else: + console.print(Panel( + f"[bold green]Email Address:[/bold green]\n{email_address}\n\n" + f"[bold green]Session Created[/bold green]\n\n" + f"[bold]Expires:[/bold] {expires_str}\n" + f"[bold]Username:[/bold] {session.username}\n" + f"[bold]Domain:[/bold] {session.domain}\n\n" + f"[dim]You can now receive emails at this address![/dim]", + title="✨ Email & Session Ready", + border_style="green" + )) + + console.print("\n[bold cyan]Next steps:[/bold cyan]") + console.print(" • Monitor inbox: [green]tacomail list {}[/green]".format(email_address)) + console.print(" • Wait for email: [green]tacomail wait {}[/green]".format(email_address)) asyncio.run(async_operation()) else: @@ -224,20 +238,26 @@ async def async_operation(): expires_str = expires_dt.strftime("%Y-%m-%d %H:%M:%S") # Display results - console.print(Panel( - f"[bold green]Email Address:[/bold green]\n{email_address}\n\n" - f"[bold green]Session Created[/bold green]\n\n" - f"[bold]Expires:[/bold] {expires_str}\n" - f"[bold]Username:[/bold] {session.username}\n" - f"[bold]Domain:[/bold] {session.domain}\n\n" - f"[dim]You can now receive emails at this address![/dim]", - title="✨ Email & Session Ready", - border_style="green" - )) + if use_plain: + print(f"email={email_address}") + print(f"username={session.username}") + print(f"domain={session.domain}") + print(f"expires={expires_str}") + else: + console.print(Panel( + f"[bold green]Email Address:[/bold green]\n{email_address}\n\n" + f"[bold green]Session Created[/bold green]\n\n" + f"[bold]Expires:[/bold] {expires_str}\n" + f"[bold]Username:[/bold] {session.username}\n" + f"[bold]Domain:[/bold] {session.domain}\n\n" + f"[dim]You can now receive emails at this address![/dim]", + title="✨ Email & Session Ready", + border_style="green" + )) - console.print("\n[bold cyan]Next steps:[/bold cyan]") - console.print(" • Monitor inbox: [green]tacomail list {}[/green]".format(email_address)) - console.print(" • Wait for email: [green]tacomail wait {}[/green]".format(email_address)) + console.print("\n[bold cyan]Next steps:[/bold cyan]") + console.print(" • Monitor inbox: [green]tacomail list {}[/green]".format(email_address)) + console.print(" • Wait for email: [green]tacomail wait {}[/green]".format(email_address)) except Exception as e: console.print(f"[red]Error creating email and session:[/red] {e}") @@ -268,15 +288,21 @@ def create_session( expires_dt = datetime.fromtimestamp(session.expires / 1000) expires_str = expires_dt.strftime("%Y-%m-%d %H:%M:%S") - console.print(Panel( - f"[bold green]Session Created[/bold green]\n\n" - f"Email: {email}\n" - f"Expires: {expires_str}\n" - f"Username: {session.username}\n" - f"Domain: {session.domain}", - title="🔐 Session", - border_style="green" - )) + if use_plain: + print(f"email={email}") + print(f"username={session.username}") + print(f"domain={session.domain}") + print(f"expires={expires_str}") + else: + console.print(Panel( + f"[bold green]Session Created[/bold green]\n\n" + f"Email: {email}\n" + f"Expires: {expires_str}\n" + f"Username: {session.username}\n" + f"Domain: {session.domain}", + title="🔐 Session", + border_style="green" + )) except Exception as e: console.print(f"[red]Error creating session:[/red] {e}") raise typer.Exit(1) @@ -302,7 +328,10 @@ def delete_session( client.delete_session(username, domain) - console.print(f"[green]✓ Session deleted for {email}[/green]") + if use_plain: + print(f"deleted={email}") + else: + console.print(f"[green]✓ Session deleted for {email}[/green]") except Exception as e: console.print(f"[red]Error deleting session:[/red] {e}") raise typer.Exit(1) @@ -327,24 +356,32 @@ def list_inbox( emails = client.get_inbox(email, limit=limit) if not emails: + if use_plain: + return console.print(f"[yellow]No emails found for {email}[/yellow]") return - table = Table(title=f"Inbox for {email}") - table.add_column("ID", style="cyan", no_wrap=True) - table.add_column("From", style="green") - table.add_column("Subject", style="white") - table.add_column("Date", style="blue") + if use_plain: + # Tab-separated output: id, from_address, subject, date + for email_obj in emails: + date_str = email_obj.date.strftime("%Y-%m-%d %H:%M") + print(f"{email_obj.id}\t{email_obj.from_.address}\t{email_obj.subject}\t{date_str}") + else: + table = Table(title=f"Inbox for {email}") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("From", style="green") + table.add_column("Subject", style="white") + table.add_column("Date", style="blue") - for email_obj in emails: - from_addr = f"{email_obj.from_.name} <{email_obj.from_.address}>" - subject = email_obj.subject[:50] + "..." if len(email_obj.subject) > 50 else email_obj.subject - date_str = email_obj.date.strftime("%Y-%m-%d %H:%M") + for email_obj in emails: + from_addr = f"{email_obj.from_.name} <{email_obj.from_.address}>" + subject = email_obj.subject[:50] + "..." if len(email_obj.subject) > 50 else email_obj.subject + date_str = email_obj.date.strftime("%Y-%m-%d %H:%M") - table.add_row(email_obj.id, from_addr, subject, date_str) + table.add_row(email_obj.id, from_addr, subject, date_str) - console.print(table) - console.print(f"\n[dim]Showing {len(emails)} email(s)[/dim]") + console.print(table) + console.print(f"\n[dim]Showing {len(emails)} email(s)[/dim]") except Exception as e: console.print(f"[red]Error listing inbox:[/red] {e}") raise typer.Exit(1) @@ -369,7 +406,21 @@ def get_email( to_addr = f"{email_obj.to.name} <{email_obj.to.address}>" date_str = email_obj.date.strftime("%Y-%m-%d %H:%M:%S") - content = f""" + if use_plain: + print(f"id={email_obj.id}") + print(f"from={email_obj.from_.address}") + print(f"from_name={email_obj.from_.name}") + print(f"to={email_obj.to.address}") + print(f"to_name={email_obj.to.name}") + print(f"subject={email_obj.subject}") + print(f"date={date_str}") + print(f"attachments={len(email_obj.attachments)}") + if email_obj.attachments: + for att in email_obj.attachments: + print(f"attachment={att.fileName}") + print(f"body={email_obj.body.text or ''}") + else: + content = f""" [bold]From:[/bold] {from_addr} [bold]To:[/bold] {to_addr} [bold]Subject:[/bold] {email_obj.subject} @@ -379,18 +430,18 @@ def get_email( [bold]Attachments:[/bold] {len(email_obj.attachments)} file(s) """ - if email_obj.attachments: - for att in email_obj.attachments: - status = "✓" if att.present else "✗" - content += f" {status} {att.fileName} (ID: {att.id})\n" + if email_obj.attachments: + for att in email_obj.attachments: + status = "✓" if att.present else "✗" + content += f" {status} {att.fileName} (ID: {att.id})\n" - content += "\n[bold]Body:[/bold]\n" - if email_obj.body.text: - content += f"\n{email_obj.body.text}\n" - else: - content += "[dim](No text body)[/dim]\n" + content += "\n[bold]Body:[/bold]\n" + if email_obj.body.text: + content += f"\n{email_obj.body.text}\n" + else: + content += "[dim](No text body)[/dim]\n" - console.print(Panel(content.strip(), title="📧 Email", border_style="blue")) + console.print(Panel(content.strip(), title="📧 Email", border_style="blue")) except Exception as e: console.print(f"[red]Error getting email:[/red] {e}") raise typer.Exit(1) @@ -409,7 +460,10 @@ def delete_email( try: client.delete_email(email, mail_id) - console.print(f"[green]✓ Email {mail_id} deleted[/green]") + if use_plain: + print(f"deleted={mail_id}") + else: + console.print(f"[green]✓ Email {mail_id} deleted[/green]") except Exception as e: console.print(f"[red]Error deleting email:[/red] {e}") raise typer.Exit(1) @@ -429,11 +483,14 @@ def clear_inbox( client = get_client() try: - if not confirm: + if not confirm and not use_plain: typer.confirm(f"Delete all emails from {email}?", abort=True) client.delete_inbox(email) - console.print(f"[green]✓ Inbox cleared for {email}[/green]") + if use_plain: + print(f"cleared={email}") + else: + console.print(f"[green]✓ Inbox cleared for {email}[/green]") except Exception as e: console.print(f"[red]Error clearing inbox:[/red] {e}") raise typer.Exit(1) @@ -453,7 +510,7 @@ def wait( None, "--filter", "-f", help="Filter by subject or sender (regex pattern)" ), print_body: bool = typer.Option( - False, "--print-body", "-p", help="Also print email body" + False, "--print-body", "-b", help="Also print email body" ), ) -> None: """Wait for a new email to arrive. @@ -464,7 +521,8 @@ def wait( client = get_client() try: - console.print(f"[dim]Waiting for email to {email}... (timeout: {timeout}s)[/dim]") + if not use_plain: + console.print(f"[dim]Waiting for email to {email}... (timeout: {timeout}s)[/dim]") if filter_pattern: # Create filter function @@ -491,19 +549,32 @@ def filter_fn(email_obj: Email) -> bool: ) if email_obj: - console.print("\n[green]✓ Email received![/green]") - console.print(f" From: {email_obj.from_.name} <{email_obj.from_.address}>") - console.print(f" Subject: {email_obj.subject}") - - # Print email body if requested - if print_body: - if email_obj.body.text: - console.print("\n[bold]Email Body:[/bold]") - console.print(f"{email_obj.body.text}") - else: - console.print("\n[dim]No text body available[/dim]") + if use_plain: + date_str = email_obj.date.strftime("%Y-%m-%d %H:%M:%S") + print(f"id={email_obj.id}") + print(f"from={email_obj.from_.address}") + print(f"from_name={email_obj.from_.name}") + print(f"subject={email_obj.subject}") + print(f"date={date_str}") + if print_body: + print(f"body={email_obj.body.text or ''}") + else: + console.print("\n[green]✓ Email received![/green]") + console.print(f" From: {email_obj.from_.name} <{email_obj.from_.address}>") + console.print(f" Subject: {email_obj.subject}") + + # Print email body if requested + if print_body: + if email_obj.body.text: + console.print("\n[bold]Email Body:[/bold]") + console.print(f"{email_obj.body.text}") + else: + console.print("\n[dim]No text body available[/dim]") else: - console.print("\n[yellow]⏱ Timeout: No email received[/yellow]") + if use_plain: + print("timeout=true") + else: + console.print("\n[yellow]⏱ Timeout: No email received[/yellow]") raise typer.Exit(1) except Exception as e: console.print(f"[red]Error waiting for email:[/red] {e}") @@ -518,10 +589,14 @@ def main( verbose: bool = typer.Option( False, "--verbose", "-v", help="Enable verbose output" ), + plain: bool = typer.Option( + False, "--plain", "-p", help="Output in plain format for easy parsing" + ), ) -> None: """Tacomail CLI - Disposable email service command-line interface.""" - global use_async + global use_async, use_plain use_async = async_mode + use_plain = plain if verbose: import logging diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..fbe4518 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,317 @@ +"""Tests for the Tacomail CLI plain output functionality.""" + +import pytest +from unittest.mock import patch, MagicMock +from datetime import datetime +from typer.testing import CliRunner + +from tacomail.cli import app +from tacomail import Email, Session + + +runner = CliRunner() + + +@pytest.fixture +def mock_email(): + """Create a mock Email object for testing.""" + email = MagicMock(spec=Email) + email.id = "test-email-id-123" + email.subject = "Test Subject" + email.date = datetime(2024, 1, 15, 10, 30, 0) + email.from_ = MagicMock() + email.from_.name = "Sender Name" + email.from_.address = "sender@example.com" + email.to = MagicMock() + email.to.name = "Recipient Name" + email.to.address = "recipient@example.com" + email.body = MagicMock() + email.body.text = "This is the email body." + email.attachments = [] + return email + + +@pytest.fixture +def mock_session(): + """Create a mock Session object for testing.""" + session = MagicMock(spec=Session) + session.username = "testuser" + session.domain = "tacomail.de" + session.expires = 1705320600000 # Some future timestamp in milliseconds + return session + + +class TestCreatePlainOutput: + """Tests for the 'create' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_create_plain_output(self, mock_get_client): + """Test that create command outputs just the email address in plain mode.""" + mock_client = MagicMock() + mock_client.get_random_address.return_value = "random123@tacomail.de" + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--plain", "create"]) + + assert result.exit_code == 0 + assert result.stdout.strip() == "random123@tacomail.de" + # Should not contain Rich formatting + assert "✨" not in result.stdout + assert "[bold" not in result.stdout + + @patch("tacomail.cli.get_client") + def test_create_with_domain_plain_output(self, mock_get_client): + """Test create command with domain option in plain mode.""" + mock_client = MagicMock() + mock_client.get_random_username.return_value = "randomuser" + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--plain", "create", "-d", "tacomail.de"]) + + assert result.exit_code == 0 + assert result.stdout.strip() == "randomuser@tacomail.de" + + +class TestListDomainsPlainOutput: + """Tests for the 'list-domains' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_list_domains_plain_output(self, mock_get_client): + """Test that list-domains outputs one domain per line in plain mode.""" + mock_client = MagicMock() + mock_client.get_domains.return_value = ["tacomail.de", "example.com", "test.org"] + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--plain", "list-domains"]) + + assert result.exit_code == 0 + lines = result.stdout.strip().split("\n") + assert lines == ["tacomail.de", "example.com", "test.org"] + # Should not contain Rich formatting + assert "•" not in result.stdout + assert "[bold" not in result.stdout + + +class TestCreateSessionPlainOutput: + """Tests for the 'create-session' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_create_session_plain_output(self, mock_get_client, mock_session): + """Test that create-session outputs key=value format in plain mode.""" + mock_client = MagicMock() + mock_client.create_session.return_value = mock_session + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--plain", "create-session", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + lines = result.stdout.strip().split("\n") + assert "email=testuser@tacomail.de" in lines + assert "username=testuser" in lines + assert "domain=tacomail.de" in lines + assert any(line.startswith("expires=") for line in lines) + # Should not contain Rich formatting + assert "🔐" not in result.stdout + + +class TestDeleteSessionPlainOutput: + """Tests for the 'delete-session' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_delete_session_plain_output(self, mock_get_client): + """Test that delete-session outputs deleted=email in plain mode.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--plain", "delete-session", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + assert result.stdout.strip() == "deleted=testuser@tacomail.de" + # Should not contain Rich formatting + assert "✓" not in result.stdout + + +class TestListInboxPlainOutput: + """Tests for the 'list' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_list_inbox_plain_output(self, mock_get_client, mock_email): + """Test that list outputs tab-separated values in plain mode.""" + mock_client = MagicMock() + mock_client.get_inbox.return_value = [mock_email] + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--plain", "list", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + # Output should be tab-separated: id, from_address, subject, date + line = result.stdout.strip() + parts = line.split("\t") + assert len(parts) == 4 + assert parts[0] == "test-email-id-123" + assert parts[1] == "sender@example.com" + assert parts[2] == "Test Subject" + + @patch("tacomail.cli.get_client") + def test_list_inbox_empty_plain_output(self, mock_get_client): + """Test that empty inbox produces no output in plain mode.""" + mock_client = MagicMock() + mock_client.get_inbox.return_value = [] + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--plain", "list", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + assert result.stdout.strip() == "" + + +class TestGetEmailPlainOutput: + """Tests for the 'get' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_get_email_plain_output(self, mock_get_client, mock_email): + """Test that get outputs key=value format in plain mode.""" + mock_client = MagicMock() + mock_client.get_email.return_value = mock_email + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--plain", "get", "testuser@tacomail.de", "email-id"]) + + assert result.exit_code == 0 + lines = result.stdout.strip().split("\n") + assert "id=test-email-id-123" in lines + assert "from=sender@example.com" in lines + assert "from_name=Sender Name" in lines + assert "to=recipient@example.com" in lines + assert "to_name=Recipient Name" in lines + assert "subject=Test Subject" in lines + assert "body=This is the email body." in lines + # Should not contain Rich formatting + assert "📧" not in result.stdout + + +class TestDeleteEmailPlainOutput: + """Tests for the 'delete' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_delete_email_plain_output(self, mock_get_client): + """Test that delete outputs deleted=id in plain mode.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--plain", "delete", "testuser@tacomail.de", "email-id-123"]) + + assert result.exit_code == 0 + assert result.stdout.strip() == "deleted=email-id-123" + + +class TestClearInboxPlainOutput: + """Tests for the 'clear' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_clear_inbox_plain_output(self, mock_get_client): + """Test that clear outputs cleared=email in plain mode (no confirmation needed).""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--plain", "clear", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + assert result.stdout.strip() == "cleared=testuser@tacomail.de" + + +class TestWaitPlainOutput: + """Tests for the 'wait' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_wait_plain_output_success(self, mock_get_client, mock_email): + """Test that wait outputs key=value format in plain mode when email received.""" + mock_client = MagicMock() + mock_client.wait_for_email.return_value = mock_email + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--plain", "wait", "testuser@tacomail.de", "-t", "1"]) + + assert result.exit_code == 0 + lines = result.stdout.strip().split("\n") + assert "id=test-email-id-123" in lines + assert "from=sender@example.com" in lines + assert "subject=Test Subject" in lines + # Should not have the waiting message + assert "Waiting for email" not in result.stdout + + @patch("tacomail.cli.get_client") + def test_wait_plain_output_timeout(self, mock_get_client): + """Test that wait outputs timeout=true in plain mode when no email.""" + mock_client = MagicMock() + mock_client.wait_for_email.return_value = None + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--plain", "wait", "testuser@tacomail.de", "-t", "1"]) + + assert result.exit_code == 1 + assert "timeout=true" in result.stdout + + @patch("tacomail.cli.get_client") + def test_wait_plain_output_with_body(self, mock_get_client, mock_email): + """Test that wait with --print-body includes body in plain mode.""" + mock_client = MagicMock() + mock_client.wait_for_email.return_value = mock_email + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--plain", "wait", "testuser@tacomail.de", "-t", "1", "--print-body"]) + + assert result.exit_code == 0 + lines = result.stdout.strip().split("\n") + assert "body=This is the email body." in lines + + +class TestCreateWithSessionPlainOutput: + """Tests for the 'create-with-session' and 'new' commands plain output.""" + + @patch("tacomail.cli.get_client") + def test_create_with_session_plain_output(self, mock_get_client, mock_session): + """Test that create-with-session outputs key=value format in plain mode.""" + mock_client = MagicMock() + mock_client.get_random_address.return_value = "random123@tacomail.de" + mock_client.create_session.return_value = mock_session + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--plain", "create-with-session"]) + + assert result.exit_code == 0 + lines = result.stdout.strip().split("\n") + assert "email=random123@tacomail.de" in lines + assert "username=testuser" in lines + assert "domain=tacomail.de" in lines + assert any(line.startswith("expires=") for line in lines) + # Should not contain Rich formatting or next steps + assert "✨" not in result.stdout + assert "Next steps" not in result.stdout + + @patch("tacomail.cli.get_client") + def test_new_command_plain_output(self, mock_get_client, mock_session): + """Test that 'new' command (alias) also works with plain output.""" + mock_client = MagicMock() + mock_client.get_random_address.return_value = "random123@tacomail.de" + mock_client.create_session.return_value = mock_session + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--plain", "new"]) + + assert result.exit_code == 0 + lines = result.stdout.strip().split("\n") + assert "email=random123@tacomail.de" in lines + + +class TestPlainOptionHelp: + """Test that the --plain option is properly documented.""" + + def test_plain_option_in_help(self): + """Test that --plain option appears in CLI help.""" + result = runner.invoke(app, ["--help"]) + + assert result.exit_code == 0 + # Output may contain ANSI escape codes, so search for "plain" which should appear + assert "plain" in result.stdout.lower() + assert "easy parsing" in result.stdout.lower() or "parsing" in result.stdout.lower() From 60009acb2c822c61323c0f3f9b578888e91398e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:19:05 +0000 Subject: [PATCH 3/5] Refactor to generic --output option with rich/plain/json formats Co-authored-by: sokripon <79755465+sokripon@users.noreply.github.com> --- src/tacomail/cli.py | 256 ++++++++++++++++++++++++++------------------ tests/test_cli.py | 216 ++++++++++++++++++++++++++++++++----- 2 files changed, 339 insertions(+), 133 deletions(-) diff --git a/src/tacomail/cli.py b/src/tacomail/cli.py index 4bcd3e8..dc94ce1 100644 --- a/src/tacomail/cli.py +++ b/src/tacomail/cli.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 """Tacomail CLI - Command-line interface for Tacomail disposable email service.""" -from typing import Optional +from typing import Optional, Any +from enum import Enum import re +import json from datetime import datetime import typer @@ -16,6 +18,14 @@ Email, ) + +class OutputFormat(str, Enum): + """Output format options for CLI commands.""" + RICH = "rich" + PLAIN = "plain" + JSON = "json" + + app = typer.Typer( name="tacomail", help="Tacomail CLI - Disposable email service command-line interface", @@ -26,7 +36,31 @@ # Global options use_async = False -use_plain = False +output_format: OutputFormat = OutputFormat.RICH + + +def output_data(data: dict[str, Any] | list[Any], rich_callback: callable) -> None: + """Output data in the configured format. + + Args: + data: The data to output (dict for single items, list for multiple items) + rich_callback: A callback function to render rich output (only called for RICH format) + """ + if output_format == OutputFormat.JSON: + print(json.dumps(data, default=str)) + elif output_format == OutputFormat.PLAIN: + if isinstance(data, list): + for item in data: + if isinstance(item, dict): + # For list of dicts, output tab-separated values + print("\t".join(str(v) for v in item.values())) + else: + print(item) + else: + for key, value in data.items(): + print(f"{key}={value}") + else: + rich_callback() def get_client(): @@ -62,14 +96,14 @@ def create( else: email_address = client.get_random_address() - if use_plain: - print(email_address) - else: + def rich_output(): console.print(Panel( f"[bold green]Generated Email:[/bold green]\n{email_address}", title="✨ Success", border_style="green" )) + + output_data({"email": email_address}, rich_output) except Exception as e: console.print(f"[red]Error creating email:[/red] {e}") raise typer.Exit(1) @@ -86,13 +120,12 @@ def list_domains() -> None: try: domains = client.get_domains() - if use_plain: - for domain in domains: - print(domain) - else: + def rich_output(): console.print(f"\n[bold]Available Domains:[/bold] ({len(domains)})") for domain in domains: console.print(f" • {domain}") + + output_data(domains, rich_output) except Exception as e: console.print(f"[red]Error fetching domains:[/red] {e}") raise typer.Exit(1) @@ -160,6 +193,33 @@ def _create_with_session_impl( """ client = get_client() + def display_results(email_address: str, session, expires_str: str) -> None: + """Display the results using the configured output format.""" + data = { + "email": email_address, + "username": session.username, + "domain": session.domain, + "expires": expires_str, + } + + def rich_output(): + console.print(Panel( + f"[bold green]Email Address:[/bold green]\n{email_address}\n\n" + f"[bold green]Session Created[/bold green]\n\n" + f"[bold]Expires:[/bold] {expires_str}\n" + f"[bold]Username:[/bold] {session.username}\n" + f"[bold]Domain:[/bold] {session.domain}\n\n" + f"[dim]You can now receive emails at this address![/dim]", + title="✨ Email & Session Ready", + border_style="green" + )) + + console.print("\n[bold cyan]Next steps:[/bold cyan]") + console.print(" • Monitor inbox: [green]tacomail list {}[/green]".format(email_address)) + console.print(" • Wait for email: [green]tacomail wait {}[/green]".format(email_address)) + + output_data(data, rich_output) + try: # Handle async mode if use_async: @@ -190,27 +250,7 @@ async def async_operation(): expires_dt = datetime.fromtimestamp(session.expires / 1000) expires_str = expires_dt.strftime("%Y-%m-%d %H:%M:%S") - # Display results - if use_plain: - print(f"email={email_address}") - print(f"username={session.username}") - print(f"domain={session.domain}") - print(f"expires={expires_str}") - else: - console.print(Panel( - f"[bold green]Email Address:[/bold green]\n{email_address}\n\n" - f"[bold green]Session Created[/bold green]\n\n" - f"[bold]Expires:[/bold] {expires_str}\n" - f"[bold]Username:[/bold] {session.username}\n" - f"[bold]Domain:[/bold] {session.domain}\n\n" - f"[dim]You can now receive emails at this address![/dim]", - title="✨ Email & Session Ready", - border_style="green" - )) - - console.print("\n[bold cyan]Next steps:[/bold cyan]") - console.print(" • Monitor inbox: [green]tacomail list {}[/green]".format(email_address)) - console.print(" • Wait for email: [green]tacomail wait {}[/green]".format(email_address)) + display_results(email_address, session, expires_str) asyncio.run(async_operation()) else: @@ -237,27 +277,7 @@ async def async_operation(): expires_dt = datetime.fromtimestamp(session.expires / 1000) expires_str = expires_dt.strftime("%Y-%m-%d %H:%M:%S") - # Display results - if use_plain: - print(f"email={email_address}") - print(f"username={session.username}") - print(f"domain={session.domain}") - print(f"expires={expires_str}") - else: - console.print(Panel( - f"[bold green]Email Address:[/bold green]\n{email_address}\n\n" - f"[bold green]Session Created[/bold green]\n\n" - f"[bold]Expires:[/bold] {expires_str}\n" - f"[bold]Username:[/bold] {session.username}\n" - f"[bold]Domain:[/bold] {session.domain}\n\n" - f"[dim]You can now receive emails at this address![/dim]", - title="✨ Email & Session Ready", - border_style="green" - )) - - console.print("\n[bold cyan]Next steps:[/bold cyan]") - console.print(" • Monitor inbox: [green]tacomail list {}[/green]".format(email_address)) - console.print(" • Wait for email: [green]tacomail wait {}[/green]".format(email_address)) + display_results(email_address, session, expires_str) except Exception as e: console.print(f"[red]Error creating email and session:[/red] {e}") @@ -288,12 +308,14 @@ def create_session( expires_dt = datetime.fromtimestamp(session.expires / 1000) expires_str = expires_dt.strftime("%Y-%m-%d %H:%M:%S") - if use_plain: - print(f"email={email}") - print(f"username={session.username}") - print(f"domain={session.domain}") - print(f"expires={expires_str}") - else: + data = { + "email": email, + "username": session.username, + "domain": session.domain, + "expires": expires_str, + } + + def rich_output(): console.print(Panel( f"[bold green]Session Created[/bold green]\n\n" f"Email: {email}\n" @@ -303,6 +325,8 @@ def create_session( title="🔐 Session", border_style="green" )) + + output_data(data, rich_output) except Exception as e: console.print(f"[red]Error creating session:[/red] {e}") raise typer.Exit(1) @@ -328,10 +352,10 @@ def delete_session( client.delete_session(username, domain) - if use_plain: - print(f"deleted={email}") - else: + def rich_output(): console.print(f"[green]✓ Session deleted for {email}[/green]") + + output_data({"deleted": email}, rich_output) except Exception as e: console.print(f"[red]Error deleting session:[/red] {e}") raise typer.Exit(1) @@ -356,17 +380,25 @@ def list_inbox( emails = client.get_inbox(email, limit=limit) if not emails: - if use_plain: + if output_format != OutputFormat.RICH: + # For non-rich formats, output empty list + output_data([], lambda: None) return console.print(f"[yellow]No emails found for {email}[/yellow]") return - if use_plain: - # Tab-separated output: id, from_address, subject, date - for email_obj in emails: - date_str = email_obj.date.strftime("%Y-%m-%d %H:%M") - print(f"{email_obj.id}\t{email_obj.from_.address}\t{email_obj.subject}\t{date_str}") - else: + # Prepare data for output + emails_data = [ + { + "id": email_obj.id, + "from": email_obj.from_.address, + "subject": email_obj.subject, + "date": email_obj.date.strftime("%Y-%m-%d %H:%M"), + } + for email_obj in emails + ] + + def rich_output(): table = Table(title=f"Inbox for {email}") table.add_column("ID", style="cyan", no_wrap=True) table.add_column("From", style="green") @@ -382,6 +414,8 @@ def list_inbox( console.print(table) console.print(f"\n[dim]Showing {len(emails)} email(s)[/dim]") + + output_data(emails_data, rich_output) except Exception as e: console.print(f"[red]Error listing inbox:[/red] {e}") raise typer.Exit(1) @@ -406,20 +440,20 @@ def get_email( to_addr = f"{email_obj.to.name} <{email_obj.to.address}>" date_str = email_obj.date.strftime("%Y-%m-%d %H:%M:%S") - if use_plain: - print(f"id={email_obj.id}") - print(f"from={email_obj.from_.address}") - print(f"from_name={email_obj.from_.name}") - print(f"to={email_obj.to.address}") - print(f"to_name={email_obj.to.name}") - print(f"subject={email_obj.subject}") - print(f"date={date_str}") - print(f"attachments={len(email_obj.attachments)}") - if email_obj.attachments: - for att in email_obj.attachments: - print(f"attachment={att.fileName}") - print(f"body={email_obj.body.text or ''}") - else: + data = { + "id": email_obj.id, + "from": email_obj.from_.address, + "from_name": email_obj.from_.name, + "to": email_obj.to.address, + "to_name": email_obj.to.name, + "subject": email_obj.subject, + "date": date_str, + "attachments": len(email_obj.attachments), + "attachment_files": [att.fileName for att in email_obj.attachments], + "body": email_obj.body.text or "", + } + + def rich_output(): content = f""" [bold]From:[/bold] {from_addr} [bold]To:[/bold] {to_addr} @@ -442,6 +476,8 @@ def get_email( content += "[dim](No text body)[/dim]\n" console.print(Panel(content.strip(), title="📧 Email", border_style="blue")) + + output_data(data, rich_output) except Exception as e: console.print(f"[red]Error getting email:[/red] {e}") raise typer.Exit(1) @@ -460,10 +496,11 @@ def delete_email( try: client.delete_email(email, mail_id) - if use_plain: - print(f"deleted={mail_id}") - else: + + def rich_output(): console.print(f"[green]✓ Email {mail_id} deleted[/green]") + + output_data({"deleted": mail_id}, rich_output) except Exception as e: console.print(f"[red]Error deleting email:[/red] {e}") raise typer.Exit(1) @@ -483,14 +520,15 @@ def clear_inbox( client = get_client() try: - if not confirm and not use_plain: + if not confirm and output_format == OutputFormat.RICH: typer.confirm(f"Delete all emails from {email}?", abort=True) client.delete_inbox(email) - if use_plain: - print(f"cleared={email}") - else: + + def rich_output(): console.print(f"[green]✓ Inbox cleared for {email}[/green]") + + output_data({"cleared": email}, rich_output) except Exception as e: console.print(f"[red]Error clearing inbox:[/red] {e}") raise typer.Exit(1) @@ -521,7 +559,7 @@ def wait( client = get_client() try: - if not use_plain: + if output_format == OutputFormat.RICH: console.print(f"[dim]Waiting for email to {email}... (timeout: {timeout}s)[/dim]") if filter_pattern: @@ -549,16 +587,18 @@ def filter_fn(email_obj: Email) -> bool: ) if email_obj: - if use_plain: - date_str = email_obj.date.strftime("%Y-%m-%d %H:%M:%S") - print(f"id={email_obj.id}") - print(f"from={email_obj.from_.address}") - print(f"from_name={email_obj.from_.name}") - print(f"subject={email_obj.subject}") - print(f"date={date_str}") - if print_body: - print(f"body={email_obj.body.text or ''}") - else: + date_str = email_obj.date.strftime("%Y-%m-%d %H:%M:%S") + data = { + "id": email_obj.id, + "from": email_obj.from_.address, + "from_name": email_obj.from_.name, + "subject": email_obj.subject, + "date": date_str, + } + if print_body: + data["body"] = email_obj.body.text or "" + + def rich_output(): console.print("\n[green]✓ Email received![/green]") console.print(f" From: {email_obj.from_.name} <{email_obj.from_.address}>") console.print(f" Subject: {email_obj.subject}") @@ -570,12 +610,16 @@ def filter_fn(email_obj: Email) -> bool: console.print(f"{email_obj.body.text}") else: console.print("\n[dim]No text body available[/dim]") + + output_data(data, rich_output) else: - if use_plain: - print("timeout=true") - else: + def rich_output(): console.print("\n[yellow]⏱ Timeout: No email received[/yellow]") + + output_data({"timeout": True}, rich_output) raise typer.Exit(1) + except typer.Exit: + raise except Exception as e: console.print(f"[red]Error waiting for email:[/red] {e}") raise typer.Exit(1) @@ -589,14 +633,14 @@ def main( verbose: bool = typer.Option( False, "--verbose", "-v", help="Enable verbose output" ), - plain: bool = typer.Option( - False, "--plain", "-p", help="Output in plain format for easy parsing" + output: OutputFormat = typer.Option( + OutputFormat.RICH, "--output", "-o", help="Output format (rich, plain, json)" ), ) -> None: """Tacomail CLI - Disposable email service command-line interface.""" - global use_async, use_plain + global use_async, output_format use_async = async_mode - use_plain = plain + output_format = output if verbose: import logging diff --git a/tests/test_cli.py b/tests/test_cli.py index fbe4518..5ba6b9b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,6 @@ -"""Tests for the Tacomail CLI plain output functionality.""" +"""Tests for the Tacomail CLI output format functionality.""" +import json import pytest from unittest.mock import patch, MagicMock from datetime import datetime @@ -46,15 +47,15 @@ class TestCreatePlainOutput: @patch("tacomail.cli.get_client") def test_create_plain_output(self, mock_get_client): - """Test that create command outputs just the email address in plain mode.""" + """Test that create command outputs key=value format in plain mode.""" mock_client = MagicMock() mock_client.get_random_address.return_value = "random123@tacomail.de" mock_get_client.return_value = mock_client - result = runner.invoke(app, ["--plain", "create"]) + result = runner.invoke(app, ["--output", "plain", "create"]) assert result.exit_code == 0 - assert result.stdout.strip() == "random123@tacomail.de" + assert "email=random123@tacomail.de" in result.stdout # Should not contain Rich formatting assert "✨" not in result.stdout assert "[bold" not in result.stdout @@ -66,10 +67,27 @@ def test_create_with_domain_plain_output(self, mock_get_client): mock_client.get_random_username.return_value = "randomuser" mock_get_client.return_value = mock_client - result = runner.invoke(app, ["--plain", "create", "-d", "tacomail.de"]) + result = runner.invoke(app, ["--output", "plain", "create", "-d", "tacomail.de"]) assert result.exit_code == 0 - assert result.stdout.strip() == "randomuser@tacomail.de" + assert "email=randomuser@tacomail.de" in result.stdout + + +class TestCreateJsonOutput: + """Tests for the 'create' command JSON output.""" + + @patch("tacomail.cli.get_client") + def test_create_json_output(self, mock_get_client): + """Test that create command outputs JSON format.""" + mock_client = MagicMock() + mock_client.get_random_address.return_value = "random123@tacomail.de" + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "create"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["email"] == "random123@tacomail.de" class TestListDomainsPlainOutput: @@ -82,7 +100,7 @@ def test_list_domains_plain_output(self, mock_get_client): mock_client.get_domains.return_value = ["tacomail.de", "example.com", "test.org"] mock_get_client.return_value = mock_client - result = runner.invoke(app, ["--plain", "list-domains"]) + result = runner.invoke(app, ["--output", "plain", "list-domains"]) assert result.exit_code == 0 lines = result.stdout.strip().split("\n") @@ -91,6 +109,19 @@ def test_list_domains_plain_output(self, mock_get_client): assert "•" not in result.stdout assert "[bold" not in result.stdout + @patch("tacomail.cli.get_client") + def test_list_domains_json_output(self, mock_get_client): + """Test that list-domains outputs JSON format.""" + mock_client = MagicMock() + mock_client.get_domains.return_value = ["tacomail.de", "example.com", "test.org"] + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "list-domains"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data == ["tacomail.de", "example.com", "test.org"] + class TestCreateSessionPlainOutput: """Tests for the 'create-session' command plain output.""" @@ -102,7 +133,7 @@ def test_create_session_plain_output(self, mock_get_client, mock_session): mock_client.create_session.return_value = mock_session mock_get_client.return_value = mock_client - result = runner.invoke(app, ["--plain", "create-session", "testuser@tacomail.de"]) + result = runner.invoke(app, ["--output", "plain", "create-session", "testuser@tacomail.de"]) assert result.exit_code == 0 lines = result.stdout.strip().split("\n") @@ -113,6 +144,22 @@ def test_create_session_plain_output(self, mock_get_client, mock_session): # Should not contain Rich formatting assert "🔐" not in result.stdout + @patch("tacomail.cli.get_client") + def test_create_session_json_output(self, mock_get_client, mock_session): + """Test that create-session outputs JSON format.""" + mock_client = MagicMock() + mock_client.create_session.return_value = mock_session + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "create-session", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["email"] == "testuser@tacomail.de" + assert data["username"] == "testuser" + assert data["domain"] == "tacomail.de" + assert "expires" in data + class TestDeleteSessionPlainOutput: """Tests for the 'delete-session' command plain output.""" @@ -123,13 +170,25 @@ def test_delete_session_plain_output(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client - result = runner.invoke(app, ["--plain", "delete-session", "testuser@tacomail.de"]) + result = runner.invoke(app, ["--output", "plain", "delete-session", "testuser@tacomail.de"]) assert result.exit_code == 0 assert result.stdout.strip() == "deleted=testuser@tacomail.de" # Should not contain Rich formatting assert "✓" not in result.stdout + @patch("tacomail.cli.get_client") + def test_delete_session_json_output(self, mock_get_client): + """Test that delete-session outputs JSON format.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "delete-session", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["deleted"] == "testuser@tacomail.de" + class TestListInboxPlainOutput: """Tests for the 'list' command plain output.""" @@ -141,7 +200,7 @@ def test_list_inbox_plain_output(self, mock_get_client, mock_email): mock_client.get_inbox.return_value = [mock_email] mock_get_client.return_value = mock_client - result = runner.invoke(app, ["--plain", "list", "testuser@tacomail.de"]) + result = runner.invoke(app, ["--output", "plain", "list", "testuser@tacomail.de"]) assert result.exit_code == 0 # Output should be tab-separated: id, from_address, subject, date @@ -159,11 +218,27 @@ def test_list_inbox_empty_plain_output(self, mock_get_client): mock_client.get_inbox.return_value = [] mock_get_client.return_value = mock_client - result = runner.invoke(app, ["--plain", "list", "testuser@tacomail.de"]) + result = runner.invoke(app, ["--output", "plain", "list", "testuser@tacomail.de"]) assert result.exit_code == 0 assert result.stdout.strip() == "" + @patch("tacomail.cli.get_client") + def test_list_inbox_json_output(self, mock_get_client, mock_email): + """Test that list outputs JSON format.""" + mock_client = MagicMock() + mock_client.get_inbox.return_value = [mock_email] + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "list", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]["id"] == "test-email-id-123" + assert data[0]["from"] == "sender@example.com" + class TestGetEmailPlainOutput: """Tests for the 'get' command plain output.""" @@ -175,7 +250,7 @@ def test_get_email_plain_output(self, mock_get_client, mock_email): mock_client.get_email.return_value = mock_email mock_get_client.return_value = mock_client - result = runner.invoke(app, ["--plain", "get", "testuser@tacomail.de", "email-id"]) + result = runner.invoke(app, ["--output", "plain", "get", "testuser@tacomail.de", "email-id"]) assert result.exit_code == 0 lines = result.stdout.strip().split("\n") @@ -189,6 +264,22 @@ def test_get_email_plain_output(self, mock_get_client, mock_email): # Should not contain Rich formatting assert "📧" not in result.stdout + @patch("tacomail.cli.get_client") + def test_get_email_json_output(self, mock_get_client, mock_email): + """Test that get outputs JSON format.""" + mock_client = MagicMock() + mock_client.get_email.return_value = mock_email + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "get", "testuser@tacomail.de", "email-id"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["id"] == "test-email-id-123" + assert data["from"] == "sender@example.com" + assert data["subject"] == "Test Subject" + assert data["body"] == "This is the email body." + class TestDeleteEmailPlainOutput: """Tests for the 'delete' command plain output.""" @@ -199,11 +290,23 @@ def test_delete_email_plain_output(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client - result = runner.invoke(app, ["--plain", "delete", "testuser@tacomail.de", "email-id-123"]) + result = runner.invoke(app, ["--output", "plain", "delete", "testuser@tacomail.de", "email-id-123"]) assert result.exit_code == 0 assert result.stdout.strip() == "deleted=email-id-123" + @patch("tacomail.cli.get_client") + def test_delete_email_json_output(self, mock_get_client): + """Test that delete outputs JSON format.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "delete", "testuser@tacomail.de", "email-id-123"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["deleted"] == "email-id-123" + class TestClearInboxPlainOutput: """Tests for the 'clear' command plain output.""" @@ -214,11 +317,23 @@ def test_clear_inbox_plain_output(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client - result = runner.invoke(app, ["--plain", "clear", "testuser@tacomail.de"]) + result = runner.invoke(app, ["--output", "plain", "clear", "testuser@tacomail.de"]) assert result.exit_code == 0 assert result.stdout.strip() == "cleared=testuser@tacomail.de" + @patch("tacomail.cli.get_client") + def test_clear_inbox_json_output(self, mock_get_client): + """Test that clear outputs JSON format.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "clear", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["cleared"] == "testuser@tacomail.de" + class TestWaitPlainOutput: """Tests for the 'wait' command plain output.""" @@ -230,7 +345,7 @@ def test_wait_plain_output_success(self, mock_get_client, mock_email): mock_client.wait_for_email.return_value = mock_email mock_get_client.return_value = mock_client - result = runner.invoke(app, ["--plain", "wait", "testuser@tacomail.de", "-t", "1"]) + result = runner.invoke(app, ["--output", "plain", "wait", "testuser@tacomail.de", "-t", "1"]) assert result.exit_code == 0 lines = result.stdout.strip().split("\n") @@ -242,15 +357,15 @@ def test_wait_plain_output_success(self, mock_get_client, mock_email): @patch("tacomail.cli.get_client") def test_wait_plain_output_timeout(self, mock_get_client): - """Test that wait outputs timeout=true in plain mode when no email.""" + """Test that wait outputs timeout=True in plain mode when no email.""" mock_client = MagicMock() mock_client.wait_for_email.return_value = None mock_get_client.return_value = mock_client - result = runner.invoke(app, ["--plain", "wait", "testuser@tacomail.de", "-t", "1"]) + result = runner.invoke(app, ["--output", "plain", "wait", "testuser@tacomail.de", "-t", "1"]) assert result.exit_code == 1 - assert "timeout=true" in result.stdout + assert "timeout=True" in result.stdout @patch("tacomail.cli.get_client") def test_wait_plain_output_with_body(self, mock_get_client, mock_email): @@ -259,12 +374,40 @@ def test_wait_plain_output_with_body(self, mock_get_client, mock_email): mock_client.wait_for_email.return_value = mock_email mock_get_client.return_value = mock_client - result = runner.invoke(app, ["--plain", "wait", "testuser@tacomail.de", "-t", "1", "--print-body"]) + result = runner.invoke(app, ["--output", "plain", "wait", "testuser@tacomail.de", "-t", "1", "--print-body"]) assert result.exit_code == 0 lines = result.stdout.strip().split("\n") assert "body=This is the email body." in lines + @patch("tacomail.cli.get_client") + def test_wait_json_output_success(self, mock_get_client, mock_email): + """Test that wait outputs JSON format when email received.""" + mock_client = MagicMock() + mock_client.wait_for_email.return_value = mock_email + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "wait", "testuser@tacomail.de", "-t", "1"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["id"] == "test-email-id-123" + assert data["from"] == "sender@example.com" + assert data["subject"] == "Test Subject" + + @patch("tacomail.cli.get_client") + def test_wait_json_output_timeout(self, mock_get_client): + """Test that wait outputs JSON format on timeout.""" + mock_client = MagicMock() + mock_client.wait_for_email.return_value = None + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "wait", "testuser@tacomail.de", "-t", "1"]) + + assert result.exit_code == 1 + data = json.loads(result.stdout.strip()) + assert data["timeout"] is True + class TestCreateWithSessionPlainOutput: """Tests for the 'create-with-session' and 'new' commands plain output.""" @@ -277,7 +420,7 @@ def test_create_with_session_plain_output(self, mock_get_client, mock_session): mock_client.create_session.return_value = mock_session mock_get_client.return_value = mock_client - result = runner.invoke(app, ["--plain", "create-with-session"]) + result = runner.invoke(app, ["--output", "plain", "create-with-session"]) assert result.exit_code == 0 lines = result.stdout.strip().split("\n") @@ -297,21 +440,40 @@ def test_new_command_plain_output(self, mock_get_client, mock_session): mock_client.create_session.return_value = mock_session mock_get_client.return_value = mock_client - result = runner.invoke(app, ["--plain", "new"]) + result = runner.invoke(app, ["--output", "plain", "new"]) assert result.exit_code == 0 lines = result.stdout.strip().split("\n") assert "email=random123@tacomail.de" in lines + @patch("tacomail.cli.get_client") + def test_create_with_session_json_output(self, mock_get_client, mock_session): + """Test that create-with-session outputs JSON format.""" + mock_client = MagicMock() + mock_client.get_random_address.return_value = "random123@tacomail.de" + mock_client.create_session.return_value = mock_session + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "create-with-session"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["email"] == "random123@tacomail.de" + assert data["username"] == "testuser" + assert data["domain"] == "tacomail.de" + assert "expires" in data + -class TestPlainOptionHelp: - """Test that the --plain option is properly documented.""" +class TestOutputOptionHelp: + """Test that the --output option is properly documented.""" - def test_plain_option_in_help(self): - """Test that --plain option appears in CLI help.""" + def test_output_option_in_help(self): + """Test that --output option appears in CLI help.""" result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 - # Output may contain ANSI escape codes, so search for "plain" which should appear + # Output may contain ANSI escape codes, so search for keywords + assert "output" in result.stdout.lower() + assert "json" in result.stdout.lower() assert "plain" in result.stdout.lower() - assert "easy parsing" in result.stdout.lower() or "parsing" in result.stdout.lower() + assert "rich" in result.stdout.lower() From 212698fd964c9b43677ed2889637a624bb5350fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:21:08 +0000 Subject: [PATCH 4/5] Fix Callable type annotation in output_data function Co-authored-by: sokripon <79755465+sokripon@users.noreply.github.com> --- src/tacomail/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tacomail/cli.py b/src/tacomail/cli.py index dc94ce1..2a1d27e 100644 --- a/src/tacomail/cli.py +++ b/src/tacomail/cli.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tacomail CLI - Command-line interface for Tacomail disposable email service.""" -from typing import Optional, Any +from typing import Optional, Any, Callable from enum import Enum import re import json @@ -39,7 +39,7 @@ class OutputFormat(str, Enum): output_format: OutputFormat = OutputFormat.RICH -def output_data(data: dict[str, Any] | list[Any], rich_callback: callable) -> None: +def output_data(data: dict[str, Any] | list[Any], rich_callback: Callable[[], None]) -> None: """Output data in the configured format. Args: From b28421f853ba77a8d5a7403d82c319482524b7fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 17:25:10 +0000 Subject: [PATCH 5/5] Update README with --output option documentation Co-authored-by: sokripon <79755465+sokripon@users.noreply.github.com> --- README.md | 45 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0f0bc4b..c5b837c 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,42 @@ The tacomail CLI provides comprehensive command-line interface for Tacomail disp ### Global Options +- `--output`, `-o` - Output format: `rich` (default), `plain`, or `json` - `--async` - Use async client instead of sync - `--verbose` - Enable verbose/debug output - `--help` - Show help message +### Output Formats + +The CLI supports three output formats via the `--output` option: + +**Rich (default)**: Beautiful formatted output with colors and panels +```bash +tacomail create +# ╭────────────── ✨ Success ──────────────╮ +# │ Generated Email: │ +# │ x7k9m2@tacomail.de │ +# ╰────────────────────────────────────────╯ +``` + +**Plain**: Simple key=value format for shell scripting +```bash +tacomail --output plain create +# email=x7k9m2@tacomail.de + +tacomail -o plain list user@tacomail.de +# id123 sender@example.com Subject Line 2026-01-15 10:30 +``` + +**JSON**: Machine-readable JSON output +```bash +tacomail --output json create +# {"email": "x7k9m2@tacomail.de"} + +tacomail -o json list user@tacomail.de +# [{"id": "id123", "from": "sender@example.com", "subject": "Subject Line", "date": "2026-01-15 10:30"}] +``` + ### Help for Specific Commands Each command has its own help: @@ -149,9 +181,8 @@ tacomail wait x7k9m2@tacomail.de ### Complete Workflow Example ```bash -# Generate email and create session -EMAIL=$(tacomail create | grep -oP 'Generated Email:' | cut -d' ' -f2) -tacomail create-session $EMAIL +# Generate email and create session (using plain output for easy parsing) +EMAIL=$(tacomail -o plain new | grep '^email=' | cut -d'=' -f2) # Monitor inbox for incoming emails tacomail wait $EMAIL --timeout 60 @@ -182,8 +213,12 @@ tacomail wait **Workflow 2: Automated script** ```bash #!/bin/bash -# Get email and session (using the short alias) -EMAIL=$(tacomail new 2>&1 | grep -oP '\S+@\S+') +# Get email and session using plain output (easy to parse) +EMAIL=$(tacomail -o plain new | grep '^email=' | cut -d'=' -f2) +echo "Created: $EMAIL" + +# Or using JSON output with jq +EMAIL=$(tacomail -o json new | jq -r '.email') echo "Created: $EMAIL" # Monitor for emails (timeout 60s)