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:
@@ -11,8 +11,6 @@ from dotenv import load_dotenv
|
||||
|
||||
from .yaml_config import check_file_permissions, resolve_env_vars
|
||||
|
||||
load_dotenv(override=False)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_REDMINE_VERIFY: Union[bool, str] = True
|
||||
@@ -249,7 +247,13 @@ class Config:
|
||||
|
||||
@classmethod
|
||||
def load_yaml(cls, path: str) -> None:
|
||||
"""Загружает YAML-конфиг. Не бросает исключений при отсутствии файла."""
|
||||
"""Загружает конфигурацию приложения.
|
||||
|
||||
Сначала подгружает .env из текущей директории (override=False:
|
||||
переменные окружения не перебиваются), затем читает YAML-конфиг.
|
||||
Не бросает исключений при отсутствии файла.
|
||||
"""
|
||||
load_dotenv(override=False)
|
||||
cls._app = AppConfig.from_yaml(path)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Отправка сгенерированного отчёта по email через SMTP."""
|
||||
|
||||
import email.charset as _charset
|
||||
import os
|
||||
import smtplib
|
||||
from email.mime.application import MIMEApplication
|
||||
@@ -12,10 +11,6 @@ from .client import RedmineAPIError
|
||||
from .config import EmailConfig
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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:
|
||||
"""Генерирует HTML-версию тела письма через HTMLFormatter."""
|
||||
from .formatters.html import HTMLFormatter
|
||||
@@ -66,11 +75,11 @@ def build_message(
|
||||
|
||||
# Тела письма: plain-text всегда, HTML по флагу
|
||||
body_container = MIMEMultipart("alternative")
|
||||
body_container.attach(MIMEText(body, "plain", "utf-8"))
|
||||
body_container.attach(_utf8_text_part(body))
|
||||
|
||||
if email_config.html:
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user