Skip to content

Commit 59077c9

Browse files
committed
Revise app.evaluate and add tests.
1 parent 7320207 commit 59077c9

15 files changed

Lines changed: 300 additions & 108 deletions

.github/workflows/build.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ jobs:
2121
- name: Build the extension
2222
run: |
2323
set -eux
24-
pip install .[dev]
24+
pip install .[dev,test]
2525
2626
jupyter labextension list
2727
jupyter labextension list 2>&1 | grep -ie "ipylab.*OK"
2828
python -m jupyterlab.browser_check
29+
pytest -v
2930
3031
- name: Package the extension
3132
run: |

examples/generic.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
"source": [
5454
"## Connection\n",
5555
"\n",
56-
"A `Connection` is subclassed from `Ipylab` providing a connection to an object in the Frontend. \n",
56+
"A `Connection` is subclassed from `Ipylab` providing a connection to an object in the frontend. \n",
5757
"\n",
5858
"Each connection has a unique `cid` (stands for connection id). The `cid` consists of the class prefix `ipylab-<CLASS NAME>|` followed by one or more parts joined with a pipe. The class prefix part of the cid determines the type of class that is created when creating a new instance.\n",
5959
"\n",

examples/icons.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@
273273
"id": "20",
274274
"metadata": {},
275275
"source": [
276-
"We can use methods on `cmd` (Connection for the cmd registered in the Frontend) to add it to the command pallet, and create a launcher."
276+
"We can use methods on `cmd` (Connection for the cmd registered in the frontend) to add it to the command pallet, and create a launcher."
277277
]
278278
},
279279
{

examples/simple_output.ipynb

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -427,27 +427,28 @@
427427
"\n",
428428
"import ipylab\n",
429429
"from ipylab.code_editor import CodeEditor\n",
430-
"from ipylab.ipylab import CachedReadOnly\n",
430+
"from ipylab.ipylab import Fixed\n",
431431
"from ipylab.simple_output import AutoScroll, SimpleOutput\n",
432432
"from ipylab.widgets import Panel\n",
433433
"\n",
434434
"\n",
435435
"class SimpleConsole(Panel):\n",
436-
" prompt = CachedReadOnly(\n",
436+
" prompt = Fixed(\n",
437437
" CodeEditor,\n",
438438
" editor_options={\"lineNumbers\": False, \"autoClosingBrackets\": True, \"highlightActiveLine\": True},\n",
439439
" mime_type=\"text/x-python\",\n",
440+
" layout={\"flex\": \"0 0 auto\"},\n",
440441
" )\n",
441-
" button_clear = CachedReadOnly(ipw.Button, description=\"Clear\", layout={\"width\": \"auto\"})\n",
442-
" autoscroll = CachedReadOnly(ipw.Checkbox, description=\"Auto scroll\", layout={\"width\": \"auto\"})\n",
443-
" header = CachedReadOnly(\n",
442+
" button_clear = Fixed(ipw.Button, description=\"Clear\", layout={\"width\": \"auto\"})\n",
443+
" autoscroll = Fixed(ipw.Checkbox, description=\"Auto scroll\", layout={\"width\": \"auto\"})\n",
444+
" header = Fixed(\n",
444445
" ipw.HBox,\n",
445446
" children=lambda parent: (parent.button_clear, parent.autoscroll),\n",
446447
" layout={\"flex\": \"0 0 auto\"},\n",
447448
" dynamic=[\"children\"],\n",
448449
" )\n",
449-
" output = CachedReadOnly(SimpleOutput)\n",
450-
" scroll = CachedReadOnly(AutoScroll, content=lambda parent: parent.output, dynamic=[\"content\"])\n",
450+
" output = Fixed(SimpleOutput)\n",
451+
" scroll = Fixed(AutoScroll, content=lambda parent: parent.output, dynamic=[\"content\"])\n",
451452
"\n",
452453
" def __init__(self, namespace_id: str, **kwgs):\n",
453454
" self.prompt.namespace_id = namespace_id\n",
@@ -464,7 +465,7 @@
464465
" self.output.push(\">>> \" + code.replace(\"\\n\", \"\\n \").strip() + \"\\n\")\n",
465466
" self.prompt.value = \"\"\n",
466467
" with redirect_stdout(f):\n",
467-
" result = await self.prompt.evaluate_code(code)\n",
468+
" result = await self.prompt.completer.evaluate(code)\n",
468469
" if isinstance(result, dict):\n",
469470
" result = repr(result)\n",
470471
" if stdout := f.getvalue():\n",
@@ -515,7 +516,7 @@
515516
"name": "python",
516517
"nbconvert_exporter": "python",
517518
"pygments_lexer": "ipython3",
518-
"version": "3.11.10"
519+
"version": "3.11.11"
519520
}
520521
},
521522
"nbformat": 4,

ipylab/common.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def to_selector(*args, prefix="ipylab"):
8787

8888

8989
class Obj(StrEnum):
90-
"The objects available to use as 'obj' in the Frontend."
90+
"The objects available to use as 'obj' in the frontend."
9191

9292
this = "this"
9393
base = "base"
@@ -124,7 +124,7 @@ class InsertMode(StrEnum):
124124

125125
class Transform(StrEnum):
126126
"""An eumeration of transformations to apply to the result of an operation
127-
performed on the Frontend prior to returning to Python and transformation
127+
performed on the frontend prior to returning to Python and transformation
128128
of the result in python.
129129
130130
Transformations that require parameters can be specified as dict with the key `transform`.

ipylab/connection.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
@register
2323
class Connection(Ipylab):
24-
"""This class provides a connection to an object in the Frontend.
24+
"""This class provides a connection to an object in the frontend.
2525
2626
`Connection` and subclasses of `Connection` are used extensiviely in ipylab
2727
to provide a connection to an object in the frontend (Javascript).

ipylab/ipylab.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ class WidgetBase(Widget):
8484

8585
@register
8686
class Ipylab(WidgetBase):
87-
"""The base class for Ipylab which has a corresponding Frontend."""
87+
"""The base class for Ipylab which has a corresponding frontend."""
8888

8989
SINGLE = False
9090

@@ -275,7 +275,7 @@ def _on_custom_msg(self, _, msg: dict, buffers: list):
275275
error = self._to_frontend_error(c) if "error" in c else None
276276
self._pending_operations.pop(c["ipylab_PY"]).set(c.get("payload"), error)
277277
elif "ipylab_FE" in c:
278-
self.to_task(self._do_operation_for_fe(c["ipylab_FE"], c["operation"], c["payload"], buffers))
278+
return self.to_task(self._do_operation_for_fe(c["ipylab_FE"], c["operation"], c["payload"], buffers))
279279
elif "closed" in c:
280280
self.close()
281281
else:

ipylab/jupyterfrontend.py

Lines changed: 122 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from ipylab.shell import Shell
2828

2929
if TYPE_CHECKING:
30+
from collections.abc import Iterable
3031
from typing import ClassVar
3132

3233

@@ -129,52 +130,9 @@ async def _do_operation_for_frontend(self, operation: str, payload: dict, buffer
129130
if not isinstance(widget, Widget):
130131
msg = f"Expected an Widget but got {type(widget)}"
131132
raise TypeError(msg)
132-
result["payload"] = await self.shell.add(widget, **payload)
133-
return result
134-
return await super()._do_operation_for_frontend(operation, payload, buffers)
135-
136-
async def _evaluate(self, options: dict, buffers: list):
137-
"""Evaluate code.
133+
return await self.shell.add(widget, **payload)
138134

139-
A call to this method should originate from either:
140-
1. An `evaluate` method call from a subclass of `Ipylab` from kernel via
141-
`jfem.evaluate` in the frontend.
142-
2. A call in the frontend to `jfem.evaluate`.
143-
"""
144-
evaluate = options["evaluate"]
145-
if isinstance(evaluate, str):
146-
evaluate = {"payload": evaluate}
147-
namespace_id = options.get("namespace_id", "")
148-
ns = self.get_namespace(namespace_id, buffers=buffers)
149-
for name, expression in evaluate.items():
150-
try:
151-
result = eval(expression, ns) # noqa: S307
152-
except SyntaxError:
153-
exec(expression, ns) # noqa: S102
154-
result = next(reversed(ns.values())) # Requires: LastUpdatedDict
155-
while callable(result) or inspect.isawaitable(result):
156-
if callable(result):
157-
kwgs = {}
158-
for p in inspect.signature(result).parameters:
159-
if p in options:
160-
kwgs[p] = options[p]
161-
if p in ns:
162-
kwgs[p] = ns[p]
163-
# We use a partial so that we can evaluate with the same namespace.
164-
ns["_partial_call"] = functools.partial(result, **kwgs)
165-
result = eval("_partial_call()", ns) # type: ignore # noqa: S307
166-
ns.pop("_partial_call")
167-
while inspect.isawaitable(result):
168-
result = await result
169-
ns[name] = result
170-
buffers = ns.pop("buffers", [])
171-
payload = ns.pop("payload", None)
172-
if payload is not None:
173-
ns["_call_count"] = n = ns.get("_call_count", 0) + 1
174-
ns[f"payload_{n}"] = payload
175-
if namespace_id == "":
176-
self.shell.add_objects_to_ipython_namespace(ns)
177-
return {"payload": payload, "buffers": buffers}
135+
return await super()._do_operation_for_frontend(operation, payload, buffers)
178136

179137
def shutdown_kernel(self, vpath: str | None = None):
180138
"Shutdown the kernel"
@@ -187,8 +145,8 @@ def start_iyplab_python_kernel(self, *, restart=False):
187145
def get_namespace(self, namespace_id="", **objects) -> LastUpdatedDict:
188146
"""Get the namespace corresponding to namespace_id.
189147
190-
The namespace is a dictionary that maintains the order by which items
191-
are added.
148+
The namespace is a `LastUpdatedDict` that maintains the order by which
149+
items are added.
192150
193151
Default oubjects are added to the namespace via the plugin hook
194152
`default_namespace_objects`.
@@ -201,6 +159,8 @@ def get_namespace(self, namespace_id="", **objects) -> LastUpdatedDict:
201159
202160
Parameters
203161
----------
162+
namespace_id: str
163+
The identifier for the namespace to use in this kernel.
204164
objects:
205165
Additional objects to add to the namespace.
206166
"""
@@ -216,42 +176,135 @@ def get_namespace(self, namespace_id="", **objects) -> LastUpdatedDict:
216176
ns.update(self.comm.kernel.shell.user_ns) # type: ignore
217177
return ns
218178

179+
async def _evaluate(self, options: dict[str, Any], buffers: list):
180+
"""Evaluate code for `evaluate`.
181+
182+
A call to this method should originate from a call to `evaluate` from
183+
app in another kernel. The call is sent as a message via the frontend."""
184+
evaluate = options["evaluate"]
185+
if isinstance(evaluate, str):
186+
evaluate = (evaluate,)
187+
namespace_id = options.get("namespace_id", "")
188+
ns = self.get_namespace(namespace_id, buffers=buffers)
189+
for row in evaluate:
190+
name, expression = ("payload", row) if isinstance(row, str) else row
191+
try:
192+
result = eval(expression, ns) # noqa: S307
193+
except SyntaxError:
194+
exec(expression, ns) # noqa: S102
195+
result = next(reversed(ns.values())) # Requires: LastUpdatedDict
196+
if not name:
197+
continue
198+
while callable(result) or inspect.isawaitable(result):
199+
if callable(result):
200+
kwgs = {}
201+
for p in inspect.signature(result).parameters:
202+
if p in options:
203+
kwgs[p] = options[p]
204+
elif p in ns:
205+
kwgs[p] = ns[p]
206+
# We use a partial so that we can evaluate with the same namespace.
207+
ns["_partial_call"] = functools.partial(result, **kwgs)
208+
result = eval("_partial_call()", ns) # type: ignore # noqa: S307
209+
ns.pop("_partial_call")
210+
if inspect.isawaitable(result):
211+
result = await result
212+
if name:
213+
ns[name] = result
214+
buffers = ns.pop("buffers", [])
215+
payload = ns.pop("payload", None)
216+
if payload is not None:
217+
ns["_call_count"] = n = ns.get("_call_count", 0) + 1
218+
ns[f"payload_{n}"] = payload
219+
if namespace_id == "":
220+
self.shell.add_objects_to_ipython_namespace(ns)
221+
return {"payload": payload, "buffers": buffers}
222+
219223
def evaluate(
220224
self,
221-
evaluate: dict[str, str | inspect._SourceObjectType] | str,
225+
evaluate: str | inspect._SourceObjectType | Iterable[str | tuple[str, str | inspect._SourceObjectType]],
222226
*,
223227
vpath: str,
224228
namespace_id="",
229+
kwgs: None | dict = None,
225230
**kwargs: Unpack[IpylabKwgs],
226231
):
227-
"""Evaluate code asynchronously in a Python kernel.
232+
"""Evaluate code asynchronously in the 'vpath' Python kernel.
233+
234+
Execution is coordinated via the frontend and will evaluate/execute the
235+
code specified. Most forms of expressions are acceptable. Awaitiables
236+
will be awaited recursively prior to sending the result.
228237
229238
Parameters
230239
----------
231-
evaluate: dict[str, str | function | module] | str
232-
An expression to evaluate or execute or mapping of values to expressions.
233-
234-
The evaluation expression will also be called and or awaited
235-
until the returned symbol is no longer callable or awaitable.
236-
String:
237-
If it is string it will be evaluated and returned.
238-
Dict: Advanced usage:
239-
A dictionary of `symbol name` to `expression` mappings to be evaluated in the kernel.
240-
Each expression is evaluated in turn adding the symbol to the namespace.
241-
242-
Expression can be a the name of a function or class. In which case it will be evaluated
243-
using parameter names matching the signature of the function or class.
244-
245-
ref: https://docs.python.org/3/library/functions.html#eval
246-
247-
Once evaluation is complete, the symbols named `payload` and `buffers` will be returned.
240+
evaluate: str | code | Iterable[str | tuple[str|None, str | code]]
241+
An expression or list of expressions to evaluate.
242+
243+
The following combinations are acceptable:
244+
1. code # Shorthand version -> payload = code
245+
2. [("payload", code)] -> payload = code
246+
3. [("payload", code1), ("", code2), code3] -> payload = code3
247+
248+
* Code is handled as a list of mappings of `symbol name` to expressions.
249+
[(symbol name, expression), ...]
250+
* The shorthand version is changed to a single element list automatically.
251+
* `code` is changed to ("payload", code) automatically.
252+
* The latest defined `"payload"` is the return value from evaluation.
253+
254+
Each expression will be evaluated and if a syntax error occurs in evaluation
255+
it will instead be executed. The latest set symbol is taken as the execution
256+
result.
257+
258+
If the result is callable or awaitable it will be called or await recursively
259+
until the result or awaitable is no longer callable or awaitable. To prevent this
260+
make the symbol name an empty string.
261+
262+
References
263+
----------
264+
* eval: https://docs.python.org/3/library/functions.html#eval
265+
* exec: https://docs.python.org/3/library/functions.html#exec
266+
267+
Once evaluation is complete, the symbols named `payload` and `buffers`
268+
will be returned.
248269
vpath:
249-
The path of kernel session where to perform the evaluation.
270+
The path of kernel session where the evaluation should take place.
250271
namespace_id:
251272
The namespace where to perform evaluation.
252-
The default namespace will also update the shell.user_ns after successful evaluation.
273+
The default namespace will also update the shell.user_ns after
274+
successful evaluation.
275+
kwgs: dict | None
276+
Specify kwgs that may be used when calling a callable.
277+
Note:The namespace is also searched.
278+
279+
Examples
280+
--------
281+
simple:
282+
``` python
283+
task = app.evaluate(
284+
"ipylab.app.shell.open_console",
285+
vpath="test",
286+
kwgs={"mode": ipylab.InsertMode.split_right, "activate": False},
287+
)
288+
# The task result will be a ShellConnection. Closing the connection should
289+
# also close the console that was opened.
290+
```
291+
292+
Advanced example:
293+
``` python
294+
async def do_something(widget, area):
295+
p = iplab.panel(content=widget)
296+
return p.add_to_shell()
297+
298+
299+
task = app.evaluate(
300+
[("widget", "ipw.Dropdown()"), do_something],
301+
area=iplab.Area.right,
302+
vpath="test",
303+
)
304+
# Task result should be a ShellConnection
305+
```
253306
"""
254-
kwgs = {"evaluate": evaluate, "vpath": vpath, "namespace_id": namespace_id}
307+
kwgs = (kwgs or {}) | {"evaluate": evaluate, "vpath": vpath, "namespace_id": namespace_id}
255308
return self.operation("evaluate", kwgs, **kwargs)
256309

257310

ipylab/log_viewer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,9 @@ def observe(change: dict):
186186
def add_to_shell(
187187
self,
188188
*,
189-
area: ipylab.Area = Area.main,
189+
area=Area.main,
190190
activate: bool = True,
191-
mode: ipylab.InsertMode = InsertMode.split_bottom,
191+
mode=InsertMode.split_bottom,
192192
rank: int | None = None,
193193
ref: ipylab.ShellConnection | None = None,
194194
options: dict | None = None,

0 commit comments

Comments
 (0)