This commit is contained in:
cjw
2026-02-12 23:22:11 +08:00
parent 7b09eb3d89
commit 89660bba4e
5988 changed files with 2517516 additions and 0 deletions
@@ -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'