diff --git a/hyperglass/cli/util.py b/hyperglass/cli/util.py index cc397c8..2ee241e 100644 --- a/hyperglass/cli/util.py +++ b/hyperglass/cli/util.py @@ -165,9 +165,9 @@ def build_ui(): ClickException: Raised on any errors. """ try: - import asyncio - from hyperglass.configuration import params, frontend_params, CONFIG_PATH + from hyperglass.compat import aiorun from hyperglass.util import build_frontend + from hyperglass.configuration import params, frontend_params, CONFIG_PATH except ImportError as e: error("Error importing UI builder: {e}", e=e) @@ -179,7 +179,7 @@ def build_ui(): dev_mode = "development" try: - build_success = asyncio.run( + build_success = aiorun( build_frontend( dev_mode=params.developer_mode, dev_url=f"http://localhost:{str(params.listen_port)}/", diff --git a/hyperglass/compat.py b/hyperglass/compat.py new file mode 100644 index 0000000..c784266 --- /dev/null +++ b/hyperglass/compat.py @@ -0,0 +1,116 @@ +"""Functions for maintaining compatability with older Python versions.""" + +# Standard Library +import sys +import asyncio +import weakref + +try: + from asyncio import get_running_loop +except ImportError: + from asyncio.events import _get_running_loop as get_running_loop + +RUNNING_PYTHON_VERSION = sys.version_info + +# _patch_loop, _patched_run, and _cancel_all_tasks are taken directly +# from github.com/nickdavis: +# https://gist.github.com/nickdavies/4a37c6cd9dcc7041fddd2d2a81cee383 + +# These functions are a backport of the functionality added in +# Python 3.7 to support asyncio.run(), which is used in several areas +# of hyperglass. Because the LTS version of Ubuntu at this time (18.04) +# still ships with Python 3.6, compatibility with Python 3.6 is the +# goal. + + +def _patch_loop(loop): + tasks = weakref.WeakSet() + + task_factory = [None] + + def _set_task_factory(factory): + task_factory[0] = factory + + def _get_task_factory(): + return task_factory[0] + + def _safe_task_factory(loop, coro): + if task_factory[0] is None: + task = asyncio.Task(coro, loop=loop) + if task._source_traceback: + del task._source_traceback[-1] + else: + task = task_factory[0](loop, coro) + tasks.add(task) + return task + + loop.set_task_factory(_safe_task_factory) + loop.set_task_factory = _set_task_factory + loop.get_task_factory = _get_task_factory + + return tasks + + +def _patched_run(main, *, debug=False): + try: + loop = get_running_loop() + except RuntimeError: + loop = None + + if loop is not None: + raise RuntimeError("asyncio.run() cannot be called from a running event loop") + + if not asyncio.iscoroutine(main): + raise ValueError("a coroutine was expected, got {!r}".format(main)) + + loop = asyncio.new_event_loop() + tasks = _patch_loop(loop) + + try: + asyncio.set_event_loop(loop) + loop.set_debug(debug) + return loop.run_until_complete(main) + finally: + try: + _cancel_all_tasks(loop, tasks) + loop.run_until_complete(loop.shutdown_asyncgens()) + finally: + asyncio.set_event_loop(None) + loop.close() + + +def _cancel_all_tasks(loop, tasks): + to_cancel = [task for task in tasks if not task.done()] + + if not to_cancel: + return + + for task in to_cancel: + task.cancel() + + loop.run_until_complete( + asyncio.gather(*to_cancel, loop=loop, return_exceptions=True) + ) + + for task in to_cancel: + if task.cancelled(): + continue + if task.exception() is not None: + loop.call_exception_handler( + { + "message": "unhandled exception during asyncio.run() shutdown", + "exception": task.exception(), + "task": task, + } + ) + + +# If local system's python version is at least 3.6, use the backported +# asyncio runner. +if RUNNING_PYTHON_VERSION >= (3, 6): + aiorun = _patched_run + +# If the local system's python version is at least 3.7, use the standard +# library's asyncio.run() +elif RUNNING_PYTHON_VERSION >= (3, 7): + aiorun = asyncio.run diff --git a/hyperglass/configuration/__init__.py b/hyperglass/configuration/__init__.py index 6a012bb..baf71ce 100644 --- a/hyperglass/configuration/__init__.py +++ b/hyperglass/configuration/__init__.py @@ -4,7 +4,6 @@ import os import copy import math -import asyncio from pathlib import Path # Third Party @@ -15,6 +14,7 @@ from pydantic import ValidationError # Project from hyperglass.util import log, check_path, set_app_path +from hyperglass.compat import aiorun from hyperglass.constants import ( CREDIT, LOG_LEVELS, @@ -80,9 +80,7 @@ async def _check_config_files(directory): STATIC_PATH = CONFIG_PATH / "static" -CONFIG_MAIN, CONFIG_DEVICES, CONFIG_COMMANDS = asyncio.run( - _check_config_files(CONFIG_PATH) -) +CONFIG_MAIN, CONFIG_DEVICES, CONFIG_COMMANDS = aiorun(_check_config_files(CONFIG_PATH)) def _set_log_level(debug, log_file=None): @@ -166,7 +164,7 @@ async def _config_devices(): return config -user_config = asyncio.run(_config_main()) +user_config = aiorun(_config_main()) # Logging Config try: @@ -177,8 +175,8 @@ except KeyError: # Read raw debug value from config to enable debugging quickly. _set_log_level(_debug) -_user_commands = asyncio.run(_config_commands()) -_user_devices = asyncio.run(_config_devices()) +_user_commands = aiorun(_config_commands()) +_user_devices = aiorun(_config_devices()) # Map imported user config files to expected schema: try: @@ -383,7 +381,7 @@ def _build_vrf_help(): "title" ] = f"{vrf.display_name}: {command_params.display_name}" - md = asyncio.run( + md = aiorun( get_markdown( config_path=cmd, default=DEFAULT_DETAILS[command], @@ -404,7 +402,7 @@ content_vrf = _build_vrf_help() content_help_params = copy.copy(content_params) content_help_params["title"] = params.web.help_menu.title -content_help = asyncio.run( +content_help = aiorun( get_markdown( config_path=params.web.help_menu, default=DEFAULT_HELP, @@ -414,7 +412,7 @@ content_help = asyncio.run( content_terms_params = copy.copy(content_params) content_terms_params["title"] = params.web.terms.title -content_terms = asyncio.run( +content_terms = aiorun( get_markdown( config_path=params.web.terms, default=DEFAULT_TERMS, params=content_terms_params ) diff --git a/hyperglass/constants.py b/hyperglass/constants.py index c3197b5..a69d799 100644 --- a/hyperglass/constants.py +++ b/hyperglass/constants.py @@ -11,7 +11,7 @@ __license__ = "BSD 3-Clause Clear License" METADATA = (__name__, __version__, __author__, __copyright__, __license__) -MIN_PYTHON_VERSION = (3, 7) +MIN_PYTHON_VERSION = (3, 6) protocol_map = {80: "http", 8080: "http", 443: "https", 8443: "https"}