refactor: remove import-time side effects

Импорт модулей больше не мутирует процесс:

- config.py: module-level load_dotenv(override=False) мутировал os.environ
  при любом импорте. Вызов перенесён в Config.load_yaml() — точку явной
  загрузки конфигурации, семантика override=False и путь по умолчанию
  сохранены. --config PATH (load_dotenv(path, override=True)) не затронут.
- mailer.py: глобальная регистрация email.charset.add_charset('utf-8', ...)
  меняла кодировку для всего процесса. Заменена на точечное применение
  8bit UTF-8 к телу письма в _utf8_text_part() — payload в UTF-8
  (surrogateescape) + Content-Transfer-Encoding: 8bit на конкретной части.
  Проверено: сериализация в байты (BytesGenerator, как в send_message)
  побайтово совпадает со старым поведением.

Тест test_env_var_takes_priority_over_dotenv адаптирован к новой точке
вызова load_dotenv; test_body_substitution переведён с as_string() на
декодированный payload (str-сериализация не-ASCII части без глобальной
мутации реестра теперь даёт base64; wire-формат 8bit сохранён и
зафиксирован test_wire_format_is_8bit_utf8).

Closes #64
This commit is contained in:
Кокос Артем Николаевич
2026-07-17 13:35:02 +07:00
parent 829f1b73fd
commit 1df1194f58
5 changed files with 106 additions and 17 deletions

View File

@@ -11,8 +11,6 @@ from dotenv import load_dotenv
from .yaml_config import check_file_permissions, resolve_env_vars from .yaml_config import check_file_permissions, resolve_env_vars
load_dotenv(override=False)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_REDMINE_VERIFY: Union[bool, str] = True DEFAULT_REDMINE_VERIFY: Union[bool, str] = True
@@ -249,7 +247,13 @@ class Config:
@classmethod @classmethod
def load_yaml(cls, path: str) -> None: def load_yaml(cls, path: str) -> None:
"""Загружает YAML-конфиг. Не бросает исключений при отсутствии файла.""" """Загружает конфигурацию приложения.
Сначала подгружает .env из текущей директории (override=False:
переменные окружения не перебиваются), затем читает YAML-конфиг.
Не бросает исключений при отсутствии файла.
"""
load_dotenv(override=False)
cls._app = AppConfig.from_yaml(path) cls._app = AppConfig.from_yaml(path)
@classmethod @classmethod

View File

@@ -1,6 +1,5 @@
"""Отправка сгенерированного отчёта по email через SMTP.""" """Отправка сгенерированного отчёта по email через SMTP."""
import email.charset as _charset
import os import os
import smtplib import smtplib
from email.mime.application import MIMEApplication from email.mime.application import MIMEApplication
@@ -12,10 +11,6 @@ from .client import RedmineAPIError
from .config import EmailConfig from .config import EmailConfig
from .types import ReportRow from .types import ReportRow
# Use 8bit transfer encoding for UTF-8 so non-ASCII text (e.g. Russian)
# appears literally in MIME output instead of base64.
_charset.add_charset("utf-8", _charset.SHORTEST, None, "utf-8")
SMTP_TIMEOUT = 30 SMTP_TIMEOUT = 30
MIME_TYPES: Dict[str, str] = { MIME_TYPES: Dict[str, str] = {
@@ -34,6 +29,20 @@ def _resolve_mime_type(file_path: str) -> str:
return MIME_TYPES.get(ext, "application/octet-stream") return MIME_TYPES.get(ext, "application/octet-stream")
def _utf8_text_part(text: str, subtype: str = "plain") -> MIMEText:
"""Создаёт текстовую MIME-часть с 8bit UTF-8 телом.
Кодировка применяется точечно к части письма, без глобальной мутации
реестра email.charset: payload хранится как UTF-8 (surrogateescape),
а Content-Transfer-Encoding выставляется в 8bit, чтобы не-ASCII текст
(например, русский) передавался литерально, а не в base64.
"""
part = MIMEText("", subtype, "utf-8")
part.set_payload(text.encode("utf-8").decode("ascii", "surrogateescape"))
part.replace_header("Content-Transfer-Encoding", "8bit")
return part
def _build_html_body(rows: List[ReportRow]) -> str: def _build_html_body(rows: List[ReportRow]) -> str:
"""Генерирует HTML-версию тела письма через HTMLFormatter.""" """Генерирует HTML-версию тела письма через HTMLFormatter."""
from .formatters.html import HTMLFormatter from .formatters.html import HTMLFormatter
@@ -66,11 +75,11 @@ def build_message(
# Тела письма: plain-text всегда, HTML по флагу # Тела письма: plain-text всегда, HTML по флагу
body_container = MIMEMultipart("alternative") body_container = MIMEMultipart("alternative")
body_container.attach(MIMEText(body, "plain", "utf-8")) body_container.attach(_utf8_text_part(body))
if email_config.html: if email_config.html:
html_body = _build_html_body(rows) html_body = _build_html_body(rows)
body_container.attach(MIMEText(html_body, "html", "utf-8")) body_container.attach(_utf8_text_part(html_body, "html"))
msg.attach(body_container) msg.attach(body_container)

View File

@@ -216,14 +216,10 @@ class TestWarnWhenTlsVerificationDisabled:
# -- #15: .env не должен переопределять переменные окружения -- # -- #15: .env не должен переопределять переменные окружения --
@mock.patch("dotenv.load_dotenv") @mock.patch("redmine_reporter.config.load_dotenv")
def test_env_var_takes_priority_over_dotenv(mock_load): def test_env_var_takes_priority_over_dotenv(mock_load):
"""load_dotenv вызывается с override=False — env vars не перебиваются .env.""" """load_dotenv вызывается с override=False — env vars не перебиваются .env."""
import importlib Config.load_yaml("/nonexistent/config.yml")
from redmine_reporter import config as cfg_mod
importlib.reload(cfg_mod)
mock_load.assert_called_once_with(override=False) mock_load.assert_called_once_with(override=False)

View File

@@ -0,0 +1,50 @@
"""#64: импорт модулей не должен давать side effects в процессе."""
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
def _run_in_subprocess(code: str, cwd: Path) -> str:
"""Выполняет code в чистом интерпретаторе с доступом к пакету."""
env = {**os.environ, "PYTHONPATH": str(REPO_ROOT)}
result = subprocess.run(
[sys.executable, "-c", code],
cwd=cwd,
env=env,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def test_config_import_does_not_mutate_os_environ(tmp_path):
"""import redmine_reporter.config не подгружает .env из текущей директории."""
(tmp_path / ".env").write_text("RR_IMPORT_PROBE=1\n")
output = _run_in_subprocess(
"import os\n"
"import redmine_reporter.config\n"
"print('RR_IMPORT_PROBE' in os.environ)\n",
cwd=tmp_path,
)
assert output == "False"
def test_mailer_import_does_not_mutate_global_charset_registry(tmp_path):
"""import redmine_reporter.mailer не меняет email.charset для всего процесса."""
output = _run_in_subprocess(
"from email.charset import BASE64, Charset\n"
"before = Charset('utf-8').body_encoding\n"
"import redmine_reporter.mailer\n"
"after = Charset('utf-8').body_encoding\n"
"print(before == BASE64, after == BASE64)\n",
cwd=tmp_path,
)
assert output == "True True"

View File

@@ -49,7 +49,37 @@ class TestBuildMessage:
msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1", []) msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1", [])
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"] plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
assert len(plain_parts) == 1 assert len(plain_parts) == 1
assert "Автор: Иванов, период: Q1" in plain_parts[0].as_string() payload = plain_parts[0].get_payload(decode=True).decode("utf-8")
assert "Автор: Иванов, период: Q1" in payload
def test_wire_format_is_8bit_utf8(self):
"""Сериализация в байты (как в smtplib.send_message) — 8bit UTF-8."""
from email.generator import BytesGenerator
from io import BytesIO
cfg = _make_email_config(body_text="Автор: {author}", attach=False)
msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", [])
buf = BytesIO()
BytesGenerator(buf, policy=msg.policy.clone(linesep="\r\n")).flatten(msg)
data = buf.getvalue()
assert b"Content-Transfer-Encoding: 8bit" in data
assert "Автор: Иванов".encode("utf-8") in data
def test_plain_part_uses_8bit_utf8(self):
"""Тела письма кодируются 8bit UTF-8 (не base64), точечно на часть."""
cfg = _make_email_config(
body_text="Автор: {author}, период: {period}", attach=False
)
msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", [])
plain = [p for p in msg.walk() if p.get_content_type() == "text/plain"][0]
assert plain["Content-Transfer-Encoding"] == "8bit"
assert (
plain.get_payload(decode=True).decode("utf-8")
== "Автор: Иванов, период: Q1"
)
def test_from_header(self): def test_from_header(self):
cfg = _make_email_config(from_="sender@example.com", attach=False) cfg = _make_email_config(from_="sender@example.com", attach=False)