From 273b23f65a419422bbd097b689f3ccfe778160d8 Mon Sep 17 00:00:00 2001
From: ahmedk <karim.ahmed@xfel.eu>
Date: Fri, 6 Sep 2024 09:49:56 +0200
Subject: [PATCH] Intoduce  Global Logger for xfel-calibrate

- New setup_logging.py module to set the global logger handler
- Use new princess feature to run setup_logging.py before executing notebook
- copy setup_logging.py to the CAL_WORKING_DIRECTORY for more simplicity in accessing the file from anywhere (should be enough for reproducability?).
- LOGGING:
  - WARNINGs to warnings.logs
  - ERRORs to errors.logs
  - ALL console logs are not using any special format on purpose
---
 bin/slurm_calibrate.sh              |  11 ++-
 notebooks/test/test-error.ipynb     | 135 ++++++++++++++++++++++++++++
 src/xfel_calibrate/calibrate.py     |   9 +-
 src/xfel_calibrate/notebooks.py     |  16 ++++
 src/xfel_calibrate/settings.py      |   3 +
 src/xfel_calibrate/setup_logging.py | 102 +++++++++++++++++++++
 6 files changed, 270 insertions(+), 6 deletions(-)
 create mode 100644 notebooks/test/test-error.ipynb
 create mode 100644 src/xfel_calibrate/setup_logging.py

diff --git a/bin/slurm_calibrate.sh b/bin/slurm_calibrate.sh
index a600fa7ea..04fadbc94 100755
--- a/bin/slurm_calibrate.sh
+++ b/bin/slurm_calibrate.sh
@@ -24,6 +24,11 @@ echo "cluster_cores: $cluster_cores"
 echo "job ID: ${SLURM_JOB_ID:-none}"
 
 export CAL_NOTEBOOK_NAME="$notebook"
+export CAL_WORKING_DIR="$(pwd)"
+
+# This is created by calibrate.py in CAL_WORKING_DIR
+setup_logger="setup-logging-nb.py"
+
 
 # make sure we use agg backend
 export MPLBACKEND=AGG
@@ -39,13 +44,11 @@ then
     sleep 15
 fi
 
-echo "Running notebook"
 if [ "$caltype" == "CORRECT" ]
 then
-  # calparrot stores and repeats calcat queries
-  ${python_path} -m calparrot -- ${python_path} -m princess ${nb_path} --save
+    ${python_path} -m calparrot -- ${python_path} -m princess ${nb_path} --save --run-before "$setup_logger"
 else
-  ${python_path} -m princess ${nb_path} --save
+    ${python_path} -m princess ${nb_path} --save --run-before "$setup_logger"
 fi
 
 # stop the cluster if requested
diff --git a/notebooks/test/test-error.ipynb b/notebooks/test/test-error.ipynb
new file mode 100644
index 000000000..4ad97550f
--- /dev/null
+++ b/notebooks/test/test-error.ipynb
@@ -0,0 +1,135 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "98e38fec",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "in_folder = \"./\" # input folder\n",
+    "out_folder = \"./\" # output folder"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "7fbce574",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from logging import debug, error, info, warning"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "ba0663ca",
+   "metadata": {},
+   "source": [
+    "## INFO/DEBUG"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "a3b5e455",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "debug(\"(DEBUG)ing for some logs\")\n",
+    "info(\"Logging some (INFO)rmation\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "1bf72c57",
+   "metadata": {},
+   "source": [
+    "## WARNINGS"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "c0d290a1",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from warnings import warn\n",
+    "if 1 < 2:\n",
+    "    warn('This inequality is true!')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "9b79ea47",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "warning(\"This is a warning message using logging standard library.\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "2b22e2e0",
+   "metadata": {},
+   "source": [
+    "## ERRORS"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "0f3bfeb7",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "error(\"Logging some (ERROR) without failing the notebook\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "c3b87719",
+   "metadata": {},
+   "outputs": [
+    {
+     "ename": "ValueError",
+     "evalue": "FAIL",
+     "output_type": "error",
+     "traceback": [
+      "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
+      "\u001b[0;31mValueError\u001b[0m                                Traceback (most recent call last)",
+      "Cell \u001b[0;32mIn[2], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mFAIL\u001b[39m\u001b[38;5;124m'\u001b[39m)\n",
+      "\u001b[0;31mValueError\u001b[0m: FAIL"
+     ]
+    }
+   ],
+   "source": [
+    "raise ValueError('FAIL')"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": ".venv",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.11.9"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/src/xfel_calibrate/calibrate.py b/src/xfel_calibrate/calibrate.py
index 90dd3c4b5..3537443c1 100755
--- a/src/xfel_calibrate/calibrate.py
+++ b/src/xfel_calibrate/calibrate.py
@@ -580,12 +580,10 @@ def make_par_table(parms):
 
     return textwrap.dedent(tmpl.render(p=col_type, lines=l_parms))
 
-
 def run(argv=None):
     """ Run a calibration task with parser arguments """
     if argv is None:
         argv = sys.argv
-
     args, nb_details = parse_argv_and_load_nb(argv)
 
     concurrency = nb_details.concurrency
@@ -675,6 +673,13 @@ def run(argv=None):
         cal_work_dir / "pycalib-run-nb.sh"
     )
 
+    # Copy the setup logger handler
+    shutil.copy2(
+        os.path.join(PKG_DIR, "setup_logging.py"),
+        cal_work_dir / "setup-logging-nb.py"
+    )
+
+
     if nb_details.user_venv:
         print("Using specified venv:", nb_details.user_venv)
         python_exe = str(nb_details.user_venv / 'bin' / 'python')
diff --git a/src/xfel_calibrate/notebooks.py b/src/xfel_calibrate/notebooks.py
index a2aa8f02e..013358f79 100644
--- a/src/xfel_calibrate/notebooks.py
+++ b/src/xfel_calibrate/notebooks.py
@@ -393,6 +393,22 @@ notebooks = {
                 "cluster cores": 1
             },
         },
+        "TEST-LOG-ERROR": {
+            "notebook": "notebooks/test/test-error.ipynb",
+            "concurrency": {
+                "parameter": None,
+                "default concurrency": None,
+                "cluster cores": 1,
+            },
+        },
+        "TEST-LOG-WARNING": {
+            "notebook": "notebooks/test/test-warning.ipynb",
+            "concurrency": {
+                "parameter": None,
+                "default concurrency": None,
+                "cluster cores": 1,
+            },
+        },
     },
     "TEST-RAISES-ERRORS": {
         "TEST-BAD-KEY": {
diff --git a/src/xfel_calibrate/settings.py b/src/xfel_calibrate/settings.py
index 15067c0a6..eb9f096a5 100644
--- a/src/xfel_calibrate/settings.py
+++ b/src/xfel_calibrate/settings.py
@@ -16,6 +16,9 @@ try_report_to_output = True
 # logo file for calibration report (located in cal_tools/cal_tools)
 logo_path = "xfel.pdf"
 
+# Logger handler
+setup_logger = "setup_loggeing.py"
+
 # the command to run this concurrently. It is prepended to the actual call
 sprof = os.environ.get("XFELCALSLURM", "exfel")  # TODO: https://git.xfel.eu/gitlab/calibration/planning/issues/3
 launcher_command = "sbatch -t 24:00:00 --requeue --output {temp_path}/slurm-%j.out --parsable"
diff --git a/src/xfel_calibrate/setup_logging.py b/src/xfel_calibrate/setup_logging.py
new file mode 100644
index 000000000..5526ef72d
--- /dev/null
+++ b/src/xfel_calibrate/setup_logging.py
@@ -0,0 +1,102 @@
+import logging
+import sys
+import traceback
+import warnings
+import os
+
+
+class ContextFilter(logging.Filter):
+    def filter(self, record):
+        record.notebook = NOTEBOOK_NAME
+        record.directory = CURRENT_WORKING_DIR
+        return True
+
+
+# Define a custom log format
+formatter = logging.Formatter(
+    '%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - '
+    '[Notebook: %(notebook)s] - [Directory: %(directory)s] - %(message)s'
+)
+
+# Retrieve notebook name and current working directory from environment variables
+NOTEBOOK_NAME = os.getenv('CAL_NOTEBOOK_NAME', 'Unknown notebook')
+CURRENT_WORKING_DIR = os.getenv('CAL_WORKING_DIR', 'Unknown directory')
+
+# Create a logger
+logger = logging.getLogger()
+logger.setLevel(logging.DEBUG)  # Capture all levels
+
+# Create file handlers
+error_handler = logging.FileHandler('errors.log')
+error_handler.setLevel(logging.ERROR)
+error_handler.setFormatter(formatter)
+
+warning_handler = logging.FileHandler('warnings.log')
+warning_handler.setLevel(logging.WARNING)
+# Avoid errors being logged in warnings.log
+warning_handler.addFilter(lambda record: record.levelno < logging.ERROR)
+warning_handler.setFormatter(formatter)
+
+# Create console handler for info and debug
+# No formatter used for console handler on purpose.
+# console is slurm.out & the notebook output cells.
+console_handler = logging.StreamHandler()
+console_handler.setLevel(logging.DEBUG)
+
+# Add the custom filter to handlers
+context_filter = ContextFilter()
+error_handler.addFilter(context_filter)
+warning_handler.addFilter(context_filter)
+console_handler.addFilter(context_filter)
+
+# Add handlers to logger
+logger.addHandler(error_handler)
+logger.addHandler(warning_handler)
+logger.addHandler(console_handler)
+
+handling_error = False
+
+
+def safe_handle_error(exc_type, exc_value, exc_traceback):
+    global handling_error
+    if handling_error:  # Avoid infinite loop of errors.
+        sys.stderr.write("Recursive error detected!\n")
+        traceback.print_exception(exc_type, exc_value, exc_traceback, file=sys.stderr)
+        return
+
+    handling_error = True
+    try:
+        # Log the error with the notebook name and additional metadata
+        logger.error(
+            "An error occurred. Exception type: %s, Message: %s",
+            exc_type.__name__, str(exc_value),
+            extra={'notebook': NOTEBOOK_NAME, 'directory': CURRENT_WORKING_DIR},
+            exc_info=(exc_type, exc_value, exc_traceback)
+        )
+
+    except Exception as log_error:
+        sys.stderr.write(f"Logging failed: {log_error}\n")
+        traceback.print_exception(exc_type, exc_value, exc_traceback, file=sys.stderr)
+    finally:
+        handling_error = False
+
+
+def handle_warning(message, category, filename, lineno, file=None, line=None):
+    try:
+        # Log the warning with the notebook name and additional metadata
+        logger.warning(
+            "Warning occurred: %s, File: %s, Line: %d",
+            message, filename, lineno,
+            extra={'notebook': NOTEBOOK_NAME, 'directory': CURRENT_WORKING_DIR}
+        )
+    except Exception as log_error:
+        sys.stderr.write(f"Logging failed: {log_error}\n")
+
+
+# Replace the handlers with our custom ones
+sys.excepthook = safe_handle_error
+warnings.showwarning = handle_warning
+
+# Override IPython's exception handling
+import IPython
+IPython.core.interactiveshell.InteractiveShell.showtraceback = lambda self, *args, **kwargs: safe_handle_error(*sys.exc_info())
\ No newline at end of file
-- 
GitLab