From 62464527569c0e959d59e21bce786a0ed501453a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Mottelet?= Date: Wed, 23 Sep 2026 11:24:33 +0200 Subject: [PATCH 01/10] Enable JupyterLab syntax highlighting via Octave CodeMirror mode Scilab cells showed as plain text in the live JupyterLab editor even though PDF/HTML export was already highlighted (nbconvert/Pygments picks language_info['name'] == 'scilab', a real Pygments lexer, so that path was unaffected). JupyterLab has no CodeMirror language for "scilab", and picks a mode by matching language_info against its registry. Reporting codemirror_mode: "octave" (Scilab's syntax being close to Octave/Matlab) makes it resolve to the closest available mode. Note that mimetype alone is not enough: JupyterLab 4.6's getMimeTypeByLanguage() builds a fallback object as {mimetype, name, ext}, but the language registry's findBest() destructures {mime, name, extensions} -- a key-name mismatch that silently drops the mimetype-only path back to plaintext. Setting codemirror_mode explicitly goes through the working code path (findByName(), case-insensitive) instead. Known limitation: Octave's CodeMirror mode only recognizes "%" as a line comment, while Scilab uses "//", so "//" comments aren't styled and JupyterLab's Toggle Comment (Ctrl+/) inserts "%" -- invalid Scilab syntax. A full fix needs a dedicated Scilab CodeMirror language (a companion JupyterLab extension); this gets every other token (strings, numbers, operators, keywords) highlighted correctly in the meantime. Fixes: https://discourse.jupyter.org/t/syntax-highlighting-scilab-language/37910 Co-Authored-By: Claude Sonnet 5 --- scilab_kernel/kernel.json | 2 +- scilab_kernel/kernel.py | 27 ++++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/scilab_kernel/kernel.json b/scilab_kernel/kernel.json index 226c55d..2ce14af 100644 --- a/scilab_kernel/kernel.json +++ b/scilab_kernel/kernel.json @@ -4,6 +4,6 @@ "-f", "{connection_file}"], "display_name": "Scilab", "language": "scilab", - "mimetype": "text/x-scilab", + "mimetype": "text/x-octave", "name": "scilab" } diff --git a/scilab_kernel/kernel.py b/scilab_kernel/kernel.py index c07315b..a180d3a 100644 --- a/scilab_kernel/kernel.py +++ b/scilab_kernel/kernel.py @@ -40,7 +40,32 @@ class ScilabKernel(ProcessMetaKernel): language_info = { 'name': 'scilab', 'file_extension': '.sci', - "mimetype": "text/x-scilab", + # JupyterLab's CodeMirror editor has no "scilab" language mode; it + # picks a mode by matching against its registry, and "octave" is + # the closest available one (Scilab's syntax is close to + # Octave/Matlab). This only affects the live editor's highlighting + # -- nbconvert/Pygments export still use the 'name' above, which + # is a real Pygments lexer ("scilab"). + # + # codemirror_mode (not just mimetype) is required: JupyterLab + # 4.6's getMimeTypeByLanguage() only consults language_info.mimetype + # through a fallback object it builds as {mimetype, name, ext}, but + # the language registry's findBest() destructures {mime, name, + # extensions} -- a key-name mismatch that makes the mimetype-only + # path silently fail (falls back to plaintext, no highlighting at + # all). Setting codemirror_mode goes through the working code path + # instead (findByName(), which is case-insensitive). + # + # Known limitation: Octave's CodeMirror mode only knows "%" as a + # line-comment token, while Scilab uses "//". So "//" comments + # won't be styled as comments, and JupyterLab's "Toggle Comment" + # (Ctrl+/) will insert "%" on a Scilab cell, which is invalid + # Scilab syntax and must be fixed up by hand. A correct fix needs a + # dedicated Scilab CodeMirror language mode (a companion JupyterLab + # extension); this is a stopgap that gets most other tokens + # (strings, numbers, operators, keywords) highlighted correctly. + "mimetype": "text/x-octave", + "codemirror_mode": "octave", "version": __version__, 'help_links': MetaKernel.help_links, } From b260daabb267187325d30bcf23aa885284463e88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Mottelet?= Date: Thu, 24 Sep 2026 09:18:41 +0200 Subject: [PATCH 02/10] Switch to jupyterlab-scilab, drop the Octave stopgap jupyterlab-scilab (https://pypi.org/project/jupyterlab-scilab/) is now published: a real CodeMirror language for Scilab (correct "//" comments, ~2200 keywords/builtins sourced from Scilab's own getscilabkeywords()/what()), so the Octave-mode stopgap from this PR is no longer needed -- and is now actively harmful: an explicit codemirror_mode always wins over JupyterLab's own name-match fallback, so keeping "octave" here would keep forcing Octave-style highlighting even once jupyterlab-scilab is installed. Switched language_info/kernel.json back to codemirror_mode: "scilab" / mimetype: "text/x-scilab", and added jupyterlab-scilab as an optional dependency (`pip install scilab-kernel[jupyterlab]`) rather than a hard one, since scilab_kernel is also used outside JupyterLab (classic Notebook, other frontends) where pulling in the full jupyterlab package would be unwanted bloat. Verified: a live kernel now reports codemirror_mode: "scilab" in its kernel_info_reply, and this resolves correctly to text/x-scilab against jupyterlab-scilab's registered language (confirmed both by a faithful reproduction of JupyterLab 4.6's actual getMimeTypeByLanguage()/findBest()/findByName() matching code, and by a live JupyterLab instance with jupyterlab-scilab installed from PyPI). Co-Authored-By: Claude Sonnet 5 --- pyproject.toml | 1 + scilab_kernel/kernel.json | 2 +- scilab_kernel/kernel.py | 43 ++++++++++++++++----------------------- 3 files changed, 20 insertions(+), 26 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3393b67..eb77ea1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ content-type = "text/x-rst" [project.optional-dependencies] test = ["pytest", "nbconvert", "jupyter_kernel_test", "nbconvert"] +jupyterlab = ["jupyterlab-scilab"] [tool.hatch.build.targets.wheel.shared-data] "jupyter-data/share" = "share" diff --git a/scilab_kernel/kernel.json b/scilab_kernel/kernel.json index 2ce14af..226c55d 100644 --- a/scilab_kernel/kernel.json +++ b/scilab_kernel/kernel.json @@ -4,6 +4,6 @@ "-f", "{connection_file}"], "display_name": "Scilab", "language": "scilab", - "mimetype": "text/x-octave", + "mimetype": "text/x-scilab", "name": "scilab" } diff --git a/scilab_kernel/kernel.py b/scilab_kernel/kernel.py index a180d3a..48e44ae 100644 --- a/scilab_kernel/kernel.py +++ b/scilab_kernel/kernel.py @@ -40,32 +40,25 @@ class ScilabKernel(ProcessMetaKernel): language_info = { 'name': 'scilab', 'file_extension': '.sci', - # JupyterLab's CodeMirror editor has no "scilab" language mode; it - # picks a mode by matching against its registry, and "octave" is - # the closest available one (Scilab's syntax is close to - # Octave/Matlab). This only affects the live editor's highlighting - # -- nbconvert/Pygments export still use the 'name' above, which - # is a real Pygments lexer ("scilab"). + # JupyterLab's CodeMirror editor has no built-in "scilab" language + # mode, so real syntax highlighting (correct "//" comments, + # keywords, etc.) needs the companion "jupyterlab-scilab" extension + # (https://pypi.org/project/jupyterlab-scilab/, `pip install + # scilab-kernel[jupyterlab]`), which registers one. Without it, + # cells show as plain text in the live editor -- nbconvert/Pygments + # export is unaffected either way, since that path uses the 'name' + # above, which is a real Pygments lexer ("scilab"). # - # codemirror_mode (not just mimetype) is required: JupyterLab - # 4.6's getMimeTypeByLanguage() only consults language_info.mimetype - # through a fallback object it builds as {mimetype, name, ext}, but - # the language registry's findBest() destructures {mime, name, - # extensions} -- a key-name mismatch that makes the mimetype-only - # path silently fail (falls back to plaintext, no highlighting at - # all). Setting codemirror_mode goes through the working code path - # instead (findByName(), which is case-insensitive). - # - # Known limitation: Octave's CodeMirror mode only knows "%" as a - # line-comment token, while Scilab uses "//". So "//" comments - # won't be styled as comments, and JupyterLab's "Toggle Comment" - # (Ctrl+/) will insert "%" on a Scilab cell, which is invalid - # Scilab syntax and must be fixed up by hand. A correct fix needs a - # dedicated Scilab CodeMirror language mode (a companion JupyterLab - # extension); this is a stopgap that gets most other tokens - # (strings, numbers, operators, keywords) highlighted correctly. - "mimetype": "text/x-octave", - "codemirror_mode": "octave", + # codemirror_mode is set explicitly (rather than relying on + # JupyterLab falling back to matching language_info.name against + # its registry) because that fallback goes through a codepath with + # a key-name mismatch bug in JupyterLab 4.6's + # getMimeTypeByLanguage() for the *mimetype* half of the fallback + # (mimetype vs mime, ext vs extensions) -- harmless here only + # because the *name* half happens to match first, but not + # something to depend on implicitly. + "mimetype": "text/x-scilab", + "codemirror_mode": "scilab", "version": __version__, 'help_links': MetaKernel.help_links, } From 1c727dbf350cfe201725700a6b1a6c7b7124471a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Mottelet?= Date: Thu, 24 Sep 2026 09:33:02 +0200 Subject: [PATCH 03/10] Fix pre-existing ruff findings blocking CI Not caused by this PR -- confirmed by cloning master fresh and running ruff there directly: it already fails with the same 39 errors. CI runs ruff over the whole repo rather than just the diff, so PR #58 inherited an already-broken check. Fixing it here since that's what's actually blocking this PR's CI. - I001/UP008/UP010/UP021/W605: mechanical (ruff --fix), reviewed -- import sorting, super() without arguments, dropped the unneeded py2 __future__ import, text=True instead of universal_newlines=True, and raw-string prefixes for the two genuinely-intended backslash literals. - UP031 (percent formatting): converted to f-strings throughout. - PLW1510: subprocess.run() for the macOS bundle-detection mdfind call now passes check=False explicitly, preserving the existing behavior (a non-zero/empty result is already handled by the `len(bundles) > 0` check right after). - TRY201: `raise e` -> `raise` in extract_figures' re-raise path (e is still used in the error_handler() branch, so the `as e` binding stays; only the bare re-raise changes, preserving the original traceback). - BLE001/S110 in _handle_svg: narrowed from bare `except Exception: pass` to `except (ValueError, ExpatError)`, since minidom parsing and the int()/unpacking calls right after are the only things that realistically fail here (GnuPlot producing an SVG shape the code doesn't expect), logged via self.log.debug instead of silently swallowed. - BLE001 in check.py: kept as a blind `except Exception`, with a justifying noqa -- it's a standalone diagnostic script whose whole purpose is to report *any* connection failure instead of crashing with a raw traceback. - RUF012: annotated the three intentionally-shared class-level dicts/ lists (language_info, and jupyter_kernel_test's own code_display_data/completion_samples convention) as typing.ClassVar. Verified: `ruff check .` passes clean, all files still py_compile, and a live kernel smoke-tested end to end (disp, %plot producing a real PNG display_data through the edited figure_size/axes_size f-strings, correct language_info). Co-Authored-By: Claude Sonnet 5 --- scilab_kernel/check.py | 13 +++--- scilab_kernel/kernel.py | 63 ++++++++++++++++-------------- scilab_kernel/magics/plot_magic.py | 2 + test_scilab_kernel.py | 6 ++- 4 files changed, 46 insertions(+), 38 deletions(-) diff --git a/scilab_kernel/check.py b/scilab_kernel/check.py index d01362e..798bde5 100644 --- a/scilab_kernel/check.py +++ b/scilab_kernel/check.py @@ -1,18 +1,19 @@ import sys + from metakernel import __version__ as mversion + from . import __version__ from .kernel import ScilabKernel - if __name__ == "__main__": - print('Scilab kernel v%s' % __version__) - print('Metakernel v%s' % mversion) - print('Python v%s' % sys.version) - print('Python path: %s' % sys.executable) + print(f'Scilab kernel v{__version__}') + print(f'Metakernel v{mversion}') + print(f'Python v{sys.version}') + print(f'Python path: {sys.executable}') print('\nConnecting to Scilab...') try: s = ScilabKernel() print('Scilab connection established') print(s.banner) - except Exception as e: + except Exception as e: # noqa: BLE001 -- diagnostic script: report any failure, don't crash with a raw traceback print(e) diff --git a/scilab_kernel/kernel.py b/scilab_kernel/kernel.py index 48e44ae..a802c2b 100644 --- a/scilab_kernel/kernel.py +++ b/scilab_kernel/kernel.py @@ -1,22 +1,24 @@ -from __future__ import print_function, absolute_import import codecs +import importlib import json import os +import platform import re import shutil +import subprocess import sys -import platform import tempfile -import importlib -import subprocess +from typing import ClassVar + if importlib.util.find_spec('winreg'): import winreg from xml.dom import minidom +from xml.parsers.expat import ExpatError +from IPython.display import SVG, Image from metakernel import MetaKernel, ProcessMetaKernel, REPLWrapper, pexpect from metakernel.pexpect import which -from IPython.display import Image, SVG from . import __version__ @@ -37,7 +39,7 @@ class ScilabKernel(ProcessMetaKernel): implementation_version = __version__, language = 'scilab' language_version = __version__, - language_info = { + language_info: ClassVar[dict] = { 'name': 'scilab', 'file_extension': '.sci', # JupyterLab's CodeMirror editor has no built-in "scilab" language @@ -124,7 +126,7 @@ def _detect_executable(self): # read the windows registry if os.name == 'nt': try: - with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, "Scilab5.sce\shell\open\command") as key: + with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"Scilab5.sce\shell\open\command") as key: cmd : str = winreg.EnumValue(key, 0)[1] executable = cmd.split(r'"')[1].replace("wscilex.exe", "wscilex-cli.exe") self.log.warning('Windows registry binary: ' + executable) @@ -134,9 +136,9 @@ def _detect_executable(self): # detect macOS bundle if platform.system() == 'Darwin': - process = subprocess.run(['mdfind', '-onlyin', '/Applications', 'kMDItemCFBundleIdentifier=org.scilab.modules.jvm.Scilab'], - stdout=subprocess.PIPE, - universal_newlines=True) + process = subprocess.run(['mdfind', '-onlyin', '/Applications', 'kMDItemCFBundleIdentifier=org.scilab.modules.jvm.Scilab'], + stdout=subprocess.PIPE, + text=True, check=False) bundles = process.stdout if len(bundles) > 0: executable = bundles.split('\n', 1)[0] + "/Contents/bin/scilab-adv-cli" @@ -163,7 +165,7 @@ def makeWrapper(self): orig_prompt = r'-[0-9]*->' prompt_cmd = None change_prompt = None - continuation_prompt = ' \>' + continuation_prompt = r' \>' self._first = True if os.name == 'nt': prompt_cmd = 'printf("-->")' @@ -184,7 +186,7 @@ def makeWrapper(self): def Write(self, message): clean_msg = message.strip("\n\r\t") - super(ScilabKernel, self).Write(clean_msg) + super().Write(clean_msg) def Print(self, text): text = str(text).strip('\x1b[0m').replace('\u0008', '').strip() @@ -192,7 +194,7 @@ def Print(self, text): if (not line.startswith(chr(27)))] text = '\n'.join(text) if text: - super(ScilabKernel, self).Print(text) + super().Print(text) def do_execute_direct(self, code, silent=False): if self._first: @@ -200,7 +202,7 @@ def do_execute_direct(self, code, silent=False): self.handle_plot_settings() setup = self._setup.strip() self.do_execute_direct(setup, True) - resp = super(ScilabKernel, self).do_execute_direct(code, silent=silent) + resp = super().do_execute_direct(code, silent=silent) if silent: return resp if self.plot_settings.get('backend', None) == 'inline': @@ -216,17 +218,18 @@ def get_kernel_help_on(self, info, level=0, none_on_fail=False): return None else: return "" - self.do_execute_direct('help %s' % obj, True) + self.do_execute_direct(f'help {obj}', True) def do_shutdown(self, restart): self.wrapper.sendline('quit') - super(ScilabKernel, self).do_shutdown(restart) + super().do_shutdown(restart) def get_completions(self, info): """ Get completions from kernel based on info dict. """ - cmd = 'completion("%s")' % info['obj'] + obj = info['obj'] + cmd = f'completion("{obj}")' output = self.do_execute_direct(cmd, True) if not output: return [] @@ -256,17 +259,17 @@ def handle_plot_settings(self): try: width, height = settings['size'].split(',') width, height = int(width), int(height) - except Exception as e: - self.Error('Error setting plot settings: %s' % e) + except (ValueError, AttributeError) as e: + self.Error(f'Error setting plot settings: {e}') - cmds.append('h.figure_size = [%s,%s];' % (width, height)) - cmds.append('h.axes_size = [%s * 0.98, %s * 0.8];' % (width, height)) + cmds.append(f'h.figure_size = [{width},{height}];') + cmds.append(f'h.axes_size = [{width} * 0.98, {height} * 0.8];') if settings['backend'] == 'inline': cmds.append('h.visible = "off";') else: cmds.append('h.visible = "on";') - super(ScilabKernel, self).do_execute_direct('\n'.join(cmds), True) + super().do_execute_direct('\n'.join(cmds), True) def make_figures(self, plot_dir=None): """Create figures for the current figures. @@ -285,7 +288,7 @@ def make_figures(self, plot_dir=None): plot_format = self._plot_fmt.lower() make_figs = '_make_figures("%s", "%s");' make_figs = make_figs % (plot_dir, plot_format) - super(ScilabKernel, self).do_execute_direct(make_figs, True) + super().do_execute_direct(make_figs, True) return plot_dir def extract_figures(self, plot_dir): @@ -309,7 +312,7 @@ def extract_figures(self, plot_dir): if self.error_handler: self.error_handler(e) else: - raise e + raise return images def _handle_svg(self, filename): @@ -323,14 +326,14 @@ def _handle_svg(self, filename): im = SVG(data=data) try: im.data = self._fix_svg_size(im.data) - except Exception: - pass + except (ValueError, ExpatError) as e: + self.log.debug(f'Could not resize SVG (unexpected shape from GnuPlot?): {e}') try: settings = self.plot_settings if settings['antialiasing']: im.data = self._fix_svg_antialiasing(im.data) - except Exception: - pass + except (ValueError, ExpatError) as e: + self.log.debug(f'Could not adjust SVG antialiasing (unexpected shape from GnuPlot?): {e}') return im def _fix_svg_size(self, data): @@ -358,8 +361,8 @@ def _fix_svg_size(self, data): width = width * settings['height'] / height height = settings['height'] - svg.setAttribute('width', '%dpx' % width) - svg.setAttribute('height', '%dpx' % height) + svg.setAttribute('width', f'{int(width)}px') + svg.setAttribute('height', f'{int(height)}px') return svg.toxml() def _fix_svg_antialiasing(self, data): diff --git a/scilab_kernel/magics/plot_magic.py b/scilab_kernel/magics/plot_magic.py index 3019afd..7bedef9 100644 --- a/scilab_kernel/magics/plot_magic.py +++ b/scilab_kernel/magics/plot_magic.py @@ -1,4 +1,6 @@ from metakernel import Magic, option + + class ScilabPlotMagic(Magic): @option( diff --git a/test_scilab_kernel.py b/test_scilab_kernel.py index 7747ad8..26338d7 100644 --- a/test_scilab_kernel.py +++ b/test_scilab_kernel.py @@ -1,6 +1,8 @@ """Example use of jupyter_kernel_test, with tests for IPython.""" import unittest +from typing import ClassVar + import jupyter_kernel_test as jkt @@ -11,12 +13,12 @@ class ScilabKernelTests(jkt.KernelTests): code_hello_world = "disp('hello, world')" - code_display_data = [ + code_display_data: ClassVar[list] = [ {'code': '%plot -f png\nplot([1,2,3])', 'mime': 'image/png'}, {'code': '%plot -f svg\nplot([1,2,3])', 'mime': 'image/svg+xml'} ] - completion_samples = [ + completion_samples: ClassVar[list] = [ { 'text': 'one', 'matches': {'ones'}, From c9f4c1d972133bd7a937efd53d3aa80203ab9bc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Mottelet?= Date: Thu, 24 Sep 2026 10:16:16 +0200 Subject: [PATCH 04/10] Fix tab completion: quoted output broke matches, plus ruff cleanup Tab completion ran completion("obj") directly and parsed Scilab's own console echo of the result. That echo used to be an unquoted, bracketed format Scilab dropped at least ~2 years ago in favor of displaying string arrays quoted (`"a" "b" ...`), which the parsing here was never updated for -- so every completion candidate now comes back wrapped in literal double quotes. Fixed by using printf("%s\n", completion(...)) instead, which prints each match bare, one per line, sidestepping Scilab's own display formatting entirely (and the quoting behind whatever future formatting changes might come). Two things needed care: - printf errors out on an empty array, so it's now only called when completion() isn't empty (checked without assigning the result to a variable -- get_completions() runs in the same persistent Scilab session as the user's own code, so nothing here should risk shadowing a variable of theirs). - Scilab's terminal control codes end up glued to the front of the *first* line of any console output. With the old quoted/bracketed format this only ever landed on a line that got filtered out by coincidence; with printf's plain output it could leak into a real match (reproduced with a single-match completion, e.g. "getscilabkeyword" -> "\x1b[4l \x08\x1b[0mgetscilabkeywords" instead of "getscilabkeywords"). Now stripped via a small regex before parsing. Verified against a live kernel: multi-match, single-match, and no-match completions all return clean results, and `who_user()` confirms nothing gets left behind in the session. Also includes the same pre-existing ruff cleanup as PR #58 (this branch was cut from master, which still has it) -- see that PR's commit message for the itemized list; same fixes, same reasoning. Co-Authored-By: Claude Sonnet 5 --- scilab_kernel/check.py | 13 ++--- scilab_kernel/kernel.py | 82 ++++++++++++++++++------------ scilab_kernel/magics/plot_magic.py | 2 + test_scilab_kernel.py | 6 ++- 4 files changed, 63 insertions(+), 40 deletions(-) diff --git a/scilab_kernel/check.py b/scilab_kernel/check.py index d01362e..798bde5 100644 --- a/scilab_kernel/check.py +++ b/scilab_kernel/check.py @@ -1,18 +1,19 @@ import sys + from metakernel import __version__ as mversion + from . import __version__ from .kernel import ScilabKernel - if __name__ == "__main__": - print('Scilab kernel v%s' % __version__) - print('Metakernel v%s' % mversion) - print('Python v%s' % sys.version) - print('Python path: %s' % sys.executable) + print(f'Scilab kernel v{__version__}') + print(f'Metakernel v{mversion}') + print(f'Python v{sys.version}') + print(f'Python path: {sys.executable}') print('\nConnecting to Scilab...') try: s = ScilabKernel() print('Scilab connection established') print(s.banner) - except Exception as e: + except Exception as e: # noqa: BLE001 -- diagnostic script: report any failure, don't crash with a raw traceback print(e) diff --git a/scilab_kernel/kernel.py b/scilab_kernel/kernel.py index c07315b..4fdcc66 100644 --- a/scilab_kernel/kernel.py +++ b/scilab_kernel/kernel.py @@ -1,25 +1,32 @@ -from __future__ import print_function, absolute_import import codecs +import importlib import json import os +import platform import re import shutil +import subprocess import sys -import platform import tempfile -import importlib -import subprocess +from typing import ClassVar + if importlib.util.find_spec('winreg'): import winreg from xml.dom import minidom +from xml.parsers.expat import ExpatError +from IPython.display import SVG, Image from metakernel import MetaKernel, ProcessMetaKernel, REPLWrapper, pexpect from metakernel.pexpect import which -from IPython.display import Image, SVG from . import __version__ +# Scilab's terminal control codes (mode/cursor resets, backspace) end up +# glued to the front of the *first* line of any console output -- used to +# strip those out of completion results, see get_completions() below. +_ANSI_ESCAPE_RE = re.compile(r'\x1b\[[0-9;]*[A-Za-z]|\x08') + def get_kernel_json(): """Get the kernel json for the kernel. @@ -37,7 +44,7 @@ class ScilabKernel(ProcessMetaKernel): implementation_version = __version__, language = 'scilab' language_version = __version__, - language_info = { + language_info: ClassVar[dict] = { 'name': 'scilab', 'file_extension': '.sci', "mimetype": "text/x-scilab", @@ -106,7 +113,7 @@ def _detect_executable(self): # read the windows registry if os.name == 'nt': try: - with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, "Scilab5.sce\shell\open\command") as key: + with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"Scilab5.sce\shell\open\command") as key: cmd : str = winreg.EnumValue(key, 0)[1] executable = cmd.split(r'"')[1].replace("wscilex.exe", "wscilex-cli.exe") self.log.warning('Windows registry binary: ' + executable) @@ -116,9 +123,9 @@ def _detect_executable(self): # detect macOS bundle if platform.system() == 'Darwin': - process = subprocess.run(['mdfind', '-onlyin', '/Applications', 'kMDItemCFBundleIdentifier=org.scilab.modules.jvm.Scilab'], - stdout=subprocess.PIPE, - universal_newlines=True) + process = subprocess.run(['mdfind', '-onlyin', '/Applications', 'kMDItemCFBundleIdentifier=org.scilab.modules.jvm.Scilab'], + stdout=subprocess.PIPE, + text=True, check=False) bundles = process.stdout if len(bundles) > 0: executable = bundles.split('\n', 1)[0] + "/Contents/bin/scilab-adv-cli" @@ -145,7 +152,7 @@ def makeWrapper(self): orig_prompt = r'-[0-9]*->' prompt_cmd = None change_prompt = None - continuation_prompt = ' \>' + continuation_prompt = r' \>' self._first = True if os.name == 'nt': prompt_cmd = 'printf("-->")' @@ -166,7 +173,7 @@ def makeWrapper(self): def Write(self, message): clean_msg = message.strip("\n\r\t") - super(ScilabKernel, self).Write(clean_msg) + super().Write(clean_msg) def Print(self, text): text = str(text).strip('\x1b[0m').replace('\u0008', '').strip() @@ -174,7 +181,7 @@ def Print(self, text): if (not line.startswith(chr(27)))] text = '\n'.join(text) if text: - super(ScilabKernel, self).Print(text) + super().Print(text) def do_execute_direct(self, code, silent=False): if self._first: @@ -182,7 +189,7 @@ def do_execute_direct(self, code, silent=False): self.handle_plot_settings() setup = self._setup.strip() self.do_execute_direct(setup, True) - resp = super(ScilabKernel, self).do_execute_direct(code, silent=silent) + resp = super().do_execute_direct(code, silent=silent) if silent: return resp if self.plot_settings.get('backend', None) == 'inline': @@ -198,22 +205,33 @@ def get_kernel_help_on(self, info, level=0, none_on_fail=False): return None else: return "" - self.do_execute_direct('help %s' % obj, True) + self.do_execute_direct(f'help {obj}', True) def do_shutdown(self, restart): self.wrapper.sendline('quit') - super(ScilabKernel, self).do_shutdown(restart) + super().do_shutdown(restart) def get_completions(self, info): """ Get completions from kernel based on info dict. """ - cmd = 'completion("%s")' % info['obj'] + obj = info['obj'] + # completion() displays its result on Scilab's own console, quoted + # (Scilab now shows string arrays as `"a" "b" ...`), which broke + # the parsing below; printf("%s\n", ...) instead prints each match + # bare, one per line. Only printf when there is at least one match: + # it errors out on an empty array. Both calls run in the same + # persistent Scilab session as the user's own code, so nothing here + # is assigned to a variable that could shadow one of theirs. + cmd = ( + f'if ~isempty(completion("{obj}")) then ' + f'printf("%s\\n",completion("{obj}")); end' + ) output = self.do_execute_direct(cmd, True) if not output: return [] - output = output.output.replace('!', '') - return [line.strip() for line in output.splitlines() + text = _ANSI_ESCAPE_RE.sub('', output.output) + return [line.strip() for line in text.splitlines() if info['obj'] in line] def handle_plot_settings(self): @@ -238,17 +256,17 @@ def handle_plot_settings(self): try: width, height = settings['size'].split(',') width, height = int(width), int(height) - except Exception as e: - self.Error('Error setting plot settings: %s' % e) + except (ValueError, AttributeError) as e: + self.Error(f'Error setting plot settings: {e}') - cmds.append('h.figure_size = [%s,%s];' % (width, height)) - cmds.append('h.axes_size = [%s * 0.98, %s * 0.8];' % (width, height)) + cmds.append(f'h.figure_size = [{width},{height}];') + cmds.append(f'h.axes_size = [{width} * 0.98, {height} * 0.8];') if settings['backend'] == 'inline': cmds.append('h.visible = "off";') else: cmds.append('h.visible = "on";') - super(ScilabKernel, self).do_execute_direct('\n'.join(cmds), True) + super().do_execute_direct('\n'.join(cmds), True) def make_figures(self, plot_dir=None): """Create figures for the current figures. @@ -267,7 +285,7 @@ def make_figures(self, plot_dir=None): plot_format = self._plot_fmt.lower() make_figs = '_make_figures("%s", "%s");' make_figs = make_figs % (plot_dir, plot_format) - super(ScilabKernel, self).do_execute_direct(make_figs, True) + super().do_execute_direct(make_figs, True) return plot_dir def extract_figures(self, plot_dir): @@ -291,7 +309,7 @@ def extract_figures(self, plot_dir): if self.error_handler: self.error_handler(e) else: - raise e + raise return images def _handle_svg(self, filename): @@ -305,14 +323,14 @@ def _handle_svg(self, filename): im = SVG(data=data) try: im.data = self._fix_svg_size(im.data) - except Exception: - pass + except (ValueError, ExpatError) as e: + self.log.debug(f'Could not resize SVG (unexpected shape from GnuPlot?): {e}') try: settings = self.plot_settings if settings['antialiasing']: im.data = self._fix_svg_antialiasing(im.data) - except Exception: - pass + except (ValueError, ExpatError) as e: + self.log.debug(f'Could not adjust SVG antialiasing (unexpected shape from GnuPlot?): {e}') return im def _fix_svg_size(self, data): @@ -340,8 +358,8 @@ def _fix_svg_size(self, data): width = width * settings['height'] / height height = settings['height'] - svg.setAttribute('width', '%dpx' % width) - svg.setAttribute('height', '%dpx' % height) + svg.setAttribute('width', f'{int(width)}px') + svg.setAttribute('height', f'{int(height)}px') return svg.toxml() def _fix_svg_antialiasing(self, data): diff --git a/scilab_kernel/magics/plot_magic.py b/scilab_kernel/magics/plot_magic.py index 3019afd..7bedef9 100644 --- a/scilab_kernel/magics/plot_magic.py +++ b/scilab_kernel/magics/plot_magic.py @@ -1,4 +1,6 @@ from metakernel import Magic, option + + class ScilabPlotMagic(Magic): @option( diff --git a/test_scilab_kernel.py b/test_scilab_kernel.py index 7747ad8..26338d7 100644 --- a/test_scilab_kernel.py +++ b/test_scilab_kernel.py @@ -1,6 +1,8 @@ """Example use of jupyter_kernel_test, with tests for IPython.""" import unittest +from typing import ClassVar + import jupyter_kernel_test as jkt @@ -11,12 +13,12 @@ class ScilabKernelTests(jkt.KernelTests): code_hello_world = "disp('hello, world')" - code_display_data = [ + code_display_data: ClassVar[list] = [ {'code': '%plot -f png\nplot([1,2,3])', 'mime': 'image/png'}, {'code': '%plot -f svg\nplot([1,2,3])', 'mime': 'image/svg+xml'} ] - completion_samples = [ + completion_samples: ClassVar[list] = [ { 'text': 'one', 'matches': {'ones'}, From 64e856a48ceed8ab478e7404686400d796a266d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Mottelet?= Date: Thu, 24 Sep 2026 10:42:05 +0200 Subject: [PATCH 05/10] Fix ~30s hang on unclosed blocks (function/if/for/... with no end) Running a cell that leaves a block unclosed -- function x=f(y) with no endfunction, an if/for/while/select/try with no matching end -- left Scilab sitting at its continuation prompt waiting for the rest of the block, and the kernel just hung. metakernel's REPLWrapper does detect this (fast, well under a second) and has a built-in recovery: send Ctrl-C, wait for a normal prompt, then raise a clean, catchable ValueError. But scilab-adv-cli does not respond to SIGINT while waiting for more input, so that recovery wait always ran out its full budget (pexpect's default of 30s) before giving up -- so every incomplete cell cost a good 30 seconds before showing a generic "Timed out" error, though the session did eventually recover on its own (confirmed: the next cell executes normally afterward). Fixed with a small REPLWrapper subclass (_ScilabREPLWrapper) that overrides just the continuation-prompt recovery step. A bare "end" reliably closes any Scilab block type (function/if/for/while/...) and returns to the top-level prompt in well under a second -- verified directly against a live Scilab process for each block type. Nested unclosed blocks (e.g. an unclosed if containing an unclosed for) need one "end" per level, so it's sent repeatedly (capped at 50 attempts) until a normal prompt reappears, matching what a user closing the blocks by hand would have to type anyway. Everything else about metakernel's flow is unchanged -- the "soft continuation" empty-line retry, the eventual ValueError, and its message ("Continuation prompt found - input was incomplete:\n") are all metakernel's own, already well-suited to surfacing a clear error to the user; this only fixes the part that was actually broken for Scilab specifically. Verified against a live kernel: unclosed function/if/for now each report the error in ~0.2-0.3s (down from 30s+), a nested unclosed if+for needs and gets two "end"s and also recovers in ~0.3s, normal complete multi-line blocks (if/end, function/endfunction, for/end) are unaffected and execute correctly, and the session remains fully functional for subsequent cells in every case. Also includes the same pre-existing ruff cleanup as #58/#59 (this branch was cut from master, which still has that debt). Co-Authored-By: Claude Sonnet 5 --- scilab_kernel/check.py | 13 ++-- scilab_kernel/kernel.py | 95 ++++++++++++++++++++---------- scilab_kernel/magics/plot_magic.py | 2 + test_scilab_kernel.py | 6 +- 4 files changed, 77 insertions(+), 39 deletions(-) diff --git a/scilab_kernel/check.py b/scilab_kernel/check.py index d01362e..798bde5 100644 --- a/scilab_kernel/check.py +++ b/scilab_kernel/check.py @@ -1,18 +1,19 @@ import sys + from metakernel import __version__ as mversion + from . import __version__ from .kernel import ScilabKernel - if __name__ == "__main__": - print('Scilab kernel v%s' % __version__) - print('Metakernel v%s' % mversion) - print('Python v%s' % sys.version) - print('Python path: %s' % sys.executable) + print(f'Scilab kernel v{__version__}') + print(f'Metakernel v{mversion}') + print(f'Python v{sys.version}') + print(f'Python path: {sys.executable}') print('\nConnecting to Scilab...') try: s = ScilabKernel() print('Scilab connection established') print(s.banner) - except Exception as e: + except Exception as e: # noqa: BLE001 -- diagnostic script: report any failure, don't crash with a raw traceback print(e) diff --git a/scilab_kernel/kernel.py b/scilab_kernel/kernel.py index c07315b..b2f022a 100644 --- a/scilab_kernel/kernel.py +++ b/scilab_kernel/kernel.py @@ -1,26 +1,58 @@ -from __future__ import print_function, absolute_import import codecs +import importlib import json import os +import platform import re import shutil +import subprocess import sys -import platform import tempfile -import importlib -import subprocess +from typing import ClassVar + if importlib.util.find_spec('winreg'): import winreg from xml.dom import minidom +from xml.parsers.expat import ExpatError +from IPython.display import SVG, Image from metakernel import MetaKernel, ProcessMetaKernel, REPLWrapper, pexpect from metakernel.pexpect import which -from IPython.display import Image, SVG from . import __version__ +class _ScilabREPLWrapper(REPLWrapper): + """A :class:`REPLWrapper` that knows how to escape Scilab's continuation + prompt. + + When a cell leaves a block unclosed (``function x=f(y)`` with no + ``endfunction``, an ``if``/``for``/``while``/... with no matching + ``end``, ...), Scilab drops into a continuation prompt waiting for the + rest of the block. REPLWrapper's own recovery for this sends Ctrl-C and + waits (up to 30s) for a normal prompt to come back -- but + ``scilab-adv-cli`` does not respond to SIGINT while waiting for more + input, so that 30s is always spent in full, and the user just sees a + generic "Timed out" error after a long pause. + + A bare "end" closes any Scilab block type and returns to the top-level + prompt in well under a second; nested unclosed blocks need one "end" + per level, so it is sent repeatedly until a normal prompt reappears. + """ + + _MAX_END_ATTEMPTS = 50 + + def interrupt(self, continuation=False): + if not continuation: + return super().interrupt(continuation=continuation) + for _ in range(self._MAX_END_ATTEMPTS): + self.sendline("end") + if self._expect_prompt(timeout=-1) == 0: + break + return self.child.before + + def get_kernel_json(): """Get the kernel json for the kernel. """ @@ -37,7 +69,7 @@ class ScilabKernel(ProcessMetaKernel): implementation_version = __version__, language = 'scilab' language_version = __version__, - language_info = { + language_info: ClassVar[dict] = { 'name': 'scilab', 'file_extension': '.sci', "mimetype": "text/x-scilab", @@ -106,7 +138,7 @@ def _detect_executable(self): # read the windows registry if os.name == 'nt': try: - with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, "Scilab5.sce\shell\open\command") as key: + with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"Scilab5.sce\shell\open\command") as key: cmd : str = winreg.EnumValue(key, 0)[1] executable = cmd.split(r'"')[1].replace("wscilex.exe", "wscilex-cli.exe") self.log.warning('Windows registry binary: ' + executable) @@ -116,9 +148,9 @@ def _detect_executable(self): # detect macOS bundle if platform.system() == 'Darwin': - process = subprocess.run(['mdfind', '-onlyin', '/Applications', 'kMDItemCFBundleIdentifier=org.scilab.modules.jvm.Scilab'], - stdout=subprocess.PIPE, - universal_newlines=True) + process = subprocess.run(['mdfind', '-onlyin', '/Applications', 'kMDItemCFBundleIdentifier=org.scilab.modules.jvm.Scilab'], + stdout=subprocess.PIPE, + text=True, check=False) bundles = process.stdout if len(bundles) > 0: executable = bundles.split('\n', 1)[0] + "/Contents/bin/scilab-adv-cli" @@ -145,7 +177,7 @@ def makeWrapper(self): orig_prompt = r'-[0-9]*->' prompt_cmd = None change_prompt = None - continuation_prompt = ' \>' + continuation_prompt = r' \>' self._first = True if os.name == 'nt': prompt_cmd = 'printf("-->")' @@ -157,7 +189,7 @@ def makeWrapper(self): echo=echo, codec_errors="ignore", encoding="utf-8") - wrapper = REPLWrapper(child, orig_prompt, change_prompt, + wrapper = _ScilabREPLWrapper(child, orig_prompt, change_prompt, prompt_emit_cmd=prompt_cmd, echo=echo, continuation_prompt_regex=continuation_prompt) @@ -166,7 +198,7 @@ def makeWrapper(self): def Write(self, message): clean_msg = message.strip("\n\r\t") - super(ScilabKernel, self).Write(clean_msg) + super().Write(clean_msg) def Print(self, text): text = str(text).strip('\x1b[0m').replace('\u0008', '').strip() @@ -174,7 +206,7 @@ def Print(self, text): if (not line.startswith(chr(27)))] text = '\n'.join(text) if text: - super(ScilabKernel, self).Print(text) + super().Print(text) def do_execute_direct(self, code, silent=False): if self._first: @@ -182,7 +214,7 @@ def do_execute_direct(self, code, silent=False): self.handle_plot_settings() setup = self._setup.strip() self.do_execute_direct(setup, True) - resp = super(ScilabKernel, self).do_execute_direct(code, silent=silent) + resp = super().do_execute_direct(code, silent=silent) if silent: return resp if self.plot_settings.get('backend', None) == 'inline': @@ -198,17 +230,18 @@ def get_kernel_help_on(self, info, level=0, none_on_fail=False): return None else: return "" - self.do_execute_direct('help %s' % obj, True) + self.do_execute_direct(f'help {obj}', True) def do_shutdown(self, restart): self.wrapper.sendline('quit') - super(ScilabKernel, self).do_shutdown(restart) + super().do_shutdown(restart) def get_completions(self, info): """ Get completions from kernel based on info dict. """ - cmd = 'completion("%s")' % info['obj'] + obj = info['obj'] + cmd = f'completion("{obj}")' output = self.do_execute_direct(cmd, True) if not output: return [] @@ -238,17 +271,17 @@ def handle_plot_settings(self): try: width, height = settings['size'].split(',') width, height = int(width), int(height) - except Exception as e: - self.Error('Error setting plot settings: %s' % e) + except (ValueError, AttributeError) as e: + self.Error(f'Error setting plot settings: {e}') - cmds.append('h.figure_size = [%s,%s];' % (width, height)) - cmds.append('h.axes_size = [%s * 0.98, %s * 0.8];' % (width, height)) + cmds.append(f'h.figure_size = [{width},{height}];') + cmds.append(f'h.axes_size = [{width} * 0.98, {height} * 0.8];') if settings['backend'] == 'inline': cmds.append('h.visible = "off";') else: cmds.append('h.visible = "on";') - super(ScilabKernel, self).do_execute_direct('\n'.join(cmds), True) + super().do_execute_direct('\n'.join(cmds), True) def make_figures(self, plot_dir=None): """Create figures for the current figures. @@ -267,7 +300,7 @@ def make_figures(self, plot_dir=None): plot_format = self._plot_fmt.lower() make_figs = '_make_figures("%s", "%s");' make_figs = make_figs % (plot_dir, plot_format) - super(ScilabKernel, self).do_execute_direct(make_figs, True) + super().do_execute_direct(make_figs, True) return plot_dir def extract_figures(self, plot_dir): @@ -291,7 +324,7 @@ def extract_figures(self, plot_dir): if self.error_handler: self.error_handler(e) else: - raise e + raise return images def _handle_svg(self, filename): @@ -305,14 +338,14 @@ def _handle_svg(self, filename): im = SVG(data=data) try: im.data = self._fix_svg_size(im.data) - except Exception: - pass + except (ValueError, ExpatError) as e: + self.log.debug(f'Could not resize SVG (unexpected shape from GnuPlot?): {e}') try: settings = self.plot_settings if settings['antialiasing']: im.data = self._fix_svg_antialiasing(im.data) - except Exception: - pass + except (ValueError, ExpatError) as e: + self.log.debug(f'Could not adjust SVG antialiasing (unexpected shape from GnuPlot?): {e}') return im def _fix_svg_size(self, data): @@ -340,8 +373,8 @@ def _fix_svg_size(self, data): width = width * settings['height'] / height height = settings['height'] - svg.setAttribute('width', '%dpx' % width) - svg.setAttribute('height', '%dpx' % height) + svg.setAttribute('width', f'{int(width)}px') + svg.setAttribute('height', f'{int(height)}px') return svg.toxml() def _fix_svg_antialiasing(self, data): diff --git a/scilab_kernel/magics/plot_magic.py b/scilab_kernel/magics/plot_magic.py index 3019afd..7bedef9 100644 --- a/scilab_kernel/magics/plot_magic.py +++ b/scilab_kernel/magics/plot_magic.py @@ -1,4 +1,6 @@ from metakernel import Magic, option + + class ScilabPlotMagic(Magic): @option( diff --git a/test_scilab_kernel.py b/test_scilab_kernel.py index 7747ad8..26338d7 100644 --- a/test_scilab_kernel.py +++ b/test_scilab_kernel.py @@ -1,6 +1,8 @@ """Example use of jupyter_kernel_test, with tests for IPython.""" import unittest +from typing import ClassVar + import jupyter_kernel_test as jkt @@ -11,12 +13,12 @@ class ScilabKernelTests(jkt.KernelTests): code_hello_world = "disp('hello, world')" - code_display_data = [ + code_display_data: ClassVar[list] = [ {'code': '%plot -f png\nplot([1,2,3])', 'mime': 'image/png'}, {'code': '%plot -f svg\nplot([1,2,3])', 'mime': 'image/svg+xml'} ] - completion_samples = [ + completion_samples: ClassVar[list] = [ { 'text': 'one', 'matches': {'ones'}, From d24bda150293e06de5add2d9907765e0edcf61fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Mottelet?= Date: Thu, 24 Sep 2026 11:32:24 +0200 Subject: [PATCH 06/10] Fix huge completion menu right after operators (+, -, ...) metakernel's do_complete() calls get_completions() unconditionally, even when the cursor sits right after a non-identifier character (e.g. "+", "-") and there is no partial word to complete -- info['obj'] is "" in that case. get_completions() didn't guard against that, so it ran completion(""), which matches every name Scilab knows (thousands), and the substring filter `if info['obj'] in line` let all of them through too (every string contains "" as a substring) -- a huge, useless completion menu on a bare Tab after an operator. Verified against a live kernel: "+" and "-" now return zero matches; normal completions ("plo", "sin", single/no-match cases) are unaffected. Co-Authored-By: Claude Sonnet 5 --- scilab_kernel/kernel.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scilab_kernel/kernel.py b/scilab_kernel/kernel.py index 3adf5f1..96f84c0 100644 --- a/scilab_kernel/kernel.py +++ b/scilab_kernel/kernel.py @@ -264,6 +264,14 @@ def get_completions(self, info): Get completions from kernel based on info dict. """ obj = info['obj'] + if not obj: + # metakernel's do_complete() calls get_completions() even when + # the cursor sits right after a non-identifier character (e.g. + # "+", "-", "/") and there is no partial word to complete -- + # info['obj'] is "" in that case. completion("") matches every + # name Scilab knows (thousands), which isn't a useful + # completion menu, so there is nothing to offer here. + return [] # completion() displays its result on Scilab's own console, quoted # (Scilab now shows string arrays as `"a" "b" ...`), which broke # the parsing below; printf("%s\n", ...) instead prints each match From 3fd21c0cb98ff41c0481fd86cea68e47bfe6ed2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Mottelet?= Date: Thu, 24 Sep 2026 11:40:22 +0200 Subject: [PATCH 07/10] Fix spurious word-from-buffer completions (e.g. after "plot(1:10,") metakernel's parser sets cursor_start to 0 (start of the whole buffer) whenever there is no partial identifier at the cursor -- regardless of whether any completions were actually found. After "plot(1:10,", that gives cursor_start=0, cursor_end=10 with zero matches from Scilab. With no matches that wide range should be irrelevant to a kernel-only completer, but JupyterLab also runs a generic word-from-document completer alongside the kernel one, and hands it a range that happens to span real words ("plot", "1", "10") already on the line -- so it offers them as if they were completions, even though nothing was actually being completed. Fixed by collapsing cursor_start to cursor_end (a zero-width point at the cursor) whenever get_completions() finds nothing, so no wide buffer range is ever reported for JupyterLab's other completer to act on. Normal completions are unaffected -- their cursor_start still reflects the real start of the partial word being completed. Verified against a live kernel: "plot(1:10," and "plot(" both now report matches=0 with cursor_start == cursor_end (previously cursor_start=0); "+"/"-" unaffected (already 0 matches, now also correctly zero-width); "plo"/"sin" completions unaffected (still return their normal candidate list with the correct word-start range). Co-Authored-By: Claude Sonnet 5 --- scilab_kernel/kernel.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/scilab_kernel/kernel.py b/scilab_kernel/kernel.py index 96f84c0..1dc3cc2 100644 --- a/scilab_kernel/kernel.py +++ b/scilab_kernel/kernel.py @@ -290,6 +290,25 @@ def get_completions(self, info): return [line.strip() for line in text.splitlines() if info['obj'] in line] + async def do_complete(self, code, cursor_pos): + content = await super().do_complete(code, cursor_pos) + if not content.get('matches'): + # metakernel's parser sets cursor_start to 0 (start of buffer) + # whenever there is no partial word at the cursor, regardless + # of whether any completions were actually found -- e.g. after + # "plot(1:10,", cursor_start ends up 0 with cursor_end at the + # cursor position, even though there is nothing to complete. + # With zero matches that wide range should never matter to a + # kernel-only completer, but JupyterLab also has its own + # generic word-from-document completer active alongside the + # kernel one, and a wide, kernel-reported range spanning real + # words ("plot", "1", "10") gives it something to offer -- an + # unwanted menu of words already on the line. Collapsing to a + # zero-width point at the cursor whenever we have nothing + # avoids handing it that range. + content['cursor_start'] = content['cursor_end'] + return content + def handle_plot_settings(self): """Handle the current plot settings""" settings = self.plot_settings From ea7ee213cab0ac82dfc1e3cdf217ce4b5a6a5b81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Mottelet?= Date: Thu, 24 Sep 2026 13:18:12 +0200 Subject: [PATCH 08/10] Restore full-list completion on empty prefix (matches IPython) Reverts the "return [] when info['obj'] is empty" guard added a couple of commits back. That guard was meant to fix a real problem (4199 raw, quoted-looking matches dumped after typing e.g. "+"), but suppressing the kernel's own completions entirely on an empty prefix isn't actually the right fix for that, and isn't how Jupyter kernels conventionally behave: IPython itself shows the full registered-name list when completing on an empty prefix, and scilab-adv-cli's own interactive completion does the same. That original "huge menu" complaint is better understood as being about the quoting/parsing bug fixed earlier in this same function, not about the match count. Confirmed there is no way to selectively suppress this for Scilab: JupyterLab's completion providers (kernel, context, ...) are always all queried and their results unconditionally merged (checked directly in @jupyterlab/completer's ProviderReconciliator) -- there is no per-language opt-out, and no rank-based short-circuiting. So the right shape for scilab_kernel's own contribution is simply to match the established, expected convention, same as every other kernel. Verified against a live kernel: "+", "-", and "plot(1:10," now all return the full ~4199-entry name list with a correct zero-width cursor range (cursor_start == cursor_end, from metakernel's own existing correction once matches is non-empty); "plo"/"sin" prefix completions and "zzzzzz" (genuinely no matches) are unaffected. Co-Authored-By: Claude Sonnet 5 --- scilab_kernel/kernel.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/scilab_kernel/kernel.py b/scilab_kernel/kernel.py index 1dc3cc2..b33bb7e 100644 --- a/scilab_kernel/kernel.py +++ b/scilab_kernel/kernel.py @@ -264,14 +264,14 @@ def get_completions(self, info): Get completions from kernel based on info dict. """ obj = info['obj'] - if not obj: - # metakernel's do_complete() calls get_completions() even when - # the cursor sits right after a non-identifier character (e.g. - # "+", "-", "/") and there is no partial word to complete -- - # info['obj'] is "" in that case. completion("") matches every - # name Scilab knows (thousands), which isn't a useful - # completion menu, so there is nothing to offer here. - return [] + # metakernel's do_complete() calls get_completions() even when the + # cursor sits right after a non-identifier character (e.g. "+", + # "-", "/") and there is no partial word to complete -- info['obj'] + # is "" in that case. completion("") then matches every name + # Scilab knows, which is intentional: it's the same convention + # IPython follows (Tab on an empty prefix browses everything), and + # scilab-adv-cli's own interactive completion does the same thing. + # # completion() displays its result on Scilab's own console, quoted # (Scilab now shows string arrays as `"a" "b" ...`), which broke # the parsing below; printf("%s\n", ...) instead prints each match From a54b6d65e54d573e1fa697e843b2c8d236f69e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Mottelet?= Date: Fri, 25 Sep 2026 14:50:37 +0200 Subject: [PATCH 09/10] Show Scilab's own errors in red, like the continuation-prompt hang Scilab runtime errors (undefined variable, wrong dimensions, syntax errors, a user's own error(...) call, ...) were printed as plain stdout -- only the unrelated continuation-prompt hang message (a synthetic Python-side error from metakernel) got the red stderr treatment. lasterror() is Scilab's own record of the last error: it returns [] if the command that just ran didn't error, or the error text otherwise, and reading it clears it -- a reliable signal regardless of the message's content, unlike pattern-matching (which can't tell a user's own error("some text") apart from ordinary output). Since a Scilab error is always the last thing printed for a command (execution stops as soon as one occurs), Write() now holds back each output chunk by one: a new chunk flushes the previous one immediately as plain stdout (live streaming during long loops is unaffected), while the true last chunk waits until the command finishes, when lasterror() says whether it should go out via Write() or Error(). Error() itself now also wraps its message with a blank line on each side (matching the continuation-prompt message) and pads out with newlines instead of spaces by delegating to metakernel's own sep-joining, and strips Scilab's own terminal control codes from the message first -- left in, an embedded reset code cancels the red partway through. Verified against a live kernel: undefined variables, wrong dimensions, syntax errors, nested-function errors, and custom error() messages (including ones with %/quotes) all show red; plain output, plotting, tab completion, error recovery in the next cell, and python -m scilab_kernel.check all unaffected. Added code_stderr to test_scilab_kernel.py so jupyter_kernel_test's own stderr test (previously skipped) now exercises this end-to-end. Co-Authored-By: Claude Sonnet 5 --- scilab_kernel/kernel.py | 66 ++++++++++++++++++++++++++++++++++++++++- test_scilab_kernel.py | 2 ++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/scilab_kernel/kernel.py b/scilab_kernel/kernel.py index b33bb7e..50c473e 100644 --- a/scilab_kernel/kernel.py +++ b/scilab_kernel/kernel.py @@ -27,6 +27,19 @@ # strip those out of completion results, see get_completions() below. _ANSI_ESCAPE_RE = re.compile(r'\x1b\[[0-9;]*[A-Za-z]|\x08') +# Used to detect, after a cell has run, whether Scilab itself flagged an +# error (undefined variable, wrong dimensions, syntax error, a user's own +# error(...) call, ...) so its output can be shown in red like any other +# error, see Write()/_had_scilab_error() below. lasterror() returns [] (an +# empty matrix, typeof "constant") when nothing went wrong, or the error +# message (typeof "string", possibly a multi-line string array for a +# syntax error) otherwise -- and, calling it clears it, so this is safe to +# run after every non-silent execution without leaking stale state into +# the next cell. +_ERROR_MARKER = '<<>>' +_ERROR_FLAG_RE = re.compile(re.escape(_ERROR_MARKER) + r'(\w+)' + re.escape(_ERROR_MARKER)) +_CHECK_ERROR_CMD = f'printf("{_ERROR_MARKER}%s{_ERROR_MARKER}", typeof(lasterror()))' + class _ScilabREPLWrapper(REPLWrapper): """A :class:`REPLWrapper` that knows how to escape Scilab's continuation @@ -202,6 +215,7 @@ def makeWrapper(self): change_prompt = None continuation_prompt = r' \>' self._first = True + self._pending_chunk = None if os.name == 'nt': prompt_cmd = 'printf("-->")' echo = False @@ -220,8 +234,18 @@ def makeWrapper(self): return wrapper def Write(self, message): + # A Scilab error is always the last thing printed for a command -- + # execution stops as soon as one occurs -- so whether the *final* + # chunk of a command's output should be shown as an error is only + # known once the command has finished (see _had_scilab_error()). + # Every chunk is therefore held back by one: each new chunk flushes + # the previous one (now known not to be the last) as plain output, + # and the true last chunk sits in self._pending_chunk until + # do_execute_direct() flushes it below, through Write or Error. clean_msg = message.strip("\n\r\t") - super().Write(clean_msg) + pending, self._pending_chunk = self._pending_chunk, clean_msg + if pending is not None: + super().Write(pending) def Print(self, text): text = str(text).strip('\x1b[0m').replace('\u0008', '').strip() @@ -231,13 +255,53 @@ def Print(self, text): if text: super().Print(text) + def Error(self, *objects, **kwargs): + # A blank line on either side sets the error text apart from + # whatever output (if any) precedes it in the same red block. + kwargs['sep'] = '\n' + super().Error('', *objects, '', **kwargs) + + def _had_scilab_error(self): + """Whether the command that just ran left an error in Scilab's own + error register (undefined variable, wrong dimensions, a user's own + error(...) call, a syntax error, ...). Calling lasterror() clears + it, so this also resets it for the next cell. + """ + try: + resp = super().do_execute_direct(_CHECK_ERROR_CMD, True) + except Exception: # noqa: BLE001 -- best-effort check, never fatal to the cell + return False + if not resp: + return False + match = _ERROR_FLAG_RE.search(resp.output) + return bool(match) and match.group(1) == 'string' + + def _flush_pending_output(self): + pending, self._pending_chunk = self._pending_chunk, None + if pending is None: + return + if self._had_scilab_error(): + # Error() wraps the whole message in RED...NORMAL, but Scilab's + # own terminal control codes (see _ANSI_ESCAPE_RE above) glued + # onto raw output include a reset (\x1b[0m) of their own -- + # left in, it cancels the red partway through the message. + self.Error(_ANSI_ESCAPE_RE.sub('', pending).strip()) + else: + super().Write(pending) + def do_execute_direct(self, code, silent=False): if self._first: self._first = False self.handle_plot_settings() setup = self._setup.strip() self.do_execute_direct(setup, True) + # try/catch in the setup script above (getd(".")) can leave an + # error behind; clear it so it isn't mistaken for one of the + # user's own further down. + self._had_scilab_error() resp = super().do_execute_direct(code, silent=silent) + if not silent: + self._flush_pending_output() if silent: return resp if self.plot_settings.get('backend', None) == 'inline': diff --git a/test_scilab_kernel.py b/test_scilab_kernel.py index 26338d7..28efa4b 100644 --- a/test_scilab_kernel.py +++ b/test_scilab_kernel.py @@ -13,6 +13,8 @@ class ScilabKernelTests(jkt.KernelTests): code_hello_world = "disp('hello, world')" + code_stderr = "zzzzzzz_undefined_variable" + code_display_data: ClassVar[list] = [ {'code': '%plot -f png\nplot([1,2,3])', 'mime': 'image/png'}, {'code': '%plot -f svg\nplot([1,2,3])', 'mime': 'image/svg+xml'} From 4ad20a2a203114bd0ba2f17b35b537c6af2c0ec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Mottelet?= Date: Fri, 25 Sep 2026 15:16:35 +0200 Subject: [PATCH 10/10] Revert the blank-line padding around Error() messages The Error() override added a leading/trailing blank line around every red message for legibility, but on reflection it's not wanted: drop it and go back to metakernel's own Error() as-is. The error detection/coloring itself (_had_scilab_error(), _flush_pending_output()) is unaffected. Co-Authored-By: Claude Sonnet 5 --- scilab_kernel/kernel.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/scilab_kernel/kernel.py b/scilab_kernel/kernel.py index 50c473e..75499af 100644 --- a/scilab_kernel/kernel.py +++ b/scilab_kernel/kernel.py @@ -255,12 +255,6 @@ def Print(self, text): if text: super().Print(text) - def Error(self, *objects, **kwargs): - # A blank line on either side sets the error text apart from - # whatever output (if any) precedes it in the same red block. - kwargs['sep'] = '\n' - super().Error('', *objects, '', **kwargs) - def _had_scilab_error(self): """Whether the command that just ran left an error in Scilab's own error register (undefined variable, wrong dimensions, a user's own