style(project): 🎨 improve all docstrings and centralize mail variables in mail_utils
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
"""CLI tool to parse, classify and report NOSYMAG LAME MDC error logs."""
|
||||||
|
|
||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from logwatcher.cli import app
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
"""Entrypoint of the package."""
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,9 +51,7 @@ def _match_n2_pattern(log_entry: LogEntry) -> str | None:
|
|||||||
def classify_log_entries(
|
def classify_log_entries(
|
||||||
log_entries: list[LogEntry],
|
log_entries: list[LogEntry],
|
||||||
) -> tuple[list[LogEntry], list[LogEntry]]:
|
) -> tuple[list[LogEntry], list[LogEntry]]:
|
||||||
"""
|
"""Separate logs that require N2 intervention from those that don't."""
|
||||||
Separate logs that require N2 intervention from those that don't.
|
|
||||||
"""
|
|
||||||
relevant_log_entries = []
|
relevant_log_entries = []
|
||||||
irrelevant_log_entries = []
|
irrelevant_log_entries = []
|
||||||
|
|
||||||
|
|||||||
@@ -19,5 +19,7 @@ FRENCH_TIMEZONE = timezone(offset=timedelta(hours=2)) # UTC+2 = CEST
|
|||||||
|
|
||||||
# file type
|
# file type
|
||||||
class SourceType(Enum):
|
class SourceType(Enum):
|
||||||
|
"""Designate which type of template to use while building reports."""
|
||||||
|
|
||||||
FILE = "fichier"
|
FILE = "fichier"
|
||||||
MAIL = "mail"
|
MAIL = "mail"
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ def setup_logging(
|
|||||||
file_path: Path = Path("output/logs/logwatcher.log"),
|
file_path: Path = Path("output/logs/logwatcher.log"),
|
||||||
verbose: bool = False,
|
verbose: bool = False,
|
||||||
) -> logging.Logger:
|
) -> logging.Logger:
|
||||||
"""
|
"""Create a new instance of Logger named 'logger' customized for the logwatcher package.
|
||||||
Create a new instance of Logger named 'logger' customized for the logwatcher package.
|
|
||||||
The format display in order the time, the level of log and the log message
|
The format display in order the time, the level of log and the log message
|
||||||
The logs are only redirect in the standard output.
|
The logs are only redirect in the standard output.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -14,13 +14,10 @@ from exchangelib import (
|
|||||||
)
|
)
|
||||||
from exchangelib.errors import UnauthorizedError
|
from exchangelib.errors import UnauthorizedError
|
||||||
|
|
||||||
from logwatcher.utils import get_or_create_folder
|
from logwatcher.mail_utils import ANALYZED_FOLDER, LOG_IN_ATTACHMENT_PATTERN, get_or_create_folder
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ANALYZED_FOLDER = "Analyzed"
|
|
||||||
LOG_IN_ATTACHMENT_PATTERN = "Le compte-rendu contient plus de 100 lignes."
|
|
||||||
|
|
||||||
|
|
||||||
def _find_log_attachment(mail: Message) -> FileAttachment | None:
|
def _find_log_attachment(mail: Message) -> FileAttachment | None:
|
||||||
"""Find the right log file attachment among other content such as images from footers.
|
"""Find the right log file attachment among other content such as images from footers.
|
||||||
@@ -53,6 +50,7 @@ def _get_attachment_content(attachment: FileAttachment) -> str:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
all logs in one string
|
all logs in one string
|
||||||
|
|
||||||
"""
|
"""
|
||||||
raw = attachment.content
|
raw = attachment.content
|
||||||
if raw is None:
|
if raw is None:
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ from exchangelib import Account, Folder
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ANALYZED_FOLDER = "Analyzed"
|
||||||
|
LOG_IN_ATTACHMENT_PATTERN = "Le compte-rendu contient plus de 100 lignes."
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_folder(account: Account, folder_name: str) -> Folder:
|
def get_or_create_folder(account: Account, folder_name: str) -> Folder:
|
||||||
"""Return the 'Analyzed' folder, creating it if it doesn't exist.
|
"""Return the 'Analyzed' folder, creating it if it doesn't exist.
|
||||||
|
|
||||||
@@ -6,9 +6,7 @@ from logwatcher.config import DATETIME_FORMAT
|
|||||||
|
|
||||||
@dataclass()
|
@dataclass()
|
||||||
class LogEntry:
|
class LogEntry:
|
||||||
"""
|
"""Represents a line in log file."""
|
||||||
Represents a line in log file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
server_ip: str
|
server_ip: str
|
||||||
mdc_server_name: str
|
mdc_server_name: str
|
||||||
@@ -19,22 +17,23 @@ class LogEntry:
|
|||||||
raw_line: str # full initial log
|
raw_line: str # full initial log
|
||||||
error_name: str = ""
|
error_name: str = ""
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self): # noqa: D105
|
||||||
return self.error_message
|
return self.error_message
|
||||||
|
|
||||||
def get_full_message(self):
|
def get_full_message(self):
|
||||||
|
"""Return raw line of the log entry."""
|
||||||
return self.raw_line
|
return self.raw_line
|
||||||
|
|
||||||
def get_start_time(self) -> str:
|
def get_start_time(self) -> str: # TODO: remove or adapt mentions of start_time in code base
|
||||||
|
"""Return formatted start time."""
|
||||||
return self.start_time.strftime(DATETIME_FORMAT)
|
return self.start_time.strftime(DATETIME_FORMAT)
|
||||||
|
|
||||||
def get_error_time(self) -> str:
|
def get_error_time(self) -> str: # TODO: remove or adapt mentions of error_time in code base
|
||||||
|
"""Return formatted error time."""
|
||||||
return self.error_time.strftime(DATETIME_FORMAT)
|
return self.error_time.strftime(DATETIME_FORMAT)
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
"""
|
"""Each property must be validated by specific regex defined in config.py."""
|
||||||
Each property must be validated by specific regex defined in config.py
|
|
||||||
"""
|
|
||||||
# error time cannot be earlier than start time
|
# error time cannot be earlier than start time
|
||||||
if self.start_time > self.error_time:
|
if self.start_time > self.error_time:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from exchangelib import Account, FileAttachment, Message
|
from exchangelib import Account, FileAttachment, Message
|
||||||
|
|
||||||
from logwatcher.utils import get_or_create_folder
|
from logwatcher.mail_utils import get_or_create_folder
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ LOG_PATTERN = re.compile(
|
|||||||
|
|
||||||
|
|
||||||
def parse_line(log_line: str) -> LogEntry | None:
|
def parse_line(log_line: str) -> LogEntry | None:
|
||||||
"""
|
"""Parse a raw log line into a LogEntry object.
|
||||||
Parse a raw log line into a LogEntry object.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
log_line: a line in a log file received by N2 technicians
|
log_line: a line in a log file received by N2 technicians
|
||||||
@@ -40,6 +39,7 @@ def parse_line(log_line: str) -> LogEntry | None:
|
|||||||
Returns:
|
Returns:
|
||||||
LogEntry if the line is a valid line, None if
|
LogEntry if the line is a valid line, None if
|
||||||
it is empty or not a valid line.
|
it is empty or not a valid line.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
match = LOG_PATTERN.search(log_line)
|
match = LOG_PATTERN.search(log_line)
|
||||||
if not match:
|
if not match:
|
||||||
@@ -62,8 +62,8 @@ def parse_line(log_line: str) -> LogEntry | None:
|
|||||||
|
|
||||||
|
|
||||||
def parse_lines(lines: Iterable[str]) -> list[LogEntry]:
|
def parse_lines(lines: Iterable[str]) -> list[LogEntry]:
|
||||||
"""
|
"""Parse an iterable of log lines into LogEntry objects.
|
||||||
Parse an iterable of log lines into LogEntry objects.
|
|
||||||
Unrecognized lines are skipped. Logs an error for lines that raise a ValueError.
|
Unrecognized lines are skipped. Logs an error for lines that raise a ValueError.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -71,6 +71,7 @@ def parse_lines(lines: Iterable[str]) -> list[LogEntry]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of parsed LogEntry objects.
|
List of parsed LogEntry objects.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
log_entries: list[LogEntry] = []
|
log_entries: list[LogEntry] = []
|
||||||
for index, line in enumerate(lines):
|
for index, line in enumerate(lines):
|
||||||
@@ -85,14 +86,14 @@ def parse_lines(lines: Iterable[str]) -> list[LogEntry]:
|
|||||||
|
|
||||||
|
|
||||||
def parse_file(log_file_path: Path) -> list[LogEntry]:
|
def parse_file(log_file_path: Path) -> list[LogEntry]:
|
||||||
"""
|
"""Transform the content of a log file into a list of LogEntry.
|
||||||
Transforms the content of a log file into a list of LogEntry.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
log_file_path: Path of a log file
|
log_file_path: Path of a log file
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A list of LogEntry
|
A list of LogEntry
|
||||||
|
|
||||||
"""
|
"""
|
||||||
with open(log_file_path, "r", encoding="windows-1252") as log_file:
|
with open(log_file_path, "r", encoding="windows-1252") as log_file:
|
||||||
logger.info(f"\tparsing log file '{log_file_path.name}' started.")
|
logger.info(f"\tparsing log file '{log_file_path.name}' started.")
|
||||||
|
|||||||
+27
-11
@@ -57,6 +57,17 @@ La liste des erreurs se trouvent en pièce jointe `n2.log`.
|
|||||||
|
|
||||||
|
|
||||||
def get_period(entries: list[LogEntry]) -> tuple[str, str]:
|
def get_period(entries: list[LogEntry]) -> tuple[str, str]:
|
||||||
|
"""Get start date and end date among dates of log entries.
|
||||||
|
|
||||||
|
Check all log entries dates and find the timestamp of earliest and latest generated logs
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entries: list of log entries
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
start date and end date in string format
|
||||||
|
|
||||||
|
"""
|
||||||
if not entries:
|
if not entries:
|
||||||
return "", ""
|
return "", ""
|
||||||
times: list[datetime] = [e.error_time for e in entries]
|
times: list[datetime] = [e.error_time for e in entries]
|
||||||
@@ -73,9 +84,8 @@ def write_log_report(
|
|||||||
end_date: str,
|
end_date: str,
|
||||||
output_dir: Path = RESULT_PATH,
|
output_dir: Path = RESULT_PATH,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""Write each relevant, irrelevant and general reports in their respective output file.
|
||||||
Write each relevant, irrelevant and general reports in their respective output
|
|
||||||
file.
|
|
||||||
Use the range date of relevant and irrelevant lists to get the period of time
|
Use the range date of relevant and irrelevant lists to get the period of time
|
||||||
the logs were generated.
|
the logs were generated.
|
||||||
|
|
||||||
@@ -88,8 +98,7 @@ def write_log_report(
|
|||||||
irrelevant: List of not n2 log entry
|
irrelevant: List of not n2 log entry
|
||||||
nb_files: Number of files scanned
|
nb_files: Number of files scanned
|
||||||
output_dir: Location where all reports will be written
|
output_dir: Location where all reports will be written
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
"""
|
||||||
logger.info("\twriting reports job started.")
|
logger.info("\twriting reports job started.")
|
||||||
reports_dict = build_reports(
|
reports_dict = build_reports(
|
||||||
@@ -116,8 +125,8 @@ def build_reports(
|
|||||||
end_date: str,
|
end_date: str,
|
||||||
nb_files: int,
|
nb_files: int,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""
|
"""Build the three output reports: n2, other, and all.
|
||||||
Build the three output reports: n2, other, and all.
|
|
||||||
"all" report contains both "n2" and "other" sections.
|
"all" report contains both "n2" and "other" sections.
|
||||||
"n2" and "other" are two unique sections.
|
"n2" and "other" are two unique sections.
|
||||||
|
|
||||||
@@ -129,8 +138,10 @@ def build_reports(
|
|||||||
start_date: Date of the oldest log entry in relevant + irrelevant list
|
start_date: Date of the oldest log entry in relevant + irrelevant list
|
||||||
end_date: Date of the newest log entry in relevant + irrelevant list
|
end_date: Date of the newest log entry in relevant + irrelevant list
|
||||||
nb_files: number of files scanned
|
nb_files: number of files scanned
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary of reports in string format
|
Dictionary of reports in string format
|
||||||
|
|
||||||
"""
|
"""
|
||||||
logger.info("\t\tbuilding reports job started.")
|
logger.info("\t\tbuilding reports job started.")
|
||||||
relevant_report = _render_target_report(
|
relevant_report = _render_target_report(
|
||||||
@@ -197,6 +208,7 @@ def build_mail_summary(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A short summary suitable for an email body.
|
A short summary suitable for an email body.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
logger.info("generating mail body...")
|
logger.info("generating mail body...")
|
||||||
return MAIL_TEMPLATE.substitute(
|
return MAIL_TEMPLATE.substitute(
|
||||||
@@ -212,30 +224,34 @@ def build_mail_summary(
|
|||||||
def _render_target_report(
|
def _render_target_report(
|
||||||
log_entries: list[LogEntry], target_template: Template
|
log_entries: list[LogEntry], target_template: Template
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""Render report using either N2_SUPPORT_TEMPLATE or OTHER_TEMPLATE.
|
||||||
Render report using either N2_SUPPORT_TEMPLATE or OTHER_TEMPLATE.
|
|
||||||
Call _render_entries() and include rendered log entries in the
|
Call _render_entries() and include rendered log entries in the
|
||||||
new report.
|
new report.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
log_entries: List of log entries
|
log_entries: List of log entries
|
||||||
target_template: Template to use for N2_SUPPORT_TEMPLATE
|
target_template: Template to use for N2_SUPPORT_TEMPLATE
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
content of report in string format
|
content of report in string format
|
||||||
|
|
||||||
"""
|
"""
|
||||||
error_list = _render_entries(log_entries)
|
error_list = _render_entries(log_entries)
|
||||||
return target_template.substitute(nb_errors=len(log_entries), error_list=error_list)
|
return target_template.substitute(nb_errors=len(log_entries), error_list=error_list)
|
||||||
|
|
||||||
|
|
||||||
def _render_entries(log_entries: list[LogEntry]) -> str:
|
def _render_entries(log_entries: list[LogEntry]) -> str:
|
||||||
"""
|
"""Render a list of log entries using the `ERROR_TEMPLATE`.
|
||||||
Render a list of log entries using the `ERROR_TEMPLATE`.
|
|
||||||
Used in generated report files in the `output` directory
|
Used in generated report files in the `output` directory
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
log_entries: List of log entries
|
log_entries: List of log entries
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
error report in string format
|
error report in string format
|
||||||
|
|
||||||
"""
|
"""
|
||||||
error_list = []
|
error_list = []
|
||||||
for index, log_entry in enumerate(log_entries, start=1):
|
for index, log_entry in enumerate(log_entries, start=1):
|
||||||
|
|||||||
@@ -10,16 +10,12 @@ from logwatcher.parser import parse_file
|
|||||||
|
|
||||||
|
|
||||||
def _pattern_name_to_filename(name: str) -> str:
|
def _pattern_name_to_filename(name: str) -> str:
|
||||||
"""
|
"""Return the fixture corresponding to the error code name."""
|
||||||
Returns the fixture corresponding to the error code name
|
|
||||||
"""
|
|
||||||
return f"CR_{name.lower()}.txt"
|
return f"CR_{name.lower()}.txt"
|
||||||
|
|
||||||
|
|
||||||
def _read_fixture(path: Path) -> str:
|
def _read_fixture(path: Path) -> str:
|
||||||
"""
|
"""Read a file with fallback Windows-1252."""
|
||||||
Read a file with fallback Windows-1252.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
content = path.read_text(encoding="utf-8")
|
content = path.read_text(encoding="utf-8")
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
@@ -31,10 +27,7 @@ def _read_fixture(path: Path) -> str:
|
|||||||
|
|
||||||
@pytest.mark.parametrize("name, pattern", N2_PATTERNS.items())
|
@pytest.mark.parametrize("name, pattern", N2_PATTERNS.items())
|
||||||
def test_pattern_matches_fixture(valid_log_dir, name, pattern):
|
def test_pattern_matches_fixture(valid_log_dir, name, pattern):
|
||||||
"""
|
"""Assert each N2 pattern must match at least one line in its fixture."""
|
||||||
Assert each N2 pattern must match at least one line in its fixture.
|
|
||||||
"""
|
|
||||||
|
|
||||||
fixture = valid_log_dir / _pattern_name_to_filename(name)
|
fixture = valid_log_dir / _pattern_name_to_filename(name)
|
||||||
if not fixture.exists():
|
if not fixture.exists():
|
||||||
msg = f"No fixture named '{fixture}'"
|
msg = f"No fixture named '{fixture}'"
|
||||||
@@ -58,9 +51,7 @@ def test_pattern_matches_fixture(valid_log_dir, name, pattern):
|
|||||||
|
|
||||||
|
|
||||||
def test_patterns_do_not_overlap():
|
def test_patterns_do_not_overlap():
|
||||||
"""
|
"""Assert that a log line can match only one N2 pattern."""
|
||||||
Assert that a log line can match only one N2 pattern.
|
|
||||||
"""
|
|
||||||
fixtures_dir = FIXTURE_PATH
|
fixtures_dir = FIXTURE_PATH
|
||||||
for fixture in fixtures_dir.glob("CR_*.txt"):
|
for fixture in fixtures_dir.glob("CR_*.txt"):
|
||||||
line_list = [
|
line_list = [
|
||||||
@@ -79,10 +70,7 @@ def test_patterns_do_not_overlap():
|
|||||||
|
|
||||||
|
|
||||||
def test_no_orphan_fixtures():
|
def test_no_orphan_fixtures():
|
||||||
"""
|
"""Assert that a fixture exist only if its associated pattern exists."""
|
||||||
Assert that a fixture exist only if its associated pattern exists.
|
|
||||||
Aucun fichier de fixture ne doit exister sans pattern associé.
|
|
||||||
"""
|
|
||||||
expected = {f"CR_{name.lower()}.txt" for name in N2_PATTERNS}
|
expected = {f"CR_{name.lower()}.txt" for name in N2_PATTERNS}
|
||||||
actual = {f.name for f in FIXTURE_PATH.glob("CR_*.txt")}
|
actual = {f.name for f in FIXTURE_PATH.glob("CR_*.txt")}
|
||||||
orphan = actual - expected
|
orphan = actual - expected
|
||||||
@@ -90,9 +78,7 @@ def test_no_orphan_fixtures():
|
|||||||
|
|
||||||
|
|
||||||
def test_classify_log_entries_all_relevant(valid_log_dir: Path):
|
def test_classify_log_entries_all_relevant(valid_log_dir: Path):
|
||||||
"""
|
"""Must return an empty irrelevant log entry list."""
|
||||||
Must return an empty irrelevant log entry list
|
|
||||||
"""
|
|
||||||
log_file = valid_log_dir / "only_relevant_logs.txt"
|
log_file = valid_log_dir / "only_relevant_logs.txt"
|
||||||
log_entries = parse_file(log_file)
|
log_entries = parse_file(log_file)
|
||||||
|
|
||||||
@@ -103,9 +89,7 @@ def test_classify_log_entries_all_relevant(valid_log_dir: Path):
|
|||||||
|
|
||||||
|
|
||||||
def test_classify_log_entries_none_relevant(valid_log_dir: Path):
|
def test_classify_log_entries_none_relevant(valid_log_dir: Path):
|
||||||
"""
|
"""Must return an empty relevant log entry list."""
|
||||||
Must return an empty relevant log entry list
|
|
||||||
"""
|
|
||||||
log_file = valid_log_dir / "no_relevant_logs.txt"
|
log_file = valid_log_dir / "no_relevant_logs.txt"
|
||||||
log_entries = parse_file(log_file)
|
log_entries = parse_file(log_file)
|
||||||
|
|
||||||
@@ -116,9 +100,7 @@ def test_classify_log_entries_none_relevant(valid_log_dir: Path):
|
|||||||
|
|
||||||
|
|
||||||
def test_classify_log_entries_mixed(valid_log_dir: Path):
|
def test_classify_log_entries_mixed(valid_log_dir: Path):
|
||||||
"""
|
"""Test classification on relevant and irrelevant log entry list."""
|
||||||
Test classification on relevant and irrelevant log entry list
|
|
||||||
"""
|
|
||||||
log_file = valid_log_dir / "mixed_logs.txt"
|
log_file = valid_log_dir / "mixed_logs.txt"
|
||||||
log_entries = parse_file(log_file)
|
log_entries = parse_file(log_file)
|
||||||
|
|
||||||
@@ -129,9 +111,7 @@ def test_classify_log_entries_mixed(valid_log_dir: Path):
|
|||||||
|
|
||||||
|
|
||||||
def test_classify_log_entries_empty_log_entries(invalid_log_dir: Path):
|
def test_classify_log_entries_empty_log_entries(invalid_log_dir: Path):
|
||||||
"""
|
"""Test classification on an empty log entry list."""
|
||||||
Test classification on an empty log entry list
|
|
||||||
"""
|
|
||||||
empty_file = invalid_log_dir / "empty_file.txt"
|
empty_file = invalid_log_dir / "empty_file.txt"
|
||||||
log_entries = parse_file(empty_file)
|
log_entries = parse_file(empty_file)
|
||||||
|
|
||||||
|
|||||||
+17
-14
@@ -1,14 +1,3 @@
|
|||||||
"""
|
|
||||||
this module contains all tests logging-related :
|
|
||||||
|
|
||||||
- the logger should have at least one StreamHandler and one FileHandler each time
|
|
||||||
logwatcher is launched.
|
|
||||||
- the StreamHandler must watch at an error level
|
|
||||||
- the FileHandler must watch at a debug
|
|
||||||
level
|
|
||||||
-
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from logging import DEBUG, ERROR, INFO, WARNING, FileHandler, StreamHandler, getLogger
|
from logging import DEBUG, ERROR, INFO, WARNING, FileHandler, StreamHandler, getLogger
|
||||||
@@ -20,6 +9,7 @@ from logwatcher.logging_config import _setup_formatter, _setup_handler, setup_lo
|
|||||||
|
|
||||||
|
|
||||||
def test_setup_handler(tmp_log_file):
|
def test_setup_handler(tmp_log_file):
|
||||||
|
"""Assert `setup_handler` instanciate one StreamHandler and one FileHandler."""
|
||||||
formatter = _setup_formatter()
|
formatter = _setup_formatter()
|
||||||
handler = _setup_handler(formatter=formatter, level=DEBUG)
|
handler = _setup_handler(formatter=formatter, level=DEBUG)
|
||||||
assert isinstance(handler, StreamHandler)
|
assert isinstance(handler, StreamHandler)
|
||||||
@@ -29,6 +19,10 @@ def test_setup_handler(tmp_log_file):
|
|||||||
|
|
||||||
|
|
||||||
def test_count_of_handler(tmp_log_file):
|
def test_count_of_handler(tmp_log_file):
|
||||||
|
"""Assert `setup_handler` instanciate exactly one StreamHandler and one FileHandler.
|
||||||
|
|
||||||
|
Must have exactly two handlers in total.
|
||||||
|
"""
|
||||||
logger = getLogger()
|
logger = getLogger()
|
||||||
count_file_handler = 0
|
count_file_handler = 0
|
||||||
count_stream_handler = 0
|
count_stream_handler = 0
|
||||||
@@ -46,6 +40,10 @@ def test_count_of_handler(tmp_log_file):
|
|||||||
|
|
||||||
|
|
||||||
def test_type_of_handlers():
|
def test_type_of_handlers():
|
||||||
|
"""Assert each handlers capture the right log level and use the right log format.
|
||||||
|
|
||||||
|
FileHandler must capute DEBUG logs while StreamHandler only capture ERROR logs.
|
||||||
|
"""
|
||||||
logger = logging.getLogger("logwatcher")
|
logger = logging.getLogger("logwatcher")
|
||||||
for handler in logger.handlers:
|
for handler in logger.handlers:
|
||||||
if isinstance(handler, FileHandler):
|
if isinstance(handler, FileHandler):
|
||||||
@@ -59,8 +57,9 @@ def test_type_of_handlers():
|
|||||||
|
|
||||||
|
|
||||||
def test_modified_format():
|
def test_modified_format():
|
||||||
"""
|
"""Assert `setup_logging` allow to use another log format.
|
||||||
when starting, the logger is first intialized, thus two handlers are at position
|
|
||||||
|
When starting, the logger is first intialized, thus two handlers are at position
|
||||||
1 and 2 in logger.handlers list.
|
1 and 2 in logger.handlers list.
|
||||||
"""
|
"""
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
@@ -82,6 +81,10 @@ def test_modified_format():
|
|||||||
|
|
||||||
|
|
||||||
def test_logging_in_correct_path(tmp_log_file):
|
def test_logging_in_correct_path(tmp_log_file):
|
||||||
|
"""Assert log file is correctly written in output directory.
|
||||||
|
|
||||||
|
Check content of the file. It must contain logs from CRITICAL to DEBUG level.
|
||||||
|
"""
|
||||||
logger = logging.getLogger("logwatcher")
|
logger = logging.getLogger("logwatcher")
|
||||||
# must be both in file and stdout
|
# must be both in file and stdout
|
||||||
logger.critical("hi- BYE")
|
logger.critical("hi- BYE")
|
||||||
@@ -100,7 +103,7 @@ def test_logging_in_correct_path(tmp_log_file):
|
|||||||
for line in content:
|
for line in content:
|
||||||
assert re.search(regex, line)
|
assert re.search(regex, line)
|
||||||
|
|
||||||
# test critical to warning
|
# test from critical to debug logs
|
||||||
assert re.search(r"CRITICAL: hi- BYE", content[0])
|
assert re.search(r"CRITICAL: hi- BYE", content[0])
|
||||||
assert re.search(r"ERROR: hi... ok, bye", content[1])
|
assert re.search(r"ERROR: hi... ok, bye", content[1])
|
||||||
assert re.search(r"WARNING: hi, are you alright?", content[2])
|
assert re.search(r"WARNING: hi, are you alright?", content[2])
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class OkProtocol:
|
|||||||
class OkAccount:
|
class OkAccount:
|
||||||
"""Account with a protocol that returns a version successfully."""
|
"""Account with a protocol that returns a version successfully."""
|
||||||
|
|
||||||
def __init__(self): # noqa: D102
|
def __init__(self): # noqa: D107
|
||||||
self.protocol = OkProtocol()
|
self.protocol = OkProtocol()
|
||||||
|
|
||||||
|
|
||||||
@@ -380,7 +380,7 @@ def test_fetch_log_messages_folder_path(make_mock_account):
|
|||||||
# extract_log_lines(content) #
|
# extract_log_lines(content) #
|
||||||
##############################
|
##############################
|
||||||
def test_extract_log_lines_split_and_clean(original_log_dir: Path):
|
def test_extract_log_lines_split_and_clean(original_log_dir: Path):
|
||||||
"""Splits content, strips \r\n, removes empty lines."""
|
r"""Split content, strips '\r\n', removes empty lines."""
|
||||||
file = original_log_dir / "CR_20260727110008.txt"
|
file = original_log_dir / "CR_20260727110008.txt"
|
||||||
content = file.read_text(encoding="windows-1252")
|
content = file.read_text(encoding="windows-1252")
|
||||||
lines = extract_log_lines(content)
|
lines = extract_log_lines(content)
|
||||||
@@ -412,7 +412,7 @@ def test_extract_log_lines_no_trailing_newline():
|
|||||||
|
|
||||||
|
|
||||||
def test_extract_log_lines_crlf():
|
def test_extract_log_lines_crlf():
|
||||||
"""Handles Windows \r\n line endings."""
|
r"""Handles Windows \r\n line endings."""
|
||||||
content = (
|
content = (
|
||||||
"Répertoire scanné : \\192.168.60.40\\e$\\MDC_1110\\Logs\n"
|
"Répertoire scanné : \\192.168.60.40\\e$\\MDC_1110\\Logs\n"
|
||||||
"\\192.168.60.40\\e$\\MDC_1110\\Logs\\26\\07\\27\\20260727083117.txt [27/07/2026 08:31:26] DOSSIER EN COURS : FLAMMIER [27/07/2026 08:31:53] Erreur FTP commande : Requested action not taken\r\n"
|
"\\192.168.60.40\\e$\\MDC_1110\\Logs\\26\\07\\27\\20260727083117.txt [27/07/2026 08:31:26] DOSSIER EN COURS : FLAMMIER [27/07/2026 08:31:53] Erreur FTP commande : Requested action not taken\r\n"
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from logwatcher.utils import get_or_create_folder
|
from logwatcher.mail_utils import get_or_create_folder
|
||||||
|
|
||||||
|
|
||||||
def test_get_or_create_analyzed_folder_exists(make_mock_account, monkeypatch):
|
def test_get_or_create_analyzed_folder_exists(make_mock_account, monkeypatch):
|
||||||
"""
|
"""Returns the existing 'Analyzed' folder without creating it.
|
||||||
Returns the existing 'Analyzed' folder without creating it.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
make_mock_account: Fake account owning the Logs folder.
|
make_mock_account: Fake account owning the Logs folder
|
||||||
monkeypatch: MonkeyPatch to generate test context
|
monkeypatch: MonkeyPatch to generate test context
|
||||||
|
|
||||||
"""
|
"""
|
||||||
existing_folder = MagicMock()
|
existing_folder = MagicMock()
|
||||||
account = make_mock_account(analyzed_folder=existing_folder)
|
account = make_mock_account(analyzed_folder=existing_folder)
|
||||||
|
|
||||||
fake_folder_cls = MagicMock()
|
fake_folder_cls = MagicMock()
|
||||||
with monkeypatch.context() as m:
|
with monkeypatch.context() as m:
|
||||||
m.setattr("logwatcher.utils.Folder", fake_folder_cls)
|
m.setattr("logwatcher.mail_utils.Folder", fake_folder_cls)
|
||||||
result = get_or_create_folder(account, "Analyzed")
|
result = get_or_create_folder(account, "Analyzed")
|
||||||
|
|
||||||
assert result is existing_folder
|
assert result is existing_folder
|
||||||
@@ -30,7 +30,7 @@ def test_get_or_create_analyzed_folder_creates(make_mock_account, monkeypatch):
|
|||||||
fake_folder_instance = MagicMock()
|
fake_folder_instance = MagicMock()
|
||||||
fake_folder_cls = MagicMock(return_value=fake_folder_instance)
|
fake_folder_cls = MagicMock(return_value=fake_folder_instance)
|
||||||
with monkeypatch.context() as m:
|
with monkeypatch.context() as m:
|
||||||
m.setattr("logwatcher.utils.Folder", fake_folder_cls)
|
m.setattr("logwatcher.mail_utils.Folder", fake_folder_cls)
|
||||||
result = get_or_create_folder(account, "Analyzed")
|
result = get_or_create_folder(account, "Analyzed")
|
||||||
|
|
||||||
assert result is fake_folder_instance
|
assert result is fake_folder_instance
|
||||||
@@ -7,6 +7,7 @@ from logwatcher.models import LogEntry
|
|||||||
|
|
||||||
|
|
||||||
def test_valid_log_entry():
|
def test_valid_log_entry():
|
||||||
|
"""Assert a log entry is correctly created when all arguments are correct."""
|
||||||
LogEntry(
|
LogEntry(
|
||||||
server_ip="192.168.13.27",
|
server_ip="192.168.13.27",
|
||||||
mdc_server_name="MDC_720",
|
mdc_server_name="MDC_720",
|
||||||
@@ -18,6 +19,7 @@ def test_valid_log_entry():
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_log_entry_invalid_dates():
|
def test_log_entry_invalid_dates():
|
||||||
|
"""Assert a log entry cannot be created if start time is not later than error time."""
|
||||||
with pytest.raises(ValueError, match="Error in date-times"):
|
with pytest.raises(ValueError, match="Error in date-times"):
|
||||||
LogEntry(
|
LogEntry(
|
||||||
server_ip="192.168.13.27",
|
server_ip="192.168.13.27",
|
||||||
@@ -30,6 +32,7 @@ def test_log_entry_invalid_dates():
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_equals_models():
|
def test_equals_models():
|
||||||
|
"""Assert two models are equals if they have the same value as attributes."""
|
||||||
log_entry_1 = LogEntry(
|
log_entry_1 = LogEntry(
|
||||||
server_ip="192.168.13.27",
|
server_ip="192.168.13.27",
|
||||||
mdc_server_name="MDC_720",
|
mdc_server_name="MDC_720",
|
||||||
|
|||||||
+21
-13
@@ -8,8 +8,8 @@ from logwatcher.parser import parse_file, parse_line, parse_lines
|
|||||||
|
|
||||||
@pytest.fixture(name="empty_log_file")
|
@pytest.fixture(name="empty_log_file")
|
||||||
def empty_log_file_fixture(invalid_log_dir: Path) -> Path:
|
def empty_log_file_fixture(invalid_log_dir: Path) -> Path:
|
||||||
"""
|
"""Path of an empty log file.
|
||||||
Path of an empty log file.
|
|
||||||
Ensures the parser does not raise an exception
|
Ensures the parser does not raise an exception
|
||||||
and returns an empty list.
|
and returns an empty list.
|
||||||
"""
|
"""
|
||||||
@@ -18,8 +18,8 @@ def empty_log_file_fixture(invalid_log_dir: Path) -> Path:
|
|||||||
|
|
||||||
@pytest.fixture(name="bad_log_file")
|
@pytest.fixture(name="bad_log_file")
|
||||||
def bad_log_file_fixture(invalid_log_dir: Path) -> Path:
|
def bad_log_file_fixture(invalid_log_dir: Path) -> Path:
|
||||||
"""
|
"""Path of a file containing only incorrectly formatted lines.
|
||||||
Path of a file containing only incorrectly formatted lines.
|
|
||||||
Each line fails at a different point in LOG_PATTERN
|
Each line fails at a different point in LOG_PATTERN
|
||||||
(invalid IP, missing timestamp, missing DOSSIER EN COURS, etc.).
|
(invalid IP, missing timestamp, missing DOSSIER EN COURS, etc.).
|
||||||
No LogEntry should be produced.
|
No LogEntry should be produced.
|
||||||
@@ -29,8 +29,8 @@ def bad_log_file_fixture(invalid_log_dir: Path) -> Path:
|
|||||||
|
|
||||||
@pytest.fixture(name="specific_logs")
|
@pytest.fixture(name="specific_logs")
|
||||||
def specific_logs_fixture(valid_log_dir: Path) -> Path:
|
def specific_logs_fixture(valid_log_dir: Path) -> Path:
|
||||||
"""
|
"""Single log lines chosen to cover specific cases.
|
||||||
Single log lines chosen to cover specific cases.
|
|
||||||
Each line is structurally valid and must be parsed
|
Each line is structurally valid and must be parsed
|
||||||
successfully. Used for unit tests of parse_line().
|
successfully. Used for unit tests of parse_line().
|
||||||
"""
|
"""
|
||||||
@@ -38,8 +38,8 @@ def specific_logs_fixture(valid_log_dir: Path) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def test_parse_line(specific_logs: Path):
|
def test_parse_line(specific_logs: Path):
|
||||||
"""
|
"""Every structurally valid line produces a complete LogEntry.
|
||||||
Every structurally valid line produces a complete LogEntry.
|
|
||||||
Ensures no required field is empty or None after parsing
|
Ensures no required field is empty or None after parsing
|
||||||
a line conforming to the LAME MDC format.
|
a line conforming to the LAME MDC format.
|
||||||
"""
|
"""
|
||||||
@@ -59,8 +59,8 @@ def test_parse_line(specific_logs: Path):
|
|||||||
|
|
||||||
|
|
||||||
def test_parse_empty_line():
|
def test_parse_empty_line():
|
||||||
"""
|
r"""An empty or structurally invalid line returns None.
|
||||||
An empty or structurally invalid line returns None.
|
|
||||||
Covered cases:
|
Covered cases:
|
||||||
- Empty string.
|
- Empty string.
|
||||||
- Directory header (\"Répertoire scanné : \\\\...\").
|
- Directory header (\"Répertoire scanné : \\\\...\").
|
||||||
@@ -73,6 +73,7 @@ def test_parse_empty_line():
|
|||||||
|
|
||||||
|
|
||||||
def test_parse_lines():
|
def test_parse_lines():
|
||||||
|
"""Assert parse_log find the valid logs among a mixed of logs."""
|
||||||
# mix of valid and invalid logs. Have 4 valid logs
|
# mix of valid and invalid logs. Have 4 valid logs
|
||||||
original_lines = [
|
original_lines = [
|
||||||
r"Répertoire scanné : \\192.168.13.22\e\MDC_240\Logs",
|
r"Répertoire scanné : \\192.168.13.22\e\MDC_240\Logs",
|
||||||
@@ -92,6 +93,7 @@ def test_parse_lines():
|
|||||||
|
|
||||||
|
|
||||||
def test_parse_valid_lines(specific_logs: Path):
|
def test_parse_valid_lines(specific_logs: Path):
|
||||||
|
"""Assert parse_lines works for all valid lines."""
|
||||||
# selected valid logs
|
# selected valid logs
|
||||||
with open(specific_logs, "r") as file:
|
with open(specific_logs, "r") as file:
|
||||||
log_lines = file.readlines()
|
log_lines = file.readlines()
|
||||||
@@ -102,20 +104,24 @@ def test_parse_valid_lines(specific_logs: Path):
|
|||||||
|
|
||||||
|
|
||||||
def test_parse_empty_lines(empty_log_file: Path):
|
def test_parse_empty_lines(empty_log_file: Path):
|
||||||
|
"""Assert parsing an empty line does not raise an error and return an empty log entry list."""
|
||||||
with open(empty_log_file, "r") as file:
|
with open(empty_log_file, "r") as file:
|
||||||
log_entries = parse_lines(file.readlines())
|
log_entries = parse_lines(file.readlines())
|
||||||
assert len(log_entries) == 0
|
assert len(log_entries) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_parse_invalid_lines(bad_log_file: Path):
|
def test_parse_invalid_lines(bad_log_file: Path):
|
||||||
|
"""Assert parsing an invalid line does not raise an error and return an empty log entry list."""
|
||||||
with open(bad_log_file, "r") as file:
|
with open(bad_log_file, "r") as file:
|
||||||
log_entries = parse_lines(file.readlines())
|
log_entries = parse_lines(file.readlines())
|
||||||
assert len(log_entries) == 0
|
assert len(log_entries) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_parse_files(original_log_dir: Path, valid_log_dir: Path):
|
def test_parse_files(original_log_dir: Path, valid_log_dir: Path):
|
||||||
"""
|
"""Assert parse_files works with real life logs.
|
||||||
|
|
||||||
Each real log file produces at least one valid LogEntry.
|
Each real log file produces at least one valid LogEntry.
|
||||||
|
|
||||||
Integration test: files provided by technicians contain
|
Integration test: files provided by technicians contain
|
||||||
a mix of valid lines and lines to be ignored.
|
a mix of valid lines and lines to be ignored.
|
||||||
Ensures the parser extracts at least one entry per file.
|
Ensures the parser extracts at least one entry per file.
|
||||||
@@ -130,7 +136,8 @@ def test_parse_files(original_log_dir: Path, valid_log_dir: Path):
|
|||||||
|
|
||||||
|
|
||||||
def test_parse_empty_file(empty_log_file):
|
def test_parse_empty_file(empty_log_file):
|
||||||
"""
|
"""Assert parsing an empty file does not raise an error and return an empty log entrt list instead.
|
||||||
|
|
||||||
An empty log file can be parsed and must returns an
|
An empty log file can be parsed and must returns an
|
||||||
empty LogEntry list.
|
empty LogEntry list.
|
||||||
"""
|
"""
|
||||||
@@ -139,7 +146,8 @@ def test_parse_empty_file(empty_log_file):
|
|||||||
|
|
||||||
|
|
||||||
def test_parse_bad_file(bad_log_file):
|
def test_parse_bad_file(bad_log_file):
|
||||||
"""
|
"""Assert parsing a bad file does not raise an error and return an empty log entry list instead.
|
||||||
|
|
||||||
A file containing only invalid lines produces nothing.
|
A file containing only invalid lines produces nothing.
|
||||||
Any line that does not match the LAME MDC structure
|
Any line that does not match the LAME MDC structure
|
||||||
(IP, timestamps, DOSSIER EN COURS, error message)
|
(IP, timestamps, DOSSIER EN COURS, error message)
|
||||||
|
|||||||
Reference in New Issue
Block a user