feat(cleaner): ✨ add command prune

this new command will be called by cron-like process to clean purge old logs and old mails. All files were formatted and mail_utils was renamed in utils since it now defines utility function not only for mail functions.
This commit is contained in:
2026-09-25 16:30:45 +02:00
parent 63c4d1387e
commit 02e85ddbfd
20 changed files with 471 additions and 164 deletions
+12
View File
@@ -8,3 +8,15 @@ EWS_URL=https://mail.client.fr/EWS/Exchange.asmx # Default mail server
## Report email ## Report email
N2_REPORT_RECIPIENTS=n2-support@client.fr N2_REPORT_RECIPIENTS=n2-support@client.fr
N2_REPORT_SUBJECT=[Logwatcher] Rapport erreurs N2 N2_REPORT_SUBJECT=[Logwatcher] Rapport erreurs N2
## Log retention
### number of days before archiving logs
LOG_RETENTION_DAYS=30
### number of days before deleting archive logs
ARCHIVED_LOG_RETENTION_DAYS=60
## Mail retention
### number of days before deleting mails
MAIL_RETENTION_DAYS=30
+2 -2
View File
@@ -82,7 +82,7 @@ def classify_log_entries(
irrelevant_log_entries = [] irrelevant_log_entries = []
previous_log_entry: LogEntry | None = None previous_log_entry: LogEntry | None = None
logger.info("\tclassification job started.") logger.info("classification job started.")
for log_entry in log_entries: for log_entry in log_entries:
error_name = _match_n2_pattern(log_entry) error_name = _match_n2_pattern(log_entry)
@@ -107,5 +107,5 @@ def classify_log_entries(
logger.info( logger.info(
f"\t\tn2 logs: {len(relevant_log_entries)}, other logs: {len(irrelevant_log_entries)}" f"\t\tn2 logs: {len(relevant_log_entries)}, other logs: {len(irrelevant_log_entries)}"
) )
logger.info("\tclassification job completed.") logger.info("classification job completed.")
return relevant_log_entries, irrelevant_log_entries return relevant_log_entries, irrelevant_log_entries
+153
View File
@@ -0,0 +1,153 @@
import datetime
import logging
import os
from pathlib import Path
from zipfile import ZipFile
from exchangelib import Account
from logwatcher.config import (
ARCHIVE_DIR,
DATE_FILE_FORMAT,
FRENCH_TIMEZONE,
LOGGING_PATH,
)
from logwatcher.utils import ANALYZED_FOLDER, LOG_FOLDER, SENT_FOLDER, is_log_file
logger = logging.getLogger(__name__)
def _get_retention_days(key: str) -> int:
"""Get specific environment variable defined by a number of days.
Args:
key: name of the environment variable
Raises:
KeyError
ValueError
Returns:
number of days found in environment variable `key`.
"""
try:
value = int(os.environ[key])
except KeyError:
logger.error(f"'{key}' is not defined in environment variables.")
raise
except ValueError:
logger.error(f"{os.environ[key]} must be an integer.")
raise
return value
def get_logs_to_archive(today: datetime.datetime) -> list[Path]:
"""Get all logs that are older than `LOG_RETENTION_DAYS`.
Args:
today: date and time of the current day
Returns:
A list of log file to archive.
"""
nb_days = _get_retention_days("LOG_RETENTION_DAYS")
logs_to_archive = []
cutoff_date = today - datetime.timedelta(days=nb_days)
for log_file in LOGGING_PATH.iterdir():
if is_log_file(log_file):
log_file_date = datetime.datetime.fromtimestamp(
log_file.stat().st_mtime, tz=FRENCH_TIMEZONE
)
if log_file_date < cutoff_date:
logs_to_archive.append(log_file)
if not logs_to_archive:
logger.warning("No log files old enough to be archived.")
return logs_to_archive
def archive_logs(today: datetime.datetime, log_files: list[Path]) -> None:
"""Archive old logs.
Due to its file name, the newly created archive could overwrite the old one
if the function is called at the same seconds.
Do nothing if `log_files` is empty.
Args:
today: date and time of the current day
log_files: log file to archive.
"""
if not log_files:
logger.info("No file to archive. Skip.")
return
ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
archive_file_name = f"archive_{today.strftime(DATE_FILE_FORMAT)}.zip"
with ZipFile(ARCHIVE_DIR / archive_file_name, "w") as archive_file:
for log_file in log_files:
logger.debug(f"Add '{log_file}' to archive.")
archive_file.write(log_file, arcname=log_file.name)
logger.debug(f"Delete '{log_file}'")
log_file.unlink()
def prune_logs(today: datetime.datetime) -> None:
"""Detect and delete old log archives.
Args:
today: date and time of the current day
"""
nb_days = _get_retention_days("ARCHIVED_LOG_RETENTION_DAYS")
cutoff_date = today - datetime.timedelta(days=nb_days)
ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
archive_files = list(ARCHIVE_DIR.iterdir())
if not archive_files:
logger.warning("No archive found.")
return
for archive_file in archive_files:
archive_date = datetime.datetime.fromtimestamp(
archive_file.stat().st_mtime, tz=FRENCH_TIMEZONE
)
if archive_date < cutoff_date:
archive_file.unlink()
def prune_mails(today: datetime.datetime, account: Account) -> None:
"""Get and delete all old mails from `ANALYZED_FOLDER` and `SENT_FOLDER`.
Args:
today: date and time of the current day
account: EWS account that owns and manage log mails
"""
analyzed_folder = account.msg_folder_root / LOG_FOLDER / ANALYZED_FOLDER
sent_folder = account.msg_folder_root / LOG_FOLDER / SENT_FOLDER
nb_days = _get_retention_days("MAIL_RETENTION_DAYS")
cutoff_date = today - datetime.timedelta(days=nb_days)
analyzed_mails = analyzed_folder.filter(datetime_sent__lt=cutoff_date)
sent_mails = sent_folder.filter(datetime_sent__lt=cutoff_date)
mails = list(analyzed_mails) + list(sent_mails)
if not mails:
logger.warning(f"No mail older than '{nb_days}' days found.")
return
ids = [(m.id, m.changekey) for m in mails]
account.bulk_delete(ids=ids)
+92 -34
View File
@@ -7,7 +7,20 @@ import typer
from logwatcher import __version__ from logwatcher import __version__
from logwatcher.classifier import classify_log_entries from logwatcher.classifier import classify_log_entries
from logwatcher.config import DATETIME_FORMAT, FRENCH_TIMEZONE, SourceType from logwatcher.cleaner import (
get_logs_to_archive,
archive_logs,
prune_logs,
prune_mails,
)
from logwatcher.config import (
DATE_FILE_FORMAT,
DATETIME_FORMAT,
FRENCH_TIMEZONE,
LOGGING_PATH,
RESULT_PATH,
SourceType,
)
from logwatcher.logging_config import setup_logging from logwatcher.logging_config import setup_logging
from logwatcher.mail_reader import ( from logwatcher.mail_reader import (
connect_to_mailbox, connect_to_mailbox,
@@ -19,6 +32,7 @@ from logwatcher.models import LogEntry
from logwatcher.notifier import send_n2_report from logwatcher.notifier import send_n2_report
from logwatcher.parser import parse_file, parse_lines from logwatcher.parser import parse_file, parse_lines
from logwatcher.reporter import build_mail_summary, get_period, write_log_report from logwatcher.reporter import build_mail_summary, get_period, write_log_report
from logwatcher.utils import is_log_file
app = typer.Typer(help="Logwatcher - Analysis and sorting of LAME MDC logs.") app = typer.Typer(help="Logwatcher - Analysis and sorting of LAME MDC logs.")
@@ -26,41 +40,46 @@ app = typer.Typer(help="Logwatcher - Analysis and sorting of LAME MDC logs.")
logger = logging.getLogger("logwatcher") logger = logging.getLogger("logwatcher")
def _is_log_file(path: Path) -> bool: def _get_files_from_dir(input_dir: Path) -> list[Path]:
"""Return True if file is a log file (.txt, .log, or extensionless). """Retrieve all log files from the `input_dir` directory.
Args: Args:
path: Path of a file input_dir: directory that contains log files
Returns: Returns:
True if file has .txt or .log extension or extensionless list of log files
False otherwise.
""" """
if not path.is_file():
return False
return path.suffix in {"", ".txt", ".log"}
def _get_files_from_dir(input_dir: Path) -> list[Path]:
if not input_dir.exists(): if not input_dir.exists():
logger.error(f"Unrecognized input directory '{input_dir}'.") logger.error(f"Unrecognized input directory '{input_dir}'.")
raise FileNotFoundError(f"No such directory at '{input_dir}'") raise FileNotFoundError(f"No such directory at '{input_dir}'")
return [file for file in input_dir.iterdir() if _is_log_file(file)] return [
current_file
for current_file in input_dir.iterdir()
if is_log_file(current_file)
]
def _files_to_log_entries(input_files: list[Path]) -> list[LogEntry]: def _files_to_log_entries(input_files: list[Path]) -> list[LogEntry]:
"""Parse each input file, raising if any path is not a file.""" """Parse each input file, raising if any path is not a file.
Args:
input_files: list of log files
Returns:
list of log entries
"""
log_entries = [] log_entries = []
for file in input_files: for current_file in input_files:
if not file.is_file(): if not current_file.is_file():
logger.error(f"Unrecognized input file '{file}'.") logger.error(f"Unrecognized input file '{current_file}'.")
raise ValueError(f"'{file}' is not a file") raise ValueError(f"'{current_file}' is not a file")
log_entries.extend(parse_file(file)) log_entries.extend(parse_file(current_file))
return log_entries return log_entries
def version_callback(value: bool): def version_callback(value: bool) -> None:
"""Print the version of package. """Print the version of package.
Stop the CLI after printing version. Stop the CLI after printing version.
@@ -81,15 +100,15 @@ def from_mails(
typer.Option( typer.Option(
"--output-dir", help="Output directories where reports will be stored." "--output-dir", help="Output directories where reports will be stored."
), ),
] = Path("output"), ] = RESULT_PATH,
): ) -> None:
"""Convert mail containing logs into reports for N2 technicians. """Convert mail containing logs into reports for N2 technicians.
Connect to the EWS server with service account, filter most recent mail logs, Connect to the EWS server with service account, filter most recent mail logs,
generate reports based on these mails and send mails to the technicians. generate reports based on these mails and send mails to the technicians.
Args: Args:
output_dir: Path to the generated reports folder output_dir: Output directories where reports will be stored.
""" """
logger.info("============ logwatcher mail started ============") logger.info("============ logwatcher mail started ============")
@@ -100,7 +119,7 @@ def from_mails(
nb_mails = len(messages) nb_mails = len(messages)
relevant, irrelevant = classify_log_entries(log_entries) relevant, irrelevant = classify_log_entries(log_entries)
today = datetime.now(FRENCH_TIMEZONE).strftime(DATETIME_FORMAT) today = datetime.now(tz=FRENCH_TIMEZONE).strftime(DATETIME_FORMAT)
start_date, end_date = get_period(relevant + irrelevant) start_date, end_date = get_period(relevant + irrelevant)
write_log_report( write_log_report(
@@ -136,7 +155,7 @@ def from_files(
typer.Option( typer.Option(
"--output-dir", help="Output directories where reports will be stored." "--output-dir", help="Output directories where reports will be stored."
), ),
] = Path("output"), ] = RESULT_PATH,
input_files: Annotated[ input_files: Annotated[
list[Path] | None, typer.Option("--input-files", help="Log files to scan.") list[Path] | None, typer.Option("--input-files", help="Log files to scan.")
] = None, ] = None,
@@ -144,7 +163,7 @@ def from_files(
Path | None, Path | None,
typer.Option("--input-dir", help="Directories containing log files."), typer.Option("--input-dir", help="Directories containing log files."),
] = None, ] = None,
): ) -> None:
"""Convert log files into two reports, one of n2 related logs, one without any n2 related logs. """Convert log files into two reports, one of n2 related logs, one without any n2 related logs.
These reports are sent by mail to n2 support technicians. These reports are sent by mail to n2 support technicians.
@@ -153,11 +172,7 @@ def from_files(
Args: Args:
input_files: Log files to scan. Either input_files or input_dir must be provided. input_files: Log files to scan. Either input_files or input_dir must be provided.
input_dir: Directories containing log files. Either input_files or input_dir must be provided. input_dir: Directories containing log files. Either input_files or input_dir must be provided.
output_dir: Required. Output directories where reports will be stored. output_dir: Output directories where reports will be stored.
version: Option that returns the version of package
Returns:
None
""" """
logger.info("============ logwatcher file started ============") logger.info("============ logwatcher file started ============")
@@ -176,11 +191,11 @@ def from_files(
logger.error("Neither input-files nor input-dir were provided.") logger.error("Neither input-files nor input-dir were provided.")
raise typer.BadParameter("Either input_files or input_dir must be provided") raise typer.BadParameter("Either input_files or input_dir must be provided")
logger.info(f"reports will be located at '{output_dir.cwd()}'") logger.info(f"reports will be located at '{output_dir.resolve()}'")
log_entries = _files_to_log_entries(log_files) log_entries = _files_to_log_entries(log_files)
relevant, irrelevant = classify_log_entries(log_entries) relevant, irrelevant = classify_log_entries(log_entries)
today = datetime.now(FRENCH_TIMEZONE).strftime(DATETIME_FORMAT) today = datetime.now(tz=FRENCH_TIMEZONE).strftime(DATETIME_FORMAT)
start_date, end_date = get_period(relevant + irrelevant) start_date, end_date = get_period(relevant + irrelevant)
write_log_report( write_log_report(
source_type=SourceType.FILE, source_type=SourceType.FILE,
@@ -195,6 +210,47 @@ def from_files(
logger.info("============ logwatcher file ended ============") logger.info("============ logwatcher file ended ============")
@app.command()
def prune(
mails: Annotated[
bool,
typer.Option("-m", "--mails", help="Prune old mails."),
] = False,
logs: Annotated[
bool,
typer.Option("-l", "--logs", help="Prune old logs."),
] = False,
) -> None:
"""Purge mails and logs based on the value of environment variables."""
if not mails and not logs:
raise typer.BadParameter("At least one of --mails or --logs must be provided.")
logger.info("============ logwatcher prune started ============")
today = datetime.now(tz=FRENCH_TIMEZONE)
if mails:
logger.debug("Mails selected to be pruned")
account = connect_to_mailbox()
logger.info("Start pruning mails.")
prune_mails(today, account)
logger.info("Pruning mails done.")
else:
logger.debug("No mails were purged.")
if logs:
logger.debug("Logs selected to be pruned")
logger.info("Start pruning logs.")
log_files = get_logs_to_archive(today)
if log_files:
archive_logs(today, log_files)
prune_logs(today)
logger.info("Pruning logs done.")
else:
logger.debug("No logs were purged.")
logger.info("============ logwatcher prune ended ============")
@app.callback() @app.callback()
def cli( def cli(
version: Annotated[ version: Annotated[
@@ -222,7 +278,9 @@ def cli(
version: if provided, script returns the version of package version: if provided, script returns the version of package
""" """
setup_logging(verbose=verbose) log_file_name = datetime.now(FRENCH_TIMEZONE).strftime(DATE_FILE_FORMAT) + ".log"
log_file = LOGGING_PATH / log_file_name # date and time of execution
setup_logging(verbose=verbose, file_path=log_file)
if __name__ == "__main__": if __name__ == "__main__":
+2
View File
@@ -7,12 +7,14 @@ from pathlib import Path
OUTPUT_PATH = Path("output") OUTPUT_PATH = Path("output")
RESULT_PATH = OUTPUT_PATH / "results" RESULT_PATH = OUTPUT_PATH / "results"
LOGGING_PATH = OUTPUT_PATH / "logs" LOGGING_PATH = OUTPUT_PATH / "logs"
ARCHIVE_DIR = LOGGING_PATH / "archives"
# test paths # test paths
TEST_PATH = Path("tests") TEST_PATH = Path("tests")
FIXTURE_PATH = TEST_PATH / "fixtures" FIXTURE_PATH = TEST_PATH / "fixtures"
# date and time # date and time
DATE_FILE_FORMAT = "%d%m%Y_%H%M%S"
DATETIME_FORMAT = "%d/%m/%Y %H:%M:%S" DATETIME_FORMAT = "%d/%m/%Y %H:%M:%S"
FRENCH_TIMEZONE = ZoneInfo("Europe/Paris") FRENCH_TIMEZONE = ZoneInfo("Europe/Paris")
+5 -3
View File
@@ -7,13 +7,13 @@ from logwatcher.config import DATETIME_FORMAT
def _setup_formatter( def _setup_formatter(
format: str = "[%(asctime)s] - %(levelname)s: %(message)s", format: str = "[%(asctime)s] - %(levelname)s: %(message)s",
datefmt: str = DATETIME_FORMAT, datefmt: str = DATETIME_FORMAT,
) -> logging.Formatter: ) -> logging.Formatter: # TODO: Add documentation
return logging.Formatter(fmt=format, datefmt=datefmt) return logging.Formatter(fmt=format, datefmt=datefmt)
def _setup_handler( def _setup_handler(
formatter: logging.Formatter, level: int, filename: Path | None = None formatter: logging.Formatter, level: int, filename: Path | None = None
) -> logging.Handler: ) -> logging.Handler: # TODO: Add documentation
handler: logging.Handler handler: logging.Handler
if filename: if filename:
handler = logging.FileHandler(filename=filename, encoding="utf-8") handler = logging.FileHandler(filename=filename, encoding="utf-8")
@@ -32,8 +32,10 @@ def setup_logging(
"""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. # TODO Complete docstring
""" """
file_path.parent.mkdir(parents=True, exist_ok=True)
_formatter = formatter if formatter else _setup_formatter() _formatter = formatter if formatter else _setup_formatter()
if verbose: if verbose:
+9 -8
View File
@@ -14,7 +14,12 @@ from exchangelib import (
) )
from exchangelib.errors import UnauthorizedError from exchangelib.errors import UnauthorizedError
from logwatcher.mail_utils import ANALYZED_FOLDER, LOG_IN_ATTACHMENT_PATTERN, get_or_create_folder from logwatcher.utils import (
ANALYZED_FOLDER,
LOG_FOLDER,
LOG_IN_ATTACHMENT_PATTERN,
get_or_create_folder,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -135,7 +140,7 @@ def fetch_log_messages(account: Account) -> list[Message]:
queryset of log-related mails queryset of log-related mails
""" """
logs_folder = account.msg_folder_root / "Logs" logs_folder = account.msg_folder_root / LOG_FOLDER
return list(logs_folder.all()) return list(logs_folder.all())
@@ -192,15 +197,11 @@ def move_analyzed_mails(account: Account, messages: list[Message]) -> None:
logger.info("No mails to move.") logger.info("No mails to move.")
return return
logger.info( logger.info(f"Moving {len(messages)} mails to '{ANALYZED_FOLDER}'")
f"Moving {len(messages)} mails to '{ANALYZED_FOLDER}'"
)
analyzed_folder = get_or_create_folder(account, ANALYZED_FOLDER) analyzed_folder = get_or_create_folder(account, ANALYZED_FOLDER)
ids = [(m.id, m.changekey) for m in messages] ids = [(m.id, m.changekey) for m in messages]
account.bulk_move(ids=ids, to_folder=analyzed_folder) account.bulk_move(ids=ids, to_folder=analyzed_folder)
logger.info( logger.info(f"Mails were successfully moved to '{ANALYZED_FOLDER}'.")
f"Mails were successfully moved to '{ANALYZED_FOLDER}'."
)
+6 -2
View File
@@ -24,11 +24,15 @@ class LogEntry:
"""Return raw line of the log entry.""" """Return raw line of the log entry."""
return self.raw_line return self.raw_line
def get_start_time(self) -> str: # TODO: remove or adapt mentions of start_time in code base def get_start_time(
self,
) -> str: # TODO: remove or adapt mentions of start_time in code base
"""Return formatted start time.""" """Return formatted start time."""
return self.start_time.strftime(DATETIME_FORMAT) return self.start_time.strftime(DATETIME_FORMAT)
def get_error_time(self) -> str: # TODO: remove or adapt mentions of error_time in code base def get_error_time(
self,
) -> str: # TODO: remove or adapt mentions of error_time in code base
"""Return formatted error time.""" """Return formatted error time."""
return self.error_time.strftime(DATETIME_FORMAT) return self.error_time.strftime(DATETIME_FORMAT)
+11 -5
View File
@@ -4,7 +4,7 @@ from pathlib import Path
from exchangelib import Account, FileAttachment, Message from exchangelib import Account, FileAttachment, Message
from logwatcher.mail_utils import get_or_create_folder from logwatcher.utils import SENT_FOLDER, get_or_create_folder
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -27,17 +27,23 @@ def send_n2_report(account: Account, summary: str, n2_log_file: Path) -> None:
# build file attachment # build file attachment
logger.info(f"Create attachment based on '{n2_log_file}' file.") logger.info(f"Create attachment based on '{n2_log_file}' file.")
n2_log_attachment = FileAttachment(name=n2_log_file.name, content=n2_log_file.read_bytes(), is_inline=False) n2_log_attachment = FileAttachment(
name=n2_log_file.name, content=n2_log_file.read_bytes(), is_inline=False
)
logger.info(f"Attachment '{n2_log_attachment.name}' was successfully created.") logger.info(f"Attachment '{n2_log_attachment.name}' was successfully created.")
# get sent folder # get sent folder
sent_folder = get_or_create_folder(account, "Sent") sent_folder = get_or_create_folder(account, SENT_FOLDER)
# create mail # create mail
logger.info(f"Create mail with account='{account.fullname}', recipients='{to}', subject='{subject}'.") logger.info(
f"Create mail with account='{account.fullname}', recipients='{to}', subject='{subject}'."
)
mail = Message(account=account, to_recipients=to, subject=subject, body=summary) mail = Message(account=account, to_recipients=to, subject=subject, body=summary)
mail.attach(n2_log_attachment) mail.attach(n2_log_attachment)
logger.info(f"Mail '{mail.subject}' was successfully created with attachment '{n2_log_attachment.name}'.") logger.info(
f"Mail '{mail.subject}' was successfully created with attachment '{n2_log_attachment.name}'."
)
# send and keep a copy to the Logs/Sent folder # send and keep a copy to the Logs/Sent folder
logger.info(f"Attempt to send mail '{mail.subject}'...") logger.info(f"Attempt to send mail '{mail.subject}'...")
+1 -1
View File
@@ -99,5 +99,5 @@ def parse_file(log_file_path: Path) -> list[LogEntry]:
logger.info(f"\tparsing log file '{log_file_path.name}' started.") logger.info(f"\tparsing log file '{log_file_path.name}' started.")
log_entries = parse_lines(log_file) log_entries = parse_lines(log_file)
logger.info(f"\t\t{len(log_entries)} lines were parsed.") logger.info(f"\t\t{len(log_entries)} lines were parsed.")
logger.info("\tparsing completed.") logger.info("parsing completed.")
return log_entries return log_entries
+19 -20
View File
@@ -48,7 +48,7 @@ ERROR_TEMPLATE = Template("""
MAIL_TEMPLATE = Template(""" MAIL_TEMPLATE = Template("""
Rapport analyse de log - $generation_date Rapport analyse de log - $generation_date
Nb de mail(s) analysé(s)\t : $nb_mail Nb de mail(s) analysé(s)\t : $nb_mails
Erreurs N2/Erreurs totales\t : $n2_errors/$total_errors Erreurs N2/Erreurs totales\t : $n2_errors/$total_errors
Période : $start_date -> $end_date Période : $start_date -> $end_date
@@ -59,7 +59,7 @@ La liste des erreurs se trouve 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. """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 Check all log entries dates and find the timestamp of earliest and latest generated logs.
Args: Args:
entries: list of log entries entries: list of log entries
@@ -84,36 +84,36 @@ 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 all error 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.
Args: Args:
end_date: # TODO
start_date: # TODO
today: # TODO
source_type: # TODO source_type: # TODO
relevant: List of n2 log entry relevant: List of n2 log entry
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
today: # TODO
start_date: # TODO
end_date: # TODO
output_dir: Location where all reports will be written output_dir: Location where all reports will be written
""" """
logger.info("\twriting reports job started.") logger.info("writing reports job started.")
reports_dict = build_reports( reports_dict = build_reports(
source_type, relevant, irrelevant, today, start_date, end_date, nb_files source_type, relevant, irrelevant, today, start_date, end_date, nb_files
) )
for target in reports_dict:
logger.info(f"\t\tgenerating report '{target}'...")
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
logger.debug(f"\t\t\t{output_dir} exists.") for target in reports_dict:
target_file = output_dir / str(target + ".log") logger.info(f"Generating report '{target}'...")
logger.debug(f"'{output_dir}' exists.")
target_file = output_dir / f"{target}.log"
target_file.write_text(reports_dict[target], encoding="windows-1252") target_file.write_text(reports_dict[target], encoding="windows-1252")
logger.debug(f"\t\t\treport was written in {target_file.name}") logger.debug(f"Report was written in {target_file.name}")
logger.info(f"\t\treport '{target}' was generated.") logger.info(f"Report '{target}' was generated.")
logger.info("\twriting reports job completed.") logger.info("Writing reports job completed.")
def build_reports( def build_reports(
@@ -143,7 +143,7 @@ def build_reports(
Dictionary of reports in string format Dictionary of reports in string format
""" """
logger.info("\t\tbuilding reports job started.") logger.info("building reports job started.")
relevant_report = _render_target_report( relevant_report = _render_target_report(
log_entries=relevant, target_template=N2_SUPPORT_TEMPLATE log_entries=relevant, target_template=N2_SUPPORT_TEMPLATE
) )
@@ -151,8 +151,7 @@ def build_reports(
irrelevant_report = _render_target_report( irrelevant_report = _render_target_report(
log_entries=irrelevant, target_template=OTHER_TEMPLATE log_entries=irrelevant, target_template=OTHER_TEMPLATE
) )
logger.info("\t\t\tn2 and other reports were generated.") logger.info("building reports job completed.")
logger.info("\t\tbuilding reports job completed.")
source_value = source_type.value.capitalize() source_value = source_type.value.capitalize()
@@ -199,7 +198,7 @@ def build_mail_summary(
"""Build a short plain-text summary of a logwatcher run. """Build a short plain-text summary of a logwatcher run.
Args: Args:
nb_mails: Number of log files parsed. nb_mails: Number of log mails parsed.
nb_errors_total: Total number of errors across all reports. nb_errors_total: Total number of errors across all reports.
nb_errors_n2: Number of N2-relevant errors. nb_errors_n2: Number of N2-relevant errors.
today: # TODO today: # TODO
@@ -213,7 +212,7 @@ def build_mail_summary(
logger.info("generating mail body...") logger.info("generating mail body...")
return MAIL_TEMPLATE.substitute( return MAIL_TEMPLATE.substitute(
generation_date=today, generation_date=today,
nb_mail=nb_mails, nb_mails=nb_mails,
n2_errors=nb_errors_n2, n2_errors=nb_errors_n2,
total_errors=nb_errors_total, total_errors=nb_errors_total,
start_date=start_date, start_date=start_date,
@@ -231,7 +230,7 @@ def _render_target_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 and OTHER_TEMPLATE
Returns: Returns:
content of report in string format content of report in string format
@@ -1,11 +1,14 @@
import logging import logging
from pathlib import Path
from exchangelib import Account, Folder from exchangelib import Account, Folder
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ANALYZED_FOLDER = "Analyzed" ANALYZED_FOLDER = "Analyzed"
LOG_FOLDER = "Logs"
LOG_IN_ATTACHMENT_PATTERN = "Le compte-rendu contient plus de 100 lignes." LOG_IN_ATTACHMENT_PATTERN = "Le compte-rendu contient plus de 100 lignes."
SENT_FOLDER = "Sent"
def get_or_create_folder(account: Account, folder_name: str) -> Folder: def get_or_create_folder(account: Account, folder_name: str) -> Folder:
@@ -19,7 +22,7 @@ def get_or_create_folder(account: Account, folder_name: str) -> Folder:
The existing or newly created 'Analyzed' folder. The existing or newly created 'Analyzed' folder.
""" """
log_folder = account.msg_folder_root / "Logs" log_folder = account.msg_folder_root / LOG_FOLDER
matches = list(log_folder.glob(folder_name)) matches = list(log_folder.glob(folder_name))
if matches: if matches:
@@ -30,3 +33,19 @@ def get_or_create_folder(account: Account, folder_name: str) -> Folder:
folder = Folder(parent=log_folder, name=folder_name) folder = Folder(parent=log_folder, name=folder_name)
folder.save() folder.save()
return folder return folder
def is_log_file(path: Path) -> bool:
"""Return True if file is a log file (.txt, .log, or extensionless).
Args:
path: Path of a file
Returns:
True if file has .txt or .log extension or extensionless
False otherwise.
"""
if not path.is_file():
return False
return path.suffix in {"", ".txt", ".log"}
+1
View File
@@ -0,0 +1 @@
"""Entry point to all package tests."""
+7 -2
View File
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock
import pytest import pytest
from exchangelib import Account, Message from exchangelib import Account, Message
from logwatcher.config import FIXTURE_PATH from logwatcher.config import FIXTURE_PATH, OUTPUT_PATH
from logwatcher.logging_config import setup_logging from logwatcher.logging_config import setup_logging
# original log directory # original log directory
@@ -19,18 +19,21 @@ VALID_LOG_DIR = FIXTURE_PATH / "valid_logs"
INVALID_LOG_DIR = FIXTURE_PATH / "invalid_logs" INVALID_LOG_DIR = FIXTURE_PATH / "invalid_logs"
# define temp directory only for testing # define temp directory only for testing
os.environ["PYTEST_DEBUG_TEMPROOT"] = str(Path("output/test").absolute()) os.environ["PYTEST_DEBUG_TEMPROOT"] = str(OUTPUT_PATH)
@pytest.fixture @pytest.fixture
def tmp_log_file(tmp_path): def tmp_log_file(tmp_path):
"""Return path of logs logwatcher related.""" """Return path of logs logwatcher related."""
return tmp_path / "logwatcher_test.log" return tmp_path / "logwatcher_test.log"
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def tmp_setup_logging(tmp_log_file): def tmp_setup_logging(tmp_log_file):
"""Redirige les logs vers un dossier temporaire pendant les tests.""" """Redirige les logs vers un dossier temporaire pendant les tests."""
setup_logging(file_path=tmp_log_file) setup_logging(file_path=tmp_log_file)
@pytest.fixture(name="original_log_dir") @pytest.fixture(name="original_log_dir")
def original_log_dir_fixture() -> Path: def original_log_dir_fixture() -> Path:
"""Return log directory containing real logs. """Return log directory containing real logs.
@@ -41,6 +44,7 @@ def original_log_dir_fixture() -> Path:
""" """
return FIXTURE_PATH / "original_logs" return FIXTURE_PATH / "original_logs"
@pytest.fixture(name="valid_log_dir") @pytest.fixture(name="valid_log_dir")
def valid_log_dir_fixture() -> Path: def valid_log_dir_fixture() -> Path:
"""Return log directory containing only valid logs. """Return log directory containing only valid logs.
@@ -51,6 +55,7 @@ def valid_log_dir_fixture() -> Path:
""" """
return FIXTURE_PATH / "valid_logs" return FIXTURE_PATH / "valid_logs"
@pytest.fixture(name="invalid_log_dir") @pytest.fixture(name="invalid_log_dir")
def invalid_log_dir_fixture() -> Path: def invalid_log_dir_fixture() -> Path:
"""Return log directory containing invalid logs. """Return log directory containing invalid logs.
+18 -34
View File
@@ -8,7 +8,7 @@ from unittest.mock import MagicMock
from logwatcher import __version__ from logwatcher import __version__
from logwatcher.cli import _get_files_from_dir, _is_log_file, app from logwatcher.cli import _get_files_from_dir, app
runner = CliRunner() runner = CliRunner()
@@ -62,30 +62,6 @@ def test_version_cli():
assert f"logwatcher version: {__version__}" in result.output assert f"logwatcher version: {__version__}" in result.output
def test_is_log_file(tmp_path: Path):
"""Assert only .log, .txt or file without extensions are valid.
Args:
tmp_path: Temporary root path for testing results
"""
# set up files
Path(tmp_path / "a.log").touch()
Path(tmp_path / "a.txt").touch()
Path(tmp_path / "a.csv").touch()
Path(tmp_path / "a.py").touch()
Path(tmp_path / "a").touch()
Path(tmp_path / "a.pdf").touch()
# test
assert _is_log_file(Path(tmp_path / "a.log"))
assert _is_log_file(Path(tmp_path / "a.txt"))
assert _is_log_file(Path(tmp_path / "a")) # without extensions
assert not _is_log_file(Path(tmp_path / "a.csv"))
assert not _is_log_file(Path(tmp_path / "a.py"))
assert not _is_log_file(Path(tmp_path / "a.pdf"))
def test_get_files_from_dir_empty_dir(tmp_path: Path): def test_get_files_from_dir_empty_dir(tmp_path: Path):
"""Assert that a non-existing directory (or wrong path) cannot be given as a working input directory. """Assert that a non-existing directory (or wrong path) cannot be given as a working input directory.
@@ -163,7 +139,9 @@ def test_cli_from_files_neither_input_files_nor_input_dir():
assert "Either input_files or input_dir must be provided" in results.output assert "Either input_files or input_dir must be provided" in results.output
def test_cli_from_files_bad_input_file(tmp_path: Path, output_path: Path, invalid_log_dir: Path): def test_cli_from_files_bad_input_file(
tmp_path: Path, output_path: Path, invalid_log_dir: Path
):
"""Test calling cli with bad input file (directory instead of file). """Test calling cli with bad input file (directory instead of file).
Must return an exit code at 1 and the ValueError exception raised Must return an exit code at 1 and the ValueError exception raised
@@ -407,18 +385,18 @@ def test_cli_from_mails_no_output_dir(monkeypatch, make_mock_account):
"""Assert everything works when no output_path is provided. """Assert everything works when no output_path is provided.
Must use "output/" directory, located in project root. Must use "output/" directory, located in project root.
""" """
messages = [ messages = [
Message(subject="subject test 1", text_body="body test 1"), Message(subject="subject test 1", text_body="body test 1"),
Message(subject="subject test 2", text_body="body test 2"), Message(subject="subject test 2", text_body="body test 2"),
Message(subject="subject test 3", text_body="body test 3") Message(subject="subject test 3", text_body="body test 3"),
] ]
with monkeypatch.context() as m: with monkeypatch.context() as m:
m.setattr("logwatcher.cli.connect_to_mailbox", lambda: make_mock_account(messages=messages, analyzed_folder="Analyzed")) m.setattr(
"logwatcher.cli.connect_to_mailbox",
lambda: make_mock_account(messages=messages, analyzed_folder="Analyzed"),
)
m.setattr("logwatcher.notifier.Message.send", MagicMock(return_value=None)) m.setattr("logwatcher.notifier.Message.send", MagicMock(return_value=None))
arguments = ["from-mails"] arguments = ["from-mails"]
@@ -442,7 +420,10 @@ def test_cli_from_mails_invalid_output_dir(tmp_path, monkeypatch):
] ]
with monkeypatch.context() as m: with monkeypatch.context() as m:
m.setattr("logwatcher.cli.connect_to_mailbox", lambda: make_mock_account(messages=messages, analyzed_folder="Analyzed")) m.setattr(
"logwatcher.cli.connect_to_mailbox",
lambda: make_mock_account(messages=messages, analyzed_folder="Analyzed"),
)
m.setattr("logwatcher.notifier.Message.send", MagicMock(return_value=None)) m.setattr("logwatcher.notifier.Message.send", MagicMock(return_value=None))
arguments = ["from-mails", "--output-dir", bad_output_dir] arguments = ["from-mails", "--output-dir", bad_output_dir]
@@ -464,11 +445,14 @@ def test_cli_from_mails_valid_output_dir(tmp_path, monkeypatch, make_mock_accoun
messages = [ messages = [
Message(subject="subject test 1", text_body="body test 1"), Message(subject="subject test 1", text_body="body test 1"),
Message(subject="subject test 2", text_body="body test 2"), Message(subject="subject test 2", text_body="body test 2"),
Message(subject="subject test 3", text_body="body test 3") Message(subject="subject test 3", text_body="body test 3"),
] ]
with monkeypatch.context() as m: with monkeypatch.context() as m:
m.setattr("logwatcher.cli.connect_to_mailbox", lambda: make_mock_account(messages=messages, analyzed_folder="Analyzed")) m.setattr(
"logwatcher.cli.connect_to_mailbox",
lambda: make_mock_account(messages=messages, analyzed_folder="Analyzed"),
)
m.setattr("logwatcher.notifier.Message.send", MagicMock(return_value=None)) m.setattr("logwatcher.notifier.Message.send", MagicMock(return_value=None))
arguments = ["from-mails", "--output-dir", output_path] arguments = ["from-mails", "--output-dir", output_path]
+30 -12
View File
@@ -11,46 +11,64 @@ def test_valid_log_entry():
LogEntry( LogEntry(
server_ip="192.168.13.27", server_ip="192.168.13.27",
mdc_server_name="MDC_720", mdc_server_name="MDC_720",
start_time=datetime.strptime("18/07/2026 11:34:41", DATETIME_FORMAT).astimezone(FRENCH_TIMEZONE), start_time=datetime.strptime("18/07/2026 11:34:41", DATETIME_FORMAT).astimezone(
FRENCH_TIMEZONE
),
store_name="PECCOUD", store_name="PECCOUD",
error_time=datetime.strptime("18/07/2026 11:35:58", DATETIME_FORMAT).astimezone(FRENCH_TIMEZONE), error_time=datetime.strptime("18/07/2026 11:35:58", DATETIME_FORMAT).astimezone(
FRENCH_TIMEZONE
),
error_message="Erreur FTP SDUpdatePrixISF : Requested action not taken", error_message="Erreur FTP SDUpdatePrixISF : Requested action not taken",
raw_line="\\\\192.168.13.27\\e\\MDC_720\\Logs\\26\\07\\18\\20260718113428.txt [18/07/2026 11:34:41] DOSSIER EN COURS : PECCOUD [18/07/2026 11:35:58] Erreur FTP SDUpdatePrixISF : Requested action not taken" raw_line="\\\\192.168.13.27\\e\\MDC_720\\Logs\\26\\07\\18\\20260718113428.txt [18/07/2026 11:34:41] DOSSIER EN COURS : PECCOUD [18/07/2026 11:35:58] Erreur FTP SDUpdatePrixISF : Requested action not taken",
) )
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.""" """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",
mdc_server_name="MDC_720", mdc_server_name="MDC_720",
start_time=datetime.strptime("18/07/2026 11:35:58", DATETIME_FORMAT).astimezone(FRENCH_TIMEZONE), start_time=datetime.strptime(
"18/07/2026 11:35:58", DATETIME_FORMAT
).astimezone(FRENCH_TIMEZONE),
store_name="PECCOUD", store_name="PECCOUD",
error_time=datetime.strptime("18/07/2026 11:34:41", DATETIME_FORMAT).astimezone(FRENCH_TIMEZONE), error_time=datetime.strptime(
"18/07/2026 11:34:41", DATETIME_FORMAT
).astimezone(FRENCH_TIMEZONE),
error_message="Erreur FTP SDUpdatePrixISF : Requested action not taken", error_message="Erreur FTP SDUpdatePrixISF : Requested action not taken",
raw_line="\\\\192.168.13.27\\e\\MDC_720\\Logs\\26\\07\\18\\20260718113428.txt [18/07/2026 11:34:41] DOSSIER EN COURS : PECCOUD [18/07/2026 11:35:58] Erreur FTP SDUpdatePrixISF : Requested action not taken" raw_line="\\\\192.168.13.27\\e\\MDC_720\\Logs\\26\\07\\18\\20260718113428.txt [18/07/2026 11:34:41] DOSSIER EN COURS : PECCOUD [18/07/2026 11:35:58] Erreur FTP SDUpdatePrixISF : Requested action not taken",
) )
def test_equals_models(): def test_equals_models():
"""Assert two models are equals if they have the same value as attributes.""" """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",
start_time=datetime.strptime("18/07/2026 11:34:41", DATETIME_FORMAT).astimezone(FRENCH_TIMEZONE), start_time=datetime.strptime("18/07/2026 11:34:41", DATETIME_FORMAT).astimezone(
FRENCH_TIMEZONE
),
store_name="PECCOUD", store_name="PECCOUD",
error_time=datetime.strptime("18/07/2026 11:35:58", DATETIME_FORMAT).astimezone(FRENCH_TIMEZONE), error_time=datetime.strptime("18/07/2026 11:35:58", DATETIME_FORMAT).astimezone(
FRENCH_TIMEZONE
),
error_message="Erreur FTP SDUpdatePrixISF : Requested action not taken", error_message="Erreur FTP SDUpdatePrixISF : Requested action not taken",
raw_line="\\\\192.168.13.27\\e\\MDC_720\\Logs\\26\\07\\18\\20260718113428.txt [18/07/2026 11:34:41] DOSSIER EN COURS : PECCOUD [18/07/2026 11:35:58] Erreur FTP SDUpdatePrixISF : Requested action not taken" raw_line="\\\\192.168.13.27\\e\\MDC_720\\Logs\\26\\07\\18\\20260718113428.txt [18/07/2026 11:34:41] DOSSIER EN COURS : PECCOUD [18/07/2026 11:35:58] Erreur FTP SDUpdatePrixISF : Requested action not taken",
) )
log_entry_2 = LogEntry( log_entry_2 = LogEntry(
server_ip="192.168.13.27", server_ip="192.168.13.27",
mdc_server_name="MDC_720", mdc_server_name="MDC_720",
start_time=datetime.strptime("18/07/2026 11:34:41", DATETIME_FORMAT).astimezone(FRENCH_TIMEZONE), start_time=datetime.strptime("18/07/2026 11:34:41", DATETIME_FORMAT).astimezone(
FRENCH_TIMEZONE
),
store_name="PECCOUD", store_name="PECCOUD",
error_time=datetime.strptime("18/07/2026 11:35:58", DATETIME_FORMAT).astimezone(FRENCH_TIMEZONE), error_time=datetime.strptime("18/07/2026 11:35:58", DATETIME_FORMAT).astimezone(
FRENCH_TIMEZONE
),
error_message="Erreur FTP SDUpdatePrixISF : Requested action not taken", error_message="Erreur FTP SDUpdatePrixISF : Requested action not taken",
raw_line="\\\\192.168.13.27\\e\\MDC_720\\Logs\\26\\07\\18\\20260718113428.txt [18/07/2026 11:34:41] DOSSIER EN COURS : PECCOUD [18/07/2026 11:35:58] Erreur FTP SDUpdatePrixISF : Requested action not taken" raw_line="\\\\192.168.13.27\\e\\MDC_720\\Logs\\26\\07\\18\\20260718113428.txt [18/07/2026 11:34:41] DOSSIER EN COURS : PECCOUD [18/07/2026 11:35:58] Erreur FTP SDUpdatePrixISF : Requested action not taken",
) )
assert log_entry_1 == log_entry_2 assert log_entry_1 == log_entry_2
+23 -5
View File
@@ -11,8 +11,14 @@ def mock_get_or_create_folder(account, folder_name): # noqa: D103
return folder_name return folder_name
@pytest.mark.parametrize("vars", [("N2_REPORT_SUBJECT", "N2_REPORT_RECIPIENTS"), ("N2_REPORT_RECIPIENTS", "N2_REPORT_SUBJECT")]) @pytest.mark.parametrize(
def test_send_n2_report_missing_recipients_env(vars, monkeypatch, ): "vars",
[
("N2_REPORT_SUBJECT", "N2_REPORT_RECIPIENTS"),
("N2_REPORT_RECIPIENTS", "N2_REPORT_SUBJECT"),
],
)
def test_send_n2_report_missing_recipients_env(vars, monkeypatch):
"""Assert that missing values in environmnent file raises KeyError.""" """Assert that missing values in environmnent file raises KeyError."""
monkeypatch.delenv("N2_REPORT_RECIPIENTS") monkeypatch.delenv("N2_REPORT_RECIPIENTS")
monkeypatch.delenv("N2_REPORT_SUBJECT") monkeypatch.delenv("N2_REPORT_SUBJECT")
@@ -24,7 +30,15 @@ def test_send_n2_report_missing_recipients_env(vars, monkeypatch, ):
assert vars[1] in err.value.args assert vars[1] in err.value.args
@pytest.mark.parametrize("recipients", ["mail@client.fr", "mail@ client.fr ", "mail1@client.fr,mail2@client.fr,mail3@client.fr", "mail1@client.fr , mail2@client.fr , mail3@client.fr"]) @pytest.mark.parametrize(
"recipients",
[
"mail@client.fr",
"mail@ client.fr ",
"mail1@client.fr,mail2@client.fr,mail3@client.fr",
"mail1@client.fr , mail2@client.fr , mail3@client.fr",
],
)
def test_send_n2_report_multiple_recipients(recipients, make_mock_account, monkeypatch): def test_send_n2_report_multiple_recipients(recipients, make_mock_account, monkeypatch):
"""Assert gathering multiple account as recipients from env file is valid. """Assert gathering multiple account as recipients from env file is valid.
@@ -42,5 +56,9 @@ def test_send_n2_report_multiple_recipients(recipients, make_mock_account, monke
with monkeypatch.context() as m: with monkeypatch.context() as m:
m.setattr("logwatcher.notifier.get_or_create_folder", mock_get_or_create_folder) m.setattr("logwatcher.notifier.get_or_create_folder", mock_get_or_create_folder)
m.setattr("logwatcher.notifier.Message.send", MagicMock(id="1", return_value=None)) m.setattr(
send_n2_report(account=make_mock_account(), summary="summary", n2_log_file=MagicMock()) "logwatcher.notifier.Message.send", MagicMock(id="1", return_value=None)
)
send_n2_report(
account=make_mock_account(), summary="summary", n2_log_file=MagicMock()
)
@@ -1,6 +1,31 @@
from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
from logwatcher.mail_utils import get_or_create_folder from logwatcher.utils import get_or_create_folder, is_log_file
def test_is_log_file(tmp_path: Path):
"""Assert only .log, .txt or file without extensions are valid.
Args:
tmp_path: Temporary root path for testing results
"""
# set up files
Path(tmp_path / "a.log").touch()
Path(tmp_path / "a.txt").touch()
Path(tmp_path / "a.csv").touch()
Path(tmp_path / "a.py").touch()
Path(tmp_path / "a").touch()
Path(tmp_path / "a.pdf").touch()
# test
assert is_log_file(Path(tmp_path / "a.log"))
assert is_log_file(Path(tmp_path / "a.txt"))
assert is_log_file(Path(tmp_path / "a")) # without extensions
assert not is_log_file(Path(tmp_path / "a.csv"))
assert not is_log_file(Path(tmp_path / "a.py"))
assert not is_log_file(Path(tmp_path / "a.pdf"))
def test_get_or_create_analyzed_folder_exists(make_mock_account, monkeypatch): def test_get_or_create_analyzed_folder_exists(make_mock_account, monkeypatch):
@@ -16,7 +41,7 @@ def test_get_or_create_analyzed_folder_exists(make_mock_account, monkeypatch):
fake_folder_cls = MagicMock() fake_folder_cls = MagicMock()
with monkeypatch.context() as m: with monkeypatch.context() as m:
m.setattr("logwatcher.mail_utils.Folder", fake_folder_cls) m.setattr("logwatcher.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 +55,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.mail_utils.Folder", fake_folder_cls) m.setattr("logwatcher.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