feat(cli): добавлен пункт Transcribe в меню «Отправить» проводника
- Зачем:
- запуск транскрипции правой кнопкой из проводника Windows без
терминала, для пользователей без прав администратора.
- Что:
- новый модуль context_menu.py: установка/удаление Transcribe.cmd
в папке SendTo (OEM-кодировка, CRLF, без реестра).
- флаги --install-menu / --uninstall-menu в cli.py с ранней
валидацией до load_config; files стал необязательным аргументом.
- раздел в README.md: установка, использование, ручное удаление
через shell:sendto, известные ограничения.
- Проверка:
- uv run pytest — 191 passed, 1 skipped.
- uv run transcribe --install-menu и правый клик → Отправить →
Transcribe на файле с кириллицей в имени.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
966efc3bfe
commit
f18ad6ea2a
@@ -921,3 +921,100 @@ def test_cli_threads_negative_rejected(tmp_path):
|
||||
audio.write_bytes(b"fake")
|
||||
out = runner.invoke(app, [str(audio), "--threads", "-1"])
|
||||
assert out.exit_code != 0
|
||||
|
||||
|
||||
# === SendTo context menu flags ===
|
||||
|
||||
|
||||
def test_cli_install_menu_success(tmp_path):
|
||||
cmd_path = tmp_path / "Transcribe.cmd"
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.install_context_menu", return_value=cmd_path) as mock_install,
|
||||
patch("local_transcriber.cli.load_config") as mock_load_config,
|
||||
patch("local_transcriber.cli.sys") as mock_sys,
|
||||
):
|
||||
mock_sys.platform = "win32"
|
||||
out = runner.invoke(app, ["--install-menu"])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert "Пункт меню установлен" in out.output
|
||||
assert cmd_path.name in out.output
|
||||
mock_install.assert_called_once_with()
|
||||
mock_load_config.assert_not_called()
|
||||
|
||||
|
||||
def test_cli_uninstall_menu_success(tmp_path):
|
||||
cmd_path = tmp_path / "Transcribe.cmd"
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.uninstall_context_menu", return_value=cmd_path) as mock_uninstall,
|
||||
patch("local_transcriber.cli.load_config") as mock_load_config,
|
||||
patch("local_transcriber.cli.sys") as mock_sys,
|
||||
):
|
||||
mock_sys.platform = "win32"
|
||||
out = runner.invoke(app, ["--uninstall-menu"])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert "Пункт меню удалён" in out.output
|
||||
assert cmd_path.name in out.output
|
||||
mock_uninstall.assert_called_once_with()
|
||||
mock_load_config.assert_not_called()
|
||||
|
||||
|
||||
def test_cli_uninstall_menu_missing_is_success():
|
||||
with (
|
||||
patch("local_transcriber.cli.uninstall_context_menu", return_value=None),
|
||||
patch("local_transcriber.cli.sys") as mock_sys,
|
||||
):
|
||||
mock_sys.platform = "win32"
|
||||
out = runner.invoke(app, ["--uninstall-menu"])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert "не был установлен" in out.output
|
||||
|
||||
|
||||
def test_cli_menu_flags_are_mutually_exclusive():
|
||||
out = runner.invoke(app, ["--install-menu", "--uninstall-menu"])
|
||||
|
||||
assert out.exit_code == 2
|
||||
assert "несовместимы" in out.output
|
||||
|
||||
|
||||
def test_cli_menu_flag_with_file_is_rejected(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
|
||||
out = runner.invoke(app, [str(audio), "--install-menu"])
|
||||
|
||||
assert out.exit_code == 2
|
||||
assert "нельзя использовать вместе с файлами" in out.output
|
||||
|
||||
|
||||
def test_cli_no_files_and_no_menu_flags_is_rejected():
|
||||
out = runner.invoke(app, [])
|
||||
|
||||
assert out.exit_code == 2
|
||||
assert "Укажите хотя бы один файл" in out.output
|
||||
|
||||
|
||||
def test_cli_menu_flags_available_only_on_windows():
|
||||
with patch("local_transcriber.cli.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
out = runner.invoke(app, ["--install-menu"])
|
||||
|
||||
assert out.exit_code == 1
|
||||
assert "только на Windows" in out.output
|
||||
|
||||
|
||||
def test_cli_menu_runtime_error_has_no_verbose_hint():
|
||||
with (
|
||||
patch("local_transcriber.cli.install_context_menu", side_effect=RuntimeError("нет APPDATA")),
|
||||
patch("local_transcriber.cli.sys") as mock_sys,
|
||||
):
|
||||
mock_sys.platform = "win32"
|
||||
out = runner.invoke(app, ["--install-menu"])
|
||||
|
||||
assert out.exit_code == 1
|
||||
assert "нет APPDATA" in out.output
|
||||
assert "--verbose" not in out.output
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from local_transcriber import context_menu
|
||||
|
||||
|
||||
def _prepare_exe(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
scripts_dir = tmp_path / "venv" / "Scripts"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
python_exe = scripts_dir / "python.exe"
|
||||
transcribe_exe = scripts_dir / "transcribe.exe"
|
||||
python_exe.write_bytes(b"")
|
||||
transcribe_exe.write_bytes(b"")
|
||||
monkeypatch.setattr(context_menu.sys, "executable", str(python_exe))
|
||||
return transcribe_exe
|
||||
|
||||
|
||||
def test_install_menu_creates_expected_cmd(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
|
||||
monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8")
|
||||
transcribe_exe = _prepare_exe(tmp_path, monkeypatch)
|
||||
|
||||
cmd_path = context_menu.install_menu()
|
||||
|
||||
assert cmd_path.name == "Transcribe.cmd"
|
||||
assert cmd_path.exists()
|
||||
assert cmd_path.read_bytes() == (
|
||||
f'@echo off\r\n"{transcribe_exe}" %*\r\npause\r\n'.encode("utf-8")
|
||||
)
|
||||
assert b"chcp" not in cmd_path.read_bytes().lower()
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="Кодировка oem доступна только на Windows")
|
||||
def test_install_menu_writes_real_oem_encoding_on_windows(tmp_path, monkeypatch):
|
||||
appdata = tmp_path / "AppData" / "Roaming"
|
||||
scripts_dir = tmp_path / "проект" / "Scripts"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
python_exe = scripts_dir / "python.exe"
|
||||
transcribe_exe = scripts_dir / "transcribe.exe"
|
||||
python_exe.write_bytes(b"")
|
||||
transcribe_exe.write_bytes(b"")
|
||||
monkeypatch.setenv("APPDATA", str(appdata))
|
||||
monkeypatch.setattr(context_menu.sys, "executable", str(python_exe))
|
||||
|
||||
cmd_path = context_menu.install_menu()
|
||||
|
||||
assert context_menu.CMD_ENCODING == "oem"
|
||||
assert cmd_path.read_bytes() == (
|
||||
f'@echo off\r\n"{transcribe_exe}" %*\r\npause\r\n'.encode("oem")
|
||||
)
|
||||
|
||||
|
||||
def test_install_menu_overwrites_existing_file(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
|
||||
monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8")
|
||||
_prepare_exe(tmp_path, monkeypatch)
|
||||
|
||||
cmd_path = context_menu.install_menu()
|
||||
cmd_path.write_text("old", encoding="utf-8")
|
||||
|
||||
second_path = context_menu.install_menu()
|
||||
|
||||
assert second_path == cmd_path
|
||||
assert "old" not in cmd_path.read_text(encoding="utf-8")
|
||||
assert "%*" in cmd_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_install_menu_creates_missing_sendto_dir(tmp_path, monkeypatch):
|
||||
appdata = tmp_path / "AppData" / "Roaming"
|
||||
monkeypatch.setenv("APPDATA", str(appdata))
|
||||
monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8")
|
||||
_prepare_exe(tmp_path, monkeypatch)
|
||||
|
||||
cmd_path = context_menu.install_menu()
|
||||
|
||||
assert cmd_path.parent == appdata / "Microsoft" / "Windows" / "SendTo"
|
||||
assert cmd_path.parent.is_dir()
|
||||
|
||||
|
||||
def test_install_menu_oem_encoding_error_is_runtime_error(tmp_path, monkeypatch):
|
||||
appdata = tmp_path / "AppData" / "Roaming"
|
||||
cmd_path = appdata / "Microsoft" / "Windows" / "SendTo" / "Transcribe.cmd"
|
||||
monkeypatch.setenv("APPDATA", str(appdata))
|
||||
monkeypatch.setattr(context_menu, "CMD_ENCODING", "ascii")
|
||||
monkeypatch.setattr(
|
||||
context_menu,
|
||||
"get_transcribe_exe",
|
||||
lambda: tmp_path / "测试" / "transcribe.exe",
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="OEM"):
|
||||
context_menu.install_menu()
|
||||
|
||||
assert not cmd_path.exists()
|
||||
|
||||
|
||||
def test_uninstall_menu_removes_file_and_missing_is_not_error(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
|
||||
monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8")
|
||||
_prepare_exe(tmp_path, monkeypatch)
|
||||
cmd_path = context_menu.install_menu()
|
||||
|
||||
removed_path = context_menu.uninstall_menu()
|
||||
missing_path = context_menu.uninstall_menu()
|
||||
|
||||
assert removed_path == cmd_path
|
||||
assert not cmd_path.exists()
|
||||
assert missing_path is None
|
||||
|
||||
|
||||
def test_get_sendto_dir_requires_appdata(monkeypatch):
|
||||
monkeypatch.delenv("APPDATA", raising=False)
|
||||
|
||||
with pytest.raises(RuntimeError, match="APPDATA"):
|
||||
context_menu.get_sendto_dir()
|
||||
|
||||
|
||||
def test_get_transcribe_exe_requires_existing_exe(tmp_path, monkeypatch):
|
||||
scripts_dir = tmp_path / "venv" / "Scripts"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
python_exe = scripts_dir / "python.exe"
|
||||
python_exe.write_bytes(b"")
|
||||
monkeypatch.setattr(context_menu.sys, "executable", str(python_exe))
|
||||
|
||||
with pytest.raises(RuntimeError, match="uv sync"):
|
||||
context_menu.get_transcribe_exe()
|
||||
Reference in New Issue
Block a user