From f90f5b54b02454f0067e545dd68559604d804971 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Thu, 10 Aug 2023 17:00:42 +0100 Subject: [PATCH] Remove more Qt-dependent code, and also remove glue._deps which is no longer really needed (pip freeze or conda list are usually sufficient) and also remove most of glue.main which concerned the Qt application --- .gitignore | 1 - .readthedocs.yml | 1 - doc/conf.py | 21 +-- glue/__init__.py | 14 -- glue/_deps.py | 300 ---------------------------------------- glue/conftest.py | 7 - glue/main.py | 249 --------------------------------- glue/tests/test_deps.py | 67 --------- glue/tests/test_main.py | 111 --------------- setup.cfg | 3 - 10 files changed, 2 insertions(+), 772 deletions(-) delete mode 100755 glue/_deps.py delete mode 100644 glue/tests/test_deps.py delete mode 100644 glue/tests/test_main.py diff --git a/.gitignore b/.gitignore index e797cbaa7..37c6fda52 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,6 @@ glue/_githash.py # Other .pylintrc *.ropeproject -glue/qt/glue_qt_resources.py *.__junk* *.orig *~ diff --git a/.readthedocs.yml b/.readthedocs.yml index 3c6a1008e..309f6d1bf 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -15,7 +15,6 @@ python: path: . extra_requirements: - docs - - qt - all formats: [] diff --git a/doc/conf.py b/doc/conf.py index fbe71a9de..7ac92db87 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -35,7 +35,6 @@ 'astropy': ('https://docs.astropy.org/en/stable/', None), 'echo': ('https://echo.readthedocs.io/en/latest/', None), 'pandas': ('https://pandas.pydata.org/pandas-docs/stable/', None), - 'PyQt5': ('https://www.riverbankcomputing.com/static/Docs/PyQt5/', None), } # Add any paths that contain templates here, relative to this directory. @@ -125,27 +124,11 @@ todo_include_todos = True autoclass_content = 'both' -nitpick_ignore = [('py:obj', 'glue.viewers.common.qt.toolbar.BasicToolbar.insertAction'), - ('py:obj', 'glue.viewers.common.qt.toolbar.BasicToolbar.setTabOrder'), - ('py:class', 'glue.viewers.histogram.layer_artist.HistogramLayerBase'), +nitpick_ignore = [('py:class', 'glue.viewers.histogram.layer_artist.HistogramLayerBase'), ('py:class', 'glue.viewers.scatter.layer_artist.ScatterLayerBase'), ('py:class', 'glue.viewers.image.layer_artist.ImageLayerBase'), ('py:class', 'glue.viewers.image.layer_artist.RGBImageLayerBase'), - ('py:class', 'glue.viewers.image.state.BaseImageLayerState'), - ('py:class', 'glue.viewers.common.qt.toolbar.BasicToolbar'), - ('py:class', 'glue.viewers.common.qt.base_widget.BaseQtViewerWidget'), - ('py:class', 'sip.voidptr'), - ('py:class', 'PyQt5.sip.voidptr'), - ('py:class', 'PYQT_SLOT')] - -nitpick_ignore_regex = [('py:class', r'PyQt5\.QtCore\.Q[A-Z][a-zA-Z]+'), - ('py:class', r'PyQt5\.QtWidgets\.Q[A-Z][a-zA-Z]+'), - ('py:class', r'PyQt6\.QtCore\.Q[A-Z][a-zA-Z]+'), - ('py:class', r'PyQt6\.QtWidgets\.Q[A-Z][a-zA-Z]+'), - ('py:class', r'Qt\.[A-Z][a-zA-Z]+'), - ('py:class', r'QPalette\.[A-Z][a-zA-Z]+'), - ('py:class', r'QWidget\.[A-Z][a-zA-Z]+'), - ('py:class', r'Q[A-Z][a-zA-Z]+')] + ('py:class', 'glue.viewers.image.state.BaseImageLayerState')] viewcode_follow_imported_members = False diff --git a/glue/__init__.py b/glue/__init__.py index 147f0b77e..91883381d 100644 --- a/glue/__init__.py +++ b/glue/__init__.py @@ -33,17 +33,3 @@ def test(no_optional_skip=False): from glue._settings_helpers import load_settings load_settings() - - -# In PyQt 5.5+, PyQt overrides the default exception catching and fatally -# crashes the Qt application without printing out any details about the error. -# Below we revert the exception hook to the original Python one. Note that we -# can't just do sys.excepthook = sys.__excepthook__ otherwise PyQt will detect -# the default excepthook is in place and override it. - - -def handle_exception(exc_type, exc_value, exc_traceback): - sys.__excepthook__(exc_type, exc_value, exc_traceback) - - -sys.excepthook = handle_exception diff --git a/glue/_deps.py b/glue/_deps.py deleted file mode 100755 index dafdaef4a..000000000 --- a/glue/_deps.py +++ /dev/null @@ -1,300 +0,0 @@ -#!/usr/bin/env python -""" -Guide users through installing Glue's dependencies -""" - -import os -from collections import OrderedDict - -import sys -import importlib - -from glue._plugin_helpers import iter_plugin_entry_points - - -class Dependency(object): - - def __init__(self, module, info, package=None, min_version=None): - self.module = module - self.info = info - self.package = package or module - self.min_version = min_version - self.failed = False - - @property - def installed(self): - try: - importlib.import_module(self.module) - return True - except ImportError: - return False - - @property - def version(self): - try: - module = __import__(self.module) - return module.__version__ - except ImportError: - return 'unknown version' - except AttributeError: - try: - return module.__VERSION__ - except AttributeError: - return 'unknown version' - - def help(self): - result = f""" -{self.module}: -****************** - -{self.info} - -PIP package name: -{self.package} -""" - return result - - def __str__(self): - if self.installed: - status = f'INSTALLED ({self.version})' - elif self.failed: - status = f'FAILED ({self.info})' - else: - status = f'MISSING ({self.info})' - return f"{self.package:>20}:\t{status}" - - -class Python(Dependency): - - def __init__(self): - self.module = 'Python' - self.package = 'Python' - self.info = 'Interpreter and core library' - - @property - def installed(self): - return True - - @property - def version(self): - return sys.version.split()[0] - - -class QtDependency(Dependency): - - def __str__(self): - if self.installed: - status = f'INSTALLED ({self.version})' - else: - status = 'NOT INSTALLED' - return f"{self.module:>20}:\t{status}" - - -class PyQt5(QtDependency): - - @property - def version(self): - try: - from PyQt5 import Qt - return f"PyQt: {Qt.PYQT_VERSION_STR} - Qt: {Qt.QT_VERSION_STR}" - except (ImportError, AttributeError): - return 'unknown version' - - -class PyQt6(QtDependency): - - @property - def version(self): - try: - from PyQt6 import QtCore - return f"PyQt: {QtCore.PYQT_VERSION_STR} - Qt: {QtCore.QT_VERSION_STR}" - except (ImportError, AttributeError): - return 'unknown version' - - -class PySide2(QtDependency): - - @property - def version(self): - try: - import PySide2 - from PySide2 import QtCore - return f"PySide2: {PySide2.__version__} - Qt: {QtCore.__version__}" - except (ImportError, AttributeError): - return 'unknown version' - - -class PySide6(QtDependency): - - @property - def version(self): - try: - import PySide6 - from PySide6 import QtCore - return f"PySide6: {PySide6.__version__} - Qt: {QtCore.__version__}" - except (ImportError, AttributeError): - return 'unknown version' - - -class QtPy(Dependency): - - @property - def installed(self): - try: - importlib.import_module(self.module) - return True - except Exception: - # QtPy raises a PythonQtError in some cases, so we can't use - # ImportError. - return False - - -# Add any dependencies here -# Make sure to add new categories to the categories tuple - -python = ( - Python(), -) - -gui_framework = ( - PyQt5('PyQt5', 'Facultative QtPy backend'), - PyQt6('PyQt6', 'Facultative QtPy backend'), - PySide2('PySide2', 'Facultative QtPy backend'), - PySide6('PySide6', 'Facultative QtPy backend') -) - -required = ( - QtPy('qtpy', 'Required', min_version='1.9'), - Dependency('setuptools', 'Required', min_version='30.3'), - Dependency('echo', 'Required', min_version='0.5'), - Dependency('numpy', 'Required', min_version='1.17'), - Dependency('matplotlib', 'Required for plotting', min_version='3.2'), - Dependency('pandas', 'Adds support for Excel files and DataFrames', min_version='1.2'), - Dependency('astropy', 'Used for FITS I/O, table reading, and WCS Parsing', min_version='4.0'), - Dependency('dill', 'Used when saving Glue sessions', min_version='0.2'), - Dependency('h5py', 'Used to support HDF5 files', min_version='2.10'), - Dependency('xlrd', 'Used to support Excel files', min_version='1.2'), - Dependency('openpyxl', 'Used to support Excel files', min_version='3.0'), - Dependency('mpl_scatter_density', 'Used to make fast scatter density plots', 'mpl-scatter-density', min_version='0.7'), -) - -general = ( - Dependency('scipy', 'Used for some image processing calculation', min_version='1.1'), - Dependency('skimage', - 'Used to read popular image formats (jpeg, png, etc.)', - 'scikit-image')) - - -ipython = ( - Dependency('IPython', 'Needed for interactive IPython terminal', min_version='4'), - Dependency('qtconsole', 'Needed for interactive IPython terminal'), - Dependency('ipykernel', 'Needed for interactive IPython terminal'), - Dependency('traitlets', 'Needed for interactive IPython terminal'), - Dependency('pygments', 'Needed for interactive IPython terminal'), - Dependency('zmq', 'Needed for interactive IPython terminal', 'pyzmq')) - - -astronomy = ( - Dependency('pyavm', 'Used to parse AVM metadata in image files', 'PyAVM'), - Dependency('spectral_cube', 'Used to read in spectral cubes', 'spectral-cube'), - Dependency('astrodendro', 'Used to read in and represent dendrograms', 'astrodendro')) - - -testing = ( - Dependency('mock', 'Used in test code'), - Dependency('pytest', 'Used in test code')) - -export = ( - Dependency('plotly', 'Used to explort plots to Plot.ly'), -) - - -def plugins(): - modules = [] - dependencies = [] - for entry_point in iter_plugin_entry_points(): - module_name, _, _ = entry_point.module.partition('.') - package = entry_point.dist.name - modules.append((module_name, package)) - for module, package in sorted(set(modules)): - dependencies.append(Dependency(module, '', package=package)) - return dependencies - - -categories = (('python', python), - ('gui framework', gui_framework), - ('required', required), - ('plugins', plugins()), - ('ipython terminal', ipython), - ('general', general), - ('astronomy', astronomy), - ('testing', testing), - ('export', export)) - - -dependencies = dict((d.package, d) for c in categories for d in c[1]) - - -def get_status(): - s = "" - for category, deps in categories: - s += "%21s" % category.upper() + os.linesep - for dep in deps: - s += str(dep) + os.linesep - s += os.linesep - return s - - -def get_status_as_odict(): - status = OrderedDict() - for category, deps in categories: - for dep in deps: - if dep.installed: - status[dep.package] = dep.version - else: - status[dep.package] = "Not installed" - return status - - -def show_status(): - print(get_status()) - - -USAGE = """usage: -#show all dependencies -glue-deps list - -#display information about a dependency -glue-deps info astropy -""" - - -def main(argv=None): - argv = argv or sys.argv - - if len(argv) < 2 or argv[1] not in ['list', 'info']: - sys.stderr.write(USAGE) - sys.exit(1) - - if argv[1] == 'info': - if len(argv) != 3: - sys.stderr.write(USAGE) - sys.stderr.write("Please specify a dependency\n") - sys.exit(1) - - dep = dependencies.get(argv[2], None) - - if dep is None: - sys.stderr.write(f"Unrecognized dependency: {argv[2]:s}\n") - sys.exit(1) - - print(dep.help()) - sys.exit(0) - - if argv[1] == 'list': - show_status() - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/glue/conftest.py b/glue/conftest.py index 92d500cf1..015a0da20 100644 --- a/glue/conftest.py +++ b/glue/conftest.py @@ -76,13 +76,6 @@ def pytest_configure(config): load_plugins() -def pytest_report_header(config): - from glue import __version__ - glue_version = "%20s:\t%s" % ("glue", __version__) - from glue._deps import get_status - return os.linesep + glue_version + os.linesep + os.linesep + get_status() - - def pytest_unconfigure(config): os.environ.pop('GLUE_TESTING') diff --git a/glue/main.py b/glue/main.py index 8fcc896c2..e9ed8e538 100755 --- a/glue/main.py +++ b/glue/main.py @@ -8,251 +8,6 @@ from glue.logger import logger -def parse(argv): - """ Parse argument list, check validity - - :param argv: Arguments passed to program - - *Returns* - A tuple of options, position arguments - """ - usage = """usage: %prog [options] [FILE FILE...] - - # start a new session - %prog - - # start a new session and load a file - %prog image.fits - - #start a new session with multiple files - %prog image.fits catalog.csv - - #restore a saved session - %prog saved_session.glu - or - %prog -g saved_session.glu - - #run a script - %prog -x script.py - - #run the test suite - %prog -t - """ - parser = optparse.OptionParser(usage=usage, - version=str(__version__)) - - parser.add_option('-x', '--execute', action='store_true', dest='script', - help="Execute FILE as a python script", default=False) - parser.add_option('-g', action='store_true', dest='restore', - help="Restore glue session from FILE", default=False) - parser.add_option('-t', '--test', action='store_true', dest='test', - help="Run test suite", default=False) - parser.add_option('-c', '--config', type='string', dest='config', - metavar='CONFIG', - help='use CONFIG as configuration file') - parser.add_option('-v', '--verbose', action='store_true', - help="Increase the vebosity level", default=False) - parser.add_option('--no-maximized', action='store_true', dest='nomax', - help="Do not start Glue maximized", default=False) - parser.add_option('--startup', dest='startup', type='string', - help="Startup actions to carry out", default='') - parser.add_option('--auto-merge', dest='auto_merge', action='store_true', - help="Automatically merge any data passed on the command-line", default='') - parser.add_option('--faulthandler', dest='faulthandler', action='store_true', - help="Run glue with the built-in faulthandler to debug segmentation faults", default=False) - - err_msg = verify(parser, argv) - if err_msg: - sys.stderr.write('\n%s\n' % err_msg) - parser.print_help() - sys.exit(1) - - return parser.parse_args(argv) - - -def verify(parser, argv): - """ Check for input errors - - :param parser: OptionParser instance - :param argv: Argument list - :type argv: List of strings - - *Returns* - An error message, or None - """ - opts, args = parser.parse_args(argv) - err_msg = None - - if opts.script and opts.restore: - err_msg = "Cannot specify -g with -x" - elif opts.script and opts.config: - err_msg = "Cannot specify -c with -x" - elif opts.script and len(args) != 1: - err_msg = "Must provide a script\n" - elif opts.restore and len(args) != 1: - err_msg = "Must provide a .glu file\n" - - return err_msg - - -def load_data_files(datafiles): - """Load data files and return a list of datasets""" - - from glue.core.data_factories import load_data - - datasets = [] - for df in datafiles: - datasets.append(load_data(df)) - - return datasets - - -def run_tests(): - from glue import test - test() - - -def start_glue(gluefile=None, config=None, datafiles=None, maximized=True, - startup_actions=None, auto_merge=False): - """Run a glue session and exit - - Parameters - ---------- - gluefile : str - An optional ``.glu`` file to restore. - config : str - An optional configuration file to use. - datafiles : str - An optional list of data files to load. - maximized : bool - Maximize screen on startup. Otherwise, use default size. - auto_merge : bool, optional - Whether to automatically merge data passed in `datafiles` (default is `False`) - """ - - import glue - - # Some Qt modules are picky in terms of being imported before the - # application is set up, so we import them here. We do it here rather - # than in get_qapp since in the past, including this code in get_qapp - # caused severe issues (e.g. segmentation faults) in plugin packages - # during testing. - try: - from qtpy import QtWebEngineWidgets # noqa - except ImportError: # Not all PyQt installations have this module - pass - - from glue.utils.qt.decorators import die_on_error - - from glue.utils.qt import get_qapp - app = get_qapp() - - splash = get_splash() - splash.show() - - # Start off by loading plugins. We need to do this before restoring - # the session or loading the configuration since these may use existing - # plugins. - load_plugins(splash=splash, require_qt_plugins=True) - - from glue.app.qt import GlueApplication - - datafiles = datafiles or [] - - hub = None - - splash.close() - - if gluefile is not None: - with die_on_error("Error restoring Glue session"): - app = GlueApplication.restore_session(gluefile, show=False) - return app.start(maximized=maximized) - - if config is not None: - glue.env = glue.config.load_configuration(search_path=[config]) - - data_collection = glue.core.DataCollection() - hub = data_collection.hub - - splash.set_progress(100) - - session = glue.core.Session(data_collection=data_collection, hub=hub) - ga = GlueApplication(session=session) - - if datafiles: - with die_on_error("Error reading data file"): - datasets = load_data_files(datafiles) - ga.add_datasets(datasets, auto_merge=auto_merge) - - if startup_actions is not None: - for name in startup_actions: - ga.run_startup_action(name) - - return ga.start(maximized=maximized) - - -def execute_script(script): - """ Run a python script and exit. - - Provides a way for people with pre-installed binaries to use - the glue library - """ - from glue.utils.qt.decorators import die_on_error - with die_on_error("Error running script"): - with open(script) as fin: - exec(fin.read()) - sys.exit(0) - - -def get_splash(): - """Instantiate a splash screen""" - from glue.app.qt.splash_screen import QtSplashScreen - splash = QtSplashScreen() - return splash - - -def main(argv=sys.argv): - - opt, args = parse(argv[1:]) - - if opt.verbose: - logger.setLevel("INFO") - - if opt.faulthandler: - import faulthandler - faulthandler.enable() - - logger.info("Input arguments: %s", sys.argv) - - # Global keywords for Glue startup. - kwargs = {'config': opt.config, - 'maximized': not opt.nomax, - 'auto_merge': opt.auto_merge} - - if opt.startup: - kwargs['startup_actions'] = opt.startup.split(',') - - if opt.test: - return run_tests() - elif opt.restore: - start_glue(gluefile=args[0], **kwargs) - elif opt.script: - execute_script(args[0]) - else: - has_file = len(args) == 1 - has_files = len(args) > 1 - has_py = has_file and args[0].endswith('.py') - has_glu = has_file and args[0].endswith('.glu') - if has_py: - execute_script(args[0]) - elif has_glu: - start_glue(gluefile=args[0], **kwargs) - elif has_file or has_files: - start_glue(datafiles=args, **kwargs) - else: - start_glue(**kwargs) - - _loaded_plugins = set() _installed_plugins = set() @@ -350,7 +105,3 @@ def load_plugins(splash=None, require_qt_plugins=False): # that were previously read. from glue._settings_helpers import load_settings load_settings() - - -if __name__ == "__main__": - sys.exit(main(sys.argv)) # pragma: no cover diff --git a/glue/tests/test_deps.py b/glue/tests/test_deps.py deleted file mode 100644 index 10814bc02..000000000 --- a/glue/tests/test_deps.py +++ /dev/null @@ -1,67 +0,0 @@ -import sys -from subprocess import check_call - -from glue.tests.helpers import requires_qt - -from .._deps import Dependency, categories - - -class TestDependency(object): - - def test_installed(self): - d = Dependency('math', 'the math module') - assert d.installed - - def test_uninstalled(self): - d = Dependency('asdfasdf', 'Non-existent module') - assert not d.installed - - def test_installed_str(self): - d = Dependency('math', 'info') - assert str(d) == " math:\tINSTALLED (unknown version)" - - def test_noinstalled_str(self): - d = Dependency('asdfasdf', 'info') - assert str(d) == " asdfasdf:\tMISSING (info)" - - def test_failed_str(self): - d = Dependency('asdfasdf', 'info') - d.failed = True - assert str(d) == " asdfasdf:\tFAILED (info)" - - -@requires_qt -def test_optional_dependency_not_imported(): - """ - Ensure that a GlueApplication instance can be created without - importing any non-required dependency - """ - optional_deps = categories[5:] - deps = [dep.module for cateogry, deps in optional_deps for dep in deps] - - code = """ -class ImportDenier(object): - __forbidden = set(%s) - - def find_module(self, mod_name, pth=None): - if pth: - return - if mod_name in self.__forbidden: - return self - - def load_module(self, mod_name): - raise ImportError("Importing %%s" %% mod_name) - - def exec_module(self, mod_name): - raise ImportError("Importing %%s" %% mod_name) - -import sys -sys.meta_path.append(ImportDenier()) - -from glue.app.qt import GlueApplication -from glue.core import data_factories -ga = GlueApplication() -""" % deps - - cmd = [sys.executable, '-c', code] - check_call(cmd) diff --git a/glue/tests/test_main.py b/glue/tests/test_main.py deleted file mode 100644 index 85d23b25d..000000000 --- a/glue/tests/test_main.py +++ /dev/null @@ -1,111 +0,0 @@ -import os -import pytest -from unittest.mock import patch - -from glue.tests.helpers import requires_qt - -from ..core import Data -from ..main import load_data_files, main, start_glue - - -def test_load_data_files(): - with patch('glue.core.data_factories.load_data') as ld: - ld.return_value = Data() - dc = load_data_files(['test.py']) - assert len(dc) == 1 - - -def check_main(cmd, glue, config, data): - """Pass command to main program, check for expected parsing""" - with patch('glue.main.start_glue') as sg: - main(cmd.split()) - args, kwargs = sg.call_args - assert kwargs.get('datafiles', None) == data - assert kwargs.get('gluefile', None) == glue - assert kwargs.get('config', None) == config - - -def check_exec(cmd, pyfile): - """Assert that main correctly dispatches to execute_script""" - with patch('glue.main.execute_script') as es: - main(cmd.split()) - args, kwargs = es.call_args - assert args[0] == pyfile - - -def test_main_single_data(): - check_main('glueqt test.fits', None, None, ['test.fits']) - - -def test_main_multi_data(): - check_main('glueqt test.fits t2.csv', None, None, ['test.fits', 't2.csv']) - - -def test_main_config(): - check_main('glueqt -c config.py', None, 'config.py', None) - - -def test_main_glu_arg(): - check_main('glueqt -g test.glu', 'test.glu', None, None) - - -def test_main_auto_glu(): - check_main('glueqt test.glu', 'test.glu', None, None) - - -def test_main_many_args(): - check_main('glueqt -c config.py data.fits d2.csv', None, - 'config.py', ['data.fits', 'd2.csv']) - - -def test_exec(): - check_exec('glueqt -x test.py', 'test.py') - - -def test_auto_exec(): - check_exec('glueqt test.py', 'test.py') - - -@requires_qt -def test_exec_real(tmpdir): - # Actually test the script execution functionlity - filename = tmpdir.join('test.py').strpath - with open(filename, 'w') as f: - f.write('a = 1') - with patch('qtpy.QtWidgets.QMessageBox') as qmb: - with patch('sys.exit') as exit: - main('glue -x {0}'.format(os.path.abspath(filename)).split()) - assert exit.called_once_with(0) - - -@pytest.mark.parametrize(('cmd'), ['glueqt -g test.glu test.fits', - 'glueqt -g test.py test.fits', - 'glueqt -x test.py -g test.glu', - 'glueqt -x test.py -c test.py', - 'glueqt -x', - 'glueqt -g', - 'glueqt -c']) -def test_invalid(cmd): - with pytest.raises(SystemExit): - main(cmd.split()) - - -@requires_qt -@pytest.mark.parametrize(('glue', 'config', 'data'), - [('test.glu', None, None), - (None, 'test.py', None), - (None, None, ['test.fits']), - (None, None, ['a.fits', 'b.fits']), - (None, 'test.py', ['a.fits'])]) -def test_start(glue, config, data): - with patch('glue.config.load_configuration') as lc: - with patch('glue.main.load_data_files') as ldf: - with patch('glue.app.qt.GlueApplication') as ga: - - ldf.return_value = Data() - - start_glue(glue, config, data) - if config: - lc.assert_called_once_with(search_path=[config]) - if data: - ldf.assert_called_once_with(data) diff --git a/setup.cfg b/setup.cfg index 786af9e8d..83d5862ce 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,9 +50,6 @@ glue.plugins = export_python = glue.plugins.tools:setup console_scripts = glue-config = glue.config_gen:main - glue-deps = glue._deps:main -gui_scripts = - glue = glue.main:main [options.extras_require] all =