731 lines
22 KiB
Python
731 lines
22 KiB
Python
"""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},
|
|
}
|