diff --git a/src/efd_unpacker/infrastructure/os_utils.py b/src/efd_unpacker/infrastructure/os_utils.py index c5cc92b..538c1a1 100644 --- a/src/efd_unpacker/infrastructure/os_utils.py +++ b/src/efd_unpacker/infrastructure/os_utils.py @@ -1,8 +1,26 @@ +import codecs +import logging import os import sys import subprocess import platform -from typing import List +from typing import Dict, List + +logger = logging.getLogger(__name__) + +CONFIG_TEMPLATES_KEY = 'ConfigurationTemplatesLocation=' + +# BOM однозначно задаёт кодировку. Кодек utf-16 (в отличие от utf-16le) +# сам снимает BOM и сам определяет порядок байт. +_BOM_ENCODINGS = ( + (codecs.BOM_UTF8, 'utf-8-sig'), + (codecs.BOM_UTF16_LE, 'utf-16'), + (codecs.BOM_UTF16_BE, 'utf-16'), +) + +# Перебор без BOM: платформа 1С пишет конфиг и в utf-8, и в cp1251, +# и в utf-16 без сигнатуры. +_FALLBACK_ENCODINGS = ('utf-8', 'cp1251', 'utf-16-le', 'utf-16-be') def get_1c_configuration_location_default() -> str: """Возвращает путь к каталогу распаковки по умолчанию в зависимости от ОС.""" @@ -41,33 +59,100 @@ def get_1c_configuration_location_from_1cestart() -> List[str]: home = os.path.expanduser('~') config_paths.append(os.path.join(home, '.1C', '1cestart', '1cestart.cfg')) for config_path in config_paths: - if os.path.isfile(config_path): - encodings_to_try = ['utf-8-sig', 'utf-16le', 'utf-8', 'cp1251'] - for encoding in encodings_to_try: - try: - with open(config_path, 'r', encoding=encoding) as f: - for line in f: - line = line.strip() - if line.startswith('ConfigurationTemplatesLocation='): - value = line.split('=', 1)[1] - if value and value not in locations: - locations.append(value) - break - except (UnicodeDecodeError, IOError): - continue + if not os.path.isfile(config_path): + continue + try: + with open(config_path, 'rb') as handle: + raw = handle.read() + except OSError as exc: + logger.warning("Не удалось прочитать %s: %s", config_path, exc) + continue + for value in _locations_from_text(_decode_config(raw)): + if value not in locations: + locations.append(value) return locations + +def _decode_config(raw: bytes) -> str: + """ + Декодирует 1cestart.cfg, выбирая кодировку по результату, а не по отсутствию ошибки. + + Прежний перебор останавливался на первом кодеке, который не бросил исключение, + а utf-16le не бросает почти никогда: любая последовательность чётной длины + декодируется в иероглифы. Поэтому корректно разбирался только utf-8, а cp1251 + проходил через раз — в зависимости от чётности размера файла. Критерий теперь + прямой: в тексте должен найтись сам ключ. + """ + encodings = [] + for bom, encoding in _BOM_ENCODINGS: + if raw.startswith(bom): + encodings.append(encoding) + break + encodings.extend(_FALLBACK_ENCODINGS) + + fallback = "" + for encoding in encodings: + try: + text = raw.decode(encoding) + except UnicodeError: + continue + if CONFIG_TEMPLATES_KEY in text: + return text + if not fallback: + fallback = text + return fallback + + +def _locations_from_text(text: str) -> List[str]: + """Значения ConfigurationTemplatesLocation из уже декодированного текста.""" + found = [] + for line in text.splitlines(): + # lstrip до strip: \ufeff не пробельный, и utf-16le оставлял BOM в начале + # первой строки, из-за чего она не проходила проверку префикса. + line = line.lstrip('\ufeff').strip() + if line.startswith(CONFIG_TEMPLATES_KEY): + value = line.split('=', 1)[1] + if value: + found.append(value) + return found + +def child_environment() -> Dict[str, str]: + """ + Окружение для системной команды открытия папки. + + Бутлоадер onefile-сборки PyInstaller указывает LD_LIBRARY_PATH на каталог + распаковки, а оригинал кладёт в LD_LIBRARY_PATH_ORIG. Без восстановления + дочерний gio/kioclient/gtk-launch подхватывает оттуда Qt и libstdc++ вместо + системных и падает с symbol lookup error. + """ + env = os.environ.copy() + original = env.pop("LD_LIBRARY_PATH_ORIG", None) + if original is None: + env.pop("LD_LIBRARY_PATH", None) + else: + env["LD_LIBRARY_PATH"] = original + return env + + def open_folder(path: str) -> bool: """Открывает указанный путь в системном файловом менеджере.""" if not os.path.exists(path): return False + # abspath, а не разделитель опций "--": xdg-open его не понимает и отвечает + # `unexpected option` с кодом 1. Абсолютный путь не может начинаться с дефиса. + target = os.path.abspath(path) try: if platform.system() == "Windows": - os.startfile(path) # type: ignore[attr-defined] - elif platform.system() == "Darwin": # macOS - subprocess.run(["open", path]) - else: # Linux - subprocess.run(["xdg-open", path]) + os.startfile(target) # type: ignore[attr-defined] + return True + command = "open" if platform.system() == "Darwin" else "xdg-open" + # Код возврата раньше не читался: xdg-open отвечает 3 или 4, когда + # ассоциации inode/directory нет, а пользователь видел «открыл». + result = subprocess.run([command, target], env=child_environment()) + if result.returncode != 0: + logger.warning("%s вернул код %s для %s", command, result.returncode, target) + return False return True - except Exception: + except Exception as exc: + logger.warning("Не удалось открыть папку %s: %s", target, exc) return False diff --git a/src/efd_unpacker/infrastructure/settings_service.py b/src/efd_unpacker/infrastructure/settings_service.py index 84888ab..4bec8d0 100644 --- a/src/efd_unpacker/infrastructure/settings_service.py +++ b/src/efd_unpacker/infrastructure/settings_service.py @@ -18,7 +18,16 @@ def __init__(self, translator: Translator, settings: Optional[QSettings] = None) self.settings = settings or QSettings("efd_unpacker", "settings") def get_output_path(self) -> str: - return self.settings.value("output_path", get_1c_configuration_location_default()) + default = get_1c_configuration_location_default() + value = self.settings.value("output_path", default) + # QSettings отдаёт то, что лежит в файле: конфиг, правленный извне, + # миграция или REG_MULTI_SZ дают list, а os.path.normpath дальше роняет + # запуск ещё до window.show() — без окна и без сообщения. + # ','.join тут нельзя: Qt при разборе срезает пробел после запятой, + # и склейка даст молча неверный каталог вместо честного отката. + if not isinstance(value, str): + return default + return value def set_output_path(self, path: str) -> None: self.settings.setValue("output_path", path) diff --git a/src/efd_unpacker/presentation/ui.py b/src/efd_unpacker/presentation/ui.py index 0509b3b..8427099 100644 --- a/src/efd_unpacker/presentation/ui.py +++ b/src/efd_unpacker/presentation/ui.py @@ -389,5 +389,16 @@ def closeEvent(self, event) -> None: event.accept() def open_output_folder(self) -> None: - if self.output_path: - open_folder(self.output_path) + if not self.output_path: + return + if open_folder(self.output_path): + return + # QMessageBox, а не show_message: последний переводит окно в UIState.ERROR + # и прячет саму кнопку «Открыть папку». Путь в тексте — чтобы его можно + # было скопировать: в состоянии SUCCESS комбобокс с путём скрыт, и узнать + # каталог распаковки из окна больше неоткуда. + QMessageBox.warning( + self, + self._t("MainWindow", "Error"), + "{}\n\n{}".format(self._t("MainWindow", "Could not open the folder"), self.output_path), + ) diff --git a/tests/qt/test_main_window.py b/tests/qt/test_main_window.py index c72966a..452c1ab 100644 --- a/tests/qt/test_main_window.py +++ b/tests/qt/test_main_window.py @@ -13,6 +13,7 @@ from efd_unpacker.domain.file_validator import FileValidator from efd_unpacker.domain.unpack_service import UnpackService +from efd_unpacker.presentation import ui from efd_unpacker.presentation.ui import MainWindow, UnpackThread @@ -215,3 +216,56 @@ def test_window_shows_the_message_without_cli_markers(qtbot, success): assert shown == "Распаковка завершена успешно" assert "[OK]" not in shown assert "[ERROR]" not in shown + + +def test_failed_folder_open_is_reported_to_the_user(qtbot, monkeypatch, tmp_path): + """ + Регресс #18: open_folder возвращал False, а вызывающий код результат не читал. + В состоянии SUCCESS комбобокс с путём скрыт, так что при молчаливом отказе + каталог распаковки узнать из окна было негде. + """ + window = _plain_window(qtbot) + window.output_path = str(tmp_path) + shown = {} + + monkeypatch.setattr(ui.MainWindow, "_t", lambda _self, _ctx, text: text) + monkeypatch.setattr(ui, "open_folder", lambda _path: False) + monkeypatch.setattr( + ui.QMessageBox, "warning", lambda _parent, title, text: shown.update(title=title, text=text) + ) + + window.open_output_folder() + + assert "Could not open the folder" in shown["text"] + assert str(tmp_path) in shown["text"], "путь должен быть в сообщении, чтобы его можно было скопировать" + + +def test_successful_folder_open_is_silent(qtbot, monkeypatch, tmp_path): + window = _plain_window(qtbot) + window.output_path = str(tmp_path) + calls = [] + + monkeypatch.setattr(ui, "open_folder", lambda _path: True) + monkeypatch.setattr(ui.QMessageBox, "warning", lambda *a, **k: calls.append(a)) + + window.open_output_folder() + + assert calls == [] + + +def test_failed_folder_open_keeps_the_window_in_success_state(qtbot, monkeypatch, tmp_path): + """ + show_message(..., is_error=True) перевёл бы окно в UIState.ERROR и спрятал + саму кнопку «Открыть папку» — поэтому здесь QMessageBox, а не он. + """ + window = _plain_window(qtbot) + window.unpack_finished(True, "Готово") + window.output_path = str(tmp_path) + + monkeypatch.setattr(ui, "open_folder", lambda _path: False) + monkeypatch.setattr(ui.QMessageBox, "warning", lambda *a, **k: None) + + window.open_output_folder() + + assert window.btn_open_folder.isVisible() or not window.isVisible() + assert window.btn_retry.isVisible() is False diff --git a/tests/unit/test_os_utils.py b/tests/unit/test_os_utils.py index c9dd4d7..4725fee 100644 --- a/tests/unit/test_os_utils.py +++ b/tests/unit/test_os_utils.py @@ -99,22 +99,36 @@ def test_cfg_cp1251_with_odd_length_works(tmp_path, monkeypatch): assert os_utils.get_1c_configuration_location_from_1cestart() == ["/opt/Шаблоны"] -@pytest.mark.xfail( - strict=True, - reason="#18: платформа 1С пишет конфиг в UTF-16, сейчас такой файл даёт пустой список", +@pytest.mark.parametrize( + "encoding", + ["utf-16", "utf-16-le", "utf-16-be", "utf-16-le-bom", "utf-16-be-bom"], + ids=["utf-16+BOM", "utf-16le без BOM", "utf-16be без BOM", "utf-16le с BOM", "utf-16be с BOM"], ) -@pytest.mark.parametrize("encoding", ["utf-16", "utf-16-le"], ids=["utf-16+BOM", "utf-16le без BOM"]) -def test_cfg_utf16_is_not_parsed(tmp_path, monkeypatch, encoding): - _write_config(tmp_path, monkeypatch, (CONFIG_LINE + "\r\n").encode(encoding)) +def test_cfg_utf16_in_every_shape(tmp_path, monkeypatch, encoding): + """ + Регресс #18: utf-16le не снимает BOM, а \ufeff не пробельный, поэтому первая + строка не проходила проверку префикса. Без BOM файл вообще не доходил до + своей ветки: байты UTF-16LE для ASCII валидны как UTF-8, и utf-8-sig + отрабатывал первым, ничего не находил и обрывал перебор. + """ + if encoding.endswith("-bom"): + base = encoding[: -len("-bom")] + payload = (b"\xff\xfe" if base.endswith("le") else b"\xfe\xff") + ( + CONFIG_LINE + "\r\n" + ).encode(base) + else: + payload = (CONFIG_LINE + "\r\n").encode(encoding) + _write_config(tmp_path, monkeypatch, payload) assert os_utils.get_1c_configuration_location_from_1cestart() == ["/opt/1c/tmplts"] -@pytest.mark.xfail( - strict=True, - reason="#18: utf-16le декодирует любую чётную последовательность без исключения и обрывает перебор", -) -def test_cfg_cp1251_with_even_length_is_lost(tmp_path, monkeypatch): +def test_cfg_cp1251_with_even_length_is_parsed(tmp_path, monkeypatch): + """ + Регресс #18: чётная длина уводила файл в utf-16le, который декодирует любую + последовательность без исключения. Разбор cp1251 работал через раз — + в зависимости от чётности размера файла. + """ payload = "ConfigurationTemplatesLocation=/opt/Шаблоныx\r\n".encode("cp1251") assert len(payload) % 2 == 0 _write_config(tmp_path, monkeypatch, payload) @@ -122,6 +136,45 @@ def test_cfg_cp1251_with_even_length_is_lost(tmp_path, monkeypatch): assert os_utils.get_1c_configuration_location_from_1cestart() == ["/opt/Шаблоныx"] +def test_cfg_large_cp1251_file_is_not_truncated(tmp_path, monkeypatch): + """ + Регресс #18: locations накапливался между попытками, поэтому частично + прочитанное под utf-8-sig оставалось, utf-16le доедал остаток и делал break. + На файле из 251 строки возвращалось 174. + """ + lines = [] + for index in range(251): + suffix = "Шаблоны" if index == 230 else str(index) + lines.append("ConfigurationTemplatesLocation=/opt/%s" % suffix) + payload = ("\r\n".join(lines) + "\r\n").encode("cp1251") + assert len(payload) > 8192 + _write_config(tmp_path, monkeypatch, payload) + + result = os_utils.get_1c_configuration_location_from_1cestart() + + assert len(result) == 251 + assert "/opt/Шаблоны" in result + + +def test_cfg_without_the_key_returns_empty(tmp_path, monkeypatch): + """Файл без ключа — не повод перебирать кодировки до иероглифов.""" + _write_config(tmp_path, monkeypatch, "CommonInfoBases=x\r\n".encode("cp1251")) + + assert os_utils.get_1c_configuration_location_from_1cestart() == [] + + +def test_cfg_unreadable_file_does_not_raise(tmp_path, monkeypatch): + home = _write_config(tmp_path, monkeypatch, (CONFIG_LINE + "\n").encode("utf-8")) + config = home / ".1C" / "1cestart" / "1cestart.cfg" + os.chmod(config, 0o000) + try: + if os.access(config, os.R_OK): + pytest.skip("права не отзываются на этой платформе") + assert os_utils.get_1c_configuration_location_from_1cestart() == [] + finally: + os.chmod(config, 0o644) + + def test_cfg_windows_reads_appdata_and_allusersprofile(tmp_path, monkeypatch): monkeypatch.setattr(sys, "platform", "win32") for name, value in (("APPDATA", tmp_path / "roaming"), ("ALLUSERSPROFILE", tmp_path / "all")): @@ -156,6 +209,17 @@ def test_open_folder_windows_uses_startfile(monkeypatch, tmp_path): startfile.assert_called_once_with(str(target)) +def _completed(returncode=0): + """subprocess.run-заглушка, фиксирующая аргументы и отдающая заданный код.""" + calls = [] + + def fake_run(args, **kwargs): + calls.append((args, kwargs)) + return subprocess.CompletedProcess(args, returncode) + + return fake_run, calls + + @pytest.mark.parametrize( "system, expected_command", [("Darwin", "open"), ("Linux", "xdg-open")], @@ -163,13 +227,85 @@ def test_open_folder_windows_uses_startfile(monkeypatch, tmp_path): def test_open_folder_uses_platform_command(monkeypatch, tmp_path, system, expected_command): target = tmp_path / "tmplts" target.mkdir() - run = mock.Mock() + fake_run, calls = _completed() monkeypatch.setattr(os_utils.platform, "system", lambda: system) - monkeypatch.setattr(os_utils.subprocess, "run", run) + monkeypatch.setattr(os_utils.subprocess, "run", fake_run) + + assert os_utils.open_folder(str(target)) is True + assert calls[0][0] == [expected_command, str(target)] + + +@pytest.mark.parametrize("returncode", [1, 3, 4], ids=["rc=1", "rc=3", "rc=4"]) +def test_open_folder_reports_a_nonzero_exit_code(monkeypatch, tmp_path, returncode): + """ + Регресс #18: код возврата не читался вообще. xdg-open отвечает 3 или 4, + когда ассоциации inode/directory нет, а пользователь видел «открыл». + """ + target = tmp_path / "tmplts" + target.mkdir() + fake_run, _calls = _completed(returncode) + + monkeypatch.setattr(os_utils.platform, "system", lambda: "Linux") + monkeypatch.setattr(os_utils.subprocess, "run", fake_run) + + assert os_utils.open_folder(str(target)) is False + + +def test_open_folder_passes_a_relative_path_as_absolute(monkeypatch, tmp_path): + """ + Путь, начинающийся с дефиса, не должен уехать в команду как опция. + Разделитель "--" тут не годится: xdg-open его не понимает. + """ + target = tmp_path / "-tmplts" + target.mkdir() + fake_run, calls = _completed() + + monkeypatch.setattr(os_utils.platform, "system", lambda: "Linux") + monkeypatch.setattr(os_utils.subprocess, "run", fake_run) assert os_utils.open_folder(str(target)) is True - run.assert_called_once_with([expected_command, str(target)]) + # Именно isabs, а не startswith(os.sep): на Windows абсолютный путь + # начинается с буквы диска, и проверка по разделителю там всегда ложна. + assert os.path.isabs(calls[0][0][1]) + assert "--" not in calls[0][0] + + +def test_open_folder_restores_the_library_path_for_the_child(monkeypatch, tmp_path): + """ + Регресс #18: дочерний процесс наследовал LD_LIBRARY_PATH бутлоадера + PyInstaller и подхватывал Qt из каталога распаковки вместо системного. + """ + target = tmp_path / "tmplts" + target.mkdir() + fake_run, calls = _completed() + + monkeypatch.setenv("LD_LIBRARY_PATH", "/tmp/_MEIxxxxxx") + monkeypatch.setenv("LD_LIBRARY_PATH_ORIG", "/usr/lib/x86_64-linux-gnu") + monkeypatch.setattr(os_utils.platform, "system", lambda: "Linux") + monkeypatch.setattr(os_utils.subprocess, "run", fake_run) + + os_utils.open_folder(str(target)) + + env = calls[0][1]["env"] + assert env["LD_LIBRARY_PATH"] == "/usr/lib/x86_64-linux-gnu" + assert "LD_LIBRARY_PATH_ORIG" not in env + + +def test_child_environment_drops_the_library_path_without_an_original(monkeypatch): + """Если ORIG нет, ключ надо убрать, а не оставить путь бутлоадера.""" + monkeypatch.setenv("LD_LIBRARY_PATH", "/tmp/_MEIxxxxxx") + monkeypatch.delenv("LD_LIBRARY_PATH_ORIG", raising=False) + + env = os_utils.child_environment() + + assert "LD_LIBRARY_PATH" not in env + + +def test_child_environment_keeps_the_rest_of_the_environment(monkeypatch): + monkeypatch.setenv("SOME_MARKER", "значение") + + assert os_utils.child_environment()["SOME_MARKER"] == "значение" def test_open_folder_swallows_launcher_failure(monkeypatch, tmp_path): @@ -186,18 +322,3 @@ def boom(*_args, **_kwargs): assert os_utils.open_folder(str(target)) is False -def test_open_folder_real_subprocess_signature(monkeypatch, tmp_path): - """Проверяем, что вызов действительно совместим с subprocess.run.""" - target = tmp_path / "tmplts" - target.mkdir() - calls = [] - - def fake_run(args, **kwargs): - calls.append(args) - return subprocess.CompletedProcess(args, 0) - - monkeypatch.setattr(os_utils.platform, "system", lambda: "Darwin") - monkeypatch.setattr(os_utils.subprocess, "run", fake_run) - - assert os_utils.open_folder(str(target)) is True - assert calls == [["open", str(target)]] diff --git a/tests/unit/test_settings_service.py b/tests/unit/test_settings_service.py index b7c3aa5..bfb0024 100644 --- a/tests/unit/test_settings_service.py +++ b/tests/unit/test_settings_service.py @@ -1,6 +1,9 @@ import unittest from unittest.mock import MagicMock, patch +from PyQt5.QtCore import QSettings + +from efd_unpacker.infrastructure import settings_service as settings_service_module from efd_unpacker.infrastructure.settings_service import SettingsService @@ -56,3 +59,47 @@ def test_get_output_path_items(self, mock_settings, mock_from_1c, mock_default) if __name__ == "__main__": unittest.main() + + +def test_non_string_setting_falls_back_to_default(tmp_path, monkeypatch): + """ + Регресс #18: QSettings отдаёт то, что лежит в файле. Конфиг, правленный + извне, даёт list, а os.path.normpath дальше роняет запуск ещё до + window.show() — без окна и без сообщения. + """ + ini = tmp_path / "settings.ini" + ini.write_text("[General]\noutput_path=/home/u/Templates, old/tmplts\n", encoding="utf-8") + settings = QSettings(str(ini), QSettings.IniFormat) + assert not isinstance(settings.value("output_path"), str), "иначе тест проверяет не то" + + monkeypatch.setattr( + settings_service_module, "get_1c_configuration_location_default", lambda: "/default/tmplts" + ) + monkeypatch.setattr( + settings_service_module, "get_1c_configuration_location_from_1cestart", lambda: [] + ) + service = SettingsService(translator=_DummyTranslator(), settings=settings) + + assert service.get_output_path() == "/default/tmplts" + # Метка именно (last used): откат отдаёт тот же путь, что и default, ветка + # last_used срабатывает первой и занимает его. Это существующее поведение + # разметки, а не следствие отката — трогать его здесь незачем. + assert service.get_output_path_items() == [("/default/tmplts", "/default/tmplts (last used)")] + + +def test_string_setting_is_used_as_is(tmp_path, monkeypatch): + ini = tmp_path / "settings.ini" + ini.write_text("[General]\noutput_path=/home/u/Templates\n", encoding="utf-8") + settings = QSettings(str(ini), QSettings.IniFormat) + + monkeypatch.setattr( + settings_service_module, "get_1c_configuration_location_default", lambda: "/default/tmplts" + ) + service = SettingsService(translator=_DummyTranslator(), settings=settings) + + assert service.get_output_path() == "/home/u/Templates" + + +class _DummyTranslator: + def translate(self, _context, source): + return source diff --git a/translations/ru.ts b/translations/ru.ts index d33751a..8d190b3 100644 --- a/translations/ru.ts +++ b/translations/ru.ts @@ -88,6 +88,10 @@ No .efd file selected Не выбран файл .efd + + Could not open the folder + Не удалось открыть папку + UnpackService