diff --git a/pyomo/common/dependencies.py b/pyomo/common/dependencies.py index 1f9f694932e..07452649858 100644 --- a/pyomo/common/dependencies.py +++ b/pyomo/common/dependencies.py @@ -12,6 +12,7 @@ import importlib.util import logging import sys +import threading import warnings from collections.abc import Mapping @@ -945,6 +946,11 @@ def __exit__(self, exc_type, exc_value, traceback): # Common optional dependencies used throughout Pyomo # +#: lock for deconflicting access to capturing the process file +#: descriptors. This starts as a threading.Lock, unless the environment +#: imports multiprocessing, in which case, it is upgraded to a +#: multiprocessing lock. +capture_output_lock = threading.Lock() yaml_load_args = {} @@ -961,6 +967,18 @@ def _finalize_ctypes(module, available): import ctypes.util +def _finalize_multiprocessing(module, available): + # Note: multiprocessing is very slow to import, but we need to make + # sure that the capture_output_lock Lock is created *before* the + # user spawns any subprocesses. tee.capture_output will look here + # for the lock, which will start out as a "dummy" lock, and then + # will be updated to a multiprocessing.Lock when the first module + # triggers the multiprocessing import. + + global capture_output_lock + capture_output_lock = module.Lock() + + def _finalize_scipy(module, available): if available: # Import key subpackages that we will want to assume are present @@ -1081,12 +1099,25 @@ def _pyutilib_importer(): with declare_modules_as_importable(globals()): + # Standard libraries that we will unconditionally import. We are + # importing it here so that import timing is better reported from + # pyomo.environ.tests.test_environ (hence the imports are not + # necessarily alphebetical) + # + # Pickle is used by Pyomo and by multiprocessing + try: + import cPickle as pickle + except ImportError: + import pickle + # Standard libraries that are slower to import and not strictly required # on all platforms / situations. ctypes, _ = attempt_import( 'ctypes', deferred_submodules=['util'], callback=_finalize_ctypes ) - multiprocessing, _ = attempt_import('multiprocessing') + multiprocessing, _ = attempt_import( + 'multiprocessing', callback=_finalize_multiprocessing + ) random, _ = attempt_import('random') # Necessary for minimum version checking for other optional dependencies @@ -1127,8 +1158,3 @@ def _pyutilib_importer(): deferred_submodules=['pyplot', 'pylab', 'backends'], catch_exceptions=(ImportError, RuntimeError), ) - -try: - import cPickle as pickle -except ImportError: - import pickle diff --git a/pyomo/common/enums.py b/pyomo/common/enums.py index 7290d9ba545..a08b3e7be68 100644 --- a/pyomo/common/enums.py +++ b/pyomo/common/enums.py @@ -249,3 +249,30 @@ class SolverAPIVersion(NamedIntEnum): minimize = ObjectiveSense.minimize maximize = ObjectiveSense.maximize + + +class CaptureOutputMode(IntEnum): + """Enum to override the default behavior of the :class:`capture_output` + context manager. + + This enum provides options for overriding the behavior of the + :class:`capture_output` context manager through the + :attr:`~pyomo.common.tee.OVERRIDE_CAPTURE_OUTPUT` flag. + + """ + + #: Setting this mode will cause :class:`capture_output` to be a noop + DISABLE = 0 + #: :class:`capture_output` will capture the standard ``sys.stdout`` / + #: ``sys.stderr`` streams + ENABLE_STREAM_CAPTURE = 1 + #: :class:`capture_output` will capture the file descriptors that + #: underlie the standard ``sys.stdout`` / ``sys.stderr`` streams + ENABLE_FD_CAPTURE = 2 + #: This is :class:`capture_output`'s normal operation (all capturing is enabled) + NORMAL = 3 + #: :class:`capture_output` will capture the standard ``sys.stdout`` / + #: ``sys.stderr`` streams, but will not attempt to capture their raw + #: file descriptors (regardless of the value of `capture_fd`) [alias + #: of ENABLE_STREAM_CAPTURE] + DISABLE_FD_CAPTURE = 1 diff --git a/pyomo/common/env.py b/pyomo/common/env.py index bd816ef7548..b06ebb8ccbe 100644 --- a/pyomo/common/env.py +++ b/pyomo/common/env.py @@ -9,7 +9,7 @@ import os -from .dependencies import ctypes, multiprocessing +from pyomo.common.dependencies import ctypes, multiprocessing def _as_bytes(val): diff --git a/pyomo/common/tee.py b/pyomo/common/tee.py index 227ae71bae8..e4dd15c5a99 100644 --- a/pyomo/common/tee.py +++ b/pyomo/common/tee.py @@ -22,8 +22,11 @@ import threading import time +import pyomo.common.dependencies as dependencies +from pyomo.common.enums import CaptureOutputMode from pyomo.common.errors import DeveloperError from pyomo.common.log import LoggingIntercept, LogStream +from pyomo.common.shutdown import python_is_shutting_down _poll_interval = 0.0001 _poll_rampup_limit = 0.099 @@ -35,6 +38,7 @@ # ~(13.1 * #threads) seconds _poll_timeout = 1 # 14 rounds: 0.0001 * 2**14 == 1.6384 _poll_timeout_deadlock = 100 # seconds +_threading_deadlock = 200 # seconds; should be longer than _poll_timeout_deadlock _pipe_buffersize = 1 << 16 # 65536 _noop = lambda: None _mswindows = sys.platform.startswith('win') @@ -54,6 +58,8 @@ logger = logging.getLogger(__name__) +OVERRIDE_CAPTURE_OUTPUT = CaptureOutputMode.NORMAL + class _SignalFlush: def __init__(self, ostream, handle): @@ -297,14 +303,14 @@ class capture_output: """ - startup_shutdown = threading.Lock() - def __init__(self, output=None, capture_fd=False): self.output = output self.output_stream = None self.old = None self.tee = None - self.capture_fd = capture_fd + self.capture_fd = capture_fd and ( + OVERRIDE_CAPTURE_OUTPUT & CaptureOutputMode.ENABLE_FD_CAPTURE + ) self.context_stack = [] def _enter_context(self, cm, prior_to=None): @@ -339,13 +345,12 @@ def _exit_context_stack(self, et, ev, tb): cm.__exit__(et, ev, tb) except: _stack = self.context_stack - FAIL.append( - f"{sys.exc_info()[0].__name__}: {sys.exc_info()[1]} ({len(_stack)+1}: {cm}@{id(cm):x})" - ) + _et, _e, _tb = sys.exc_info() + FAIL.append(f"{_et.__name__}: {_e} ({len(_stack)+1}: {cm}@{id(cm):x})") return FAIL def __enter__(self): - if not capture_output.startup_shutdown.acquire(timeout=_poll_timeout_deadlock): + if not dependencies.capture_output_lock.acquire(timeout=_threading_deadlock): # This situation *shouldn't* happen. If it does, it is # unlikely that the user can fix it (or even debug it). # Instead they should report it back to us. @@ -358,20 +363,22 @@ def __enter__(self): # was trying to start up / run (so the other solver held # the lock, but the GC interrupted that thread and # wouldn't let go). - raise DeveloperError("Deadlock starting capture_output") + if not python_is_shutting_down(): + raise DeveloperError("Deadlock starting capture_output") try: return self._enter_impl() finally: - capture_output.startup_shutdown.release() + dependencies.capture_output_lock.release() def __exit__(self, et, ev, tb): - if not capture_output.startup_shutdown.acquire(timeout=_poll_timeout_deadlock): + if not dependencies.capture_output_lock.acquire(timeout=_threading_deadlock): # See comments & breadcrumbs in __enter__() above. - raise DeveloperError("Deadlock closing capture_output") + if not python_is_shutting_down(): + raise DeveloperError("Deadlock closing capture_output") try: return self._exit_impl(et, ev, tb) finally: - capture_output.startup_shutdown.release() + dependencies.capture_output_lock.release() def _enter_impl(self): self.old = (sys.stdout, sys.stderr) @@ -486,8 +493,11 @@ def _enter_impl(self): # exception. self._exit_context_stack(*sys.exc_info()) raise - sys.stdout = self.tee.STDOUT - sys.stderr = self.tee.STDERR + if OVERRIDE_CAPTURE_OUTPUT & CaptureOutputMode.ENABLE_STREAM_CAPTURE: + sys.stdout = self.tee.STDOUT + sys.stderr = self.tee.STDERR + else: + self.old = None buf = self.tee.ostreams if len(buf) == 1: buf = buf[0] diff --git a/pyomo/common/tests/test_multithread.py b/pyomo/common/tests/test_multithread.py index a47933d7ba9..b290b6307eb 100644 --- a/pyomo/common/tests/test_multithread.py +++ b/pyomo/common/tests/test_multithread.py @@ -8,11 +8,16 @@ # ____________________________________________________________________________________ import threading -import pyomo.common.unittest as unittest -from pyomo.common.multithread import * from threading import Thread +from multiprocessing.dummy import Pool as ThreadPool + +import pyomo.common.unittest as unittest + +from pyomo.common.multithread import MultiThreadWrapper, MultiThreadWrapperWithMain from pyomo.opt.base.solvers import check_available_solvers +import pyomo.environ as pyo + class Dummy: """asdfg""" @@ -103,10 +108,6 @@ def thread_func(): ) def test_solve(self): # Based on the minimal example in https://github.com/Pyomo/pyomo/issues/2475 - import pyomo.environ as pyo - from pyomo.opt import SolverFactory - from multiprocessing.dummy import Pool as ThreadPool - model = pyo.ConcreteModel() model.nVars = pyo.Param(initialize=4) model.N = pyo.RangeSet(model.nVars) @@ -115,7 +116,7 @@ def test_solve(self): model.cuts = pyo.ConstraintList() def test(model): - opt = SolverFactory('glpk') + opt = pyo.SolverFactory('glpk') opt.solve(model) # Iterate, adding a cut to exclude the previously found solution diff --git a/pyomo/common/tests/test_tee.py b/pyomo/common/tests/test_tee.py index b1c697dbb22..d07f0042503 100644 --- a/pyomo/common/tests/test_tee.py +++ b/pyomo/common/tests/test_tee.py @@ -21,6 +21,7 @@ from pyomo.common.errors import DeveloperError from pyomo.common.log import LoggingIntercept, LogStream from pyomo.common.tempfiles import TempfileManager +import pyomo.common.dependencies as deps import pyomo.common.tee as tee import pyomo.common.unittest as unittest @@ -581,28 +582,40 @@ def test_capture_output_stack_error(self): logging.getLogger('pyomo.common.tee').handlers.clear() def test_atomic_deadlock(self): - save_poll = tee._poll_timeout_deadlock - tee._poll_timeout_deadlock = 0.01 + save_poll = tee._threading_deadlock + tee._threading_deadlock = 0.01 + + # Ensure multiprocessing is loaded: + deps.multiprocessing.Lock co = tee.capture_output() try: - tee.capture_output.startup_shutdown.acquire() + deps.capture_output_lock.acquire() with self.assertRaisesRegex( DeveloperError, "Deadlock starting capture_output" ): with tee.capture_output(): pass - tee.capture_output.startup_shutdown.release() + deps.capture_output_lock.release() with self.assertRaisesRegex( DeveloperError, "Deadlock closing capture_output" ): with co: - tee.capture_output.startup_shutdown.acquire() + deps.capture_output_lock.acquire() finally: - tee._poll_timeout_deadlock = save_poll - if tee.capture_output.startup_shutdown.locked(): - tee.capture_output.startup_shutdown.release() + tee._threading_deadlock = save_poll + # We would like to just test if out Lock was acquired and + # then release it if necessary. Unfortunately, + # multiprocessing.Lock doesn't support locked(), so we will + # just catch and eat the error for releasing an unlocked + # lock. + # + ## if deps.capture_output_lock.locked(): + try: + deps.capture_output_lock.release() + except ValueError: + pass co.reset() def test_capture_output_invalid_ostream(self): @@ -710,6 +723,22 @@ def flush(self): finally: tee._poll_timeout, tee._poll_timeout_deadlock = _save + def test_capture_output_override(self): + capture1 = tee.capture_output(capture_fd=True) + self.assertTrue(capture1.capture_fd) + with capture1 as OUT1: + try: + orig = tee.OVERRIDE_CAPTURE_OUTPUT + tee.OVERRIDE_CAPTURE_OUTPUT = tee.CaptureOutputMode.DISABLE + capture2 = tee.capture_output(capture_fd=True) + with capture2 as OUT2: + self.assertFalse(capture2.capture_fd) + print("Hello, World") + self.assertEqual(OUT2.getvalue(), "") + finally: + tee.OVERRIDE_CAPTURE_OUTPUT = orig + self.assertEqual(OUT1.getvalue(), "Hello, World\n") + class BufferTester: def setUp(self): diff --git a/pyomo/common/tests/test_unittest.py b/pyomo/common/tests/test_unittest.py index d00e3c1124f..78c12215a68 100644 --- a/pyomo/common/tests/test_unittest.py +++ b/pyomo/common/tests/test_unittest.py @@ -8,12 +8,13 @@ # ____________________________________________________________________________________ import datetime -import multiprocessing import os import pickle import time import pyomo.common.unittest as unittest +import pyomo.common.dependencies as deps +from pyomo.common.dependencies import multiprocessing from pyomo.common.log import LoggingIntercept from pyomo.common.tee import capture_output from pyomo.common.tempfiles import TempfileManager @@ -171,8 +172,28 @@ def test_assertStructuredAlmostEqual_numericvalue(self): def test_timeout_fcn_call(self): self.assertEqual(short_sleep(), 42) - with self.assertRaisesRegex(TimeoutError, 'test timed out after 0.01 seconds'): - long_sleep() + with LoggingIntercept() as LOG: + with self.assertRaisesRegex( + TimeoutError, 'test timed out after 0.01 seconds' + ): + long_sleep() + self.assertEqual(LOG.getvalue(), "") + deps.capture_output_lock.acquire() + save = unittest._timeout_terminate_timeout + unittest._timeout_terminate_timeout = 0.01 + try: + with self.assertRaisesRegex( + TimeoutError, 'test timed out after 0.01 seconds' + ): + long_sleep() + finally: + unittest._timeout_terminate_timeout = save + deps.capture_output_lock.release() + self.assertEqual( + LOG.getvalue(), + "Failed to acquire capture_output_lock Lock before " + "terminating subprocess on timeout: process deadlock is likely.\n", + ) with self.assertRaisesRegex( NameError, r"name 'foo' is not defined\s+Original traceback:" ): diff --git a/pyomo/common/unittest.py b/pyomo/common/unittest.py index 25c87c1f276..4bf4df76ef5 100644 --- a/pyomo/common/unittest.py +++ b/pyomo/common/unittest.py @@ -29,6 +29,7 @@ # specifically later from unittest import * import unittest as _unittest +import pyomo.common.dependencies as deps from pyomo.common.collections import Mapping, Sequence from pyomo.common.dependencies import attempt_import, check_min_version, multiprocessing @@ -44,6 +45,10 @@ # (and then enforce a strict dependence on pytest) pytest, pytest_available = attempt_import('pytest') +#: A time limit for acquiring the capture_output_lock lock +#: before terminating a subprocess +_timeout_terminate_timeout = 2 # seconds + def _defaultFormatter(msg, default): return msg or default @@ -487,7 +492,27 @@ def test_timer(*args, **kwargs): if pipe_recv.poll(seconds): resultType, result, stdout = pipe_recv.recv() else: - test_proc.terminate() + # Note: because we are using capture_output within + # the _runner handler, we can trigger a deadlock + # when we call terminate() while the _runner's + # capture_output holds the capture_output_lock lock + # (terminate() bypasses all __exit__ handlers!). To + # avoid that, we will grab the lock here before + # terminating the subprocess. + locked = deps.capture_output_lock.acquire( + timeout=_timeout_terminate_timeout + ) + if not locked: + logging.getLogger(__name__).error( + "Failed to acquire capture_output_lock " + "Lock before terminating subprocess on timeout: " + "process deadlock is likely." + ) + try: + test_proc.terminate() + finally: + if locked: + deps.capture_output_lock.release() raise timeout_raises( "test timed out after %s seconds" % (seconds,) ) from None diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index 716872c9d89..ddfd23f4d96 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -16,7 +16,6 @@ import sys import struct import re -import pathlib from pyomo.common.dependencies import attempt_import from pyomo.common.errors import InfeasibleConstraintException diff --git a/pyomo/environ/tests/test_environ.py b/pyomo/environ/tests/test_environ.py index b012e9e656e..cea66290ae6 100644 --- a/pyomo/environ/tests/test_environ.py +++ b/pyomo/environ/tests/test_environ.py @@ -10,6 +10,8 @@ # Unit Tests for pyomo.base.misc # +import math +import os import re import sys import subprocess @@ -20,25 +22,49 @@ class ImportData: def __init__(self): self.tpl = {} - self.pyomo = {} + self.module = {} def update(self, other): self.tpl.update(other.tpl) - self.pyomo.update(other.pyomo) + self.module.update(other.module) + def imax(self, other): + for k, v in other.tpl.items(): + if k in self.tpl: + v = max(v, self.tpl[k]) + self.tpl[k] = v + self.module.update(other.module) -def collect_import_time(module): + +def collect_import_time(module, preimport=""): + basemodule = module.split('.')[0] + if preimport: + cmd = f"{preimport}; import {module}" + else: + cmd = f"import {module}" + env = dict(os.environ) + env['PYTHONPATH'] = os.pathsep.join(filter(None, sys.path)) + env.pop('COVERAGE_PROCESS_START', None) output = subprocess.check_output( - [sys.executable, '-X', 'importtime', '-c', 'import %s' % (module,)], + [sys.executable, '-S', '-X', 'importtime', '-c', cmd], stderr=subprocess.STDOUT, + env=env, ) # Note: test only runs in PY3 output = output.decode() line_re = re.compile(r'.*:\s*(\d+) \|\s*(\d+) \| ( *)([^ ]+)') - data = [] + header_re = re.compile(r'.*:\s*(.*)') + results = [] + data = None for line in output.splitlines(): g = line_re.match(line) if not g: + g = header_re.match(line) + if g: + data = [] + results.append(data) + else: + raise RuntimeError(f"Unrecognized line: '{line}'") continue _self = int(g.group(1)) _cumul = int(g.group(2)) @@ -48,24 +74,95 @@ def collect_import_time(module): while len(data) < _level + 1: data.append(ImportData()) if len(data) > _level + 1: - assert len(data) == _level + 2 + if len(data) != _level + 2: + raise RuntimeError( + f"Error processing line '{line}': unexpected unindent" + ) inner = data.pop() inner.tpl = { (k if '(from' in k else "%s (from %s)" % (k, _module), v) for k, v in inner.tpl.items() } - if _module.startswith('pyomo'): + if _module.startswith(basemodule): data[_level].update(inner) - data[_level].pyomo[_module] = _self - else: - if _level > 0: - data[_level].tpl[_module] = _cumul - elif _module.startswith('pyomo'): - data[_level].pyomo[_module] = _self - elif _level > 0: + data[_level].module[_module] = _self + else: # _level > 0: + data[_level].tpl[_module] = _cumul + elif _module.startswith(basemodule): + data[_level].module[_module] = _self + else: # _level > 0: data[_level].tpl[_module] = _self - assert len(data) == 1 - return data[0] + ans = None + for d in results: + assert len(d) == 1 + d = d[0] + if not d.module: + continue + if ans: + raise RuntimeError( + "Multiple timing results imported target module '{module}'" + ) + ans = d + return ans, output + + +def summarize_import_time(module, data, raw_output): + print(raw_output) + print("\n") + + modname = module.split('.')[0] + + N = int(math.log10(max(max(data.module.values()), max(data.tpl.values())))) + 4 + print(f"{modname.title()} (by module time):") + print( + "\n".join( + f"%{N}d: %s" % (v, k) + for k, v in sorted(data.module.items(), key=lambda x: x[1]) + ) + ) + tpls = sorted( + (*_mod.split(' ', maxsplit=1), '', _time) for _mod, _time in data.tpl.items() + ) + print("TPLS:") + _line_fmt = f" %{max(len(l[0]) for l in tpls)}s: %6d %s" + print("\n".join(_line_fmt % (l[0], l[-1], l[1]) for l in tpls)) + tpl = {} + for k, v in data.tpl.items(): + _mod = k.split()[0].split('.')[0] + _base_time, _base_cat = tpl.get(_mod, (0, 0)) + tpl[_mod] = _base_time + v, _base_cat | (1 if ' ' in k else 2) + tpl_by_time = sorted(tpl.items(), key=lambda x: x[1]) + + pyomo_time = sum(data.module.values()) + tpl_time = sum(data.tpl.values()) + total = float(pyomo_time + tpl_time) + python_time = sum(t for m, (t, s) in tpl_by_time if s & 1 == 0) + module_tpl_time = sum(t for m, (t, s) in tpl_by_time if s & 1) + assert abs(python_time + module_tpl_time - tpl_time) < 1 + + print("TPLS (by package time):") + _line_fmt = f" %{max(len(k) for k in tpl)}s: %6d (%4.1f%%)%s" + source = {1: '', 2: ' *', 3: ' *+'} + print( + "\n".join( + _line_fmt % (m, t, 100 * t / total, source[s]) for m, (t, s) in tpl_by_time + ) + ) + N = len(modname) + 8 + print( + f"\n%-{N}s %6d (%4.1f%%)" + % (f"{modname.title()}:", pyomo_time, 100 * pyomo_time / total) + ) + print( + f"%-{N}s %6d (%4.1f%%)" + % (f"TPL ({modname}):", module_tpl_time, 100 * module_tpl_time / total) + ) + print( + f"%-{N}s %6d (%4.1f%%)" + % ("TPL (python):", python_time, 100 * python_time / total) + ) + + return python_time, module_tpl_time, pyomo_time, tpl_by_time class TestPyomoEnviron(unittest.TestCase): @@ -85,47 +182,29 @@ def test_not_auto_imported(self): ) @unittest.skipIf( - 'pypy_version_info' in dir(sys), "PyPy does not support '-X importtime" + 'pypy_version_info' in dir(sys), "PyPy does not support '-X importtime'" ) def test_tpl_import_time(self): - data = collect_import_time('pyomo.environ') - pyomo_time = sum(data.pyomo.values()) - tpl_time = sum(data.tpl.values()) - total = float(pyomo_time + tpl_time) - print("Pyomo (by module time):") - print( - "\n".join( - " %s: %s" % i for i in sorted(data.pyomo.items(), key=lambda x: x[1]) - ) - ) - print("TPLS:") - _line_fmt = " %%%ds: %%6d %%s" % ( - max(len(k[: k.find(' ')]) for k in data.tpl), - ) - print( - "\n".join( - _line_fmt % (k[: k.find(' ')], v, k[k.find(' ') :]) - for k, v in sorted(data.tpl.items()) - ) + data, output = collect_import_time( + 'pyomo.environ', + # We used to pre-load and pre-start multiprocessing so that the + # asynchronous task triggered by creating a Lock will not be + # interleaved in the importtime report: + # + ##'import time, multiprocessing; multiprocessing.Lock(); time.sleep(0.25)', + # + # This is no longer needed as we have removed + # multiprocessing from the list of required modules for + # pyomo.environ. ) - tpl = {} - for k, v in data.tpl.items(): - _mod = k[: k.find(' ')].split('.')[0] - tpl[_mod] = tpl.get(_mod, 0) + v - tpl_by_time = sorted(tpl.items(), key=lambda x: x[1]) - print("TPLS (by package time):") - print( - "\n".join( - " %12s: %6d (%4.1f%%)" % (m, t, 100 * t / total) - for m, t in tpl_by_time - ) + python_time, module_tpl_time, pyomo_time, tpl_by_time = summarize_import_time( + 'pyomo.environ', data, output ) - print("Pyomo: %6d (%4.1f%%)" % (pyomo_time, 100 * pyomo_time / total)) - print("TPL: %6d (%4.1f%%)" % (tpl_time, 100 * tpl_time / total)) + # Arbitrarily choose a threshold 10% more than the expected # value (at time of writing, TPL imports were 52-57% of the # import time on a development machine) - self.assertLess(tpl_time / total, 0.65) + self.assertLess(module_tpl_time / (module_tpl_time + pyomo_time), 0.33) # Spot-check the (known) worst offenders. The following are # modules from the "standard" library. Their order in the list # of slow-loading TPLs can vary from platform to platform. @@ -137,15 +216,18 @@ def test_tpl_import_time(self): 'base64', # Imported on Windows 'bisect', # Imported by dae, dataportal, contrib/mpc 'cPickle', + 'copy', # Imported by ply, et al. 'csv', 'ctypes', # mandatory import in core/base/external.py; TODO: fix this 'datetime', # imported by contrib.solver 'decimal', + 'encodings', # We tabulate modules imported by python 'gc', # Imported on MacOS, Windows; Linux in 3.10 'glob', 'heapq', # Added in Python 3.10 'importlib', 'inspect', + 'io', 'json', # Imported on Windows 'locale', # Added in Python 3.9 'logging',