Merge branch 'refactor/62-64-56-config'
This commit is contained in:
@@ -129,9 +129,9 @@ DEFAULT_TO_DATE=2026-01-31
|
||||
| `REDMINE_USER` | Да, если нет токена | Логин Redmine. |
|
||||
| `REDMINE_PASSWORD` | Да, если нет токена | Пароль Redmine. |
|
||||
| `REDMINE_AUTHOR` | Нет | Имя автора для отчёта. |
|
||||
| `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию (`YYYY-MM-DD`). |
|
||||
| `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию (`YYYY-MM-DD`). Если не задана (и нет в YAML) — 1-е число текущего месяца. |
|
||||
| `DEFAULT_TO_DATE` | Нет | Конечная дата периода по умолчанию (`YYYY-MM-DD`). Если не задана, а `DEFAULT_FROM_DATE` задана — используется сегодняшняя дата. |
|
||||
| `REDMINE_VERIFY` | Нет | TLS-проверка: `true` / `false` / путь к CA bundle. |
|
||||
| `REDMINE_VERIFY` | Нет | TLS-проверка: `true` (по умолчанию — стандартная проверка средствами requests) / `false` / путь к CA bundle. Семантика совпадает с `redmine.verify_ssl` в YAML. |
|
||||
|
||||
## Использование
|
||||
|
||||
@@ -141,7 +141,8 @@ source .venv/bin/activate
|
||||
|
||||
### Основные сценарии
|
||||
|
||||
Отчёт за период по умолчанию:
|
||||
Отчёт за период по умолчанию — текущий месяц (с 1-го числа по сегодня),
|
||||
если период не задан через `--date`, env или YAML:
|
||||
|
||||
```bash
|
||||
redmine-reporter
|
||||
|
||||
@@ -65,6 +65,22 @@ email:
|
||||
attach: true
|
||||
```
|
||||
|
||||
### `redmine.verify_ssl` — проверка TLS-сертификата
|
||||
|
||||
Управляет проверкой TLS-сертификата Redmine. Семантика едина с переменной
|
||||
окружения `REDMINE_VERIFY`:
|
||||
|
||||
| Значение | Поведение |
|
||||
|---|---|
|
||||
| `true` (по умолчанию) | Стандартная проверка TLS средствами requests (системные CA / certifi) |
|
||||
| `false` | Проверка отключена — при запуске выводится предупреждение о риске MITM |
|
||||
| строка с путём, например `/etc/ssl/my-ca.pem` | Путь к собственному CA-bundle, передаётся в requests как есть |
|
||||
|
||||
До версии с унификацией `verify_ssl: true` подставлял захардкоженный путь
|
||||
`/etc/ssl/certs/ca-certificates.crt`, который отсутствует на части
|
||||
дистрибутивов. Теперь `true` в YAML и `REDMINE_VERIFY=true` в env работают
|
||||
одинаково — оба включают стандартную проверку без привязки к конкретному пути.
|
||||
|
||||
### `period.precision` — точность периода
|
||||
|
||||
Определяет, как вычисляется следующий период после фиксации:
|
||||
@@ -91,6 +107,13 @@ period:
|
||||
Аналогично работает `DEFAULT_TO_DATE`: если переменная не задана, а
|
||||
`DEFAULT_FROM_DATE` задана, конец периода = сегодня.
|
||||
|
||||
### Период по умолчанию — текущий месяц
|
||||
|
||||
Если период не задан ни одним из источников (`--date`, `DEFAULT_FROM_DATE`,
|
||||
`period.default_from`), отчёт строится за текущий месяц: начало периода —
|
||||
1-е число текущего месяца, конец — сегодняшняя дата. Период вычисляется
|
||||
на момент запуска и не хранится в коде или конфиге.
|
||||
|
||||
### `--commit` — автофиксация периода
|
||||
|
||||
Флаг `--commit` сохраняет использованный период в YAML-конфиг, чтобы следующий запуск автоматически начинался с нового периода.
|
||||
@@ -422,7 +445,7 @@ DEFAULT_TO_DATE=2026-06-30
|
||||
```
|
||||
|
||||
Если ни `.env`, ни YAML не заданы — используются кодовые дефолты (текущий месяц
|
||||
как период, стандартный путь сертификатов, пустой автор).
|
||||
как период, стандартная проверка TLS, пустой автор).
|
||||
|
||||
## Безопасность
|
||||
|
||||
|
||||
@@ -11,11 +11,9 @@ 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 = "/etc/ssl/certs/ca-certificates.crt"
|
||||
DEFAULT_REDMINE_VERIFY: Union[bool, str] = True
|
||||
FALSE_VALUES = {"0", "false", "no", "off"}
|
||||
TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||
|
||||
@@ -157,17 +155,23 @@ class AppConfig:
|
||||
|
||||
@classmethod
|
||||
def _resolve_verify(cls, raw: dict) -> Union[bool, str]:
|
||||
"""Семантика verify_ssl, единая с REDMINE_VERIFY из env:
|
||||
|
||||
- true (bool или строка) → True — стандартная проверка TLS (requests);
|
||||
- false (bool или строка) → False — проверка отключена;
|
||||
- любая другая строка → путь к CA-bundle как есть.
|
||||
"""
|
||||
value = raw.get("redmine", {}).get("verify_ssl")
|
||||
if value is None:
|
||||
return DEFAULT_REDMINE_VERIFY
|
||||
if isinstance(value, bool):
|
||||
return DEFAULT_REDMINE_VERIFY if value else False
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.lower()
|
||||
if normalized in FALSE_VALUES:
|
||||
return False
|
||||
if normalized in TRUE_VALUES:
|
||||
return DEFAULT_REDMINE_VERIFY
|
||||
return True
|
||||
return resolve_env_vars(value)
|
||||
return DEFAULT_REDMINE_VERIFY
|
||||
|
||||
@@ -243,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)
|
||||
|
||||
|
||||
@@ -5,12 +5,7 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from redmine_reporter.config import (
|
||||
DEFAULT_REDMINE_VERIFY,
|
||||
AppConfig,
|
||||
Config,
|
||||
EmailConfig,
|
||||
)
|
||||
from redmine_reporter.config import AppConfig, Config, EmailConfig
|
||||
|
||||
|
||||
@mock.patch.dict(
|
||||
@@ -104,9 +99,64 @@ def test_get_default_date_range_fallback():
|
||||
assert result == f"{start.isoformat()}--{today.isoformat()}"
|
||||
|
||||
|
||||
# -- #56: дефолтный период — текущий месяц (детерминированные тесты) --
|
||||
|
||||
|
||||
class TestDefaultPeriodIsCurrentMonth:
|
||||
"""Без --date, env и YAML период = с 1-го числа текущего месяца по сегодня."""
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_mid_month(self):
|
||||
from datetime import date
|
||||
|
||||
with mock.patch("redmine_reporter.config.date") as mock_date:
|
||||
mock_date.today.return_value = date(2026, 3, 14)
|
||||
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
|
||||
Config._app = None
|
||||
assert Config.get_default_date_range() == "2026-03-01--2026-03-14"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_first_day_of_month(self):
|
||||
from datetime import date
|
||||
|
||||
with mock.patch("redmine_reporter.config.date") as mock_date:
|
||||
mock_date.today.return_value = date(2026, 5, 1)
|
||||
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
|
||||
Config._app = None
|
||||
assert Config.get_default_date_range() == "2026-05-01--2026-05-01"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_yaml_default_from_falls_back_to_today(self):
|
||||
"""default_from задан, default_to нет → конец периода = сегодня."""
|
||||
from datetime import date
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("period:\n default_from: '2026-04-01'\n")
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
with mock.patch("redmine_reporter.config.date") as mock_date:
|
||||
mock_date.today.return_value = date(2026, 4, 20)
|
||||
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
|
||||
assert Config.get_default_date_range() == "2026-04-01--2026-04-20"
|
||||
|
||||
@mock.patch.dict(os.environ, {"DEFAULT_FROM_DATE": "2026-02-01"}, clear=True)
|
||||
def test_env_from_without_to_falls_back_to_today(self):
|
||||
"""DEFAULT_FROM_DATE задана, DEFAULT_TO_DATE нет → конец = сегодня."""
|
||||
from datetime import date
|
||||
|
||||
Config._app = None
|
||||
with mock.patch("redmine_reporter.config.date") as mock_date:
|
||||
mock_date.today.return_value = date(2026, 2, 10)
|
||||
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
|
||||
assert Config.get_default_date_range() == "2026-02-01--2026-02-10"
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_get_redmine_verify_default():
|
||||
assert Config.get_redmine_verify() == DEFAULT_REDMINE_VERIFY
|
||||
"""Без явной настройки проверка TLS включена (стандартные CA requests)."""
|
||||
Config._app = None
|
||||
assert Config.get_redmine_verify() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "false", "False", "no", "off"])
|
||||
@@ -219,14 +269,10 @@ class TestWarnWhenTlsVerificationDisabled:
|
||||
# -- #15: .env не должен переопределять переменные окружения --
|
||||
|
||||
|
||||
@mock.patch("dotenv.load_dotenv")
|
||||
@mock.patch("redmine_reporter.config.load_dotenv")
|
||||
def test_env_var_takes_priority_over_dotenv(mock_load):
|
||||
"""load_dotenv вызывается с override=False — env vars не перебиваются .env."""
|
||||
import importlib
|
||||
|
||||
from redmine_reporter import config as cfg_mod
|
||||
|
||||
importlib.reload(cfg_mod)
|
||||
Config.load_yaml("/nonexistent/config.yml")
|
||||
|
||||
mock_load.assert_called_once_with(override=False)
|
||||
|
||||
@@ -355,16 +401,57 @@ class TestAppConfigFromYaml:
|
||||
|
||||
assert cfg.redmine_api_key == "secret-token"
|
||||
|
||||
def test_verify_ssl_true_returns_default_ca_path(self):
|
||||
"""verify_ssl: true → DEFAULT_REDMINE_VERIFY (путь), не Python True."""
|
||||
def test_verify_ssl_true_returns_true(self):
|
||||
"""verify_ssl: true → bool True (стандартная проверка TLS)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("redmine:\n verify_ssl: true\n")
|
||||
|
||||
cfg = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
assert cfg.redmine_verify == DEFAULT_REDMINE_VERIFY
|
||||
assert cfg.redmine_verify is not True # не бул!
|
||||
assert cfg.redmine_verify is True
|
||||
|
||||
@pytest.mark.parametrize("value", ["true", "True", "yes", "on", "1"])
|
||||
def test_verify_ssl_true_string_returns_true(self, value):
|
||||
"""Строковые true-значения в YAML → bool True, как и из env."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(f"redmine:\n verify_ssl: '{value}'\n")
|
||||
|
||||
cfg = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
assert cfg.redmine_verify is True
|
||||
|
||||
def test_verify_ssl_custom_path_returns_path(self):
|
||||
"""Явный путь к CA-bundle в YAML передаётся как есть."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("redmine:\n verify_ssl: /etc/ssl/my-ca.pem\n")
|
||||
|
||||
cfg = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
assert cfg.redmine_verify == "/etc/ssl/my-ca.pem"
|
||||
|
||||
@pytest.mark.parametrize("value", ["false", "False", "no", "off", "0"])
|
||||
def test_verify_ssl_false_string_returns_false(self, value):
|
||||
"""Строковые false-значения в YAML → bool False."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(f"redmine:\n verify_ssl: '{value}'\n")
|
||||
|
||||
cfg = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
assert cfg.redmine_verify is False
|
||||
|
||||
def test_verify_ssl_missing_returns_true(self):
|
||||
"""Без verify_ssl в YAML — проверка TLS включена (True)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("redmine:\n url: https://x.com/\n")
|
||||
|
||||
cfg = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
assert cfg.redmine_verify is True
|
||||
|
||||
def test_verify_ssl_false_returns_false(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
|
||||
50
tests/test_import_side_effects.py
Normal file
50
tests/test_import_side_effects.py
Normal 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"
|
||||
@@ -49,7 +49,37 @@ class TestBuildMessage:
|
||||
msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1", [])
|
||||
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
|
||||
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):
|
||||
cfg = _make_email_config(from_="sender@example.com", attach=False)
|
||||
|
||||
Reference in New Issue
Block a user