Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 106 additions & 21 deletions src/efd_unpacker/infrastructure/os_utils.py
Original file line number Diff line number Diff line change
@@ -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:
"""Возвращает путь к каталогу распаковки по умолчанию в зависимости от ОС."""
Expand Down Expand Up @@ -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
11 changes: 10 additions & 1 deletion src/efd_unpacker/infrastructure/settings_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 13 additions & 2 deletions src/efd_unpacker/presentation/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
54 changes: 54 additions & 0 deletions tests/qt/test_main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Loading
Loading