otpilot 3.2.0__tar.gz → 3.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {otpilot-3.2.0 → otpilot-3.2.1}/PKG-INFO +3 -1
  2. {otpilot-3.2.0 → otpilot-3.2.1}/README.md +2 -0
  3. {otpilot-3.2.0 → otpilot-3.2.1}/pyproject.toml +2 -1
  4. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/application/feedback.py +0 -1
  5. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/application/ports.py +2 -0
  6. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/application/services.py +13 -12
  7. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/config.py +1 -1
  8. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/daemon.py +4 -3
  9. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/daemon/linux.py +1 -1
  10. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/daemon/macos.py +2 -1
  11. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/logging/setup.py +5 -2
  12. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/terminal/hotkey_capture.py +1 -1
  13. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/terminal/keyboard.py +14 -13
  14. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/providers/imap/provider.py +1 -1
  15. otpilot-3.2.1/tests/test_logging.py +53 -0
  16. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_watch.py +54 -0
  17. otpilot-3.2.0/tests/test_logging.py +0 -7
  18. {otpilot-3.2.0 → otpilot-3.2.1}/.gitignore +0 -0
  19. {otpilot-3.2.0 → otpilot-3.2.1}/CHANGELOG.md +0 -0
  20. {otpilot-3.2.0 → otpilot-3.2.1}/LICENSE +0 -0
  21. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/__init__.py +0 -0
  22. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/__main__.py +0 -0
  23. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/application/__init__.py +0 -0
  24. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/application/doctor.py +0 -0
  25. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/application/settings.py +0 -0
  26. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/application/telemetry.py +0 -0
  27. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cache/__init__.py +0 -0
  28. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cache/memory.py +0 -0
  29. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/__init__.py +0 -0
  30. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/app.py +0 -0
  31. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/__init__.py +0 -0
  32. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/config_controller.py +0 -0
  33. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/doctor.py +0 -0
  34. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/fetch.py +0 -0
  35. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/hotkey.py +0 -0
  36. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/login.py +0 -0
  37. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/logout.py +0 -0
  38. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/telemetry.py +0 -0
  39. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/watch.py +0 -0
  40. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/config/__init__.py +0 -0
  41. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/config/models.py +0 -0
  42. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/__init__.py +0 -0
  43. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/diagnostics.py +0 -0
  44. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/errors.py +0 -0
  45. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/extraction.py +0 -0
  46. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/models.py +0 -0
  47. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/otp.py +0 -0
  48. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/providers.py +0 -0
  49. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/search.py +0 -0
  50. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/state.py +0 -0
  51. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/__init__.py +0 -0
  52. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/clipboard/__init__.py +0 -0
  53. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/clipboard/pyperclip_adapter.py +0 -0
  54. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/config_storage/__init__.py +0 -0
  55. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/config_storage/toml.py +0 -0
  56. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/credentials/__init__.py +0 -0
  57. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/credentials/keyring_store.py +0 -0
  58. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/daemon/__init__.py +0 -0
  59. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/daemon/factory.py +0 -0
  60. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/daemon/state.py +0 -0
  61. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/daemon/windows.py +0 -0
  62. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/diagnostics/__init__.py +0 -0
  63. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/diagnostics/platform.py +0 -0
  64. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/hotkeys/__init__.py +0 -0
  65. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/hotkeys/pynput_adapter.py +0 -0
  66. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/logging/__init__.py +0 -0
  67. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/notifications/__init__.py +0 -0
  68. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/platform/__init__.py +0 -0
  69. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/platform/environment.py +0 -0
  70. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/preferences/__init__.py +0 -0
  71. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/preferences/toml.py +0 -0
  72. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/terminal/ui.py +0 -0
  73. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/preferences/__init__.py +0 -0
  74. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/preferences/models.py +0 -0
  75. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/providers/__init__.py +0 -0
  76. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/providers/gmail/__init__.py +0 -0
  77. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/providers/gmail/config.py +0 -0
  78. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/providers/imap/__init__.py +0 -0
  79. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/providers/imap/search.py +0 -0
  80. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/providers/registry.py +0 -0
  81. {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/state/__init__.py +0 -0
  82. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_cli.py +0 -0
  83. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_cli_config.py +0 -0
  84. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_cli_doctor.py +0 -0
  85. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_cli_telemetry.py +0 -0
  86. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_clipboard.py +0 -0
  87. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_community_messages.py +0 -0
  88. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_config.py +0 -0
  89. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_config_controller.py +0 -0
  90. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_config_pty.py +0 -0
  91. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_config_ui.py +0 -0
  92. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_credentials.py +0 -0
  93. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_daemon.py +0 -0
  94. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_extraction.py +0 -0
  95. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_feedback.py +0 -0
  96. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_fetch_service.py +0 -0
  97. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_hotkey.py +0 -0
  98. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_imap_provider.py +0 -0
  99. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_keyboard.py +0 -0
  100. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_packaging.py +0 -0
  101. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_provider_registry.py +0 -0
  102. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_telemetry.py +0 -0
  103. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_telemetry_config.py +0 -0
  104. {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_telemetry_suggestion.py +0 -0
  105. {otpilot-3.2.0 → otpilot-3.2.1}/web/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: otpilot
3
- Version: 3.2.0
3
+ Version: 3.2.1
4
4
  Summary: Privacy-focused CLI that retrieves email OTPs via IMAP and copies them to the clipboard.
5
5
  Project-URL: Repository, https://github.com/CodeWithJenil/otpilot3.0
6
6
  Project-URL: Issues, https://github.com/CodeWithJenil/otpilot3.0/issues
@@ -50,6 +50,8 @@ Description-Content-Type: text/markdown
50
50
 
51
51
  OTPilot is a local-first command-line tool for retrieving one-time passwords from Gmail over IMAP. Use it when you want a simpler workflow for email-based verification codes.
52
52
 
53
+ ![OTPilot demo](otpilot.gif)
54
+
53
55
  - **Clipboard support:** use `otpilot fetch --copy` to copy the detected OTP without printing its value to terminal output.
54
56
  - **Local-first design:** email retrieval happens on your machine; OTPilot does not require a hosted relay or sync service.
55
57
  - **Opt-in telemetry:** usage telemetry is disabled by default and is sent only if you explicitly enable it.
@@ -9,6 +9,8 @@
9
9
 
10
10
  OTPilot is a local-first command-line tool for retrieving one-time passwords from Gmail over IMAP. Use it when you want a simpler workflow for email-based verification codes.
11
11
 
12
+ ![OTPilot demo](otpilot.gif)
13
+
12
14
  - **Clipboard support:** use `otpilot fetch --copy` to copy the detected OTP without printing its value to terminal output.
13
15
  - **Local-first design:** email retrieval happens on your machine; OTPilot does not require a hosted relay or sync service.
14
16
  - **Opt-in telemetry:** usage telemetry is disabled by default and is sent only if you explicitly enable it.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "otpilot"
7
- version = "3.2.0"
7
+ version = "3.2.1"
8
8
  description = "Privacy-focused CLI that retrieves email OTPs via IMAP and copies them to the clipboard."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -69,6 +69,7 @@ include = [
69
69
 
70
70
  [tool.pytest.ini_options]
71
71
  testpaths = ["tests"]
72
+ pythonpath = ["src"]
72
73
  addopts = "-q"
73
74
 
74
75
  [tool.ruff]
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  import random
6
6
  import sys
7
- import typer
8
7
  import webbrowser
9
8
  from dataclasses import dataclass
10
9
  from typing import Protocol
@@ -1,5 +1,7 @@
1
1
  """Application-layer ports implemented by infrastructure adapters."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  from collections.abc import Callable, Mapping
4
6
  from dataclasses import dataclass
5
7
  from pathlib import Path
@@ -130,14 +130,14 @@ class WatchService:
130
130
 
131
131
  Three monitoring modes are supported:
132
132
 
133
- 1. **Hotkey-enabled** ({\@literal hotkey_enabled=true}): Wait for the configured
133
+ 1. **Hotkey-enabled** (`hotkey_enabled=True`): Wait for the configured
134
134
  global hotkey. Each valid hotkey activation triggers an immediate mailbox check.
135
135
  Overlapping checks are prevented.
136
136
 
137
- 2. **IMAP IDLE** ({\@literal idle_provider} is set): Uses IMAP IDLE for
137
+ 2. **IMAP IDLE** (`idle_provider` is set): Uses IMAP IDLE for
138
138
  near-instant OTP detection. The provider must be authenticated.
139
139
 
140
- 3. **Polling fallback** (default): Checks the mailbox every {\@literal poll_interval_seconds}.
140
+ 3. **Polling fallback** (default): Checks the mailbox every `poll_interval_seconds`.
141
141
  Both branches share the same duplicate-suppression logic via
142
142
  ``processed_message_ids``.
143
143
  """
@@ -146,12 +146,11 @@ class WatchService:
146
146
  poll_interval_seconds: float = 3.00
147
147
  sleep: Callable[[float], None] = field(default=sleep)
148
148
  hotkey_enabled: bool = False
149
- idle_provider: object | None = field(default=None)
150
- idle_account_id: AccountId | None = field(default=None)
151
- status: Callable[[str], None] = field(default=lambda message: None)
149
+ hotkey: str = "ctrl+shift+o"
152
150
  # Set by the daemon factory to enable IMAP IDLE; None ⟹ polling fallback.
153
151
  idle_provider: object | None = field(default=None)
154
152
  idle_account_id: AccountId | None = field(default=None)
153
+ status: Callable[[str], None] = field(default=lambda message: None)
155
154
 
156
155
  def run(self) -> None:
157
156
  """Run the watch loop until interrupted or a fatal error occurs."""
@@ -160,13 +159,15 @@ class WatchService:
160
159
  try:
161
160
  if self.hotkey_enabled:
162
161
  # ── Hotkey-enabled branch ─────────────────────────────────────
163
- # Wait for the configured global hotkey.
164
- # Each valid hotkey activation triggers an immediate mailbox check.
165
- # Overlapping checks are prevented via the active lock.
162
+ # Wait for the configured global hotkey using HotkeyService.
166
163
  from otpilot.infrastructure.hotkeys import PynputHotkeyListener
167
- listener = PynputHotkeyListener()
168
- listener.register(self.hotkey, self._on_hotkey_press)
169
- listener.run()
164
+ hotkey_service = HotkeyService(
165
+ fetch_service=self.fetch_service,
166
+ listener=PynputHotkeyListener(),
167
+ hotkey=self.hotkey,
168
+ status=self.status,
169
+ )
170
+ hotkey_service.run()
170
171
  elif self.idle_provider is not None and hasattr(self.idle_provider, "idle_fetch"):
171
172
  # ── IMAP IDLE branch ─────────────────────────────────────────
172
173
  # idle_provider is already authenticated by the daemon factory.
@@ -31,7 +31,7 @@ def command(
31
31
  non_interactive: bool = typer.Option(
32
32
  False, "--non-interactive", "-n", help="Run in non-interactive mode."
33
33
  ),
34
- ):
34
+ ) -> None:
35
35
  if ctx.invoked_subcommand is not None:
36
36
  return
37
37
  console = Console()
@@ -1,9 +1,9 @@
1
1
  """CLI commands for daemon management."""
2
2
 
3
3
  import sys
4
- import time
5
4
  import typer
6
5
 
6
+ from otpilot.application.ports import DaemonManager, DaemonStatus
7
7
  from otpilot.application.services import FetchOtpService, WatchService
8
8
  from otpilot.application.settings import SettingsService
9
9
  from otpilot.cache.memory import InMemoryOtpCache
@@ -75,6 +75,7 @@ def build_daemon_watch_service() -> WatchService:
75
75
  poll_interval_seconds=config.poll_interval_seconds,
76
76
  status=lambda message: None, # Silent in daemon mode
77
77
  hotkey_enabled=config.hotkey_enabled,
78
+ hotkey=config.hotkey,
78
79
  idle_provider=idle_provider,
79
80
  idle_account_id=idle_account_id,
80
81
  )
@@ -120,7 +121,7 @@ app = typer.Typer(
120
121
  )
121
122
 
122
123
 
123
- def _get_daemon_manager():
124
+ def _get_daemon_manager() -> DaemonManager:
124
125
  """Get the platform-specific daemon manager."""
125
126
  try:
126
127
  return create_daemon_manager()
@@ -129,7 +130,7 @@ def _get_daemon_manager():
129
130
  raise typer.Exit(code=1) from exc
130
131
 
131
132
 
132
- def _print_status(status) -> None:
133
+ def _print_status(status: DaemonStatus) -> None:
133
134
  """Print daemon status in a consistent format."""
134
135
  typer.echo(f"Installed: {'Yes' if status.installed else 'No'}")
135
136
  typer.echo(f"Running: {'Yes' if status.running else 'No'}")
@@ -80,7 +80,7 @@ WantedBy=default.target
80
80
  content = self._build_service_content()
81
81
  self._service_path.write_text(content, encoding="utf-8")
82
82
 
83
- def _systemctl_user(self, *args: str) -> subprocess.CompletedProcess:
83
+ def _systemctl_user(self, *args: str) -> subprocess.CompletedProcess[str]:
84
84
  """Run systemctl --user command."""
85
85
  return subprocess.run(
86
86
  ["systemctl", "--user", *args],
@@ -9,6 +9,7 @@ import subprocess
9
9
  import sys
10
10
  import time
11
11
  from pathlib import Path
12
+ from typing import Any
12
13
 
13
14
  from otpilot.application.ports import DaemonManager, DaemonStatus
14
15
  from otpilot.domain.errors import OTPilotError
@@ -72,7 +73,7 @@ class MacOSDaemonManager(DaemonManager):
72
73
  if pid is None or not self._is_process_running(pid):
73
74
  self._clear_pid()
74
75
 
75
- def _build_plist(self, autostart: bool = False) -> dict:
76
+ def _build_plist(self, autostart: bool = False) -> dict[str, Any]:
76
77
  """Build the LaunchAgent plist dictionary."""
77
78
  python_exe = self._get_python_executable()
78
79
  working_dir = self._get_working_directory()
@@ -5,8 +5,12 @@ import re
5
5
  from collections.abc import Mapping
6
6
  from typing import Any
7
7
 
8
+ # BUG-01 fix: the original pattern used r"([^\\\s]+)" which in a raw string is
9
+ # the character class [^\\s] ("not backslash or s"), capturing only the first
10
+ # character of a secret value. The correct pattern is r"([^\s]+)" which means
11
+ # "not whitespace", so the full secret value is always captured and redacted.
8
12
  SECRET_PATTERNS = (
9
- re.compile(r"(?i)(password|app_password|token|secret|credential)=([^\\s]+)"),
13
+ re.compile(r"(?i)(password|app_password|token|secret|credential)=([^\s]+)"),
10
14
  )
11
15
 
12
16
 
@@ -29,4 +33,3 @@ def configure_logging(level: int = logging.INFO, extra: Mapping[str, Any] | None
29
33
  logging.basicConfig(level=level, handlers=[handler], force=True)
30
34
  if extra:
31
35
  logging.getLogger("otpilot").debug("logging configured", extra=dict(extra))
32
-
@@ -162,7 +162,7 @@ class HotkeyCapture:
162
162
 
163
163
  if isinstance(key, keyboard.KeyCode):
164
164
  if key.char:
165
- char = key.char.lower()
165
+ char = str(key.char).lower()
166
166
  if "\x01" <= char <= "\x1a":
167
167
  # Ctrl+letter arrives as a control character (e.g. Ctrl+O
168
168
  # is "\\x0f"); map it back to the plain letter.
@@ -102,19 +102,20 @@ def parse_key(
102
102
  if ch == "\x04":
103
103
  return KeyEvent(key=Key.CTRL_D, ctrl=True)
104
104
  if ch == "\x1b":
105
- next_ch = read_next() if read_next else None
106
- if next_ch == "[":
107
- third = read_next()
108
- if third == "A":
109
- return KeyEvent(key=Key.UP)
110
- if third == "B":
111
- return KeyEvent(key=Key.DOWN)
112
- if third == "C":
113
- return KeyEvent(key=Key.RIGHT)
114
- if third == "D":
115
- return KeyEvent(key=Key.LEFT)
116
- if third == "3" and read_next() == "~":
117
- return KeyEvent(key=Key.BACKSPACE)
105
+ if read_next is not None:
106
+ next_ch = read_next()
107
+ if next_ch == "[":
108
+ third = read_next()
109
+ if third == "A":
110
+ return KeyEvent(key=Key.UP)
111
+ if third == "B":
112
+ return KeyEvent(key=Key.DOWN)
113
+ if third == "C":
114
+ return KeyEvent(key=Key.RIGHT)
115
+ if third == "D":
116
+ return KeyEvent(key=Key.LEFT)
117
+ if third == "3" and read_next() == "~":
118
+ return KeyEvent(key=Key.BACKSPACE)
118
119
  return KeyEvent(key=Key.ESCAPE)
119
120
  if ch in ("\r", "\n"):
120
121
  # macOS Return sends ``\r``; cooked terminals translate it to ``\n``.
@@ -228,7 +228,7 @@ class ImapProvider:
228
228
  # Enter IDLE for up to _IDLE_REFRESH_SECONDS seconds.
229
229
  try:
230
230
  client.idle()
231
- responses = client.idle_check(timeout=_IDLE_REFRESH_SECONDS)
231
+ client.idle_check(timeout=_IDLE_REFRESH_SECONDS)
232
232
  client.idle_done()
233
233
  except IMAP_OPERATION_ERRORS as exc:
234
234
  logger.warning("IMAP IDLE: connection error during IDLE: %s", exc)
@@ -0,0 +1,53 @@
1
+ import logging
2
+
3
+ from otpilot.infrastructure.logging.setup import RedactingFormatter, redact
4
+
5
+
6
+ def test_redact_masks_secret_like_values() -> None:
7
+ assert "password=[REDACTED]" in redact("password=abc123")
8
+ assert "abc123" not in redact("password=abc123")
9
+
10
+
11
+ def test_redact_handles_characters_in_secret_value() -> None:
12
+ # Regression test for BUG-01: r"([^\\\s]+)" previously stopped at 's' and backslash
13
+ result = redact("password=mysecret123")
14
+ assert result == "password=[REDACTED]"
15
+ assert "secret" not in result
16
+
17
+ result_app_pw = redact("app_password=super-secret-password-xyz")
18
+ assert result_app_pw == "app_password=[REDACTED]"
19
+ assert "super" not in result_app_pw
20
+ assert "secret" not in result_app_pw
21
+
22
+
23
+ def test_redact_all_secret_keys() -> None:
24
+ for key in ("password", "app_password", "token", "secret", "credential"):
25
+ log_line = f"Connecting with {key}=supersecretval now"
26
+ redacted = redact(log_line)
27
+ assert f"{key}=[REDACTED]" in redacted
28
+ assert "supersecretval" not in redacted
29
+
30
+
31
+ def test_redact_case_insensitive() -> None:
32
+ log_line = "PASSWORD=TopSecretValue and Token=AnotherSecret"
33
+ redacted = redact(log_line)
34
+ assert "PASSWORD=[REDACTED]" in redacted
35
+ assert "Token=[REDACTED]" in redacted
36
+ assert "TopSecretValue" not in redacted
37
+ assert "AnotherSecret" not in redacted
38
+
39
+
40
+ def test_redacting_formatter_redacts_record() -> None:
41
+ formatter = RedactingFormatter("%(levelname)s: %(message)s")
42
+ record = logging.LogRecord(
43
+ name="test",
44
+ level=logging.INFO,
45
+ pathname="",
46
+ lineno=0,
47
+ msg="Login failed with app_password=my-super-secret-1234",
48
+ args=(),
49
+ exc_info=None,
50
+ )
51
+ formatted = formatter.format(record)
52
+ assert "app_password=[REDACTED]" in formatted
53
+ assert "my-super-secret-1234" not in formatted
@@ -202,3 +202,57 @@ def test_idle_watch_falls_back_to_poll_when_no_idle_provider() -> None:
202
202
  svc = FakeFetchService([make_result("poll-msg"), make_result("poll-msg")], [])
203
203
  WatchService(svc, sleep=stop_after_first_poll).run()
204
204
  assert svc.copied == ["482731"]
205
+
206
+
207
+ def test_watch_service_fields_initialization() -> None:
208
+ """Regression test for BUG-02: WatchService fields should be unique and properly initialized."""
209
+ import dataclasses
210
+ field_names = [f.name for f in dataclasses.fields(WatchService)]
211
+ assert len(field_names) == len(set(field_names)), "WatchService has duplicate fields"
212
+
213
+ provider = object()
214
+ account_id = AccountId("test@example.com")
215
+ svc = FakeFetchService([], [])
216
+ watch = WatchService(
217
+ fetch_service=svc, # type: ignore[arg-type]
218
+ idle_provider=provider,
219
+ idle_account_id=account_id,
220
+ hotkey_enabled=False,
221
+ )
222
+ assert watch.idle_provider is provider
223
+ assert watch.idle_account_id == account_id
224
+
225
+
226
+ def test_watch_service_hotkey_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
227
+ """Regression test: WatchService with hotkey_enabled=True runs HotkeyService without AttributeError."""
228
+ class FakeListener:
229
+ def __init__(self) -> None:
230
+ self.registered_hotkey: str | None = None
231
+ self.ran = False
232
+
233
+ def register(self, hotkey: str, callback: object) -> None:
234
+ self.registered_hotkey = hotkey
235
+
236
+ def run(self) -> None:
237
+ self.ran = True
238
+ raise KeyboardInterrupt
239
+
240
+ def shutdown(self) -> None:
241
+ pass
242
+
243
+ fake_listener = FakeListener()
244
+ monkeypatch.setattr("otpilot.infrastructure.hotkeys.PynputHotkeyListener", lambda: fake_listener)
245
+
246
+ class FakeFetchWithReady(FakeFetchService):
247
+ def validate_ready(self) -> None:
248
+ pass
249
+
250
+ svc = FakeFetchWithReady([], [])
251
+ watch = WatchService(
252
+ fetch_service=svc, # type: ignore[arg-type]
253
+ hotkey_enabled=True,
254
+ hotkey="ctrl+alt+o",
255
+ )
256
+ watch.run()
257
+ assert fake_listener.ran is True
258
+ assert fake_listener.registered_hotkey == "ctrl+alt+o"
@@ -1,7 +0,0 @@
1
- from otpilot.infrastructure.logging.setup import redact
2
-
3
-
4
- def test_redact_masks_secret_like_values() -> None:
5
- assert "password=[REDACTED]" in redact("password=abc123")
6
- assert "abc123" not in redact("password=abc123")
7
-
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes