Make log files behave like artifacts as dictated by the Artifact class.
Implicitly, this will automatically place all the logs in a structured
manner.

Signed-off-by: Luca Vizzarro <luca.vizza...@arm.com>
Reviewed-by: Paul Szczepanek <paul.szczepa...@arm.com>
---
 dts/framework/logger.py   | 113 +++++++++++++++++++++-----------------
 dts/framework/runner.py   |   5 +-
 dts/framework/test_run.py |  17 ++----
 3 files changed, 69 insertions(+), 66 deletions(-)

diff --git a/dts/framework/logger.py b/dts/framework/logger.py
index f43b442bc9..230513c01e 100644
--- a/dts/framework/logger.py
+++ b/dts/framework/logger.py
@@ -2,43 +2,54 @@
 # Copyright(c) 2010-2014 Intel Corporation
 # Copyright(c) 2022-2023 PANTHEON.tech s.r.o.
 # Copyright(c) 2022-2023 University of New Hampshire
+# Copyright(c) 2025 Arm Limited
 
 """DTS logger module.
 
 The module provides several additional features:
 
     * The storage of DTS execution stages,
-    * Logging to console, a human-readable log file and a machine-readable log 
file,
-    * Optional log files for specific stages.
+    * Logging to console, a human-readable log artifact and a machine-readable 
log artifact,
+    * Optional log artifacts for specific stages.
 """
 
 import logging
-from logging import FileHandler, StreamHandler
-from pathlib import Path
-from typing import ClassVar
+from logging import StreamHandler
+from typing import TYPE_CHECKING, ClassVar, NamedTuple
+
+if TYPE_CHECKING:
+    from framework.testbed_model.artifact import Artifact
 
 date_fmt = "%Y/%m/%d %H:%M:%S"
 stream_fmt = "%(asctime)s - %(stage)s - %(name)s - %(levelname)s - %(message)s"
 dts_root_logger_name = "dts"
 
 
+class ArtifactHandler(NamedTuple):
+    """A logger handler with an associated artifact."""
+
+    artifact: "Artifact"
+    handler: StreamHandler
+
+
 class DTSLogger(logging.Logger):
     """The DTS logger class.
 
     The class extends the :class:`~logging.Logger` class to add the DTS 
execution stage information
     to log records. The stage is common to all loggers, so it's stored in a 
class variable.
 
-    Any time we switch to a new stage, we have the ability to log to an 
additional log file along
-    with a supplementary log file with machine-readable format. These two log 
files are used until
-    a new stage switch occurs. This is useful mainly for logging per test 
suite.
+    Any time we switch to a new stage, we have the ability to log to an 
additional log artifact
+    along with a supplementary log artifact with machine-readable format. 
These two log artifacts
+    are used until a new stage switch occurs. This is useful mainly for 
logging per test suite.
     """
 
     _stage: ClassVar[str] = "pre_run"
-    _extra_file_handlers: list[FileHandler] = []
+    _root_artifact_handlers: list[ArtifactHandler] = []
+    _extra_artifact_handlers: list[ArtifactHandler] = []
 
     def __init__(self, *args, **kwargs):
-        """Extend the constructor with extra file handlers."""
-        self._extra_file_handlers = []
+        """Extend the constructor with extra artifact handlers."""
+        self._extra_artifact_handlers = []
         super().__init__(*args, **kwargs)
 
     def makeRecord(self, *args, **kwargs) -> logging.LogRecord:
@@ -56,7 +67,7 @@ def makeRecord(self, *args, **kwargs) -> logging.LogRecord:
         record.stage = DTSLogger._stage
         return record
 
-    def add_dts_root_logger_handlers(self, verbose: bool, output_dir: str) -> 
None:
+    def add_dts_root_logger_handlers(self, verbose: bool) -> None:
         """Add logger handlers to the DTS root logger.
 
         This method should be called only on the DTS root logger.
@@ -65,18 +76,16 @@ def add_dts_root_logger_handlers(self, verbose: bool, 
output_dir: str) -> None:
         Three handlers are added:
 
             * A console handler,
-            * A file handler,
-            * A supplementary file handler with machine-readable logs
+            * An artifact handler,
+            * A supplementary artifact handler with machine-readable logs
               containing more debug information.
 
-        All log messages will be logged to files. The log level of the console 
handler
+        All log messages will be logged to artifacts. The log level of the 
console handler
         is configurable with `verbose`.
 
         Args:
             verbose: If :data:`True`, log all messages to the console.
                 If :data:`False`, log to console with the :data:`logging.INFO` 
level.
-            output_dir: The directory where the log files will be located.
-                The names of the log files correspond to the name of the 
logger instance.
         """
         self.setLevel(1)
 
@@ -86,70 +95,76 @@ def add_dts_root_logger_handlers(self, verbose: bool, 
output_dir: str) -> None:
             sh.setLevel(logging.INFO)
         self.addHandler(sh)
 
-        self._add_file_handlers(Path(output_dir, self.name))
+        self._root_artifact_handlers = self._add_artifact_handlers(self.name)
 
-    def set_stage(self, stage: str, log_file_path: Path | None = None) -> None:
-        """Set the DTS execution stage and optionally log to files.
+    def set_stage(self, stage: str, log_file_name: str | None = None) -> None:
+        """Set the DTS execution stage and optionally log to artifact files.
 
         Set the DTS execution stage of the DTSLog class and optionally add
-        file handlers to the instance if the log file name is provided.
+        artifact handlers to the instance if the log artifact file name is 
provided.
 
-        The file handlers log all messages. One is a regular human-readable 
log file and
-        the other one is a machine-readable log file with extra debug 
information.
+        The artifact handlers log all messages. One is a regular 
human-readable log artifact and
+        the other one is a machine-readable log artifact with extra debug 
information.
 
         Args:
             stage: The DTS stage to set.
-            log_file_path: An optional path of the log file to use. This 
should be a full path
-                (either relative or absolute) without suffix (which will be 
appended).
+            log_file_name: An optional name of the log artifact file to use. 
This should be without
+                suffix (which will be appended).
         """
-        self._remove_extra_file_handlers()
+        self._remove_extra_artifact_handlers()
 
         if DTSLogger._stage != stage:
             self.info(f"Moving from stage '{DTSLogger._stage}' to stage 
'{stage}'.")
             DTSLogger._stage = stage
 
-        if log_file_path:
-            
self._extra_file_handlers.extend(self._add_file_handlers(log_file_path))
+        if log_file_name:
+            
self._extra_artifact_handlers.extend(self._add_artifact_handlers(log_file_name))
 
-    def _add_file_handlers(self, log_file_path: Path) -> list[FileHandler]:
-        """Add file handlers to the DTS root logger.
+    def _add_artifact_handlers(self, log_file_name: str) -> 
list[ArtifactHandler]:
+        """Add artifact handlers to the DTS root logger.
 
-        Add two type of file handlers:
+        Add two type of artifact handlers:
 
-            * A regular file handler with suffix ".log",
-            * A machine-readable file handler with suffix ".verbose.log".
+            * A regular artifact handler with suffix ".log",
+            * A machine-readable artifact handler with suffix ".verbose.log".
               This format provides extensive information for debugging and 
detailed analysis.
 
         Args:
-            log_file_path: The full path to the log file without suffix.
+            log_file_name: The name of the artifact log file without suffix.
 
         Returns:
-            The newly created file handlers.
-
+            The newly created artifact handlers.
         """
-        fh = FileHandler(f"{log_file_path}.log")
-        fh.setFormatter(logging.Formatter(stream_fmt, date_fmt))
-        self.addHandler(fh)
+        from framework.testbed_model.artifact import Artifact
+
+        log_artifact = Artifact("local", f"{log_file_name}.log")
+        handler = StreamHandler(log_artifact.open("w"))
+        handler.setFormatter(logging.Formatter(stream_fmt, date_fmt))
+        self.addHandler(handler)
 
-        verbose_fh = FileHandler(f"{log_file_path}.verbose.log")
-        verbose_fh.setFormatter(
+        verbose_log_artifact = Artifact("local", 
f"{log_file_name}.verbose.log")
+        verbose_handler = StreamHandler(verbose_log_artifact.open("w"))
+        verbose_handler.setFormatter(
             logging.Formatter(
                 
"%(asctime)s|%(stage)s|%(name)s|%(levelname)s|%(pathname)s|%(lineno)d|"
                 
"%(funcName)s|%(process)d|%(thread)d|%(threadName)s|%(message)s",
                 datefmt=date_fmt,
             )
         )
-        self.addHandler(verbose_fh)
+        self.addHandler(verbose_handler)
 
-        return [fh, verbose_fh]
+        return [
+            ArtifactHandler(log_artifact, handler),
+            ArtifactHandler(verbose_log_artifact, verbose_handler),
+        ]
 
-    def _remove_extra_file_handlers(self) -> None:
-        """Remove any extra file handlers that have been added to the 
logger."""
-        if self._extra_file_handlers:
-            for extra_file_handler in self._extra_file_handlers:
-                self.removeHandler(extra_file_handler)
+    def _remove_extra_artifact_handlers(self) -> None:
+        """Remove any extra artifact handlers that have been added to the 
logger."""
+        if self._extra_artifact_handlers:
+            for extra_artifact_handler in self._extra_artifact_handlers:
+                self.removeHandler(extra_artifact_handler.handler)
 
-            self._extra_file_handlers = []
+            self._extra_artifact_handlers = []
 
 
 def get_dts_logger(name: str | None = None) -> DTSLogger:
diff --git a/dts/framework/runner.py b/dts/framework/runner.py
index 0a3d92b0c8..ae5ac014e2 100644
--- a/dts/framework/runner.py
+++ b/dts/framework/runner.py
@@ -9,7 +9,6 @@
 The module is responsible for preparing DTS and running the test run.
 """
 
-import os
 import sys
 import textwrap
 
@@ -45,9 +44,7 @@ def __init__(self):
             sys.exit(e.severity)
 
         self._logger = get_dts_logger()
-        if not os.path.exists(SETTINGS.output_dir):
-            os.makedirs(SETTINGS.output_dir)
-        self._logger.add_dts_root_logger_handlers(SETTINGS.verbose, 
SETTINGS.output_dir)
+        self._logger.add_dts_root_logger_handlers(SETTINGS.verbose)
 
         test_suites_result = ResultNode(label="test_suites")
         self._result = TestRunResult(test_suites=test_suites_result)
diff --git a/dts/framework/test_run.py b/dts/framework/test_run.py
index f70580f8fd..5609380c95 100644
--- a/dts/framework/test_run.py
+++ b/dts/framework/test_run.py
@@ -103,7 +103,6 @@
 from collections.abc import Iterable
 from dataclasses import dataclass
 from functools import cached_property
-from pathlib import Path
 from types import MethodType
 from typing import ClassVar, Protocol, Union
 
@@ -115,7 +114,6 @@
 from framework.settings import SETTINGS
 from framework.test_result import Result, ResultNode, TestRunResult
 from framework.test_suite import BaseConfig, TestCase, TestSuite
-from framework.testbed_model.artifact import Artifact
 from framework.testbed_model.capability import (
     Capability,
     get_supported_capabilities,
@@ -259,11 +257,11 @@ class State(Protocol):
     test_run: TestRun
     result: TestRunResult | ResultNode
 
-    def before(self):
+    def before(self) -> None:
         """Hook before the state is processed."""
-        self.logger.set_stage(self.logger_name, self.log_file_path)
+        self.logger.set_stage(self.logger_name, self.get_log_file_name())
 
-    def after(self):
+    def after(self) -> None:
         """Hook after the state is processed."""
         return
 
@@ -280,13 +278,6 @@ def get_log_file_name(self) -> str | None:
         """Name of the log file for this state."""
         return None
 
-    @property
-    def log_file_path(self) -> Path | None:
-        """Path to the log file for this state."""
-        if file_name := self.get_log_file_name():
-            return Path(SETTINGS.output_dir, file_name)
-        return None
-
     def next(self) -> Union["State", None]:
         """Next state."""
 
@@ -604,7 +595,7 @@ class TestCaseState(State):
 
     def get_log_file_name(self) -> str | None:
         """Get the log file name."""
-        return self.test_suite.name
+        return self.test_case.name
 
 
 @dataclass
-- 
2.43.0

Reply via email to