diff --git a/README.md b/README.md index 1a5b361..68c7896 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,18 @@ The application-facing API is: ```python from ipython_demo import App +from ipython_demo import CallOptions app = App() call = app.run_code( "print('hello'); 2 + 2", shell_name="new", - collapsed=False, + options=CallOptions(collapsed=False), ) ``` -`call` contains the submitted `code`, `call_id`, `shell_name`, `collapsed` -state, a typed `result`, and ordered typed `events`. Event sequences are +`call` contains the submitted `code`, `call_id`, `shell_name`, typed `options`, +a typed `result`, and ordered typed `events`. Event sequences are stable within a call, so a frontend can use `(call_id, sequence)` as its idempotency and React key pair. diff --git a/src/ipython_demo/__init__.py b/src/ipython_demo/__init__.py index 66731bb..e759132 100644 --- a/src/ipython_demo/__init__.py +++ b/src/ipython_demo/__init__.py @@ -10,6 +10,7 @@ from .models import ( CallError, CallEvent, CallEventKind, + CallOptions, CallResponse, CallResult, ShellInfo, @@ -27,6 +28,7 @@ __all__ = [ "CallEvent", "CallEventKind", "CallError", + "CallOptions", "CallResponse", "CallResult", "error_from_execution_result", @@ -40,8 +42,3 @@ __all__ = [ "run_cell_and_collect", "setup_shell", ] - - -def main() -> None: - """Run the package's minimal command-line entry point.""" - print("Hello from ipython_demo!") diff --git a/src/ipython_demo/app.py b/src/ipython_demo/app.py index cbf3f99..9ee1de1 100644 --- a/src/ipython_demo/app.py +++ b/src/ipython_demo/app.py @@ -6,7 +6,7 @@ from uuid import uuid4 from wonderwords import RandomWord -from .models import CallResponse, ShellInfo +from .models import CallOptions, CallResponse, ShellInfo from .shell import Shell @@ -59,9 +59,11 @@ class App: code: str, shell_name: str = "last", *, - collapsed: bool = False, + options: CallOptions | None = None, ) -> CallResponse: """Run code in the selected shell and return the complete call payload.""" + if options is None: + options = CallOptions() selected_name, shell = self._select_shell(shell_name) call_id = uuid4().hex result, events = shell.run_cell(code, call_id=call_id) @@ -69,7 +71,7 @@ class App: call_id=call_id, shell_name=selected_name, code=code, - collapsed=collapsed, + options=options, result=result, events=events, shell=shell.describe(), @@ -84,6 +86,10 @@ def main() -> None: with contextlib.redirect_stdout(io.StringIO()): calls = [ app.run_code("print('hello from the shell'); 2 + 2", shell_name="new"), - app.run_code("40 + 2", shell_name="last", collapsed=True), + app.run_code( + "40 + 2", + shell_name="last", + options=CallOptions(collapsed=True), + ), ] print(json.dumps([asdict(call) for call in calls], indent=2, default=repr)) diff --git a/src/ipython_demo/models.py b/src/ipython_demo/models.py index 196034a..bce2b82 100644 --- a/src/ipython_demo/models.py +++ b/src/ipython_demo/models.py @@ -15,6 +15,13 @@ CallEventKind = Literal[ ShellStatus = Literal["ready", "running", "error"] +@dataclass +class CallOptions: + """Presentation and execution hints attached to one call.""" + + collapsed: bool = False + + @dataclass class CallEvent: """One ordered, UI-safe event emitted while a call executes.""" @@ -54,7 +61,7 @@ class CallResponse: call_id: str shell_name: str code: str - collapsed: bool + options: CallOptions result: CallResult events: list[CallEvent] shell: ShellInfo diff --git a/src/ipython_demo/shell.py b/src/ipython_demo/shell.py index 0ca88d8..5582306 100644 --- a/src/ipython_demo/shell.py +++ b/src/ipython_demo/shell.py @@ -1,5 +1,5 @@ from collections import defaultdict -from datetime import datetime, timezone +from datetime import UTC, datetime from uuid import uuid4 from IPython.core.interactiveshell import InteractiveShell @@ -25,7 +25,7 @@ class Shell: def __init__(self, name: str): self.name = name self.shell_id = uuid4().hex - self.created_at = datetime.now(timezone.utc).isoformat() + self.created_at = datetime.now(UTC).isoformat() self.last_used_at: str | None = None self.status: ShellStatus = "ready" self._last_execution_count = 0 @@ -55,7 +55,7 @@ class Shell: self.status = "running" try: call_result, events = run_cell_and_collect( - self.init_shell(), + self.shell, code, call_id=call_id, ) @@ -64,7 +64,7 @@ class Shell: raise else: self.status = "ready" - self.last_used_at = datetime.now(timezone.utc).isoformat() + self.last_used_at = datetime.now(UTC).isoformat() self._last_execution_count = call_result.execution_count or 0 return call_result, events @@ -114,9 +114,11 @@ def run_cell_and_collect( isolate_output_history(shell) execution = shell.run_cell(code, store_history=True) - records = tuple( - history_manager.outputs.get(execution.execution_count, ()) - ) if execution.execution_count is not None else () + records = ( + tuple(history_manager.outputs.get(execution.execution_count, ())) + if execution.execution_count is not None + else () + ) events = [ event_from_history_output( record, @@ -170,9 +172,9 @@ def setup_shell(shell: InteractiveShell, plt: bool = False, pylab: bool = True): # This is the non-GUI part of enable_matplotlib(). if plt: activate_matplotlib(backend) - shell.magics_manager.registry[ - "ExecutionMagics" - ].default_runner = mpl_runner(shell.safe_execfile) + shell.magics_manager.registry["ExecutionMagics"].default_runner = mpl_runner( + shell.safe_execfile + ) # This connects matplotlib-inline to THIS shell. configure_inline_support(shell, backend) @@ -190,9 +192,3 @@ def setup_shell(shell: InteractiveShell, plt: bool = False, pylab: bool = True): import_pylab(pylab_ns, import_all=False) # no import * allowed shell.user_ns.update(pylab_ns) shell.user_ns_hidden.update(pylab_ns) - - -# tisi = InteractiveShell.instance() -# default_shell = Shell("default_shell") -# default_shell.init_shell(tisi) -# use me, or dont. idc. but we need to give the default instance the select_figure_formats because i neeeeeeeeeeeeeeeeeeeed it. diff --git a/tests/test_app.py b/tests/test_app.py index 131aa40..be5bcc7 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -9,7 +9,7 @@ import unittest from unittest.mock import Mock, patch from ipython_demo.app import App -from ipython_demo.models import CallResponse +from ipython_demo.models import CallOptions, CallResponse class AppTests(unittest.TestCase): @@ -41,7 +41,7 @@ class AppTests(unittest.TestCase): payload = json.loads(completed.stdout) self.assertEqual(len(payload), 2) self.assertEqual(payload[0]["events"][0]["kind"], "stdout") - self.assertTrue(payload[1]["collapsed"]) + self.assertTrue(payload[1]["options"]["collapsed"]) def test_run_code_returns_frontend_facing_call_response(self): app = App() @@ -50,17 +50,20 @@ class AppTests(unittest.TestCase): call = app.run_code( "print('hello'); 2 + 2", shell_name="new", - collapsed=True, + options=CallOptions(collapsed=True), ) self.assertIsInstance(call, CallResponse) self.assertEqual(call.code, "print('hello'); 2 + 2") - self.assertTrue(call.collapsed) + self.assertTrue(call.options.collapsed) self.assertEqual(call.result.result, 4) - self.assertEqual([event.kind for event in call.events], [ - "stdout", - "execute_result", - ]) + self.assertEqual( + [event.kind for event in call.events], + [ + "stdout", + "execute_result", + ], + ) def test_last_reuses_shell_but_each_call_has_new_id(self): app = App() diff --git a/tests/test_shell.py b/tests/test_shell.py index 2d998bb..751b07a 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -39,7 +39,9 @@ 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) + 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() @@ -66,9 +68,12 @@ class ShellTests(unittest.TestCase): sequence=4, ) - self.assertEqual(stdout, models_module.CallEvent( - call_id="call-1", sequence=3, kind="stdout", text="hello\n" - )) + self.assertEqual( + stdout, + models_module.CallEvent( + call_id="call-1", sequence=3, kind="stdout", text="hello\n" + ), + ) self.assertEqual(result.data, {"text/plain": "42"}) def test_run_cell_and_collect_returns_execution_and_events(self): @@ -159,7 +164,9 @@ class ShellTests(unittest.TestCase): wrapper.init_shell() def fail_capture_output(*args, **kwargs): - raise AssertionError("Shell.run_cell must use the shell-local output records") + raise AssertionError( + "Shell.run_cell must use the shell-local output records" + ) with patch.object( capture, diff --git a/uv.lock b/uv.lock index bee2bcd..e43df0a 100644 --- a/uv.lock +++ b/uv.lock @@ -125,7 +125,6 @@ dependencies = [ { name = "ipython" }, { name = "matplotlib" }, { name = "matplotlib-inline" }, - { name = "sympy" }, { name = "wonderwords" }, ] @@ -134,7 +133,6 @@ requires-dist = [ { name = "ipython", specifier = ">=9.16.1" }, { name = "matplotlib", specifier = ">=3.11.1" }, { name = "matplotlib-inline", specifier = ">=0.1.7" }, - { name = "sympy", specifier = ">=1.14.0" }, { name = "wonderwords", specifier = ">=2.2.0" }, ] @@ -281,15 +279,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, ] -[[package]] -name = "mpmath" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, -] - [[package]] name = "numpy" version = "2.5.2" @@ -526,18 +515,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] -[[package]] -name = "sympy" -version = "1.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mpmath" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, -] - [[package]] name = "traitlets" version = "5.16.1"