init
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
"""Scooby.
|
||||
|
||||
Great Dane turned Python environment detective
|
||||
==============================================
|
||||
|
||||
A lightweight toolset to easily report your Python environment's package
|
||||
versions and hardware resources.
|
||||
|
||||
History
|
||||
-------
|
||||
The scooby reporting is derived from the versioning-scripts created by Dieter
|
||||
Werthmüller for ``empymod``, ``emg3d``, and the ``SimPEG`` framework
|
||||
(https://empymod.github.io; https://simpeg.xyz). It was heavily inspired by
|
||||
``ipynbtools.py`` from ``qutip`` (https://github.com/qutip) and
|
||||
``watermark.py`` from https://github.com/rasbt/watermark.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from scooby.knowledge import (
|
||||
get_standard_lib_modules,
|
||||
in_ipykernel,
|
||||
in_ipython,
|
||||
meets_version, # noqa: F401
|
||||
version_tuple, # noqa: F401
|
||||
)
|
||||
from scooby.report import AutoReport, Report, get_version
|
||||
from scooby.tracker import TrackedReport, track_imports, untrack_imports
|
||||
|
||||
doo = Report
|
||||
|
||||
__all__ = [
|
||||
'AutoReport',
|
||||
'Report',
|
||||
'TrackedReport',
|
||||
'doo',
|
||||
'get_standard_lib_modules',
|
||||
'get_version',
|
||||
'in_ipykernel',
|
||||
'in_ipython',
|
||||
'track_imports',
|
||||
'untrack_imports',
|
||||
]
|
||||
|
||||
|
||||
__author__ = 'Dieter Werthmüller, Bane Sullivan, Alex Kaszynski, and contributors'
|
||||
__license__ = 'MIT'
|
||||
__copyright__ = '2019, Dieter Werthmüller & Bane Sullivan'
|
||||
try:
|
||||
from scooby.version import version as __version__
|
||||
except ImportError: # Only happens if not properly installed.
|
||||
from datetime import datetime, timezone
|
||||
|
||||
__version__ = 'unknown-' + datetime.now(timezone.utc).strftime('%Y%m%d')
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Create entry point for the command-line interface (CLI)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import scooby
|
||||
from scooby.report import AutoReport, Report
|
||||
|
||||
|
||||
def main(args: list[str] | None = None) -> None:
|
||||
"""Parse command line inputs of CLI interface."""
|
||||
# If not explicitly called, catch arguments
|
||||
if args is None:
|
||||
args = sys.argv[1:]
|
||||
|
||||
# Start CLI-arg-parser and define arguments
|
||||
parser = argparse.ArgumentParser(description='Great Dane turned Python environment detective.')
|
||||
|
||||
# arg: Packages
|
||||
parser.add_argument(
|
||||
'packages',
|
||||
nargs='*',
|
||||
default=None,
|
||||
type=str,
|
||||
help=('names of the packages to report'),
|
||||
)
|
||||
|
||||
# arg: Report of a package
|
||||
parser.add_argument(
|
||||
'--report',
|
||||
'-r',
|
||||
default=None,
|
||||
type=str,
|
||||
help=('print `Report()` of this package'),
|
||||
)
|
||||
|
||||
# arg: Sort
|
||||
parser.add_argument(
|
||||
'--no-opt',
|
||||
action='store_true',
|
||||
default=None,
|
||||
help='do not show the default optional packages. Defaults to True if '
|
||||
'using --report and defaults to False otherwise.',
|
||||
)
|
||||
|
||||
# arg: Sort
|
||||
parser.add_argument(
|
||||
'--sort',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help='sort the packages when the report is shown',
|
||||
)
|
||||
|
||||
# arg: Version
|
||||
parser.add_argument(
|
||||
'--version',
|
||||
'-v',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help='only display scooby version',
|
||||
)
|
||||
|
||||
# Call act with command line arguments as dict.
|
||||
act(vars(parser.parse_args(args)))
|
||||
|
||||
|
||||
def act(args_dict: dict[str, Any]) -> None:
|
||||
"""Act upon CLI inputs."""
|
||||
# Quick exit if only scooby version.
|
||||
if args_dict.pop('version'):
|
||||
print(f'scooby v{scooby.__version__}')
|
||||
return
|
||||
|
||||
report = args_dict.pop('report')
|
||||
no_opt = args_dict.pop('no_opt')
|
||||
packages = args_dict.pop('packages')
|
||||
|
||||
if no_opt is None:
|
||||
if report is None:
|
||||
no_opt = False
|
||||
else:
|
||||
no_opt = True
|
||||
|
||||
# Report of another package.
|
||||
if report:
|
||||
try:
|
||||
module = importlib.import_module(report)
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
print(module.Report())
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
return
|
||||
|
||||
try:
|
||||
print(AutoReport(report))
|
||||
except PackageNotFoundError:
|
||||
print(
|
||||
f'Package `{report}` has no Report class and `importlib` could not '
|
||||
'be used to autogenerate one.',
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
else:
|
||||
return
|
||||
|
||||
# Collect input.
|
||||
inp = {'additional': packages, 'sort': args_dict['sort']}
|
||||
|
||||
# Define optional as empty list if no-opt.
|
||||
if no_opt:
|
||||
inp['optional'] = []
|
||||
|
||||
# Print the report.
|
||||
print(Report(**inp))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,227 @@
|
||||
"""The knowledge base.
|
||||
|
||||
Knowledge
|
||||
=========
|
||||
|
||||
It contains, for instance, known odd locations of version information for
|
||||
particular modules (``VERSION_ATTRIBUTES``, ``VERSION_METHODS``)
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import sysconfig
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
PACKAGE_ALIASES = {
|
||||
'vtkmodules': 'vtk',
|
||||
'vtkmodules.all': 'vtk',
|
||||
}
|
||||
|
||||
# Define unusual version locations
|
||||
VERSION_ATTRIBUTES = {
|
||||
'PyQt5': 'Qt.PYQT_VERSION_STR',
|
||||
'sip': 'SIP_VERSION_STR',
|
||||
}
|
||||
|
||||
|
||||
def get_pyqt5_version() -> str:
|
||||
"""Return the PyQt5 version."""
|
||||
try:
|
||||
from PyQt5.Qt import PYQT_VERSION_STR
|
||||
except ImportError:
|
||||
return 'Version unknown'
|
||||
|
||||
return PYQT_VERSION_STR
|
||||
|
||||
|
||||
VERSION_METHODS: dict[str, Callable[[], str]] = {
|
||||
'PyQt5': get_pyqt5_version,
|
||||
}
|
||||
|
||||
|
||||
# Check the environments
|
||||
def in_ipython() -> bool:
|
||||
"""Check if we are in a IPython environment.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool : True
|
||||
``True`` when in an IPython environment.
|
||||
|
||||
"""
|
||||
try:
|
||||
__IPYTHON__ # noqa: B018
|
||||
except NameError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def in_ipykernel() -> bool:
|
||||
"""Check if in a ipykernel (most likely Jupyter) environment.
|
||||
|
||||
Warning:
|
||||
-------
|
||||
There is no way to tell if the code is being executed in a notebook
|
||||
(Jupyter Notebook or Jupyter Lab) or a kernel is used but executed in a
|
||||
QtConsole, or in an IPython console, or any other frontend GUI. However, if
|
||||
`in_ipykernel` returns True, you are most likely in a Jupyter Notebook/Lab,
|
||||
just keep it in mind that there are other possibilities.
|
||||
|
||||
Returns:
|
||||
-------
|
||||
bool : True if using an ipykernel
|
||||
|
||||
"""
|
||||
ipykernel = False
|
||||
if in_ipython():
|
||||
try:
|
||||
ipykernel: bool = type(get_ipython()).__module__.startswith('ipykernel.')
|
||||
except NameError:
|
||||
pass
|
||||
return ipykernel
|
||||
|
||||
|
||||
def get_standard_lib_modules() -> set[str]:
|
||||
"""Return a set of the names of all modules in the standard library."""
|
||||
site_path = Path(sysconfig.get_path('stdlib'))
|
||||
if getattr(sys, 'frozen', False): # within pyinstaller
|
||||
lib_path = site_path / '..'
|
||||
if lib_path.is_dir():
|
||||
names = lib_path.iterdir()
|
||||
stdlib_pkgs = {p.stem for p in names if p.suffix == '.py'}
|
||||
else:
|
||||
stdlib_pkgs = {}
|
||||
|
||||
else:
|
||||
names = site_path.iterdir()
|
||||
stdlib_pkgs = {p.stem if p.suffix == '.py' else p.name for p in names}
|
||||
|
||||
return {
|
||||
'python',
|
||||
'sys',
|
||||
'__builtin__',
|
||||
'__builtins__',
|
||||
'builtins',
|
||||
'session',
|
||||
'math',
|
||||
'itertools',
|
||||
'binascii',
|
||||
'array',
|
||||
'atexit',
|
||||
'fcntl',
|
||||
'errno',
|
||||
'gc',
|
||||
'time',
|
||||
'unicodedata',
|
||||
'mmap',
|
||||
}.union(stdlib_pkgs)
|
||||
|
||||
|
||||
def version_tuple(v: str) -> tuple[int, ...]:
|
||||
"""Convert a version string to a tuple containing ints.
|
||||
|
||||
Non-numeric version strings will be converted to 0. For example:
|
||||
``'0.28.0dev0'`` will be converted to ``'0.28.0'``
|
||||
|
||||
Returns
|
||||
-------
|
||||
ver_tuple : tuple
|
||||
Length 3 tuple representing the major, minor, and patch
|
||||
version.
|
||||
|
||||
"""
|
||||
split_v = v.split('.')
|
||||
while len(split_v) < 3:
|
||||
split_v.append('0')
|
||||
|
||||
if len(split_v) > 3:
|
||||
msg = 'Version strings containing more than three parts cannot be parsed'
|
||||
raise ValueError(msg)
|
||||
|
||||
vals: list[int] = []
|
||||
for item in split_v:
|
||||
if item.isnumeric():
|
||||
vals.append(int(item))
|
||||
else:
|
||||
vals.append(0)
|
||||
|
||||
return tuple(vals)
|
||||
|
||||
|
||||
def meets_version(version: str, meets: str) -> bool:
|
||||
"""Check if a version string meets a minimum version.
|
||||
|
||||
This is a simplified way to compare version strings. For a more robust
|
||||
tool, please check out the ``packaging`` library:
|
||||
|
||||
https://github.com/pypa/packaging
|
||||
|
||||
Parameters
|
||||
----------
|
||||
version : str
|
||||
Version string. For example ``'0.25.1'``.
|
||||
|
||||
meets : str
|
||||
Version string. For example ``'0.25.2'``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
newer : bool
|
||||
True if version ``version`` is greater or equal to version ``meets``.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> meets_version('0.25.1', '0.25.2')
|
||||
False
|
||||
|
||||
>>> meets_version('0.26.0', '0.25.2')
|
||||
True
|
||||
|
||||
"""
|
||||
va = version_tuple(version)
|
||||
vb = version_tuple(meets)
|
||||
|
||||
if len(va) != len(vb):
|
||||
msg = 'Versions are not comparable.'
|
||||
raise AssertionError(msg)
|
||||
|
||||
for i in range(len(va)):
|
||||
if va[i] > vb[i]:
|
||||
return True
|
||||
if va[i] < vb[i]:
|
||||
return False
|
||||
|
||||
# Arrived here if same version
|
||||
return True
|
||||
|
||||
|
||||
def get_filesystem_type() -> str | Literal[False]:
|
||||
"""Get the type of the file system at the path of the scooby package."""
|
||||
try:
|
||||
import psutil # lazy-load see PR#85
|
||||
except ImportError:
|
||||
psutil = False
|
||||
from pathlib import Path # lazy-load see PR#85
|
||||
import platform # lazy-load see PR#85
|
||||
|
||||
# Skip Windows due to https://github.com/banesullivan/scooby/issues/75
|
||||
fs_type: str | Literal[False]
|
||||
if psutil and platform.system() != 'Windows':
|
||||
# Code by https://stackoverflow.com/a/35291824/10504481
|
||||
my_path = str(Path(__file__).resolve())
|
||||
best_match = ''
|
||||
fs_type = ''
|
||||
for part in psutil.disk_partitions():
|
||||
if my_path.startswith(part.mountpoint) and len(best_match) < len(part.mountpoint):
|
||||
fs_type = part.fstype
|
||||
best_match = part.mountpoint
|
||||
else:
|
||||
fs_type = False
|
||||
return fs_type
|
||||
@@ -0,0 +1,730 @@
|
||||
"""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 += ' <tr>\n'
|
||||
html += " <td style='"
|
||||
if ncol == 1:
|
||||
html += 'text-align: left; '
|
||||
else:
|
||||
html += 'text-align: center; '
|
||||
if nrow == 0:
|
||||
html += 'font-weight: bold; font-size: 1.2em; '
|
||||
html += border + " colspan='"
|
||||
html += f"{2 * ncol}'>{txt}</td>\n"
|
||||
html += ' </tr>\n'
|
||||
return html
|
||||
|
||||
def cols(html: str, version: str, name: str, ncol: int, i: int) -> tuple[str, int]:
|
||||
r"""Print package information in two cells."""
|
||||
# Check if we have to start a new row
|
||||
if i > 0 and i % ncol == 0:
|
||||
html += ' </tr>\n'
|
||||
html += ' <tr>\n'
|
||||
|
||||
align = 'left' if ncol == 1 else 'right'
|
||||
html += f" <td style='text-align: {align};"
|
||||
html += ' ' + border + f'>{name}</td>\n'
|
||||
|
||||
html += " <td style='text-align: left; "
|
||||
html += border + f'>{version}</td>\n'
|
||||
|
||||
return html, i + 1
|
||||
|
||||
# Start html-table
|
||||
html = "<table style='border: 1.5px solid;"
|
||||
if self.max_width:
|
||||
html += f' max-width: {self.max_width}px;'
|
||||
html += "'>\n"
|
||||
|
||||
# Date and time info as title
|
||||
html = colspan(html, self.date, self.ncol, 0)
|
||||
|
||||
# Platform/OS details
|
||||
html += ' <tr>\n'
|
||||
repr_dict = self.to_dict()
|
||||
i = 0
|
||||
for key in [
|
||||
'OS',
|
||||
'CPU(s)',
|
||||
'Machine',
|
||||
'Architecture',
|
||||
'RAM',
|
||||
'Environment',
|
||||
'File system',
|
||||
]:
|
||||
if key in repr_dict:
|
||||
html, i = cols(html, repr_dict[key], key, self.ncol, i)
|
||||
for meta in self._extra_meta:
|
||||
html, i = cols(html, meta[1], meta[0], self.ncol, i)
|
||||
# Finish row
|
||||
html += ' </tr>\n'
|
||||
|
||||
# Python details
|
||||
html = colspan(html, 'Python ' + self.sys_version, self.ncol, 1)
|
||||
html += ' <tr>\n'
|
||||
|
||||
# Loop over packages
|
||||
i = 0 # Reset count for rows.
|
||||
for name, version in self.packages.items():
|
||||
html, i = cols(html, version, name, self.ncol, i)
|
||||
# Fill up the row
|
||||
while i % self.ncol != 0:
|
||||
html += ' <td style= ' + border + '></td>\n'
|
||||
html += ' <td style= ' + border + '></td>\n'
|
||||
i += 1
|
||||
# Finish row
|
||||
html += ' </tr>\n'
|
||||
|
||||
# MKL details
|
||||
if self.mkl_info:
|
||||
html = colspan(html, self.mkl_info, self.ncol, 2)
|
||||
|
||||
# Finish
|
||||
html += '</table>'
|
||||
|
||||
return html
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
"""Return report as dict for storage."""
|
||||
out: dict[str, str] = {}
|
||||
|
||||
# Date and time info
|
||||
out['Date'] = self.date
|
||||
|
||||
# Platform/OS details
|
||||
out['OS'] = self.system
|
||||
out['CPU(s)'] = str(self.cpu_count)
|
||||
out['Machine'] = self.machine
|
||||
out['Architecture'] = self.architecture
|
||||
if self.filesystem:
|
||||
out['File system'] = self.filesystem
|
||||
if self.total_ram != 'unknown':
|
||||
out['RAM'] = self.total_ram
|
||||
out['Environment'] = self.python_environment
|
||||
for meta in self._extra_meta:
|
||||
out[meta[1]] = meta[0]
|
||||
|
||||
# Python details
|
||||
out['Python'] = self.sys_version
|
||||
|
||||
# Loop over packages
|
||||
out.update(self._packages)
|
||||
|
||||
out['other'] = json.dumps(self.other_packages)
|
||||
|
||||
# MKL details
|
||||
if self.mkl_info:
|
||||
out['MKL'] = self.mkl_info
|
||||
|
||||
return out
|
||||
|
||||
|
||||
class AutoReport(Report):
|
||||
"""Auto-generate a scooby.Report for a package.
|
||||
|
||||
This will generate a report based on the distribution requirements of the package.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
module: str | ModuleType,
|
||||
additional: str | None = None,
|
||||
ncol: int = 3,
|
||||
text_width: int = 80,
|
||||
sort: bool = False,
|
||||
show_other: bool = False,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
if not isinstance(module, (str, ModuleType)):
|
||||
msg = f'Cannot generate report for type ({type(module)})'
|
||||
raise TypeError(msg)
|
||||
|
||||
if isinstance(module, ModuleType):
|
||||
module = module.__name__
|
||||
|
||||
# Autogenerate from distribution requirements
|
||||
deps = get_distribution_dependencies(module, separate_extras=True)
|
||||
core = [module, *deps.pop('core')]
|
||||
optional = [ # flatten all extras from the nested "optional" dict
|
||||
pkg for dep_list in deps['optional'].values() for pkg in dep_list
|
||||
]
|
||||
|
||||
Report.__init__(
|
||||
self,
|
||||
additional=additional,
|
||||
core=core,
|
||||
optional=optional,
|
||||
ncol=ncol,
|
||||
text_width=text_width,
|
||||
sort=sort,
|
||||
show_other=show_other,
|
||||
)
|
||||
|
||||
|
||||
# This functionaliy might also be of interest on its own.
|
||||
def get_version(module: str | ModuleType) -> tuple[str, str | None]:
|
||||
"""Get the version of ``module`` by passing the package or it's name.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
module : str or module
|
||||
Name of a module to import or the module itself.
|
||||
|
||||
|
||||
Returns
|
||||
-------
|
||||
name : str
|
||||
Package name
|
||||
|
||||
version : str or None
|
||||
Version of module.
|
||||
|
||||
"""
|
||||
# module is (1) a module or (2) a string.
|
||||
if not isinstance(module, (str, ModuleType)):
|
||||
msg = f'Cannot fetch version from type ({type(module)})'
|
||||
raise TypeError(msg)
|
||||
|
||||
# module is module; get name
|
||||
if isinstance(module, ModuleType):
|
||||
name = module.__name__
|
||||
else:
|
||||
name = module
|
||||
module = None
|
||||
|
||||
# Check aliased names
|
||||
if name in PACKAGE_ALIASES:
|
||||
name = PACKAGE_ALIASES[name]
|
||||
|
||||
# try importlib.metadata before loading the module
|
||||
try:
|
||||
return name, importlib_version(name)
|
||||
except PackageNotFoundError:
|
||||
module = None
|
||||
|
||||
# importlib could not find the package, try to load it
|
||||
if module is None:
|
||||
try:
|
||||
module = importlib.import_module(name)
|
||||
except ImportError:
|
||||
return name, MODULE_NOT_FOUND
|
||||
except Exception: # noqa: BLE001
|
||||
return name, MODULE_TROUBLE
|
||||
|
||||
# Try common version names on loaded module
|
||||
for v_string in ('__version__', 'version'):
|
||||
try:
|
||||
return name, getattr(module, v_string)
|
||||
except AttributeError: # noqa: PERF203
|
||||
pass
|
||||
|
||||
# Try the VERSION_ATTRIBUTES library
|
||||
try:
|
||||
attr = VERSION_ATTRIBUTES[name]
|
||||
return name, getattr(module, attr)
|
||||
except (KeyError, AttributeError):
|
||||
pass
|
||||
|
||||
# Try the VERSION_METHODS library
|
||||
try:
|
||||
method = VERSION_METHODS[name]
|
||||
return name, method()
|
||||
except (KeyError, ImportError):
|
||||
pass
|
||||
|
||||
# If still not found, return VERSION_NOT_FOUND
|
||||
return name, VERSION_NOT_FOUND
|
||||
|
||||
|
||||
def platform() -> ModuleType:
|
||||
"""Return platform as lazy load; see PR#85."""
|
||||
import platform
|
||||
|
||||
return platform
|
||||
|
||||
|
||||
def get_distribution_dependencies(
|
||||
dist_name: str,
|
||||
*,
|
||||
separate_extras: bool = False,
|
||||
) -> list[str] | dict[str, list[str]]:
|
||||
"""Get required and extra dependencies of a package distribution.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
dist_name : str
|
||||
Name of the package distribution.
|
||||
|
||||
separate_extras : bool, default: False
|
||||
Separate extra (optional) dependencies by name. If ``True`` a ``dict``
|
||||
is returned with a ``'core'`` key with all required dependencies,
|
||||
and a ``'optional'`` key which includes any extras as separate keys.
|
||||
|
||||
.. versionadded:: 0.11
|
||||
|
||||
Returns
|
||||
-------
|
||||
dependencies : list | dict[str, list[str]]
|
||||
List of dependency names, or dict of dependencies separated by extras
|
||||
name if ``separate_extras`` is ``True``.
|
||||
|
||||
"""
|
||||
try:
|
||||
dist = distribution(dist_name)
|
||||
except PackageNotFoundError:
|
||||
msg = f'Package `{dist_name}` has no distribution.'
|
||||
raise PackageNotFoundError(msg) from None
|
||||
|
||||
def _package_name(requirement: str) -> str:
|
||||
for sep in (' ', ';', '<', '=', '>', '!'):
|
||||
requirement = requirement.split(sep, 1)[0]
|
||||
return requirement.strip()
|
||||
|
||||
requires = dist.requires or []
|
||||
if not separate_extras:
|
||||
# Use dict for ordered and unique keys
|
||||
return list({_package_name(pkg): None for pkg in requires}.keys())
|
||||
|
||||
deps_dict: dict[str, dict[str, None | dict[str, None]]] = {'core': {}, 'optional': {}}
|
||||
|
||||
for req in requires:
|
||||
name = _package_name(req)
|
||||
# Extract the extra name from a requirement string like "extra == 'dev'"
|
||||
extras_match = re.search(r"extra\s*==\s*['\"]?([\w-]+)['\"]?", req)
|
||||
if extras_match:
|
||||
extra_name = extras_match.group(1)
|
||||
if extra_name not in deps_dict['optional']:
|
||||
deps_dict['optional'][extra_name] = {}
|
||||
deps_dict['optional'][extra_name][name] = None
|
||||
else:
|
||||
deps_dict['core'][name] = None
|
||||
|
||||
# Convert dicts of names → lists while preserving order
|
||||
return {
|
||||
'core': list(deps_dict['core'].keys()),
|
||||
'optional': {k: list(v.keys()) for k, v in deps_dict['optional'].items() if v},
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Track imports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from scooby.knowledge import get_standard_lib_modules
|
||||
from scooby.report import Report
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping, Sequence
|
||||
from types import ModuleType
|
||||
|
||||
|
||||
TRACKING_SUPPORTED = False
|
||||
SUPPORT_MESSAGE = (
|
||||
'Tracking is not supported for this version of Python. Try using a modern version of Python.'
|
||||
)
|
||||
try:
|
||||
import builtins
|
||||
|
||||
CLASSIC_IMPORT = builtins.__import__
|
||||
TRACKING_SUPPORTED = True
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
# The variable we track all imports in
|
||||
TRACKED_IMPORTS: list[str | ModuleType] = ['scooby']
|
||||
|
||||
MODULES_TO_IGNORE = {
|
||||
'pyMKL',
|
||||
'mkl',
|
||||
'vtkmodules',
|
||||
'mpl_toolkits',
|
||||
}
|
||||
|
||||
|
||||
STDLIB_PKGS: set[str] = set()
|
||||
|
||||
|
||||
def _criterion(name: str) -> bool:
|
||||
return (
|
||||
len(name) > 0
|
||||
and name not in STDLIB_PKGS
|
||||
and not name.startswith('_')
|
||||
and name not in MODULES_TO_IGNORE
|
||||
)
|
||||
|
||||
|
||||
if TRACKING_SUPPORTED:
|
||||
|
||||
def scooby_import(
|
||||
name: str,
|
||||
globals: Mapping[str, object] | None = None, # noqa: A002
|
||||
locals: Mapping[str, object] | None = None, # noqa: A002
|
||||
fromlist: Sequence[str] = (),
|
||||
level: int = 0,
|
||||
) -> ModuleType:
|
||||
"""Override of the import method to track package names."""
|
||||
m = CLASSIC_IMPORT(name, globals=globals, locals=locals, fromlist=fromlist, level=level)
|
||||
name = name.split('.')[0]
|
||||
if level == 0 and _criterion(name):
|
||||
TRACKED_IMPORTS.append(name)
|
||||
return m
|
||||
|
||||
|
||||
def track_imports() -> None:
|
||||
"""Track all imported modules for the remainder of this session."""
|
||||
if not TRACKING_SUPPORTED:
|
||||
raise RuntimeError(SUPPORT_MESSAGE)
|
||||
global STDLIB_PKGS
|
||||
STDLIB_PKGS = get_standard_lib_modules()
|
||||
builtins.__import__ = scooby_import
|
||||
|
||||
|
||||
def untrack_imports() -> None:
|
||||
"""Stop tracking imports and return to the builtin import method.
|
||||
|
||||
This will also clear the tracked imports.
|
||||
"""
|
||||
if not TRACKING_SUPPORTED:
|
||||
raise RuntimeError(SUPPORT_MESSAGE)
|
||||
builtins.__import__ = CLASSIC_IMPORT
|
||||
TRACKED_IMPORTS.clear()
|
||||
TRACKED_IMPORTS.append('scooby')
|
||||
|
||||
|
||||
class TrackedReport(Report):
|
||||
"""A class to inspect the active environment and generate a report.
|
||||
|
||||
Generates a report based on all imported modules. Simply pass the
|
||||
``globals()`` dictionary.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
additional: list[str | ModuleType] | None = None,
|
||||
ncol: int = 3,
|
||||
text_width: int = 80,
|
||||
sort: bool = False,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
if not TRACKING_SUPPORTED:
|
||||
raise RuntimeError(SUPPORT_MESSAGE)
|
||||
if len(TRACKED_IMPORTS) < 2:
|
||||
msg = (
|
||||
'There are no tracked imports, please use '
|
||||
'`scooby.track_imports()` before running your '
|
||||
'code.'
|
||||
)
|
||||
raise RuntimeError(
|
||||
msg,
|
||||
)
|
||||
|
||||
Report.__init__(
|
||||
self,
|
||||
additional=additional,
|
||||
core=TRACKED_IMPORTS,
|
||||
ncol=ncol,
|
||||
text_width=text_width,
|
||||
sort=sort,
|
||||
optional=[],
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
# file generated by setuptools-scm
|
||||
# don't change, don't track in version control
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"__version_tuple__",
|
||||
"version",
|
||||
"version_tuple",
|
||||
"__commit_id__",
|
||||
"commit_id",
|
||||
]
|
||||
|
||||
TYPE_CHECKING = False
|
||||
if TYPE_CHECKING:
|
||||
from typing import Tuple
|
||||
from typing import Union
|
||||
|
||||
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
||||
COMMIT_ID = Union[str, None]
|
||||
else:
|
||||
VERSION_TUPLE = object
|
||||
COMMIT_ID = object
|
||||
|
||||
version: str
|
||||
__version__: str
|
||||
__version_tuple__: VERSION_TUPLE
|
||||
version_tuple: VERSION_TUPLE
|
||||
commit_id: COMMIT_ID
|
||||
__commit_id__: COMMIT_ID
|
||||
|
||||
__version__ = version = '0.11.0'
|
||||
__version_tuple__ = version_tuple = (0, 11, 0)
|
||||
|
||||
__commit_id__ = commit_id = 'g3cd900331'
|
||||
Reference in New Issue
Block a user