"""The main module containing the `Report` class.""" from __future__ import annotations from datetime import datetime, timezone import importlib from importlib.metadata import ( PackageNotFoundError, distribution, distributions, version as importlib_version, ) import json import re import sys from types import ModuleType from typing import Any, Literal, cast from .knowledge import ( PACKAGE_ALIASES, VERSION_ATTRIBUTES, VERSION_METHODS, get_filesystem_type, in_ipykernel, in_ipython, ) MODULE_NOT_FOUND = 'Module not found' MODULE_TROUBLE = 'Trouble importing' VERSION_NOT_FOUND = 'Version unknown' # Info classes class PlatformInfo: """Internal helper class to access details about the computer platform.""" def __init__(self) -> None: """Initialize.""" self._mkl_info: str | None # for typing purpose self._filesystem: str | Literal[False] @property def system(self) -> str: """Return the system/OS name. E.g. ``'Linux (name version)'``, ``'Windows'``, or ``'Darwin'``. An empty string is returned if the value cannot be determined. """ s = platform().system() if s == 'Linux': try: s += ( f' ({platform().freedesktop_os_release()["NAME"]} ' f'{platform().freedesktop_os_release()["VERSION_ID"]})' ) except Exception: # noqa: BLE001 pass elif s == 'Windows': try: release, version, csd, ptype = platform().win32_ver() s += f' ({release} {version} {csd} {ptype})' except Exception: # noqa: BLE001 pass elif s == 'Darwin': try: release, _, _ = platform().mac_ver() s += f' (macOS {release})' except Exception: # noqa: BLE001 pass elif s == 'Java': # TODO: parse platform().java_ver() pass return s @property def platform(self) -> str: """Return the platform.""" return platform().platform() @property def machine(self) -> str: """Return the machine type, e.g. 'i386'. An empty string is returned if the value cannot be determined. """ return platform().machine() @property def architecture(self) -> str: """Return the bit architecture used for the executable.""" return platform().architecture()[0] @property def cpu_count(self) -> int: """Return the number of CPUs in the system.""" if not hasattr(self, '_cpu_count'): import multiprocessing # lazy-load see PR#85 self._cpu_count = multiprocessing.cpu_count() return self._cpu_count @property def total_ram(self) -> str: """Return total RAM info. If not available, returns 'unknown'. """ if not hasattr(self, '_total_ram'): try: import psutil # lazy-load see PR#85 tmem = psutil.virtual_memory().total self._total_ram = f'{tmem / (1024.0**3):.1f} GiB' except ImportError: self._total_ram = 'unknown' return self._total_ram @property def mkl_info(self) -> str | None: """Return MKL info. If not available, returns 'unknown'. """ if not hasattr(self, '_mkl_info'): try: import mkl # lazy-load see PR#85 mkl.get_version_string() except (ImportError, AttributeError): mkl = False try: import numexpr # lazy-load see PR#85 except ImportError: numexpr = False # Get mkl info from numexpr or mkl, if available if mkl: self._mkl_info = cast('str', mkl.get_version_string()) elif numexpr: self._mkl_info = cast('str', numexpr.get_vml_version()) else: self._mkl_info = None return self._mkl_info @property def date(self) -> str: """Return the date formatted as a string.""" now_utc = datetime.now(timezone.utc) return now_utc.strftime('%a %b %d %H:%M:%S %Y %Z') @property def filesystem(self) -> str | Literal[False]: """Get the type of the file system at the path of the scooby package.""" if not hasattr(self, '_filesystem'): self._filesystem = get_filesystem_type() return self._filesystem class PythonInfo: """Internal helper class to access Python info and package versions.""" def __init__( self, additional: list[str | ModuleType] | None, core: list[str | ModuleType] | None, optional: list[str | ModuleType] | None, sort: bool, ) -> None: """Initialize python info.""" self._packages: dict[str, Any] = {} # Holds name of packages and their version self._sort = sort # Add packages in the following order: self._add_packages(additional) # Provided by the user self._add_packages(core) # Provided by a module dev self._add_packages(optional, optional=True) # Optional packages def _add_packages( self, packages: list[str | ModuleType] | None, optional: bool = False, ) -> None: """Add all packages to list; optional ones only if available.""" # Ensure arguments are a list if isinstance(packages, (str, ModuleType)): pckgs: list[str | ModuleType] = [ packages, ] elif packages is None or len(packages) < 1: pckgs = [] else: pckgs = list(packages) # Loop over packages for pckg in pckgs: name, version = get_version(pckg) if not (version == MODULE_NOT_FOUND and optional): self._packages[name] = version @property def sys_version(self) -> str: """Return the system version.""" return sys.version @property def python_environment(self) -> Literal['Jupyter', 'IPython', 'Python']: """Return the python environment.""" if in_ipykernel(): return 'Jupyter' if in_ipython(): return 'IPython' return 'Python' @property def packages(self) -> dict[str, Any]: """Return versions of all additional, core, and optional packages. Includes available and unavailable/unknown. """ pckg_dict = dict(self._packages) if self._sort: packages: dict[str, Any] = {} for name in sorted(pckg_dict.keys(), key=lambda x: x.lower()): packages[name] = pckg_dict[name] pckg_dict = packages return pckg_dict @property def installed_packages(self) -> dict[str, str]: """Return versions of all installed packages. .. versionadded:: 0.11 """ # sort case-insensitively by name installed = sorted( (dist.metadata['Name'] for dist in distributions()), key=str.lower, ) packages: dict[str, str] = {} for pkg in installed: name, version = get_version(pkg) packages[name] = version return packages @property def other_packages(self) -> dict[str, str]: """Packages which are installed but not labeled as additional, core, or optional. This is effectively ``installed_packages`` - ``packages``. .. versionadded:: 0.11 """ packages = self.packages installed: dict[str, str] = self.installed_packages other: dict[str, str] = installed.copy() for key in installed: if key in packages: other.pop(key) return other # The main Report instance class Report(PlatformInfo, PythonInfo): """Have Scooby report the active Python environment. Displays the system information when a ``__repr__`` method is called (through outputting or printing). Parameters ---------- additional : list(ModuleType), list(str) List of packages or package names to add to output information. core : list(ModuleType), list(str) The core packages to list first. optional : list(ModuleType), list(str) A list of packages to list if they are available. If not available, no warnings or error will be thrown. Defaults to ``['numpy', 'scipy', 'IPython', 'matplotlib', 'scooby']`` ncol : int, optional Number of package-columns in html table (no effect in text-version); Defaults to 3. text_width : int, optional The text width for non-HTML display modes. sort : bool, optional Sort the packages when the report is shown. extra_meta : tuple(tuple(str, str), ...), optional Additional two component pairs of meta information to display. max_width : int, optional Max-width of html-table. By default None. show_other : bool, default: False Show all other installed packages not already included in ``additional``, ``core``, or ``other``. These packages are always sorted alphabetically. .. versionadded:: 0.11 """ def __init__( self, additional: list[str | ModuleType] | None = None, core: list[str | ModuleType] | None = None, optional: list[str | ModuleType] | None = None, ncol: int = 4, text_width: int = 80, sort: bool = False, extra_meta: tuple[tuple[str, str], ...] | list[tuple[str, str]] | None = None, max_width: int | None = None, show_other: bool = False, ) -> None: """Initialize report.""" # Set default optional packages to investigate if optional is None: optional = ['numpy', 'scipy', 'IPython', 'matplotlib', 'scooby'] PythonInfo.__init__(self, additional=additional, core=core, optional=optional, sort=sort) self.ncol = int(ncol) self.text_width = int(text_width) self.max_width = max_width self.show_other = show_other if extra_meta is not None: if not isinstance(extra_meta, (list, tuple)): msg = '`extra_meta` must be a list/tuple of key-value pairs.' raise TypeError(msg) if len(extra_meta) == 2 and isinstance(extra_meta[0], str): extra_meta = [extra_meta] for meta in extra_meta: if not isinstance(meta, (list, tuple)) or len(meta) != 2: msg = 'Each chunk of meta info must have two values.' raise TypeError(msg) else: extra_meta = [] self._extra_meta = extra_meta def __repr__(self) -> str: """Return Plain-text version information.""" def line_sep(sep: str = '-', *, newlines: bool = False) -> str: line = self.text_width * sep return '\n' + line + '\n' if newlines else line import textwrap # lazy-load see PR#85 # Width for text-version text = line_sep(newlines=True) # Date and time info as title date_text = ' Date: ' mult = 0 indent = len(date_text) for txt in textwrap.wrap(self.date, self.text_width - indent): date_text += ' ' * mult + txt + '\n' mult = indent text += date_text + '\n' # Get length of longest package: min of 18 and max of 40 if self._packages: row_width = min(40, max(18, len(max(self._packages.keys(), key=len)))) else: row_width = 18 # Platform/OS details repr_dict = self.to_dict() for key in [ 'OS', 'CPU(s)', 'Machine', 'Architecture', 'RAM', 'Environment', 'File system', ]: if key in repr_dict: text += f'{key:>{row_width}} : {repr_dict[key]}\n' for key, value in self._extra_meta: text += f'{key:>{row_width}} : {value}\n' # Python details text += '\n' for txt in textwrap.wrap('Python ' + self.sys_version, self.text_width - 4): text += ' ' + txt + '\n' if self._packages: text += '\n' # Loop over packages package_template = '{name:>{row_width}} : {version}\n' for name, version in self.packages.items(): text += package_template.format(name=name, version=version, row_width=row_width) # MKL details if self.mkl_info: text += '\n' for txt in textwrap.wrap(self.mkl_info, self.text_width - 4): text += ' ' + txt + '\n' if self.show_other: text = text.rstrip() text += line_sep('·', newlines=True) for name, version in self.other_packages.items(): text += package_template.format(name=name, version=version, row_width=row_width) # Finish text += line_sep() return text def _repr_html_(self) -> str: """Return HTML-rendered version information.""" # Define html-styles border = "border: 1px solid;'" def colspan(html: str, txt: str, ncol: int, nrow: int) -> str: r"""Print txt in a row spanning whole table.""" html += '
| \n' html += ' | \n' i += 1 # Finish row html += ' |