style(project): 🎨 improve all docstrings and centralize mail variables in mail_utils

This commit is contained in:
2026-09-17 12:59:07 +02:00
parent 6909ee7acf
commit 5e43a0cbb8
17 changed files with 116 additions and 101 deletions
+2
View File
@@ -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
+1
View File
@@ -2,6 +2,7 @@ from logwatcher.cli import app
def main(): def main():
"""Entrypoint of the package."""
app() app()
+1 -3
View File
@@ -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 = []
+2
View File
@@ -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"
+2 -2
View File
@@ -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.
""" """
+2 -4
View File
@@ -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.
+8 -9
View File
@@ -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(
+1 -1
View File
@@ -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__)
+7 -6
View File
@@ -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
View File
@@ -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):
+9 -29
View File
@@ -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
View File
@@ -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])
+3 -3
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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)