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.
- {otpilot-3.2.0 → otpilot-3.2.1}/PKG-INFO +3 -1
- {otpilot-3.2.0 → otpilot-3.2.1}/README.md +2 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/pyproject.toml +2 -1
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/application/feedback.py +0 -1
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/application/ports.py +2 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/application/services.py +13 -12
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/config.py +1 -1
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/daemon.py +4 -3
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/daemon/linux.py +1 -1
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/daemon/macos.py +2 -1
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/logging/setup.py +5 -2
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/terminal/hotkey_capture.py +1 -1
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/terminal/keyboard.py +14 -13
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/providers/imap/provider.py +1 -1
- otpilot-3.2.1/tests/test_logging.py +53 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_watch.py +54 -0
- otpilot-3.2.0/tests/test_logging.py +0 -7
- {otpilot-3.2.0 → otpilot-3.2.1}/.gitignore +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/CHANGELOG.md +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/LICENSE +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/__main__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/application/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/application/doctor.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/application/settings.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/application/telemetry.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cache/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cache/memory.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/app.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/config_controller.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/doctor.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/fetch.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/hotkey.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/login.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/logout.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/telemetry.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/cli/commands/watch.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/config/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/config/models.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/diagnostics.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/errors.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/extraction.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/models.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/otp.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/providers.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/search.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/domain/state.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/clipboard/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/clipboard/pyperclip_adapter.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/config_storage/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/config_storage/toml.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/credentials/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/credentials/keyring_store.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/daemon/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/daemon/factory.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/daemon/state.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/daemon/windows.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/diagnostics/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/diagnostics/platform.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/hotkeys/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/hotkeys/pynput_adapter.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/logging/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/notifications/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/platform/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/platform/environment.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/preferences/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/preferences/toml.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/infrastructure/terminal/ui.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/preferences/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/preferences/models.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/providers/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/providers/gmail/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/providers/gmail/config.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/providers/imap/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/providers/imap/search.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/providers/registry.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/src/otpilot/state/__init__.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_cli.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_cli_config.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_cli_doctor.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_cli_telemetry.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_clipboard.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_community_messages.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_config.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_config_controller.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_config_pty.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_config_ui.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_credentials.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_daemon.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_extraction.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_feedback.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_fetch_service.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_hotkey.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_imap_provider.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_keyboard.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_packaging.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_provider_registry.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_telemetry.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_telemetry_config.py +0 -0
- {otpilot-3.2.0 → otpilot-3.2.1}/tests/test_telemetry_suggestion.py +0 -0
- {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.
|
|
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
|
+

|
|
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
|
+

|
|
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.
|
|
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]
|
|
@@ -130,14 +130,14 @@ class WatchService:
|
|
|
130
130
|
|
|
131
131
|
Three monitoring modes are supported:
|
|
132
132
|
|
|
133
|
-
1. **Hotkey-enabled** (
|
|
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** (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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.
|
|
@@ -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)=([
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|