File size: 22,606 Bytes
c0e2219 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 | """
Вспомогательные утилиты: процессы, архивы, сеть, чтение потоков.
"""
import os
import re
import threading
import time
from os import name as os_name
from pathlib import Path
from shutil import move as shutil_move
from subprocess import (
PIPE, Popen, STDOUT, TimeoutExpired,
check_output, CalledProcessError,
)
from sys import stdout as sys_stdout
from typing import TypedDict
from urllib.parse import unquote, urlparse
from requests import get as get_url, head as get_head
from requests.structures import CaseInsensitiveDict
from ._config import WORK_FOLDER, HEADERS
from ._logger import logger
# ---------------------------------------------------------------------------
# Типы
# ---------------------------------------------------------------------------
class RunResult(TypedDict):
status_code: int
output: str
# ---------------------------------------------------------------------------
# Процессы
# ---------------------------------------------------------------------------
def run(
command: str,
cwd: str | Path | None = None,
live_output: bool = False,
timeout: float | None = None,
) -> RunResult:
"""
Выполняет shell-команду, возвращает код выхода и вывод.
Args:
command: Shell-команда.
cwd: Рабочая директория (None — текущая).
live_output: Выводить прогресс в реальном времени.
timeout: Таймаут в секундах (None — без ограничений).
Returns:
RunResult: {'status_code': int, 'output': str}.
При таймауте status_code == -1.
"""
_encodings = ['utf-8', 'iso-8859-5', 'windows-1251', 'cp866', 'koi8-r', 'mac_cyrillic']
_progress_re = re.compile(r'\d+%|\d+/\d+')
def _decode(data: bytes) -> str:
for enc in _encodings:
try:
return data.decode(enc)
except UnicodeDecodeError:
continue
return data.decode('utf-8', errors='replace')
process = Popen(command, shell=True, cwd=cwd, stdout=PIPE, stderr=STDOUT)
# Режим без живого вывода: communicate() с поддержкой таймаута
if not live_output:
try:
stdout_data, _ = process.communicate(timeout=timeout)
except TimeoutExpired:
process.kill()
process.communicate()
msg = f'Таймаут ({timeout}с): {command}'
logger.warning(msg)
return RunResult(status_code=-1, output=msg)
lines = [ln.strip() for ln in _decode(stdout_data).splitlines() if ln.strip()]
return RunResult(status_code=process.returncode, output='\n'.join(lines))
# Режим живого вывода (для ручного использования, таймаут приблизительный)
final_output: list[str] = []
last_progress = ''
deadline = time.time() + timeout if timeout else None
for raw_line in iter(process.stdout.readline, b''): # type: ignore[union-attr]
if deadline and time.time() > deadline:
process.kill()
process.wait()
final_output.append(f'[ТАЙМАУТ после {timeout}с]')
break
line = _decode(raw_line.strip())
if _progress_re.search(line):
last_progress = line
sys_stdout.write('\r' + line)
sys_stdout.flush()
else:
final_output.append(line)
sys_stdout.write(line + '\n')
sys_stdout.flush()
try:
process.wait(timeout=5)
except TimeoutExpired:
process.kill()
process.wait()
if last_progress:
final_output.append(last_progress)
return RunResult(status_code=process.returncode, output='\n'.join(final_output))
def kill_process_by_name(name: str) -> None:
"""
Завершает процессы текущего пользователя по подстроке имени.
Ограничение по UID предотвращает случайное завершение чужих процессов.
"""
run(f'pkill -TERM -u "$(id -u)" -f "{name}" 2>/dev/null; true', timeout=5)
def is_process_running(process_name: str | Path) -> bool:
try:
output = check_output(['pgrep', '-f', str(process_name)], text=True)
return bool(output.strip())
except CalledProcessError:
return False
def terminate_process(process_name: str, process_obj: Popen) -> None:
"""
Корректно завершает процесс туннеля.
Сначала SIGTERM + wait(5с), при зависании — SIGKILL.
"""
for stream in (process_obj.stdout, process_obj.stderr, process_obj.stdin):
if stream:
try:
stream.close()
except Exception:
pass
try:
process_obj.terminate()
process_obj.wait(timeout=5)
except TimeoutExpired:
process_obj.kill()
process_obj.wait()
except Exception:
pass
kill_process_by_name(process_name)
def _drain_raw(raw_stream: object) -> None:
"""Сливает данные из сырого потока в никуда, предотвращая блокировку пайпа."""
try:
os.set_blocking(raw_stream.fileno(), True) # type: ignore[attr-defined]
except Exception:
pass
try:
while True:
data = raw_stream.read(4096) # type: ignore[attr-defined]
if not data:
break
except Exception:
pass
def drain_process_output(process: Popen) -> None:
"""
Запускает daemon-потоки для слива stdout/stderr долгоживущего процесса.
Без этого туннельный процесс заблокируется, когда системный буфер
пайпа (обычно 64 КБ) заполнится непрочитанными логами.
"""
for stream in (process.stdout, process.stderr):
if stream:
raw = getattr(stream, 'raw', stream)
t = threading.Thread(target=_drain_raw, args=(raw,), daemon=True)
t.start()
def read_until_pattern(
process: Popen,
url_pattern: str,
timeout: float = 20.0,
read_from_stderr: bool = False,
read_both_streams: bool = False,
) -> tuple[str, str]:
"""
Читает вывод процесса с таймаутом, ища URL-паттерн.
Использует блокирующие read() в daemon-потоках вместо select() +
неблокирующих read(). select()-подход даёт гонку при быстрых коротких
записях (< 1мс): select сигнализирует «readable», но к моменту вызова
read() данные ещё не попали в буфер пайпа — read возвращает пусто.
Блокирующий read в потоке этой гонки не имеет: он просто ждёт данных.
После нахождения URL потоки автоматически переходят в режим дренирования:
продолжают читать и отбрасывать данные, предотвращая переполнение
системного буфера пайпа (обычно 64 КБ) за время жизни туннеля.
Args:
process: Запущенный Popen с открытыми потоками PIPE.
url_pattern: Regex для поиска URL.
timeout: Максимальное время ожидания (сек).
read_from_stderr: Читать из stderr (иначе stdout).
read_both_streams: Читать из обоих потоков одновременно (для SSH).
Returns:
Кортеж (matched_url, full_output_so_far).
Raises:
RuntimeError: Паттерн не найден за timeout секунд.
"""
import queue as _queue
if read_both_streams:
active = [s for s in (process.stdout, process.stderr) if s]
elif read_from_stderr:
active = [process.stderr] if process.stderr else []
else:
active = [process.stdout] if process.stdout else []
if not active:
raise RuntimeError('Потоки вывода процесса недоступны.')
q: _queue.Queue[bytes | None] = _queue.Queue()
drain_mode = threading.Event()
def _reader(stream) -> None:
"""
Блокирующий читатель в daemon-потоке.
Пока URL не найден — пушит чанки в очередь.
После установки drain_mode — читает и отбрасывает (дренирование).
"""
raw = getattr(stream, 'raw', stream)
try:
# Явно возвращаем в блокирующий режим на случай,
# если где-то раньше FD был переведён в неблокирующий
os.set_blocking(raw.fileno(), True)
except Exception:
pass
try:
while True:
chunk = raw.read(4096)
if not chunk: # EOF — процесс закрыл поток
break
if drain_mode.is_set():
pass # Дренирование: читаем и отбрасываем
else:
q.put(chunk) # Ищем URL: передаём в очередь
except Exception:
pass
finally:
# Сигнал EOF только если мы всё ещё ищем URL
if not drain_mode.is_set():
q.put(None)
for stream in active:
t = threading.Thread(target=_reader, args=(stream,), daemon=True)
t.start()
buffer = b''
deadline = time.time() + timeout
n_sentinels = 0
while time.time() < deadline:
remaining = max(0.01, deadline - time.time())
try:
item = q.get(timeout=min(remaining, 0.5))
except _queue.Empty:
if process.poll() is not None:
# Процесс завершился — дочитываем остатки из очереди
while True:
try:
item = q.get_nowait()
except _queue.Empty:
break
if item is None:
n_sentinels += 1
else:
buffer += item
break
continue
if item is None:
n_sentinels += 1
if n_sentinels >= len(active):
break # Все читатели достигли EOF
continue
buffer += item
text = buffer.decode('utf-8', errors='ignore')
match = re.search(url_pattern, text)
if match:
drain_mode.set() # Переключаем потоки в режим дренирования
return match.group(), text
raise RuntimeError(
f'URL не найден за {timeout} сек. Вывод процесса:\n'
+ buffer.decode('utf-8', errors='ignore')
)
def move_path(old_path: Path | str, new_path: Path | str) -> None:
old, new = Path(old_path), Path(new_path)
if not old.exists():
raise RuntimeError(f'Не найден исходный путь для перемещения: {old}')
try:
old.replace(new)
except Exception:
try:
shutil_move(str(old), str(new))
except Exception:
if os_name == 'posix':
run(f'mv "{old}" "{new}"')
else:
run(f'move "{old}" "{new}"')
# ---------------------------------------------------------------------------
# Файлы и архивы
# ---------------------------------------------------------------------------
def _snapshot(folder: Path) -> set[Path]:
"""Рекурсивный снимок всех файлов в директории (включая вложенные папки)."""
if not folder.exists():
return set()
return {f for f in folder.rglob('*') if f.is_file()}
def determine_archive_format(filepath: str | Path) -> str | None:
"""Определяет формат архива по сигнатуре байт (magic bytes)."""
filepath = Path(filepath)
zip_sig = bytes([0x50, 0x4B, 0x03, 0x04])
seven_z = bytes([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C])
lzma_xz = bytes([0xFD, 0x37, 0x7A, 0x58, 0x5A])
tgz = bytes([0x1F, 0x8B])
tbz = bytes([0x42, 0x5A, 0x68])
ustar = bytes([0x75, 0x73, 0x74, 0x61, 0x72])
with filepath.open('rb') as fh:
header = fh.read(262)
if header.startswith(zip_sig): return 'zip'
if header.startswith(seven_z): return '7z'
if header.startswith(lzma_xz): return '7z'
if header.startswith(tgz): return 'tar.gz'
if header.startswith(tbz): return 'tar.bz2'
if header[0x101:0x101 + len(ustar)] == ustar: return 'tar'
return None
def unpack_archive(
archive_path: str | Path,
dest_path: str | Path,
rm_archive: bool = True,
) -> None:
"""
Распаковывает архив в указанную директорию.
Поддерживает: tar, tar.gz, tar.bz2, tar.xz, gz (одиночный файл), zip, 7z.
При неудаче основного метода делает fallback к 7z.
Принимает URL — скачивает архив сам.
"""
if str(archive_path).startswith(('https://', 'http://')):
archive_path = download(str(archive_path), save_path=WORK_FOLDER, progress=False)
archive_path = Path(archive_path)
dest_path = Path(dest_path)
if not archive_path.exists():
raise RuntimeError(f'Архив не найден: {archive_path}')
fmt = determine_archive_format(archive_path)
dest_path.mkdir(parents=True, exist_ok=True)
def _rm() -> None:
if rm_archive:
archive_path.unlink(missing_ok=True)
def _try_7z() -> None:
result = run(
f'7z -bso0 -bd -mmt4 -slp -y x "{archive_path}" -o"{dest_path}"',
timeout=180,
)
if result['status_code'] != 0:
raise RuntimeError(
f'Не удалось распаковать {archive_path.name}.\n{result["output"]}'
)
_rm()
try:
if fmt == 'zip' or archive_path.suffix == '.zip':
r = run(f'unzip -o "{archive_path}" -d "{dest_path}"', timeout=180)
if r['status_code'] != 0:
raise RuntimeError(r['output'])
_rm()
elif fmt in ('7z',) or archive_path.suffix == '.7z':
r = run(
f'7z -bso0 -bd -slp -y x "{archive_path}" -o"{dest_path}"',
timeout=180,
)
if r['status_code'] != 0:
raise RuntimeError(r['output'])
_rm()
elif fmt in ('tar', 'tar.gz', 'tar.bz2') or archive_path.suffix in (
'.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tar.xz', '.txz', '.gz',
):
# GNU tar определяет формат сжатия автоматически через -xpf
r = run(f'tar -xpf "{archive_path}" -C "{dest_path}"', timeout=180)
if r['status_code'] != 0:
# Возможно, это одиночный gzip-файл без tar-обёртки (напр. tmole-linux.gz)
gz_r = run(f'gzip -df "{archive_path}"', timeout=60)
if gz_r['status_code'] != 0:
raise RuntimeError(
f'tar: {r["output"]}\ngzip: {gz_r["output"]}'
)
# gzip -f уже удалил .gz и создал распакованный файл на месте
return
_rm()
else:
_try_7z()
except RuntimeError as e:
logger.debug(f'Основной метод распаковки не сработал ({e}), fallback к 7z.')
_try_7z()
# ---------------------------------------------------------------------------
# Сеть
# ---------------------------------------------------------------------------
def is_ipv4(address: str) -> bool:
pattern = re.compile(
r'^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$'
)
return bool(pattern.match(address))
def is_valid_url(url: str, custom_headers: dict | None = None) -> bool:
"""
Проверяет доступность URL (HEAD или GET запрос).
Returns:
True, если сервер вернул HTTP 2xx.
"""
req_headers = HEADERS.copy()
if custom_headers:
req_headers.update(custom_headers)
for _ in range(2):
try:
with get_url(url, headers=req_headers, allow_redirects=True, stream=True, timeout=10) as resp:
if 200 <= resp.status_code < 300:
return True
except Exception:
try:
resp = get_head(url, headers=req_headers, allow_redirects=True, timeout=10)
if 200 <= resp.status_code < 300:
return True
except Exception:
pass
else:
break
return False
def get_filename_from_headers(headers: CaseInsensitiveDict) -> str | None:
cd = headers.get('content-disposition')
if not cd:
return headers.get('filename')
for part in cd.split(';'):
part = part.strip()
if part.startswith('filename*='):
encoding, _, encoded = part[len('filename*='):].partition("''")
return unquote(encoded, encoding=encoding)
if part.startswith('filename='):
return part[len('filename='):].strip('"')
return None
def download(
url: str,
filename: str | Path | None = None,
save_path: str | Path | None = None,
progress: bool = True,
extra_headers: dict[str, str] | None = None,
) -> Path:
"""
Скачивает файл по URL.
Args:
url: URL файла.
filename: Имя для сохранения (None — из заголовков или URL).
save_path: Папка сохранения (None — текущая директория).
progress: Показывать прогресс загрузки.
extra_headers: Дополнительные HTTP-заголовки.
Returns:
Path к скачанному файлу.
Raises:
RuntimeError: При ошибке сети или записи файла.
"""
save_path = Path(save_path) if save_path else Path.cwd()
save_path.mkdir(parents=True, exist_ok=True)
req_headers = HEADERS.copy()
if extra_headers:
req_headers.update(extra_headers)
try:
resp = get_url(url, stream=True, allow_redirects=True, headers=req_headers, timeout=60)
resp.raise_for_status()
except Exception as e:
raise RuntimeError(f'Не удалось скачать файл по ссылке {url}:\n{e}')
file_size = int(resp.headers.get('content-length', 0))
raw_name = (
str(filename)
if filename
else (get_filename_from_headers(resp.headers) or Path(urlparse(resp.url).path).name)
)
file_name = Path(raw_name).name
file_path = save_path / file_name
chunk_size = max(4096, file_size // 2000) if file_size else 4096
downloaded = 0
start = time.time()
try:
with open(file_path, 'wb') as fp:
for chunk in resp.iter_content(chunk_size=chunk_size):
if chunk:
fp.write(chunk)
if progress and file_size:
downloaded += len(chunk)
elapsed = time.time() - start
print(
f'\rзагрузка {file_name}: '
f'{downloaded / file_size * 100:.2f}% | {elapsed:.2f} сек.',
end='',
)
except Exception as e:
raise RuntimeError(f'Не удалось сохранить файл из {url}:\n{e}')
if progress and file_size:
print()
logger.debug(f'Скачан: {file_path} ({file_size} байт)')
return file_path
def get_github_latest_release_url(repo: str, asset_pattern: str) -> str:
"""
Возвращает URL загрузки актуального релизного ассета с GitHub.
Args:
repo: Репозиторий в формате 'owner/repo'.
asset_pattern: Регулярное выражение для имени ассета.
Returns:
browser_download_url для найденного ассета.
Raises:
RuntimeError: Если ассет не найден или GitHub API недоступен.
"""
api_url = f'https://api.github.com/repos/{repo}/releases/latest'
resp = get_url(api_url, timeout=10)
resp.raise_for_status()
assets = resp.json().get('assets', [])
for asset in assets:
if re.search(asset_pattern, asset['name']):
return asset['browser_download_url']
available = [a['name'] for a in assets]
raise RuntimeError(
f'Ассет "{asset_pattern}" не найден в {repo}. '
f'Доступные: {available}'
)
|