Repository: terrapower/armi
Pull request #1689 (2024-04-18 18:45:28)

Adding system info logging for Linux
## What is the change?

Adding detailed system information for OS and processors, for a wide range of Linux operating systems.

Because there are so many different flavors of Linux, the new system info logging has multiple redundant ways to find the same OS/processor information. The code will try each one in turn before giving up and trying the next one. In this way, we should support a wide array of Linux flavors seamlessly.

## Why is the change being made?

ARMI is supposed to support Windows _and_ Linux.

---

## Checklist

- [X] This PR has only [one purpose or idea](https://terrapower.github.io/armi/developer/tooling.html#one-idea-one-pr).
- [X] [Tests](https://terrapower.github.io/armi/developer/tooling.html#test-it) have been added/updated to verify any new/changed code.

<!-- Check the code quality -->

- [X] The code style follows [good practices](https://terrapower.github.io/armi/developer/standards_and_practices.html).
- [X] The commit message(s) follow [good practices](https://terrapower.github.io/armi/developer/tooling.html).

<!-- Check the project-level cruft -->

- [X] The [release notes](https://terrapower.github.io/armi/developer/tooling.html#add-release-notes) have been updated if necessary.
- [X] The [documentation](https://terrapower.github.io/armi/developer/tooling.html#document-it) is still up-to-date in the `doc` folder.
- [X] The dependencies are still up-to-date in `pyproject.toml`.


Patch:
diff --git a/.coveragerc b/.coveragerc
deleted file mode 100644
index fd25fbcd..00000000
--- a/.coveragerc
+++ /dev/null
@@ -1,35 +0,0 @@
-# Don't worry about coverage for the grid GUI (for now)
-[run]
-omit =
-    armi/cli/gridGui.py
-    armi/utils/gridEditor.py
-    armi/utils/tests/test_gridGui.py
-    venv/
-source = armi
-# change default .coverage file to something that doesn't have a dot
-# because the Windows file server can't handle dots.
-data_file = coverage_results.cov
-
-[coverage:run]
-parallel = true
-
-[report]
-omit =
-    armi/cli/gridGui.py
-    armi/utils/gridEditor.py
-    */tests/*
-
-exclude_lines =
-    # Don't complain about missing debug-only code:
-    def __repr__
-    if self\.debug
-
-    # Don't complain if tests don't hit defensive assertion code:
-    raise AssertionError
-    raise KeyboardInterrupt
-    raise NotImplementedError
-    except ImportError
-    pass
-
-    # Don't complain if non-runnable code isn't run:
-    if __name__ == .__main__.:
diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml
index ba737fee..b3cd8de0 100644
--- a/.github/workflows/coverage.yaml
+++ b/.github/workflows/coverage.yaml
@@ -30,10 +30,10 @@ jobs:
           pip install -e .[memprof,mpi,test]
       - name: Run Coverage
         run: |
-          coverage run --rcfile=.coveragerc -m pytest -n 4 --cov=armi --cov-config=.coveragerc --cov-report=lcov --ignore=venv armi
-          mpiexec -n 2 --use-hwthread-cpus coverage run --rcfile=.coveragerc -m pytest --cov=armi --cov-config=.coveragerc --cov-report=lcov --cov-append --ignore=venv armi/tests/test_mpiFeatures.py || true
-          mpiexec -n 2 --use-hwthread-cpus coverage run --rcfile=.coveragerc -m pytest --cov=armi --cov-config=.coveragerc --cov-report=lcov --cov-append --ignore=venv armi/tests/test_mpiParameters.py || true
-          coverage combine --rcfile=.coveragerc --keep -a
+          coverage run --rcfile=pyproject.toml -m pytest -n 4 --cov=armi --cov-config=pyproject.toml --cov-report=lcov --ignore=venv armi
+          mpiexec -n 2 --use-hwthread-cpus coverage run --rcfile=pyproject.toml -m pytest --cov=armi --cov-config=pyproject.toml --cov-report=lcov --cov-append --ignore=venv armi/tests/test_mpiFeatures.py || true
+          mpiexec -n 2 --use-hwthread-cpus coverage run --rcfile=pyproject.toml -m pytest --cov=armi --cov-config=pyproject.toml --cov-report=lcov --cov-append --ignore=venv armi/tests/test_mpiParameters.py || true
+          coverage combine --rcfile=pyproject.toml --keep -a
       - name: Publish to coveralls.io
         uses: coverallsapp/github-action@v1.1.2
         with:
diff --git a/armi/bookkeeping/report/reportingUtils.py b/armi/bookkeeping/report/reportingUtils.py
index 3a0cfcf3..673eecf7 100644
--- a/armi/bookkeeping/report/reportingUtils.py
+++ b/armi/bookkeeping/report/reportingUtils.py
@@ -78,7 +78,7 @@ def writeWelcomeHeaders(o, cs):
             (Operator_ArmiCodebase, context.ROOT),
             (Operator_WorkingDirectory, os.getcwd()),
             (Operator_PythonInterperter, sys.version),
-            (Operator_MasterMachine, os.environ.get("COMPUTERNAME", "?")),
+            (Operator_MasterMachine, getNodeName()),
             (Operator_NumProcessors, context.MPI_SIZE),
             (Operator_Date, context.START_TIME),
         ]
@@ -191,16 +191,9 @@ def writeWelcomeHeaders(o, cs):
                 nodeMappingData.append(
                     (uniqueName, numProcessors, ", ".join(matchingProcs))
                 )
-                # If this is on Windows: run sys info on each unique node too
-                if "win" in sys.platform:
-                    sysInfoCmd = (
-                        'systeminfo | findstr /B /C:"OS Name" /B /C:"OS Version" /B '
-                        '/C:"Processor" && systeminfo | findstr /E /C:"Mhz"'
-                    )
-                    out = subprocess.run(
-                        sysInfoCmd, capture_output=True, text=True, shell=True
-                    )
-                    sysInfo += out.stdout
+
+                sysInfo += getSystemInfo()
+
             runLog.header("=========== Machine Information ===========")
             runLog.info(
                 tabulate.tabulate(
@@ -209,6 +202,7 @@ def writeWelcomeHeaders(o, cs):
                     tablefmt="armi",
                 )
             )
+
             if sysInfo:
                 runLog.header("=========== System Information ===========")
                 runLog.info(sysInfo)
@@ -241,6 +235,148 @@ def writeWelcomeHeaders(o, cs):
     _writeReactorCycleInformation(o, cs)
 
 
+def getNodeName():
+    """Get the name of this compute node.
+
+    First, look in context.py. Then try various Linux tools. Then try Windows commands.
+
+    Returns
+    -------
+    str
+        Compute node name.
+    """
+    hostNames = [
+        context.MPI_NODENAME,
+        context.MPI_NODENAMES[0],
+        subprocess.run("hostname", capture_output=True, text=True, shell=True).stdout,
+        subprocess.run("uname -n", capture_output=True, text=True, shell=True).stdout,
+        os.environ.get("COMPUTERNAME", context.LOCAL),
+    ]
+    for nodeName in hostNames:
+        if nodeName and nodeName != context.LOCAL:
+            return nodeName
+
+    return context.LOCAL
+
+
+def _getSystemInfoWindows():
+    """Get system information, assuming the system is Windows.
+
+    Returns
+    -------
+    str
+        Basic system information: OS name, OS version, basic processor information
+
+    Examples
+    --------
+    Example results:
+
+        OS Name:         Microsoft Windows 10 Enterprise
+        OS Version:      10.0.19041 N/A Build 19041
+        Processor(s):    1 Processor(s) Installed.
+                         [01]: Intel64 Family 6 Model 142 Stepping 12 GenuineIntel ~801 Mhz
+    """
+    cmd = (
+        'systeminfo | findstr /B /C:"OS Name" /B /C:"OS Version" /B '
+        '/C:"Processor" && systeminfo | findstr /E /C:"Mhz"'
+    )
+    return subprocess.run(cmd, capture_output=True, text=True, shell=True).stdout
+
+
+def _getSystemInfoLinux():
+    """Get system information, assuming the system is Linux.
+
+    This method uses multiple, redundant variations on common Linux command utilities to get the
+    information necessary. While it is not possible to guarantee what programs or files will be
+    available on "all Linux operating system", this collection of tools is widely supported and
+    should provide a reasonably broad-distribution coverage.
+
+    Returns
+    -------
+    str
+        Basic system information: OS name, OS version, basic processor information
+
+    Examples
+    --------
+    Example results:
+
+        OS Info:  Ubuntu 22.04.3 LTS
+        Processor(s):
+            processor   : 0
+            vendor_id   : GenuineIntel
+            cpu family  : 6
+            model       : 126
+            model name  : Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
+            ...
+    """
+    # get OS name / version
+    linuxOsCommands = [
+        'cat /etc/os-release | grep "^PRETTY_NAME=" | cut -d = -f 2',
+        "uname -a",
+        "lsb_release -d | cut -d : -f 2",
+        'hostnamectl | grep "Operating System" | cut -d : -f 2',
+    ]
+    osInfo = ""
+    for cmd in linuxOsCommands:
+        osInfo = subprocess.run(
+            cmd, capture_output=True, text=True, shell=True
+        ).stdout.strip()
+        if osInfo:
+            break
+
+    if not osInfo:
+        runLog.warning("Linux OS information not found.")
+        return ""
+
+    # get processor information
+    linuxProcCommands = ["cat /proc/cpuinfo", "lscpu", "lshw -class CPU"]
+    procInfo = ""
+    for cmd in linuxProcCommands:
+        procInfo = subprocess.run(
+            cmd, capture_output=True, text=True, shell=True
+        ).stdout
+        if procInfo:
+            break
+
+    if not procInfo:
+        runLog.warning("Linux processor information not found.")
+        return ""
+
+    # build output string
+    out = "OS Info:  "
+    out += osInfo.strip()
+    out += "\nProcessor(s):\n    "
+    out += procInfo.strip().replace("\n", "\n    ")
+    out += "\n"
+
+    return out
+
+
+def getSystemInfo():
+    """Get system information, assuming the system is Windows or Linux.
+
+    Notes
+    -----
+    The format of the system information will be different on Windows vs Linux.
+
+    Returns
+    -------
+    str
+        Basic system information: OS name, OS version, basic processor information
+    """
+    # Get basic system information (on Windows and Linux)
+    if "win" in sys.platform:
+        return _getSystemInfoWindows()
+    elif "linux" in sys.platform:
+        return _getSystemInfoLinux()
+    else:
+        runLog.warning(
+            f"Cannot get system information for {sys.platform} because ARMI only "
+            + "supports Linux and Windows."
+        )
+        return ""
+
+
 def getInterfaceStackSummary(o):
     data = []
     for ii, i in enumerate(o.interfaces, start=1):
@@ -390,8 +526,7 @@ def _makeBOLAssemblyMassSummary(massSum):
             line += "{0:<25.3f}".format(s[val])
         str_.append("{0:12s}{1}".format(val, line))
 
-    # print blocks in this assembly
-    # up to 10
+    # print blocks in this assembly up to 10
     for i in range(10):
         line = " " * 12
         for s in massSum:
@@ -401,6 +536,7 @@ def _makeBOLAssemblyMassSummary(massSum):
                 line += " " * 25
         if re.search(r"\S", line):  # \S matches any non-whitespace character.
             str_.append(line)
+
     return "\n".join(str_)
 
 
@@ -425,10 +561,10 @@ def writeCycleSummary(core):
 
     Parameters
     ----------
-    core:  armi.reactor.reactors.Core
+    core: armi.reactor.reactors.Core
     cs: armi.settings.caseSettings.Settings
     """
-    # would io be worth considering for this?
+    # Would io be worth considering for this?
     cycle = core.r.p.cycle
     str_ = []
     runLog.important("Cycle {0} Summary:".format(cycle))
@@ -446,7 +582,6 @@ def setNeutronBalancesReport(core):
     Parameters
     ----------
     core  : armi.reactor.reactors.Core
-
     """
     if not core.getFirstBlock().p.rateCap:
         runLog.warning(
@@ -642,7 +777,7 @@ def makeCoreDesignReport(core, cs):
 
     Parameters
     ----------
-    core:  armi.reactor.reactors.Core
+    core: armi.reactor.reactors.Core
     cs: armi.settings.caseSettings.Settings
     """
     coreDesignTable = report.data.Table(
diff --git a/armi/cases/case.py b/armi/cases/case.py
index dec8e69b..3cafa4be 100644
--- a/armi/cases/case.py
+++ b/armi/cases/case.py
@@ -30,10 +30,8 @@ import cProfile
 import glob
 import os
 import pathlib
-import platform
 import pstats
 import re
-import shutil
 import sys
 import textwrap
 import time
@@ -62,8 +60,8 @@ from armi.utils.customExceptions import NonexistentSetting
 from armi.utils.directoryChangers import DirectoryChanger
 from armi.utils.directoryChangers import ForcedCreationDirectoryChanger
 
-# change from default .coverage to help with Windows dotfile issues.
-# Must correspond with data_file entry in `coveragerc`!
+# Change from default .coverage to help with Windows dotfile issues.
+# Must correspond with data_file entry in `pyproject.toml`!
 COVERAGE_RESULTS_FILE = "coverage_results.cov"
 
 
@@ -444,6 +442,10 @@ class Case:
         """Helper to provide the coverage configuration file according to the OS. A
         user-supplied file will take precedence, and is not checked for a dot-filename.
 
+        Notes
+        -----
+        ARMI replaced the ".coveragerc" file has been replaced by "pyproject.toml".
+
         Parameters
         ----------
         userCovFile : str
@@ -455,21 +457,14 @@ class Case:
         Returns
         -------
         covFile : str
-            path of coveragerc file
+            path of pyprojec.toml file
         """
         # User-defined file takes precedence.
         if userCovFile:
             return os.path.abspath(userCovFile)
 
         covRcDir = os.path.abspath(context.PROJECT_ROOT)
-        covFile = os.path.join(covRcDir, ".coveragerc")
-        if platform.system() == "Windows":
-            covFileWin = os.path.join(covRcDir, "coveragerc")
-            if makeCopy is True:
-                # Make a copy of the file without the dot in the name
-                shutil.copy(covFile, covFileWin)
-            return covFileWin
-        return covFile
+        return os.path.join(covRcDir, "pyproject.toml")
 
     def _startProfiling(self):
         """Helper to the Case.run(): start the Python profiling,
diff --git a/armi/context.py b/armi/context.py
index 49b8c43a..4927b025 100644
--- a/armi/context.py
+++ b/armi/context.py
@@ -104,8 +104,9 @@ MPI_COMM = None
 # MPI_SIZE is the total number of CPUs
 MPI_RANK = 0
 MPI_SIZE = 1
-MPI_NODENAME = "local"
-MPI_NODENAMES = ["local"]
+LOCAL = "local"
+MPI_NODENAME = LOCAL
+MPI_NODENAMES = [LOCAL]
 
 
 try:
diff --git a/doc/release/0.3.rst b/doc/release/0.3.rst
index bb58d291..b2ad25ad 100644
--- a/doc/release/0.3.rst
+++ b/doc/release/0.3.rst
@@ -9,6 +9,7 @@ Release Date: TBD
 New Features
 ------------
 #. Conserve mass by component in assembly.setBlockMesh(). (`PR#1665 <https://github.com/terrapower/armi/pull/1665>`_)
+#. System information is now also logged on Linux. (`PR#1689 <https://github.com/terrapower/armi/pull/1689>`_)
 #. TBD
 
 API Changes
@@ -42,6 +43,7 @@ Bug Fixes
 Quality Work
 ------------
 #. We now enforce a maximum line length of 120 characters, using ``ruff``. (`PR#1646 <https://github.com/terrapower/armi/pull/1646>`_)
+#. Move ``.coveragerc`` file information into ``pyproject.toml``. (`PR#1692 <https://github.com/terrapower/armi/pull/1692>`_)
 #. TBD
 
 Changes that Affect Requirements
diff --git a/pyproject.toml b/pyproject.toml
index 7d7de0b0..7adb1802 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -219,6 +219,45 @@ filterwarnings = [
     "ignore: the matrix subclass is not the recommended way:PendingDeprecationWarning",
 ]
 
+[tool.coverage.run]
+exclude_also = [
+    "armi/cli/gridGui.py",
+    "armi/utils/gridEditor.py",
+    "armi/utils/tests/test_gridGui.py",
+    "venv/",
+    ]
+source = ["armi"]
+parallel = true
+# Change default .coverage file to something that doesn't have a dot
+# because some Windows services can't handle dots.
+data_file = "coverage_results.cov"
+
+[tool.coverage.report]
+# Regexes for lines to exclude from consideration
+omit = [
+    "armi/cli/gridGui.py",
+    "armi/utils/gridEditor.py",
+    "*/tests/*",
+    ]
+
+exclude_also = [
+    # Don't complain about missing debug-only code:
+    "def __repr__",
+    "if self\\.debug",
+
+    # Don't complain if tests don't hit defensive assertion code:
+    "raise AssertionError",
+    "raise KeyboardInterrupt",
+    "raise NotImplementedError",
+    "except ImportError",
+    "pass",
+
+    # Don't complain if non-runnable code isn't run:
+    "if __name__ == .__main__.:",
+    ]
+
+ignore_errors = true
+
 
 #######################################################################
 #              DATA FILES TO BE INCLUDED WITH THE PROJECT             #


Test patch:
diff --git a/.github/workflows/unittests.yaml b/.github/workflows/unittests.yaml
index 8679712c..15c0b236 100644
--- a/.github/workflows/unittests.yaml
+++ b/.github/workflows/unittests.yaml
@@ -30,6 +30,6 @@ jobs:
         run: |
           pip install -e .[memprof,mpi,test]
           pytest -n 4 armi
-          mpiexec -n 2 --use-hwthread-cpus coverage run --rcfile=.coveragerc -m pytest --cov=armi --cov-config=.coveragerc --cov-report=lcov --cov-append --ignore=venv armi/tests/test_mpiFeatures.py || true
-          mpiexec -n 2 --use-hwthread-cpus coverage run --rcfile=.coveragerc -m pytest --cov=armi --cov-config=.coveragerc --cov-report=lcov --cov-append --ignore=venv armi/tests/test_mpiParameters.py || true
-          coverage combine --rcfile=.coveragerc --keep -a
+          mpiexec -n 2 --use-hwthread-cpus coverage run --rcfile=pyproject.toml -m pytest --cov=armi --cov-config=pyproject.toml --cov-report=lcov --cov-append --ignore=venv armi/tests/test_mpiFeatures.py || true
+          mpiexec -n 2 --use-hwthread-cpus coverage run --rcfile=pyproject.toml -m pytest --cov=armi --cov-config=pyproject.toml --cov-report=lcov --cov-append --ignore=venv armi/tests/test_mpiParameters.py || true
+          coverage combine --rcfile=pyproject.toml --keep -a
diff --git a/armi/bookkeeping/report/tests/test_report.py b/armi/bookkeeping/report/tests/test_report.py
index 648d1bde..87298060 100644
--- a/armi/bookkeeping/report/tests/test_report.py
+++ b/armi/bookkeeping/report/tests/test_report.py
@@ -15,12 +15,18 @@
 """Really basic tests of the report Utils."""
 import logging
 import os
+import subprocess
 import unittest
+from unittest.mock import patch
 
 from armi import runLog, settings
 from armi.bookkeeping import report
 from armi.bookkeeping.report import data, reportInterface
 from armi.bookkeeping.report.reportingUtils import (
+    _getSystemInfoLinux,
+    _getSystemInfoWindows,
+    getNodeName,
+    getSystemInfo,
     makeBlockDesignReport,
     setNeutronBalancesReport,
     summarizePinDesign,
@@ -35,6 +41,79 @@ from armi.tests import mockRunLogs
 from armi.utils.directoryChangers import TemporaryDirectoryChanger
 
 
+class _MockReturnResult:
+    """Mocking the subprocess.run() return object."""
+
+    def __init__(self, stdout):
+        self.stdout = stdout
+
+
+class TestReportingUtils(unittest.TestCase):
+    def test_getSystemInfoLinux(self):
+        """Test _getSystemInfoLinux() on any operating system, by mocking the system calls."""
+        osInfo = '"Ubuntu 22.04.3 LTS"'
+        procInfo = """processor : 0
+vendor_id   : GenuineIntel
+cpu family  : 6
+model       : 126
+model name  : Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
+...
+"""
+        correctResult = """OS Info:  "Ubuntu 22.04.3 LTS"
+Processor(s):
+    processor : 0
+    vendor_id   : GenuineIntel
+    cpu family  : 6
+    model       : 126
+    model name  : Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
+    ..."""
+
+        def __mockSubprocessRun(*args, **kwargs):
+            if "os-release" in args[0]:
+                return _MockReturnResult(osInfo)
+            else:
+                return _MockReturnResult(procInfo)
+
+        with patch.object(subprocess, "run", side_effect=__mockSubprocessRun):
+            out = _getSystemInfoLinux()
+            self.assertEqual(out.strip(), correctResult)
+
+    @patch("subprocess.run")
+    def test_getSystemInfoWindows(self, mockSubprocess):
+        """Test _getSystemInfoWindows() on any operating system, by mocking the system call."""
+        windowsResult = """OS Name:         Microsoft Windows 10 Enterprise
+OS Version:      10.0.19041 N/A Build 19041
+Processor(s):    1 Processor(s) Installed.
+                 [01]: Intel64 Family 6 Model 142 Stepping 12 GenuineIntel ~801 Mhz"""
+
+        mockSubprocess.return_value = _MockReturnResult(windowsResult)
+
+        out = _getSystemInfoWindows()
+        self.assertEqual(out, windowsResult)
+
+    def test_getSystemInfo(self):
+        """Basic sanity check of getSystemInfo() running in the wild.
+
+        This test should pass if it is run on Window or mainstream Linux distros. But we expect this
+        to fail if the test is run on some other OS.
+        """
+        out = getSystemInfo()
+        substrings = ["OS ", "Processor(s):"]
+        for sstr in substrings:
+            self.assertIn(sstr, out)
+
+        self.assertGreater(len(out), sum(len(sstr) + 5 for sstr in substrings))
+
+    def test_getNodeName(self):
+        """Test that the getNodeName() method returns a non-empty string.
+
+        It is hard to know what string SHOULD be return here, and it would depend on how the OS is
+        set up on your machine or cluster. But this simple test needs to pass as-is on Windows
+        and Linux.
+        """
+        self.assertGreater(len(getNodeName()), 0)
+
+
 class TestReport(unittest.TestCase):
     def setUp(self):
         self.test_group = data.Table(settings.Settings(), "banana")
diff --git a/armi/cases/tests/test_cases.py b/armi/cases/tests/test_cases.py
index 0483868f..5d00da5f 100644
--- a/armi/cases/tests/test_cases.py
+++ b/armi/cases/tests/test_cases.py
@@ -139,10 +139,7 @@ class TestArmiCase(unittest.TestCase):
         covRcDir = os.path.abspath(context.PROJECT_ROOT)
         # Don't actually copy the file, just check the file paths match
         covRcFile = case._getCoverageRcFile(userCovFile="", makeCopy=False)
-        if platform.system() == "Windows":
-            self.assertEqual(covRcFile, os.path.join(covRcDir, "coveragerc"))
-        else:
-            self.assertEqual(covRcFile, os.path.join(covRcDir, ".coveragerc"))
+        self.assertEqual(covRcFile, os.path.join(covRcDir, "pyproject.toml"))
 
         userFile = "UserCovRc"
         covRcFile = case._getCoverageRcFile(userCovFile=userFile, makeCopy=False)