diff --git a/src/ipython_demo/__init__.py b/src/ipython_demo/__init__.py index e759132..6ab1e8c 100644 --- a/src/ipython_demo/__init__.py +++ b/src/ipython_demo/__init__.py @@ -1,6 +1,6 @@ """Small, side-effect-free API for the in-process IPython runner.""" -from .app import App, generate_good_names +from .app import App from .events import ( error_from_execution_result, event_from_execution_result, @@ -22,22 +22,23 @@ from .shell import ( run_cell_and_collect, setup_shell, ) +from .utils import generate_good_names __all__ = [ "App", + "CallError", "CallEvent", "CallEventKind", - "CallError", "CallOptions", "CallResponse", "CallResult", - "error_from_execution_result", - "event_from_execution_result", "Shell", - "event_from_history_output", - "generate_good_names", "ShellInfo", "ShellStatus", + "error_from_execution_result", + "event_from_execution_result", + "event_from_history_output", + "generate_good_names", "isolate_output_history", "run_cell_and_collect", "setup_shell", diff --git a/src/ipython_demo/app.py b/src/ipython_demo/app.py index 9ee1de1..77d0c31 100644 --- a/src/ipython_demo/app.py +++ b/src/ipython_demo/app.py @@ -4,22 +4,12 @@ import json from dataclasses import asdict from uuid import uuid4 -from wonderwords import RandomWord +from ipython_demo.utils import generate_good_names from .models import CallOptions, CallResponse, ShellInfo from .shell import Shell -_WORD_GENERATOR = RandomWord() - - -def generate_good_names() -> str: - """Generate a readable, collision-resistant shell name.""" - adjective = _WORD_GENERATOR.word(include_parts_of_speech=["adjectives"]) - noun = _WORD_GENERATOR.word(include_parts_of_speech=["nouns"]) - return f"{adjective}-{noun}-{uuid4().hex[:4]}" - - class App: """Coordinate named shells and expose frontend-shaped call responses.""" diff --git a/src/ipython_demo/models.py b/src/ipython_demo/models.py index bce2b82..03082c4 100644 --- a/src/ipython_demo/models.py +++ b/src/ipython_demo/models.py @@ -1,7 +1,6 @@ from dataclasses import dataclass from typing import Literal - CallEventKind = Literal[ "stdout", "stderr", diff --git a/src/ipython_demo/utils.py b/src/ipython_demo/utils.py index 2c5eee6..3ee3dbc 100644 --- a/src/ipython_demo/utils.py +++ b/src/ipython_demo/utils.py @@ -1,8 +1,10 @@ import inspect +from uuid import uuid4 + +from wonderwords import RandomWord def line(): - this = inspect.currentframe() if not this: # i want ?. operator SOOOOOOOO return None @@ -10,3 +12,13 @@ def line(): if not frame: return None return frame.f_lineno + + +_WORD_GENERATOR = RandomWord() + + +def generate_good_names() -> str: + """Generate a readable, collision-resistant shell name.""" + adjective = _WORD_GENERATOR.word(include_categories=["adjectives"]) + noun = _WORD_GENERATOR.word(include_categories=["nouns"]) + return f"{adjective}-{noun}-{uuid4().hex[:4]}" diff --git a/tests/test_app.py b/tests/test_app.py index be5bcc7..869da85 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,47 +1,31 @@ import contextlib -import importlib import io -import json import re -import subprocess -import sys import unittest from unittest.mock import Mock, patch +from ipython_demo import utils from ipython_demo.app import App from ipython_demo.models import CallOptions, CallResponse class AppTests(unittest.TestCase): def test_generate_good_names_uses_readable_random_words(self): - app_module = importlib.import_module("ipython_demo.app") generator = Mock() + assert isinstance(generator.word, Mock) generator.word.side_effect = ["luminous", "otter"] with patch.object( - app_module, + utils, "_WORD_GENERATOR", generator, create=True, ): - name = app_module.generate_good_names() + name = utils.generate_good_names() self.assertRegex(name, re.compile(r"^[a-z]+-[a-z]+-[0-9a-f]{4}$")) - generator.word.assert_any_call(include_parts_of_speech=["adjectives"]) - generator.word.assert_any_call(include_parts_of_speech=["nouns"]) - - def test_main_emits_one_json_payload(self): - completed = subprocess.run( - [sys.executable, "-c", "from ipython_demo.app import main; main()"], - capture_output=True, - text=True, - check=True, - ) - - payload = json.loads(completed.stdout) - self.assertEqual(len(payload), 2) - self.assertEqual(payload[0]["events"][0]["kind"], "stdout") - self.assertTrue(payload[1]["options"]["collapsed"]) + generator.word.assert_any_call(include_categories=["adjectives"]) + generator.word.assert_any_call(include_categories=["nouns"]) def test_run_code_returns_frontend_facing_call_response(self): app = App() diff --git a/tests/test_my_ideas.py b/tests/test_my_ideas.py new file mode 100644 index 0000000..02a33da --- /dev/null +++ b/tests/test_my_ideas.py @@ -0,0 +1,35 @@ +import unittest +from collections.abc import Callable +from typing import Any + +from IPython.core.interactiveshell import InteractiveShell + +from ipython_demo.shell import Shell + + +class TestMyIdeas(unittest.TestCase): + def test_MY_two_instances(self): + InteractiveShell1 = Shell("InteractiveShell1").init_shell() + InteractiveShell2 = Shell("InteractiveShell2").init_shell() + + def ok[T]( + resolve: Callable[[InteractiveShell], Any], + method: Callable[[T, T], Any], + ) -> None: + t1 = resolve(InteractiveShell1) + t2 = resolve(InteractiveShell2) + method(t1, t2) + + ok( + lambda shell: getattr(shell.history_manager, "outputs", None), + self.assertIsNot, + ) + ok(lambda shell: shell.display_formatter, self.assertIsNot) + ok(lambda shell: shell.display_pub, self.assertIsNot) + ok(lambda shell: shell.displayhook, self.assertIsNot) + ok(lambda shell: shell.history_manager, self.assertIsNot) + ok(lambda shell: shell.user_ns, self.assertIsNot) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_shell.py b/tests/test_shell.py index 751b07a..d8f7578 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -1,61 +1,21 @@ import contextlib -import importlib.util import io -import os -import subprocess -import sys -import types import unittest from pathlib import Path from unittest.mock import patch -from IPython.core.interactiveshell import InteractiveShell from IPython.core.history import HistoryOutput +from IPython.core.interactiveshell import InteractiveShell from IPython.utils import capture +import ipython_demo.events as events_module +import ipython_demo.models as models_module +import ipython_demo.shell as shell_module PACKAGE_ROOT = Path(__file__).parents[1] / "src" / "ipython_demo" -package = types.ModuleType("ipython_demo") -package.__path__ = [str(PACKAGE_ROOT)] -sys.modules["ipython_demo"] = package - - -def load_package_module(name: str): - path = PACKAGE_ROOT / f"{name}.py" - spec = importlib.util.spec_from_file_location(f"ipython_demo.{name}", path) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -models_module = load_package_module("models") -events_module = load_package_module("events") -shell_module = load_package_module("shell") class ShellTests(unittest.TestCase): - def test_init_shell_has_no_seed_escape_hatch(self): - import inspect - - self.assertNotIn( - "seed", inspect.signature(shell_module.Shell.init_shell).parameters - ) - - def test_package_import_has_no_experiment_side_effects(self): - env = os.environ.copy() - env["PYTHONPATH"] = str(PACKAGE_ROOT.parent) - completed = subprocess.run( - [sys.executable, "-c", "import ipython_demo; print('imported')"], - capture_output=True, - text=True, - env=env, - check=True, - ) - - self.assertEqual(completed.stdout, "imported\n") - def test_event_from_history_output_preserves_streams_and_mime(self): stdout = events_module.event_from_history_output( HistoryOutput("out_stream", {"stream": ["hello", "\n"]}), @@ -105,9 +65,13 @@ class ShellTests(unittest.TestCase): ) self.assertIsInstance(execution.error, models_module.CallError) + assert execution.error self.assertEqual(execution.error.ename, "ValueError") self.assertEqual(execution.error.evalue, "boom") + self.assertTrue(events, "no events") self.assertEqual(events[-1].kind, "error") + self.assertTrue(events[-1].data, "no data in last event") + assert events[-1].data self.assertEqual(events[-1].data["ename"], "ValueError") self.assertEqual(events[-1].data["evalue"], "boom") @@ -168,16 +132,18 @@ class ShellTests(unittest.TestCase): "Shell.run_cell must use the shell-local output records" ) - with patch.object( - capture, - "capture_output", - fail_capture_output, + with ( + patch.object( + capture, + "capture_output", + fail_capture_output, + ), + contextlib.redirect_stdout(io.StringIO()), ): - with contextlib.redirect_stdout(io.StringIO()): - result, events = wrapper.run_cell( - "print('hello'); 42", - call_id="call-1", - ) + result, events = wrapper.run_cell( + "print('hello'); 42", + call_id="call-1", + ) self.assertEqual(result.result, 42) self.assertEqual([event.kind for event in events], ["stdout", "execute_result"])