20 KiB
План исправления Redis — диффы с объяснениями
Дата: 2026-02-28
Аудит: REDIS_AUDIT.md
Статус: Верифицирован, готов к реализации
Верификация: 2026-02-28 — все 7 диффов проверены против кодовой базы
DIFF 1: Парсер команд — shlex вместо split (БАГ #1, КРИТИЧНЫЙ)
Почему
command.split() разбивает строку по ВСЕМ пробелам, игнорируя кавычки.
shlex.split() понимает одинарные, двойные кавычки и экранирование — стандарт
для shell-подобного парсинга.
split(): "SET key 'hello world'" → ['SET', 'key', "'hello", "world'"] ← СЛОМАНО
shlex.split(): "SET key 'hello world'" → ['SET', 'key', 'hello world'] ← ПРАВИЛЬНО
Файл: core/redis_client.py строка 78
ДО:
def execute(self, command: str) -> str:
"""Parse a raw command string, execute via redis-py, return formatted."""
if not self._conn:
return "[not connected]"
parts = command.split()
if not parts:
return ""
ПОСЛЕ:
def execute(self, command: str) -> str:
"""Parse a raw command string, execute via redis-py, return formatted."""
if not self._conn:
return "[not connected]"
import shlex
try:
parts = shlex.split(command)
except ValueError:
parts = command.split() # fallback для незакрытых кавычек
if not parts:
return ""
Файл: tools/ssh.py строка 958
ДО:
try:
parts = command.split()
if not parts:
print("ERROR: Empty Redis command")
sys.exit(1)
result = r.execute_command(*parts)
ПОСЛЕ:
try:
import shlex
try:
parts = shlex.split(command)
except ValueError:
parts = command.split()
if not parts:
print("ERROR: Empty Redis command")
sys.exit(1)
result = r.execute_command(*parts)
Результат
# Было:
--redis ALIAS "SET key 'hello world'" → syntax error
# Стало:
--redis ALIAS "SET key 'hello world'" → True
--redis ALIAS "GET key" → hello world
DIFF 2: GUI stats — прямые методы вместо execute (БАГ #2, СРЕДНИЙ)
Почему
client.execute("DBSIZE") возвращает "(integer) 27199166" — уже отформатированную строку.
Этот текст отображается в label как есть, включая (integer).
client.execute("INFO memory") возвращает строку где _format() заменяет \r\n на \n,
но парсер ищет \r\n → ничего не находит → memory всегда "—".
Решение: Использовать client.dbsize() (int) и client.info("memory") (dict) напрямую.
Файл: gui/tabs/redis_tab.py метод _refresh_stats() строки 188-210
ДО:
def _refresh_stats(self):
if not self._current_alias:
return
def _do():
try:
client = self._get_client()
db = int(self._db_var.get())
client.select_db(db)
keys_count = client.execute("DBSIZE")
info = client.execute("INFO memory")
# Parse memory from INFO output
memory = "—"
if isinstance(info, str):
for line in info.split("\r\n"):
if line.startswith("used_memory_human:"):
memory = line.split(":")[1].strip()
break
keys_text = str(keys_count) if keys_count is not None else "—"
self.after(0, lambda: self._keys_label.configure(
text=t("redis_keys") + f": {keys_text}"))
self.after(0, lambda: self._memory_label.configure(
text=t("redis_memory") + f": {memory}"))
except Exception:
pass
threading.Thread(target=_do, daemon=True).start()
ПОСЛЕ:
def _refresh_stats(self):
if not self._current_alias:
return
def _do():
try:
client = self._get_client()
db = int(self._db_var.get())
client.select_db(db)
# Прямые методы возвращают int и dict, не форматированные строки
keys_count = client.dbsize()
info = client.info("memory")
memory = info.get("used_memory_human", "—") if info else "—"
keys_text = f"{keys_count:,}" if keys_count is not None else "—"
self.after(0, lambda: self._keys_label.configure(
text=t("redis_keys") + f": {keys_text}"))
self.after(0, lambda: self._memory_label.configure(
text=t("redis_memory") + f": {memory}"))
except Exception:
pass
threading.Thread(target=_do, daemon=True).start()
Результат
# Было:
Keys: (integer) 27199166 Memory: —
# Стало:
Keys: 27,199,166 Memory: 2.82G
DIFF 3: SSL поддержка в GUI RedisClient (БАГ #3, СРЕДНИЙ)
Почему
CLI (tools/ssh.py) передаёт ssl=server.get("ssl", False) при создании подключения.
GUI (redis_client.py) этот параметр НЕ передаёт → невозможно подключиться к Redis с TLS.
Дополнительно: server_dialog.py не показывает поле ssl для Redis в UI.
Файл: core/redis_client.py конструктор и connect()
ДО:
class RedisClient:
def __init__(self, server: dict):
self._host = server["ip"]
self._port = int(server.get("port", 6379))
self._password = server.get("password") or None
self._db = int(server.get("db_index", 0))
self._conn = None
def connect(self) -> bool:
try:
r = _get_redis()
self._conn = r.Redis(
host=self._host,
port=self._port,
password=self._password,
db=self._db,
decode_responses=True,
socket_timeout=5,
socket_connect_timeout=5,
)
ПОСЛЕ:
class RedisClient:
def __init__(self, server: dict):
self._host = server["ip"]
self._port = int(server.get("port", 6379))
self._password = server.get("password") or None
self._db = int(server.get("db_index", 0))
self._ssl = server.get("ssl", False) or server.get("use_ssl", False)
self._conn = None
def connect(self) -> bool:
try:
r = _get_redis()
self._conn = r.Redis(
host=self._host,
port=self._port,
password=self._password,
db=self._db,
decode_responses=True,
socket_timeout=5,
socket_connect_timeout=5,
ssl=self._ssl,
)
Файл: gui/server_dialog.py строка 22 — FIELD_MAP
ДО:
"redis": ["password", "db_index"],
ПОСЛЕ:
"redis": ["password", "db_index", "use_ssl"],
Почему ssl и use_ssl
В проекте используются оба ключа в разных местах:
tools/ssh.pyчитаетserver.get("ssl", False)gui/server_dialog.pyсохраняетuse_ssl(для WinRM)
Поэтому в RedisClient проверяем оба: server.get("ssl", False) or server.get("use_ssl", False).
DIFF 4: CLI error handling (БАГ #4, НИЗКИЙ)
Почему
При ошибке Redis (WRONGTYPE, NOSCRIPT, синтаксис) выводится полный Python traceback с двойным повтором. Пользователь видит нечитаемое сообщение.
Файл: tools/ssh.py функция run_redis_cmd() строки 957-973
ДО:
try:
parts = command.split()
if not parts:
print("ERROR: Empty Redis command")
sys.exit(1)
result = r.execute_command(*parts)
if isinstance(result, list):
for i, item in enumerate(result):
print(f"{i + 1}) {item}")
print(f"\n({len(result)} items)")
elif isinstance(result, dict):
for k, v in result.items():
print(f"{k}: {v}")
elif isinstance(result, bytes):
print(result.decode("utf-8", errors="replace"))
else:
print(result)
finally:
r.close()
ПОСЛЕ:
try:
import shlex
try:
parts = shlex.split(command)
except ValueError:
parts = command.split()
if not parts:
print("ERROR: Empty Redis command")
sys.exit(1)
try:
result = r.execute_command(*parts)
except Exception as e:
print(f"(error) {e}", file=sys.stderr)
sys.exit(1)
if isinstance(result, list):
for i, item in enumerate(result):
print(f"{i + 1}) {item}")
print(f"\n({len(result)} items)")
elif isinstance(result, dict):
for k, v in result.items():
print(f"{k}: {v}")
elif isinstance(result, bytes):
print(result.decode("utf-8", errors="replace"))
else:
print(result)
finally:
r.close()
Результат
# Было:
ERROR: ResponseError: WRONGTYPE Operation against a key holding the wrong kind of value
ERROR: ResponseError: WRONGTYPE Operation against a key holding the wrong kind of value
# Стало:
(error) WRONGTYPE Operation against a key holding the wrong kind of value
DIFF 5: Двойной PING → одинарный (БАГ #5, НИЗКИЙ)
Почему
connect() делает self._conn.ping(). Затем check_connection() делает ещё один
self._conn.ping(). Два network round-trip вместо одного при каждом status check.
При 10 серверах × каждые 30 секунд = 20 лишних пингов.
Файл: core/status_checker.py строки 98-110
ДО:
def _check_redis(self, server: dict) -> bool:
"""Check Redis via PING."""
try:
from core.redis_client import RedisClient
client = RedisClient(server)
result = client.connect()
if result:
ok = client.check_connection()
client.disconnect()
return ok
return False
except Exception:
return False
ПОСЛЕ:
def _check_redis(self, server: dict) -> bool:
"""Check Redis via PING."""
try:
from core.redis_client import RedisClient
client = RedisClient(server)
result = client.connect() # connect() уже делает ping()
client.disconnect()
return result
except Exception:
return False
DIFF 6: CLI status — добавить try-except и close (БАГ #6, НИЗКИЙ)
Почему
Если Redis offline, r.ping() бросает исключение. r.close() не вызывается (утечка).
Ошибка не перехвачена → traceback вместо OFFLINE.
Файл: tools/ssh.py строки 698-703
ДО:
if stype == "redis":
import redis as redis_lib
r = redis_lib.Redis(host=server["ip"], port=server.get("port", 6379),
password=server.get("password", "") or None,
db=server.get("db_index", 0), socket_timeout=10,
ssl=server.get("ssl", False))
r.ping()
r.close()
return "ONLINE"
ПОСЛЕ:
if stype == "redis":
import redis as redis_lib
r = redis_lib.Redis(host=server["ip"], port=server.get("port", 6379),
password=server.get("password", "") or None,
db=server.get("db_index", 0), socket_timeout=10,
ssl=server.get("ssl", False))
try:
r.ping()
return "ONLINE"
finally:
r.close()
DIFF 7: Дедупликация подключения в CLI (БАГ #7, УЛУЧШЕНИЕ)
Почему
Три функции (run_redis_cmd, redis_info, redis_keys) содержат одинаковый блок
из 6 строк для создания Redis-подключения. При изменении параметров (например, добавление
ssl_cert_reqs) нужно менять в 3 местах.
Файл: tools/ssh.py — добавить хелпер перед строкой 946
ДОБАВИТЬ:
def _get_redis_client(server: dict):
"""Create a Redis client from server config. Single source of truth."""
import redis as redis_lib
return redis_lib.Redis(
host=server["ip"],
port=server.get("port", 6379),
password=server.get("password", "") or None,
db=server.get("db_index", 0),
decode_responses=True,
socket_timeout=10,
ssl=server.get("ssl", False),
)
Затем заменить во всех трёх функциях:
# Было (в каждой из 3 функций):
import redis as redis_lib
host = server["ip"]
port = server.get("port", 6379)
password = server.get("password", "") or None
db_index = server.get("db_index", 0)
ssl_enabled = server.get("ssl", False)
r = redis_lib.Redis(host=host, port=port, password=password, db=db_index,
decode_responses=True, socket_timeout=10, ssl=ssl_enabled)
# Стало:
r = _get_redis_client(server)
Порядок применения
| # | Дифф | Файлы | Строк | Приоритет |
|---|---|---|---|---|
| 1 | Парсер shlex | redis_client.py, ssh.py |
8 | КРИТИЧНЫЙ |
| 2 | GUI stats | redis_tab.py |
10 | СРЕДНИЙ |
| 3 | SSL в GUI | redis_client.py, server_dialog.py |
4 | СРЕДНИЙ |
| 4 | CLI error handling | ssh.py |
6 | НИЗКИЙ |
| 5 | Double PING | status_checker.py |
2 | НИЗКИЙ |
| 6 | CLI status fix | ssh.py |
4 | НИЗКИЙ |
| 7 | Дедупликация | ssh.py |
-12 (убрано дублей) | УЛУЧШЕНИЕ |
Всего: ~20 строк нового кода, -12 строк дублей = +8 строк нетто.
Схема: что сломано и что фиксит каждый дифф
Пользователь вводит: SET mykey "hello world"
│
├─ CLI (ssh.py) ├─ GUI (redis_tab.py)
│ │ │ │
│ command.split() │ client.execute()
│ ❌ DIFF 1 фиксит │ │
│ │ command.split() ← redis_client.py
│ │ ❌ DIFF 1 фиксит
│ │
│ ├─ _refresh_stats()
│ │ execute("DBSIZE") → "(integer) N"
│ │ ❌ DIFF 2 фиксит → dbsize() → int
│ │
│ │ execute("INFO memory") → строка без \r\n
│ │ ❌ DIFF 2 фиксит → info("memory") → dict
│ │
├─ SSL подключение ├─ SSL подключение
│ ✅ Работает │ ❌ DIFF 3 фиксит
Результаты верификации (аудит плана)
Каждый дифф проверен тремя независимыми аудиторами против текущей кодовой базы.
Сводная таблица
| DIFF | Точное совпадение ДО↔код | Риски | Статус |
|---|---|---|---|
| 1 — shlex | ✅ redis_client.py:78 + ssh.py:958 | Нулевой. 11 команд протестированы | ОДОБРЕН |
| 2 — stats | ✅ redis_tab.py:191-208 | Нулевой. dbsize()→int, info()→dict подтверждено | ОДОБРЕН |
| 3 — SSL | ✅ redis_client.py:21-43 + dialog:22 | Нулевой. use_ssl чекбокс уже существует в UI | ОДОБРЕН |
| 4 — errors | ✅ ssh.py:957-975 | Нулевой. finally вызывается даже при sys.exit | ОДОБРЕН |
| 5 — ping | ✅ status_checker.py:98-110 | Нулевой. connect() содержит ping() | ОДОБРЕН |
| 6 — status | ✅ ssh.py:698-703 | Нулевой. try-finally корректно | ОДОБРЕН |
| 7 — dedup | ✅ 3 копии по 9 строк | НИЗКИЙ — см. замечание ниже | ОДОБРЕН с замечанием |
Замечания аудиторов
DIFF 1:
- shlex.split() протестирован на: PING, GET, SET, HGETALL, INFO, SCAN, SELECT, MSET, CONFIG SET, значения с кавычками, Windows-пути
- Все 11 типов команд парсятся корректно
- Fallback на split() при ValueError (незакрытые кавычки) — корректный
- Inline import shlex соответствует паттерну проекта (lazy imports)
DIFF 2:
dbsize()всегда возвращает int (0 при ошибке, никогда None)info("memory")возвращает dict с ключом"used_memory_human"f"{keys_count:,}"безопасен для 0 и любого int- Thread race condition существует но pre-existing — DIFF 2 не вводит новых
DIFF 3:
use_ssl— согласовано с паттерном проекта (winrm, grafana, prometheus используют то же)- UI чекбокс
use_sslуже существует в server_dialog.py, нужно только добавить в FIELD_MAP - Dual-key fallback
server.get("ssl") or server.get("use_ssl")покрывает CLI и GUI пути ssl=Falseпо умолчанию — не ломает существующие подключения
DIFF 5:
connect()вызываетself._conn.ping()на строке 42 redis_client.py — подтверждено- Второй ping через
check_connection()— гарантированно дубликат
DIFF 7 — ЗАМЕЧАНИЕ:
- Status check в ssh.py:698-703 содержит 4-й дубликат подключения Redis
- План НЕ включает его в дедупликацию
- Рекомендация: Обновить status check тоже при реализации DIFF 7
- Примечание: decode_responses=True в хелпере безопасен для status check (ping() не зависит от декодирования)
Итог верификации
7 из 7 диффов ОДОБРЕНЫ. Ни один не сломает существующую функциональность. Единственное замечание: DIFF 7 стоит расширить на status check (4-й дубликат).
Связанные документы
REDIS_AUDIT.md— полный отчёт аудитаplans/reliable-sftp-upload.md— план по SFTPSFTP_UPLOAD_AUDIT.md— аудит загрузкиSFTP_UPLOAD_DIFFS.md— диффы загрузки