✨feat(project): add logging in project

add logging in modules classier.py, parser.py, reporter.py. Update logging in cli.
This commit is contained in:
2026-09-01 17:21:17 +02:00
parent 24a77363d0
commit a43afb9df7
4 changed files with 107 additions and 45 deletions
+18 -4
View File
@@ -1,5 +1,6 @@
import re import re
from logwatcher import logger
from logwatcher.models import LogEntry from logwatcher.models import LogEntry
# ============================================================ # ============================================================
@@ -35,17 +36,19 @@ N2_PATTERNS = {
"PARTIAL_INTEGRATION": r"Erreur : (?P<bl_number>(?P<sap_code>\d{4})_(?P<order_number>\w+)_DESADV) : ISC ERROR CODE:(?P<isc_error_code>\d+) \(attention intégration partielle du BL ou d'une commande\)", "PARTIAL_INTEGRATION": r"Erreur : (?P<bl_number>(?P<sap_code>\d{4})_(?P<order_number>\w+)_DESADV) : ISC ERROR CODE:(?P<isc_error_code>\d+) \(attention intégration partielle du BL ou d'une commande\)",
"SAME_LINE_EXCEPT_DISCOUNT": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) ERREUR (?P<model_code>(?P<nosymag_model_code>\w+ (?P<color_code>\w+))(?P<brand_code>\w{3})) / SetCOMBCDE -> OrderNumber : \d+ / Une commande ne peut pas contenir plusieurs lignes avec le même modèle, la même collection, la même date de livraison et avoir des remises différentes", "SAME_LINE_EXCEPT_DISCOUNT": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) ERREUR (?P<model_code>(?P<nosymag_model_code>\w+ (?P<color_code>\w+))(?P<brand_code>\w{3})) / SetCOMBCDE -> OrderNumber : \d+ / Une commande ne peut pas contenir plusieurs lignes avec le même modèle, la même collection, la même date de livraison et avoir des remises différentes",
"MULTIPLE_ROWS_IN_SINGLETON_SELECT": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) ERREUR (?P<model_code>(?P<nosymag_model_code>\w+ (?P<color_code>\w+))(?P<brand_code>\w{3})) / SizeRange (?P<size_range>\d+\.\d+) / SetMrkFourn -> (?: \[[^\]]+\])+multiple rows in singleton select", "MULTIPLE_ROWS_IN_SINGLETON_SELECT": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) ERREUR (?P<model_code>(?P<nosymag_model_code>\w+ (?P<color_code>\w+))(?P<brand_code>\w{3})) / SizeRange (?P<size_range>\d+\.\d+) / SetMrkFourn -> (?: \[[^\]]+\])+multiple rows in singleton select",
# low priority errors # low priority errors
"SIZE_NOT_FOUND": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) WARNING (\d+ \w+) / SizeRange (?P<size_range>\d+\.\d+) - Columnx \d+ / Taille Inexistante", "SIZE_NOT_FOUND": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) WARNING (\d+ \w+) / SizeRange (?P<size_range>\d+\.\d+) - Columnx \d+ / Taille Inexistante",
"UNSYNCED_ORD_SD_FILES": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) WARNING (?P<model_code>(?P<nosymag_model_code>\w+ (?P<color_code>\w+)(?P<brand_code>\w{3}))) / OrderNumber : \d+ / Le modèle n'existe pas dans le fichier SD correspondant", "UNSYNCED_ORD_SD_FILES": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) WARNING (?P<model_code>(?P<nosymag_model_code>\w+ (?P<color_code>\w+)(?P<brand_code>\w{3}))) / OrderNumber : \d+ / Le modèle n'existe pas dans le fichier SD correspondant",
"CODE_MODEL_SIZE_OUT_OF_BOUND": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) WARNING [\w ]+ \w+ / SetArticle -> SizeRange \d+\.\d+ / (?: \[[^\]]+\])+-\w+\. Données trop volumineuses pour la variable (?: \[[^\]]+\]). Long. max. = \[\d+\], long. réelle = \[\d+\] Conseil : attribuez une valeur supérieure au paramètre TFDParam.Size", "CODE_MODEL_SIZE_OUT_OF_BOUND": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) WARNING [\w ]+ \w+ / SetArticle -> SizeRange \d+\.\d+ / (?: \[[^\]]+\])+-\w+\. Données trop volumineuses pour la variable (?: \[[^\]]+\]). Long. max. = \[\d+\], long. réelle = \[\d+\] Conseil : attribuez une valeur supérieure au paramètre TFDParam.Size",
"USER_NOT_CREATED_ON_NOSYMAG": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) ERREUR / OrderNumber : \d+ / Utilisateur : configuration incorrecte \(valeur 0\) - vérifier dans IF_Config", "USER_NOT_CREATED_ON_NOSYMAG": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) ERREUR / OrderNumber : \d+ / Utilisateur : configuration incorrecte \(valeur 0\) - vérifier dans IF_Config",
"INCORRECT_MDC_XML_FILES": r"Exception : Impossible de convertir le variant de type \(Null\) en type \(OleStr\)" "INCORRECT_MDC_XML_FILES": r"Exception : Impossible de convertir le variant de type \(Null\) en type \(OleStr\)",
} }
# compiled all patterns # compiled all patterns
_COMPILED_N2_PATTERNS = {error_name: re.compile(pattern) for error_name, pattern in N2_PATTERNS.items()} _COMPILED_N2_PATTERNS = {
error_name: re.compile(pattern) for error_name, pattern in N2_PATTERNS.items()
}
def _match_n2_pattern(log_entry: LogEntry) -> str | None: def _match_n2_pattern(log_entry: LogEntry) -> str | None:
for error_code, pattern in _COMPILED_N2_PATTERNS.items(): for error_code, pattern in _COMPILED_N2_PATTERNS.items():
@@ -53,19 +56,30 @@ def _match_n2_pattern(log_entry: LogEntry) -> str|None:
return error_code return error_code
return None return None
def classify_log_entries(log_entries: list[LogEntry]) -> tuple[list[LogEntry], list[LogEntry]]:
def classify_log_entries(
log_entries: 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 = []
logger.info("\tclassification 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)
if error_name: if error_name:
logger.info(
f"\t\tError '{error_name}' associated with log entry '{log_entry.error_message}'. This is a n2 log entry"
)
log_entry.error_name = error_name log_entry.error_name = error_name
relevant_log_entries.append(log_entry) relevant_log_entries.append(log_entry)
else: else:
irrelevant_log_entries.append(log_entry) irrelevant_log_entries.append(log_entry)
logger.info(
f"\t\tn2 logs: {len(relevant_log_entries)}, other logs: {len(irrelevant_log_entries)}"
)
logger.info("\tclassification job completed.")
return relevant_log_entries, irrelevant_log_entries return relevant_log_entries, irrelevant_log_entries
+6 -4
View File
@@ -97,25 +97,27 @@ def cli(
Returns: Returns:
None None
""" """
logger.info("logwatcher starts its work.") logger.info("============ logwatcher started ============")
if input_files and input_dir: if input_files and input_dir:
raise typer.BadParameter( raise typer.BadParameter(
"Only one of input_files or input_dir must be provided." "Only one of input_files or input_dir must be provided."
) )
if input_files: if input_files:
logger.info(f"input_files option selected.\n input_files: '{input_files}'") logger.info(f"input_files option selected. input_files: {input_files}")
log_files = input_files log_files = input_files
elif input_dir: elif input_dir:
logger.info(f"input_dir option selected with value '{input_dir}'") logger.info(f"input_dir option selected with value {input_dir}")
log_files = _get_files_from_dir(input_dir) log_files = _get_files_from_dir(input_dir)
else: else:
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()}'")
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)
write_log_report(relevant, irrelevant, len(log_files), output_dir) write_log_report(relevant, irrelevant, len(log_files), output_dir)
logger.info("logwatcher has finished its task.") logger.info("============ logwatcher ended ============")
if __name__ == "__main__": if __name__ == "__main__":
+15 -10
View File
@@ -45,20 +45,24 @@ def parse_log_line(log_line: str) -> LogEntry | None:
""" """
match = LOG_PATTERN.search(log_line) match = LOG_PATTERN.search(log_line)
if not match: if not match:
logger.debug(f"Unrecognize format. Ignoring line {log_line}.")
return None return None
data = match.groupdict() data = match.groupdict()
return LogEntry( return LogEntry(
server_ip=data["server_ip"], server_ip=data["server_ip"],
mdc_server_name=data["mdc_server_name"], mdc_server_name=data["mdc_server_name"],
start_time=datetime.strptime(data["start_time"], DATETIME_FORMAT).astimezone(FRENCH_TIMEZONE), start_time=datetime.strptime(data["start_time"], DATETIME_FORMAT).astimezone(
FRENCH_TIMEZONE
),
store_name=data["store_name"], store_name=data["store_name"],
error_time=datetime.strptime(data["error_time"], DATETIME_FORMAT).astimezone(FRENCH_TIMEZONE), error_time=datetime.strptime(data["error_time"], DATETIME_FORMAT).astimezone(
FRENCH_TIMEZONE
),
error_message=data["error_message"], error_message=data["error_message"],
raw_line=log_line raw_line=log_line,
) )
def parse_log_file(log_file_path: Path) -> list[LogEntry]: def parse_log_file(log_file_path: Path) -> list[LogEntry]:
"""Transforms the content of a log file into a list of LogEntry. """Transforms the content of a log file into a list of LogEntry.
Uses `parse_log_line` to parse each line of the choosen log file. Uses `parse_log_line` to parse each line of the choosen log file.
@@ -72,17 +76,18 @@ def parse_log_file(log_file_path: Path) -> list[LogEntry]:
""" """
log_entry_list: list[LogEntry] = [] log_entry_list: list[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.")
for index, log_line in enumerate(log_file): for index, log_line in enumerate(log_file):
try: try:
log_entry = parse_log_line(log_line) log_entry = parse_log_line(log_line)
except ValueError as error: except ValueError as error:
logger.error(f"Error while parsing the log file {log_file.name} at {log_line}: {error}") logger.error(
f"\t\tline {index}: error while parsing the log file '{log_file.name}': {error}"
)
else: else:
if log_entry: if log_entry:
log_entry_list.append(log_entry) log_entry_list.append(log_entry)
logger.debug(f"Line {index} was successfully parsed.") logger.info(f"\t\t{len(log_entry_list)} lines were parsed.")
else: logger.info("\tparsing completed.")
logger.debug(f"Line {index} is empty. Skip.")
return log_entry_list return log_entry_list
+42 -1
View File
@@ -2,6 +2,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from string import Template from string import Template
from logwatcher import logger
from logwatcher.config import DATETIME_FORMAT, RESULT_PATH from logwatcher.config import DATETIME_FORMAT, RESULT_PATH
from logwatcher.models import LogEntry from logwatcher.models import LogEntry
@@ -59,15 +60,30 @@ def write_log_report(
file. 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:
relevant: List of n2 log entry
irrelevant: List of not n2 log entry
nb_files: Number of files scanned
output_dir: Location where all reports will be written
Returns:
None
""" """
logger.info("\twriting reports job started.")
start_date, end_date = _get_period(relevant + irrelevant) start_date, end_date = _get_period(relevant + irrelevant)
logger.info(f"\t\tperiod found: {start_date} -> {end_date}")
reports_dict = build_reports(relevant, irrelevant, start_date, end_date, nb_files) reports_dict = build_reports(relevant, irrelevant, start_date, end_date, nb_files)
for target in reports_dict: 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.")
target_file = output_dir / str(target + ".log") target_file = output_dir / str(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.info(f"\t\treport '{target}' was generated.")
logger.info("\twriting reports job completed.")
def build_reports( def build_reports(
@@ -77,7 +93,17 @@ def build_reports(
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.
Args:
relevant: List of n2 log entry
irrelevant: List of not n2 log entry
start_date: Date of the oldest log entry in relevant + irrelevant list
end_date: Date of the newest log entry in relevant + irrelevant list
nb_files: number of files scanned
Returns:
Dictionary of reports in string format
""" """
logger.info("\t\tbuilding 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
) )
@@ -85,6 +111,8 @@ 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("\t\tbuilding reports job completed.")
return { return {
"n2": BASE_TEMPLATE.substitute( "n2": BASE_TEMPLATE.substitute(
@@ -112,11 +140,19 @@ def build_reports(
# build templates # build templates
def _render_target_report(log_entries: list[LogEntry], target_template) -> str: def _render_target_report(
log_entries: list[LogEntry], target_template: Template
) -> 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:
log_entries: List of log entries
target_template: Template to use for N2_SUPPORT_TEMPLATE
Returns:
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)
@@ -126,6 +162,11 @@ 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:
log_entries: List of log entries
Returns:
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):