218 lines
7.8 KiB
Python
218 lines
7.8 KiB
Python
import pytest
|
|
|
|
pytest.importorskip("qtpy")
|
|
from qtpy import QtWidgets
|
|
|
|
from eco.widgets import console_kernel, kernel_registry
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def qapp():
|
|
return QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolated_registry(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(kernel_registry, "_registry", [])
|
|
monkeypatch.setattr(kernel_registry, "DEFAULT_LOG_DIR", tmp_path)
|
|
|
|
|
|
# -- _subprocess_env -----------------------------------------------------------
|
|
#
|
|
# Regression coverage for a real bug: a subprocess kernel spawned with a
|
|
# plain os.environ-inherited env couldn't `import eco.widgets`, even
|
|
# though the calling process (same repo checkout) had no trouble with it.
|
|
# Root cause: the calling terminal's sys.path had the repo root on it via
|
|
# IPython's %run (how eco's own startup script makes itself importable in
|
|
# the first place), not via PYTHONPATH -- a live sys.path mutation like
|
|
# that never propagates to a child process through plain environment
|
|
# inheritance. Confirmed for real against a live spawned kernel (not just
|
|
# unit-tested) while building this fix.
|
|
|
|
|
|
def test_subprocess_env_prepends_current_sys_path_to_pythonpath(monkeypatch):
|
|
import os as _os
|
|
import sys as _sys
|
|
|
|
monkeypatch.setattr(_sys, "path", ["/fake/repo/root", "/usr/lib/python3.12"])
|
|
monkeypatch.setenv("PYTHONPATH", "/existing/path")
|
|
|
|
env = console_kernel._subprocess_env()
|
|
|
|
expected_prefix = _os.pathsep.join(["/fake/repo/root", "/usr/lib/python3.12"])
|
|
assert env["PYTHONPATH"] == expected_prefix + _os.pathsep + "/existing/path"
|
|
|
|
|
|
def test_subprocess_env_without_existing_pythonpath(monkeypatch):
|
|
import sys as _sys
|
|
|
|
monkeypatch.setattr(_sys, "path", ["/fake/repo/root"])
|
|
monkeypatch.delenv("PYTHONPATH", raising=False)
|
|
|
|
env = console_kernel._subprocess_env()
|
|
|
|
assert env["PYTHONPATH"] == "/fake/repo/root"
|
|
|
|
|
|
def test_subprocess_env_preserves_other_environment_variables(monkeypatch):
|
|
monkeypatch.setenv("SOME_OTHER_VAR", "keep-me")
|
|
env = console_kernel._subprocess_env()
|
|
assert env["SOME_OTHER_VAR"] == "keep-me"
|
|
|
|
|
|
# -- can_use_inprocess_kernel -------------------------------------------------
|
|
|
|
|
|
def test_can_use_inprocess_kernel_true_with_no_running_ipython(monkeypatch):
|
|
monkeypatch.setattr("IPython.get_ipython", lambda: None)
|
|
assert console_kernel.can_use_inprocess_kernel() is True
|
|
|
|
|
|
def test_can_use_inprocess_kernel_false_with_a_running_ipython_shell(monkeypatch):
|
|
monkeypatch.setattr("IPython.get_ipython", lambda: object())
|
|
assert console_kernel.can_use_inprocess_kernel() is False
|
|
|
|
|
|
# -- pure message-field extraction --------------------------------------------
|
|
|
|
|
|
def test_extract_result_fields():
|
|
msg = {"content": {"data": {"text/plain": "42", "text/html": "<b>42</b>"}}}
|
|
assert console_kernel.extract_result_fields(msg) == {"text": "42"}
|
|
|
|
|
|
def test_extract_result_fields_missing_data():
|
|
assert console_kernel.extract_result_fields({"content": {}}) == {"text": None}
|
|
|
|
|
|
def test_extract_stream_fields():
|
|
msg = {"content": {"name": "stdout", "text": "hello\n"}}
|
|
assert console_kernel.extract_stream_fields(msg) == {"name": "stdout", "text": "hello\n"}
|
|
|
|
|
|
def test_extract_error_fields():
|
|
msg = {"content": {"ename": "ValueError", "evalue": "bad", "traceback": ["..."]}}
|
|
assert console_kernel.extract_error_fields(msg) == {"ename": "ValueError", "evalue": "bad"}
|
|
|
|
|
|
# -- in-process kernel (real) --------------------------------------------------
|
|
|
|
|
|
def test_build_inprocess_kernel_shares_a_given_namespace_dict(qapp, tmp_path):
|
|
shared = {"foo": 1}
|
|
km, kc, session = console_kernel.build_inprocess_kernel(
|
|
kind="desktop", label="bernina", shared_user_ns=shared
|
|
)
|
|
try:
|
|
assert km.kernel.shell.user_ns is shared
|
|
assert session.kind == "desktop"
|
|
assert session.label == "bernina"
|
|
assert session in kernel_registry.all_sessions()
|
|
finally:
|
|
console_kernel.stop_kernel(km, kc, session)
|
|
assert session not in kernel_registry.all_sessions()
|
|
|
|
|
|
def test_build_inprocess_kernel_pushes_vars_into_a_fresh_namespace(qapp):
|
|
km, kc, session = console_kernel.build_inprocess_kernel(
|
|
kind="desktop", label="bernina", push_vars={"namespace": "the-namespace-object"}
|
|
)
|
|
try:
|
|
assert km.kernel.shell.user_ns["namespace"] == "the-namespace-object"
|
|
finally:
|
|
console_kernel.stop_kernel(km, kc, session)
|
|
|
|
|
|
# -- subprocess kernel (real) --------------------------------------------------
|
|
|
|
|
|
def test_build_subprocess_kernel_starts_a_real_kernel_process(qapp):
|
|
km, kc, session = console_kernel.build_subprocess_kernel(kind="console", label="test")
|
|
try:
|
|
assert km.has_kernel
|
|
assert session.pid is not None
|
|
assert session.connection_file is not None
|
|
assert session in kernel_registry.all_sessions()
|
|
finally:
|
|
console_kernel.stop_kernel(km, kc, session)
|
|
assert session not in kernel_registry.all_sessions()
|
|
|
|
|
|
# -- build_console_widget ------------------------------------------------------
|
|
#
|
|
# Uses a fake in place of console_kernel.LoggingJupyterWidget -- see
|
|
# tests/test_desktop_app.py's _FakeConsoleWidget docstring for why a real
|
|
# RichJupyterWidget subclass isn't constructed directly in these tests.
|
|
|
|
|
|
class _FakeSession:
|
|
def __init__(self):
|
|
self.logged = []
|
|
|
|
def log_input(self, code):
|
|
self.logged.append(code)
|
|
|
|
def log_event(self, *a, **kw):
|
|
pass
|
|
|
|
|
|
class _FakeConsoleWidget:
|
|
def __init__(self, session=None):
|
|
self.session = session
|
|
self.kernel_manager = None
|
|
self.kernel_client = None
|
|
self.banner = ""
|
|
self.executed = []
|
|
|
|
def execute(self, code, hidden=False):
|
|
self.executed.append(code)
|
|
if self.session is not None and not hidden:
|
|
self.session.log_input(code)
|
|
|
|
|
|
def test_build_console_widget_wires_manager_client_and_banner(monkeypatch, qapp):
|
|
monkeypatch.setattr(console_kernel, "LoggingJupyterWidget", _FakeConsoleWidget)
|
|
session = _FakeSession()
|
|
console = console_kernel.build_console_widget(
|
|
"manager", "client", session, banner="hello"
|
|
)
|
|
assert console.kernel_manager == "manager"
|
|
assert console.kernel_client == "client"
|
|
assert console.banner == "hello"
|
|
assert console.session is session
|
|
# every console gets Jedi disabled (hidden, not logged -- see
|
|
# build_console_widget's docstring for why: it can fully resolve a
|
|
# lazy device proxy just from being a completion candidate), plus the
|
|
# guarded_eval allow-list patch that lets completion work *past* an
|
|
# already-resolved Proxy, plus the two-Tab confirmation gate for
|
|
# completing into a still-unresolved one (see
|
|
# console_kernel.GUARDED_EVAL_PROXY_PATCH_CODE/LAZY_COMPLETION_GATE_CODE)
|
|
assert console.executed == [
|
|
"get_ipython().Completer.use_jedi = False",
|
|
console_kernel.GUARDED_EVAL_PROXY_PATCH_CODE,
|
|
console_kernel.LAZY_COMPLETION_GATE_CODE,
|
|
]
|
|
assert session.logged == []
|
|
|
|
|
|
def test_build_console_widget_runs_startup_code_through_the_widget_not_the_client(monkeypatch, qapp):
|
|
monkeypatch.setattr(console_kernel, "LoggingJupyterWidget", _FakeConsoleWidget)
|
|
session = _FakeSession()
|
|
console = console_kernel.build_console_widget(
|
|
"manager", "client", session, startup_code="namespace = 1"
|
|
)
|
|
assert console.executed == [
|
|
"get_ipython().Completer.use_jedi = False",
|
|
console_kernel.GUARDED_EVAL_PROXY_PATCH_CODE,
|
|
console_kernel.LAZY_COMPLETION_GATE_CODE,
|
|
"namespace = 1",
|
|
]
|
|
# logged via the widget's own execute path, same as anything else typed
|
|
# into the console -- not by build_subprocess_kernel itself. The
|
|
# hidden jedi-disable call isn't logged (see the test above).
|
|
assert session.logged == ["namespace = 1"]
|
|
|
|
|
|
def test_stop_kernel_is_a_no_op_with_all_nones():
|
|
console_kernel.stop_kernel(None, None, None) # must not raise
|