diff mbox series

rteval: Implement initial dmidecode support

Message ID 20240304102603.89558-1-tglozar@redhat.com
State New
Headers show
Series rteval: Implement initial dmidecode support | expand

Commit Message

Tomas Glozar March 4, 2024, 10:26 a.m. UTC
From: Tomas Glozar <tglozar@redhat.com>

Previously rteval used python-dmidecode to gather DMI data from a
system. Since python-dmidecode is without a maintainer, its support was
removed in d142f0d2 ("rteval: Disable use of python-dmidecode").

Add get_dmidecode_xml() function into rteval/sysinfo/dmi.py that does
simple parsing of dmidecode command-line tool output without any
structure changes and include it into the rteval report.

Notes:
- ProcessWarnings() in rteval.sysinfo.dmi was reworked into a class
  method of DMIinfo and to use the class's __log field as logger. It
  now also does not ignore warnings that appear when running rteval as
  non-root, since that is no longer supported. Additionally,
  a duplicate call in rteval-cmd was removed.
- rteval/rteval_dmi.xsl XSLT template was left untouched and is
  currectly not used. In a future commit, it is expected to be rewritten
  to transform the XML format outputted by get_dmidecode_xml() into the
  same format that was used with python-dmidecode.

Signed-off-by: Tomas Glozar <tglozar@redhat.com>
---
 rteval-cmd                 |   2 -
 rteval/sysinfo/__init__.py |   2 +-
 rteval/sysinfo/dmi.py      | 178 ++++++++++++++++++++++++-------------
 3 files changed, 118 insertions(+), 64 deletions(-)

Comments

John Kacur March 20, 2024, 3:58 p.m. UTC | #1
On Mon, 4 Mar 2024, tglozar@redhat.com wrote:

> From: Tomas Glozar <tglozar@redhat.com>
> 
> Previously rteval used python-dmidecode to gather DMI data from a
> system. Since python-dmidecode is without a maintainer, its support was
> removed in d142f0d2 ("rteval: Disable use of python-dmidecode").
> 
> Add get_dmidecode_xml() function into rteval/sysinfo/dmi.py that does
> simple parsing of dmidecode command-line tool output without any
> structure changes and include it into the rteval report.
> 
> Notes:
> - ProcessWarnings() in rteval.sysinfo.dmi was reworked into a class
>   method of DMIinfo and to use the class's __log field as logger. It
>   now also does not ignore warnings that appear when running rteval as
>   non-root, since that is no longer supported. Additionally,
>   a duplicate call in rteval-cmd was removed.
> - rteval/rteval_dmi.xsl XSLT template was left untouched and is
>   currectly not used. In a future commit, it is expected to be rewritten
>   to transform the XML format outputted by get_dmidecode_xml() into the
>   same format that was used with python-dmidecode.
> 
> Signed-off-by: Tomas Glozar <tglozar@redhat.com>
> ---
>  rteval-cmd                 |   2 -
>  rteval/sysinfo/__init__.py |   2 +-
>  rteval/sysinfo/dmi.py      | 178 ++++++++++++++++++++++++-------------
>  3 files changed, 118 insertions(+), 64 deletions(-)
> 
> diff --git a/rteval-cmd b/rteval-cmd
> index a5e8746..018a414 100755
> --- a/rteval-cmd
> +++ b/rteval-cmd
> @@ -268,8 +268,6 @@ if __name__ == '__main__':
>                  | (rtevcfg.debugging and Log.DEBUG)
>          logger.SetLogVerbosity(loglev)
>  
> -        dmi.ProcessWarnings(logger=logger)
> -
>          # Load modules
>          loadmods = LoadModules(config, logger=logger)
>          measuremods = MeasurementModules(config, logger=logger)
> diff --git a/rteval/sysinfo/__init__.py b/rteval/sysinfo/__init__.py
> index d3f9efb..09af52e 100644
> --- a/rteval/sysinfo/__init__.py
> +++ b/rteval/sysinfo/__init__.py
> @@ -30,7 +30,7 @@ class SystemInfo(KernelInfo, SystemServices, dmi.DMIinfo, CPUtopology,
>          NetworkInfo.__init__(self, logger=logger)
>  
>          # Parse initial DMI decoding errors
> -        dmi.ProcessWarnings(logger=logger)
> +        self.ProcessWarnings()
>  
>          # Parse CPU info
>          CPUtopology._parse(self)
> diff --git a/rteval/sysinfo/dmi.py b/rteval/sysinfo/dmi.py
> index c01a0ee..f1aab9f 100644
> --- a/rteval/sysinfo/dmi.py
> +++ b/rteval/sysinfo/dmi.py
> @@ -3,6 +3,7 @@
>  #   Copyright 2009 - 2013   Clark Williams <williams@redhat.com>
>  #   Copyright 2009 - 2013   David Sommerseth <davids@redhat.com>
>  #   Copyright 2022          John Kacur <jkacur@redhat.com>
> +#   Copyright 2024          Tomas Glozar <tglozar@redhat.com>
>  #
>  """ dmi.py class to wrap DMI Table Information """
>  
> @@ -10,65 +11,125 @@ import sys
>  import os
>  import libxml2
>  import lxml.etree
> +import shutil
> +import re
> +from subprocess import Popen, PIPE, SubprocessError
>  from rteval.Log import Log
>  from rteval import xmlout
>  from rteval import rtevalConfig
>  
> -try:
> -    # import dmidecode
> -    dmidecode_avail = False
> -except ModuleNotFoundError:
> -    dmidecode_avail = False
> -
> -def set_dmidecode_avail(val):
> -    """ Used to set global variable dmidecode_avail from a function """
> -    global dmidecode_avail
> -    dmidecode_avail = val
> -
> -def ProcessWarnings(logger=None):
> -    """ Process Warnings from dmidecode """
> -
> -    if not dmidecode_avail:
> -        return
> -
> -    if not hasattr(dmidecode, 'get_warnings'):
> -        return
> -
> -    warnings = dmidecode.get_warnings()
> -    if warnings is None:
> -        return
> -
> -    ignore1  = '/dev/mem: Permission denied'
> -    ignore2 = 'No SMBIOS nor DMI entry point found, sorry.'
> -    ignore3 = 'Failed to open memory buffer (/dev/mem): Permission denied'
> -    ignore = (ignore1, ignore2, ignore3)
> -    for warnline in warnings.split('\n'):
> -        # Ignore these warnings, as they are "valid" if not running as root
> -        if warnline in ignore:
> -            continue
>  
> -        # All other warnings will be printed
> -        if len(warnline) > 0:
> -            logger.log(Log.DEBUG, f"** DMI WARNING ** {warnline}")
> -            set_dmidecode_avail(False)
> +def get_dmidecode_xml(dmidecode_executable):
> +    """
> +    Transform human-readable dmidecode output into machine-processable XML format
> +    :param dmidecode_executable: Path to dmidecode tool executable
> +    :return: Tuple of values with resulting XML and dmidecode warnings
> +    """
> +    proc = Popen(dmidecode_executable, text=True, stdout=PIPE, stderr=PIPE)
> +    outs, errs = proc.communicate()
> +    parts = outs.split("\n\n")
> +    if len(parts) < 2:
> +        raise RuntimeError("Parsing dmidecode output failed")
> +    header = parts[0]
> +    handles = parts[1:]
> +    root = lxml.etree.Element("dmidecode")
> +    # Parse dmidecode output header
> +    # Note: Only supports SMBIOS data currently
> +    regex = re.compile(r"# dmidecode (\d+\.\d+)\n"
> +                       r"Getting SMBIOS data from sysfs\.\n"
> +                       r"SMBIOS ((?:\d+\.)+\d+) present\.\n"
> +                       r"(?:(\d+) structures occupying (\d+) bytes\.\n)?"
> +                       r"Table at (0x[0-9A-Fa-f]+)\.", re.MULTILINE)
> +    match = re.match(regex, header)
> +    if match is None:
> +        raise RuntimeError("Parsing dmidecode output failed")
> +    root.attrib["dmidecodeversion"] = match.group(1)
> +    root.attrib["smbiosversion"] = match.group(2)
> +    if match.group(3) is not None:
> +        root.attrib["structures"] = match.group(3)
> +    if match.group(4) is not None:
> +        root.attrib["size"] = match.group(4)
> +    root.attrib["address"] = match.group(5)
> +
> +    # Generate element per handle in dmidecode output
> +    for handle_text in handles:
> +        if not handle_text:
> +            # Empty line
> +            continue
>  
> -    dmidecode.clear_warnings()
> +        handle = lxml.etree.Element("Handle")
> +        lines = handle_text.splitlines()
> +        # Parse handle header
> +        if len(lines) < 2:
> +            raise RuntimeError("Parsing dmidecode handle failed")
> +        header, name, content = lines[0], lines[1], lines[2:]
> +        match = re.match(r"Handle (0x[0-9A-Fa-f]{4}), "
> +                         r"DMI type (\d+), (\d+) bytes", header)
> +        if match is None:
> +            raise RuntimeError("Parsing dmidecode handle failed")
> +        handle.attrib["address"] = match.group(1)
> +        handle.attrib["type"] = match.group(2)
> +        handle.attrib["bytes"] = match.group(3)
> +        handle.attrib["name"] = name
> +
> +        # Parse all fields in handle and create an element for each
> +        list_field = None
> +        for index, line in enumerate(content):
> +            line = content[index]
> +            if line.rfind("\t") > 0:
> +                # We are inside a list field, add value to it
> +                value = lxml.etree.Element("Value")
> +                value.text = line.strip()
> +                list_field.append(value)
> +                continue
> +            line = line.lstrip().split(":", 1)
> +            if len(line) != 2:
> +                raise RuntimeError("Parsing dmidecode field failed")
> +            if not line[1] or (index + 1 < len(content) and
> +                               content[index + 1].rfind("\t") > 0):
> +                # No characters after : or next line is inside list field
> +                # means a list field
> +                # Note: there are list fields which specify a number of
> +                # items, for example "Installable Languages", so merely
> +                # checking for no characters after : is not enough
> +                list_field = lxml.etree.Element("List")
> +                list_field.attrib["Name"] = line[0].strip()
> +                handle.append(list_field)
> +            else:
> +                # Regular field
> +                field = lxml.etree.Element("Field")
> +                field.attrib["Name"] = line[0].strip()
> +                field.text = line[1].strip()
> +                handle.append(field)
> +
> +        root.append(handle)
> +
> +    return root, errs
>  
>  
>  class DMIinfo:
> -    '''class used to obtain DMI info via python-dmidecode'''
> +    '''class used to obtain DMI info via dmidecode'''
>  
>      def __init__(self, logger=None):
>          self.__version = '0.6'
>          self._log = logger
>  
> -        if not dmidecode_avail:
> -            logger.log(Log.DEBUG, "DMI info unavailable, ignoring DMI tables")
> +        dmidecode_executable = shutil.which("dmidecode")
> +        if dmidecode_executable is None:
> +            logger.log(Log.DEBUG, "DMI info unavailable,"
> +                                  " ignoring DMI tables")
>              self.__fake = True
>              return
>  
>          self.__fake = False
> -        self.__dmixml = dmidecode.dmidecodeXML()
> +        try:
> +            self.__dmixml, self.__warnings = get_dmidecode_xml(
> +                dmidecode_executable)
> +        except (RuntimeError, OSError, SubprocessError) as error:
> +            logger.log(Log.DEBUG, "DMI info unavailable: {};"
> +                                  " ignoring DMI tables".format(str(error)))
> +            self.__fake = True
> +            return
>  
>          self.__xsltparser = self.__load_xslt('rteval_dmi.xsl')
>  
> @@ -88,30 +149,25 @@ class DMIinfo:
>  
>          raise RuntimeError(f'Could not locate XSLT template for DMI data ({fname})')
>  
> +    def ProcessWarnings(self):
> +        """Prints out warnings from dmidecode into log if there were any"""
> +        if self.__fake or self._log is None:
> +            return
> +        for warnline in self.__warnings.split('\n'):
> +            if len(warnline) > 0:
> +                self._log.log(Log.DEBUG, f"** DMI WARNING ** {warnline}")
> +
>      def MakeReport(self):
>          """ Add DMI information to final report """
> -        rep_n = libxml2.newNode("DMIinfo")
> -        rep_n.newProp("version", self.__version)
>          if self.__fake:
> +            rep_n = libxml2.newNode("DMIinfo")
> +            rep_n.newProp("version", self.__version)
>              rep_n.addContent("No DMI tables available")
>              rep_n.newProp("not_available", "1")
> -        else:
> -            self.__dmixml.SetResultType(dmidecode.DMIXML_DOC)
> -            try:
> -                dmiqry = xmlout.convert_libxml2_to_lxml_doc(self.__dmixml.QuerySection('all'))
> -            except Exception as ex1:
> -                self._log.log(Log.DEBUG, f'** EXCEPTION {str(ex1)}, will query BIOS only')
> -                try:
> -                    # If we can't query 'all', at least query 'bios'
> -                    dmiqry = xmlout.convert_libxml2_to_lxml_doc(self.__dmixml.QuerySection('bios'))
> -                except Exception as ex2:
> -                    rep_n.addContent("No DMI tables available")
> -                    rep_n.newProp("not_available", "1")
> -                    self._log.log(Log.DEBUG, f'** EXCEPTION {str(ex2)}, dmi info will not be reported')
> -                    return rep_n
> -            resdoc = self.__xsltparser(dmiqry)
> -            dmi_n = xmlout.convert_lxml_to_libxml2_nodes(resdoc.getroot())
> -            rep_n.addChild(dmi_n)
> +            return rep_n
> +        rep_n = xmlout.convert_lxml_to_libxml2_nodes(self.__dmixml)
> +        rep_n.setName("DMIinfo")
> +        rep_n.newProp("version", self.__version)
>          return rep_n
>  
>  def unit_test(rootdir):
> @@ -130,12 +186,12 @@ def unit_test(rootdir):
>          log = Log()
>          log.SetLogVerbosity(Log.DEBUG|Log.INFO)
>  
> -        ProcessWarnings(logger=log)
>          if os.getuid() != 0:
>              print("** ERROR **  Must be root to run this unit_test()")
>              return 1
>  
>          d = DMIinfo(logger=log)
> +        d.ProcessWarnings()
>          dx = d.MakeReport()
>          x = libxml2.newDoc("1.0")
>          x.setRootElement(dx)
> -- 
Signed-off-by: John Kacur <jkacur@redhat.com>
diff mbox series

Patch

diff --git a/rteval-cmd b/rteval-cmd
index a5e8746..018a414 100755
--- a/rteval-cmd
+++ b/rteval-cmd
@@ -268,8 +268,6 @@  if __name__ == '__main__':
                 | (rtevcfg.debugging and Log.DEBUG)
         logger.SetLogVerbosity(loglev)
 
-        dmi.ProcessWarnings(logger=logger)
-
         # Load modules
         loadmods = LoadModules(config, logger=logger)
         measuremods = MeasurementModules(config, logger=logger)
diff --git a/rteval/sysinfo/__init__.py b/rteval/sysinfo/__init__.py
index d3f9efb..09af52e 100644
--- a/rteval/sysinfo/__init__.py
+++ b/rteval/sysinfo/__init__.py
@@ -30,7 +30,7 @@  class SystemInfo(KernelInfo, SystemServices, dmi.DMIinfo, CPUtopology,
         NetworkInfo.__init__(self, logger=logger)
 
         # Parse initial DMI decoding errors
-        dmi.ProcessWarnings(logger=logger)
+        self.ProcessWarnings()
 
         # Parse CPU info
         CPUtopology._parse(self)
diff --git a/rteval/sysinfo/dmi.py b/rteval/sysinfo/dmi.py
index c01a0ee..f1aab9f 100644
--- a/rteval/sysinfo/dmi.py
+++ b/rteval/sysinfo/dmi.py
@@ -3,6 +3,7 @@ 
 #   Copyright 2009 - 2013   Clark Williams <williams@redhat.com>
 #   Copyright 2009 - 2013   David Sommerseth <davids@redhat.com>
 #   Copyright 2022          John Kacur <jkacur@redhat.com>
+#   Copyright 2024          Tomas Glozar <tglozar@redhat.com>
 #
 """ dmi.py class to wrap DMI Table Information """
 
@@ -10,65 +11,125 @@  import sys
 import os
 import libxml2
 import lxml.etree
+import shutil
+import re
+from subprocess import Popen, PIPE, SubprocessError
 from rteval.Log import Log
 from rteval import xmlout
 from rteval import rtevalConfig
 
-try:
-    # import dmidecode
-    dmidecode_avail = False
-except ModuleNotFoundError:
-    dmidecode_avail = False
-
-def set_dmidecode_avail(val):
-    """ Used to set global variable dmidecode_avail from a function """
-    global dmidecode_avail
-    dmidecode_avail = val
-
-def ProcessWarnings(logger=None):
-    """ Process Warnings from dmidecode """
-
-    if not dmidecode_avail:
-        return
-
-    if not hasattr(dmidecode, 'get_warnings'):
-        return
-
-    warnings = dmidecode.get_warnings()
-    if warnings is None:
-        return
-
-    ignore1  = '/dev/mem: Permission denied'
-    ignore2 = 'No SMBIOS nor DMI entry point found, sorry.'
-    ignore3 = 'Failed to open memory buffer (/dev/mem): Permission denied'
-    ignore = (ignore1, ignore2, ignore3)
-    for warnline in warnings.split('\n'):
-        # Ignore these warnings, as they are "valid" if not running as root
-        if warnline in ignore:
-            continue
 
-        # All other warnings will be printed
-        if len(warnline) > 0:
-            logger.log(Log.DEBUG, f"** DMI WARNING ** {warnline}")
-            set_dmidecode_avail(False)
+def get_dmidecode_xml(dmidecode_executable):
+    """
+    Transform human-readable dmidecode output into machine-processable XML format
+    :param dmidecode_executable: Path to dmidecode tool executable
+    :return: Tuple of values with resulting XML and dmidecode warnings
+    """
+    proc = Popen(dmidecode_executable, text=True, stdout=PIPE, stderr=PIPE)
+    outs, errs = proc.communicate()
+    parts = outs.split("\n\n")
+    if len(parts) < 2:
+        raise RuntimeError("Parsing dmidecode output failed")
+    header = parts[0]
+    handles = parts[1:]
+    root = lxml.etree.Element("dmidecode")
+    # Parse dmidecode output header
+    # Note: Only supports SMBIOS data currently
+    regex = re.compile(r"# dmidecode (\d+\.\d+)\n"
+                       r"Getting SMBIOS data from sysfs\.\n"
+                       r"SMBIOS ((?:\d+\.)+\d+) present\.\n"
+                       r"(?:(\d+) structures occupying (\d+) bytes\.\n)?"
+                       r"Table at (0x[0-9A-Fa-f]+)\.", re.MULTILINE)
+    match = re.match(regex, header)
+    if match is None:
+        raise RuntimeError("Parsing dmidecode output failed")
+    root.attrib["dmidecodeversion"] = match.group(1)
+    root.attrib["smbiosversion"] = match.group(2)
+    if match.group(3) is not None:
+        root.attrib["structures"] = match.group(3)
+    if match.group(4) is not None:
+        root.attrib["size"] = match.group(4)
+    root.attrib["address"] = match.group(5)
+
+    # Generate element per handle in dmidecode output
+    for handle_text in handles:
+        if not handle_text:
+            # Empty line
+            continue
 
-    dmidecode.clear_warnings()
+        handle = lxml.etree.Element("Handle")
+        lines = handle_text.splitlines()
+        # Parse handle header
+        if len(lines) < 2:
+            raise RuntimeError("Parsing dmidecode handle failed")
+        header, name, content = lines[0], lines[1], lines[2:]
+        match = re.match(r"Handle (0x[0-9A-Fa-f]{4}), "
+                         r"DMI type (\d+), (\d+) bytes", header)
+        if match is None:
+            raise RuntimeError("Parsing dmidecode handle failed")
+        handle.attrib["address"] = match.group(1)
+        handle.attrib["type"] = match.group(2)
+        handle.attrib["bytes"] = match.group(3)
+        handle.attrib["name"] = name
+
+        # Parse all fields in handle and create an element for each
+        list_field = None
+        for index, line in enumerate(content):
+            line = content[index]
+            if line.rfind("\t") > 0:
+                # We are inside a list field, add value to it
+                value = lxml.etree.Element("Value")
+                value.text = line.strip()
+                list_field.append(value)
+                continue
+            line = line.lstrip().split(":", 1)
+            if len(line) != 2:
+                raise RuntimeError("Parsing dmidecode field failed")
+            if not line[1] or (index + 1 < len(content) and
+                               content[index + 1].rfind("\t") > 0):
+                # No characters after : or next line is inside list field
+                # means a list field
+                # Note: there are list fields which specify a number of
+                # items, for example "Installable Languages", so merely
+                # checking for no characters after : is not enough
+                list_field = lxml.etree.Element("List")
+                list_field.attrib["Name"] = line[0].strip()
+                handle.append(list_field)
+            else:
+                # Regular field
+                field = lxml.etree.Element("Field")
+                field.attrib["Name"] = line[0].strip()
+                field.text = line[1].strip()
+                handle.append(field)
+
+        root.append(handle)
+
+    return root, errs
 
 
 class DMIinfo:
-    '''class used to obtain DMI info via python-dmidecode'''
+    '''class used to obtain DMI info via dmidecode'''
 
     def __init__(self, logger=None):
         self.__version = '0.6'
         self._log = logger
 
-        if not dmidecode_avail:
-            logger.log(Log.DEBUG, "DMI info unavailable, ignoring DMI tables")
+        dmidecode_executable = shutil.which("dmidecode")
+        if dmidecode_executable is None:
+            logger.log(Log.DEBUG, "DMI info unavailable,"
+                                  " ignoring DMI tables")
             self.__fake = True
             return
 
         self.__fake = False
-        self.__dmixml = dmidecode.dmidecodeXML()
+        try:
+            self.__dmixml, self.__warnings = get_dmidecode_xml(
+                dmidecode_executable)
+        except (RuntimeError, OSError, SubprocessError) as error:
+            logger.log(Log.DEBUG, "DMI info unavailable: {};"
+                                  " ignoring DMI tables".format(str(error)))
+            self.__fake = True
+            return
 
         self.__xsltparser = self.__load_xslt('rteval_dmi.xsl')
 
@@ -88,30 +149,25 @@  class DMIinfo:
 
         raise RuntimeError(f'Could not locate XSLT template for DMI data ({fname})')
 
+    def ProcessWarnings(self):
+        """Prints out warnings from dmidecode into log if there were any"""
+        if self.__fake or self._log is None:
+            return
+        for warnline in self.__warnings.split('\n'):
+            if len(warnline) > 0:
+                self._log.log(Log.DEBUG, f"** DMI WARNING ** {warnline}")
+
     def MakeReport(self):
         """ Add DMI information to final report """
-        rep_n = libxml2.newNode("DMIinfo")
-        rep_n.newProp("version", self.__version)
         if self.__fake:
+            rep_n = libxml2.newNode("DMIinfo")
+            rep_n.newProp("version", self.__version)
             rep_n.addContent("No DMI tables available")
             rep_n.newProp("not_available", "1")
-        else:
-            self.__dmixml.SetResultType(dmidecode.DMIXML_DOC)
-            try:
-                dmiqry = xmlout.convert_libxml2_to_lxml_doc(self.__dmixml.QuerySection('all'))
-            except Exception as ex1:
-                self._log.log(Log.DEBUG, f'** EXCEPTION {str(ex1)}, will query BIOS only')
-                try:
-                    # If we can't query 'all', at least query 'bios'
-                    dmiqry = xmlout.convert_libxml2_to_lxml_doc(self.__dmixml.QuerySection('bios'))
-                except Exception as ex2:
-                    rep_n.addContent("No DMI tables available")
-                    rep_n.newProp("not_available", "1")
-                    self._log.log(Log.DEBUG, f'** EXCEPTION {str(ex2)}, dmi info will not be reported')
-                    return rep_n
-            resdoc = self.__xsltparser(dmiqry)
-            dmi_n = xmlout.convert_lxml_to_libxml2_nodes(resdoc.getroot())
-            rep_n.addChild(dmi_n)
+            return rep_n
+        rep_n = xmlout.convert_lxml_to_libxml2_nodes(self.__dmixml)
+        rep_n.setName("DMIinfo")
+        rep_n.newProp("version", self.__version)
         return rep_n
 
 def unit_test(rootdir):
@@ -130,12 +186,12 @@  def unit_test(rootdir):
         log = Log()
         log.SetLogVerbosity(Log.DEBUG|Log.INFO)
 
-        ProcessWarnings(logger=log)
         if os.getuid() != 0:
             print("** ERROR **  Must be root to run this unit_test()")
             return 1
 
         d = DMIinfo(logger=log)
+        d.ProcessWarnings()
         dx = d.MakeReport()
         x = libxml2.newDoc("1.0")
         x.setRootElement(dx)