diff --git a/src/gnuplot_kernel/kernel.py b/src/gnuplot_kernel/kernel.py index 5701932..af84a2d 100644 --- a/src/gnuplot_kernel/kernel.py +++ b/src/gnuplot_kernel/kernel.py @@ -1,12 +1,15 @@ from __future__ import annotations - +import os +import time +from IPython.display import Image, SVG +import re +import tempfile import contextlib import sys import uuid from itertools import chain from pathlib import Path -from typing import cast - +from typing import cast, Iterable from IPython.display import SVG, Image from metakernel import MetaKernel, ProcessMetaKernel, pexpect from metakernel.process_metakernel import TextOutput @@ -24,6 +27,149 @@ class GnuplotKernel(ProcessMetaKernel): """ GnuplotKernel """ + _pending_unlink: list[Path] = [] + + + @staticmethod + def _wait_nonzero(path, timeout=5.0, poll=0.01): + t0 = time.time() + while time.time() - t0 < timeout: + try: + if path.stat().st_size > 0: + return True + except FileNotFoundError: + pass + time.sleep(poll) + return False + + + + @staticmethod + def _wait_size_stable(path: Path, timeout=20.0, poll=0.02, stable_rounds=5) -> int: + """ + Wait until file size stops changing for `stable_rounds` consecutive polls. + Returns the last observed size (may be 0 if timeout). + """ + t0 = time.time() + last = -1 + stable = 0 + last_size = 0 + while time.time() - t0 < timeout: + try: + sz = path.stat().st_size + except FileNotFoundError: + sz = 0 + + last_size = sz + if sz > 0 and sz == last: + stable += 1 + if stable >= stable_rounds: + return sz + else: + stable = 0 + last = sz + + time.sleep(poll) + return last_size + + @staticmethod + def _read_complete_bytes_retry(path: Path, timeout=20.0, poll=0.01) -> bytes: + """ + Windows 上大图写入时,文件可能: + - 被写端独占锁住(read 会 PermissionError/WinError 32) + - size 非零但仍在增长(读到截断内容) + 这里用“读前 size == 读后 size 且 len(data) == size”作为完成判据。 + """ + t0 = time.time() + last_exc: Exception | None = None + delay = poll + while time.time() - t0 < timeout: + # try: + # target = path.stat().st_size + # except FileNotFoundError: + # target = 0 + + # if target <= 0: + try: + before = path.stat().st_size + except FileNotFoundError: + before = 0 + + if before <= 0: + time.sleep(poll) + continue + + try: + data = path.read_bytes() + # 若仍在写入,常见现象是 len(data) < target + # if len(data) == target: + # # 再确认一次 size 没继续变大(避免 race) + # stable = GnuplotKernel._wait_size_stable(path, timeout=timeout, poll=poll) + # if stable == len(data): + # return data + + try: + after = path.stat().st_size + except FileNotFoundError: + after = 0 + + # 写入完成且未增长,才认为完整 + if after == before and len(data) == after: + return data + + except (PermissionError, OSError) as e: + last_exc = e + + # time.sleep(poll) + time.sleep(delay) + # 指数退避,上限 50ms + delay = min(delay * 1.6, 0.02) + + + if last_exc: + raise last_exc + return b"" + + + + + + + + + + + + + + + + + + @staticmethod + def _read_bytes_retry(path, timeout=5.0, poll=0.02): + t0 = time.time() + last_exc = None + while time.time() - t0 < timeout: + try: + data = path.read_bytes() + if data: + return data + except (PermissionError, OSError) as e: + last_exc = e + time.sleep(poll) + if last_exc: + raise last_exc + return b"" + + + + + + + + + implementation = "Gnuplot Kernel" implementation_version = get_version("gnuplot_kernel") @@ -59,18 +205,80 @@ class GnuplotKernel(ProcessMetaKernel): wrapper: GnuplotREPLWrapper _bad_prompts: set = set() - def check_prompt(self): - """ - Print warning if the prompt looks bad + # def check_prompt(self): + # """ + # Print warning if the prompt looks bad + + # A bad prompt is one that does not contain the string 'gnuplot>'. + # The warning is printed once per bad prompt. + # """ + # prompt = cast("str", self.wrapper.prompt) + # if "gnuplot>" not in prompt and prompt not in self._bad_prompts: + # print(f"Warning: The prompt is currently set to '{prompt}'") + # self._bad_prompts.add(prompt) - A bad prompt is one that does not contain the string 'gnuplot>'. - The warning is printed once per bad prompt. + + def _iter_inline_candidates(self) -> list[Path]: + """ + Return inline image files currently present (sorted). + The existing code likely already has iter_image_files(); this helper is + only for the quiescence wait to repeatedly sample the directory. """ + return list(self.iter_image_files()) + + def _wait_inline_quiescence( + self, + timeout: float = 2.0, + poll: float = 0.01, + settle: float = 0.08, + ) -> None: + """ + Wait until inline output files stop appearing/changing. + We treat the output as 'ready' when: + - number of candidate files stops increasing, AND + - newest mtime stops changing for `settle` seconds. + This prevents missing plots in a single cell with multiple `plot` lines, + especially when notebook 'Run All' removes inter-cell idle gaps. + """ + t0 = time.time() + last_count = -1 + last_newest_mtime = -1.0 + stable_since = None # type: float | None + + while time.time() - t0 < timeout: + files = self._iter_inline_candidates() + count = len(files) + newest_mtime = -1.0 + if files: + # newest mtime among candidates + try: + newest_mtime = max(p.stat().st_mtime for p in files) + except FileNotFoundError: + newest_mtime = -1.0 + + changed = (count != last_count) or (newest_mtime != last_newest_mtime) + + if changed: + last_count = count + last_newest_mtime = newest_mtime + stable_since = None + else: + if stable_since is None: + stable_since = time.time() + elif time.time() - stable_since >= settle: + return + + time.sleep(poll) + + + + def check_prompt(self): prompt = cast("str", self.wrapper.prompt) + if prompt == "__GPK_READY__": + return if "gnuplot>" not in prompt and prompt not in self._bad_prompts: print(f"Warning: The prompt is currently set to '{prompt}'") self._bad_prompts.add(prompt) - def do_execute_direct(self, code, silent=False): # We wrap the real function so that gnuplot_kernel can # give a message when an exception occurs. Without @@ -160,22 +368,30 @@ def set_output_inline(lines): code = "\n".join(lines) return code + # def get_image_filename(self): + # """ + # Create file to which gnuplot will write the plot + + # Returns the filename. + # """ + # # we could use tempfile.NamedTemporaryFile but we do not + # # want to create the file, gnuplot will create it. + # # Later on when we check if the file exists we know + # # whodunnit. + # fmt = self.plot_settings["format"] + # filename = Path( + # f"/tmp/gnuplot-inline-{uuid.uuid1()}.{IMG_COUNTER_FMT}.{fmt}" + # ) + # self._image_files.append(filename) + # return filename def get_image_filename(self): - """ - Create file to which gnuplot will write the plot - - Returns the filename. - """ - # we could use tempfile.NamedTemporaryFile but we do not - # want to create the file, gnuplot will create it. - # Later on when we check if the file exists we know - # whodunnit. fmt = self.plot_settings["format"] - filename = Path( - f"/tmp/gnuplot-inline-{uuid.uuid1()}.{IMG_COUNTER_FMT}.{fmt}" - ) + tmpdir = Path(tempfile.gettempdir()) + filename = tmpdir / f"gnuplot-inline-{uuid.uuid1()}.{IMG_COUNTER_FMT}.{fmt}" + # print(filename) self._image_files.append(filename) - return filename + return filename.as_posix() + def iter_image_files(self): """ @@ -189,17 +405,87 @@ def iter_image_files(self): ) return it + # def display_images(self): + # """ + # Display images if gnuplot wrote to them + # """ + # settings = self.plot_settings + # if self.inline_plotting: + # _Image = SVG if settings["format"] == "svg" else Image + # else: + # return + + # for filename in self.iter_image_files(): + # try: + # size = filename.stat().st_size + # except FileNotFoundError: + # size = 0 + + # if not size: + # msg = ( + # "Failed to read and display image file from gnuplot." + # "Possibly:\n" + # "1. You have plotted to a non interactive terminal.\n" + # "2. You have an invalid expression." + # ) + # print(msg) + # continue + + # im = _Image(str(filename)) + # self.Display(im) + + + + def display_images(self): - """ - Display images if gnuplot wrote to them - """ + # Key fix: do not start reading before gnuplot finishes emitting + # all images for this cell. + if os.name == "nt" and self.inline_plotting: + files0 = self._iter_inline_candidates() + max_sz = 0 + for p in files0: + try: + max_sz = max(max_sz, p.stat().st_size) + except FileNotFoundError: + pass + if max_sz >= 10_000_000: + self._wait_inline_quiescence(timeout=8.0, poll=0.02, settle=0.18) + else: + self._wait_inline_quiescence(timeout=2.0, poll=0.01, settle=0.08) + + + # 非阻塞清理上一轮未删掉的文件 + if os.name == "nt" and self._pending_unlink: + keep: list[Path] = [] + for p in self._pending_unlink: + try: + p.unlink() + except (FileNotFoundError, PermissionError): + keep.append(p) + self._pending_unlink = keep + + + + settings = self.plot_settings - if self.inline_plotting: - _Image = SVG if settings["format"] == "svg" else Image - else: + if not self.inline_plotting: return + fmt = str(settings.get("format", "png")).lower().lstrip(".") + if fmt == "jpg": + fmt = "jpeg" + for filename in self.iter_image_files(): + + # if os.name == "nt": + # self._wait_nonzero(filename, timeout=10.0, poll=0.02) + + if os.name == "nt": + # 大图:等 size 稳定,而不是只等到“非零” + self._wait_size_stable(filename, timeout=10.0, poll=0.02, stable_rounds=5) + + + try: size = filename.stat().st_size except FileNotFoundError: @@ -207,16 +493,61 @@ def display_images(self): if not size: msg = ( - "Failed to read and display image file from gnuplot." - "Possibly:\n" + "Failed to read and display image file from gnuplot.Possibly:\n" "1. You have plotted to a non interactive terminal.\n" "2. You have an invalid expression." ) print(msg) continue - im = _Image(str(filename)) - self.Display(im) + + + + + # if fmt == "svg": + # data = filename.read_text(encoding="utf-8", errors="replace") + # self.Display(SVG(data=data)) + + # else: + # data = self._read_bytes_retry(filename, timeout=10.0, poll=0.02) + # self.Display(Image(data=data, format=fmt)) + + try: + if fmt == "svg": + data = filename.read_text(encoding="utf-8", errors="replace") + self.Display(SVG(data=data)) + else: + # 关键:读“完整文件”,避免截断 + WinError 32 直接冒泡 + if os.name == "nt": + # data = self._read_complete_bytes_retry(filename, timeout=10.0, poll=0.01) + # data = self._read_complete_bytes_retry(filename, timeout=30.0, poll=0.02) + + + # 自适应:文件越大,允许更久的写入完成时间 + try: + sz0 = filename.stat().st_size + except FileNotFoundError: + sz0 = 0 + if sz0 >= 20_000_000: # 约 20MB,典型大图 + timeout, poll = 20.0, 0.02 + elif sz0 >= 2_000_000: # 中等 + timeout, poll = 8.0, 0.01 + else: # 小图 + timeout, poll = 2.0, 0.005 + data = self._read_complete_bytes_retry(filename, timeout=timeout, poll=poll) + + + + else: + data = self._read_bytes_retry(filename, timeout=10.0, poll=0.02) + self.Display(Image(data=data, format=fmt)) + except (PermissionError, OSError) as e: + # 不把异常抛到 do_execute_direct 外层,避免额外的 “Error: ...” 输出 + print(f"Error: {e}") + continue + + + def delete_image_files(self): """ @@ -225,15 +556,64 @@ def delete_image_files(self): # After display_images(), the real images are # no longer required. for filename in self.iter_image_files(): - with contextlib.suppress(FileNotFoundError): - filename.unlink() + # with contextlib.suppress(FileNotFoundError): + # filename.unlink() + + if os.name == "nt": + # t0 = time.time() + # while True: + # try: + # filename.unlink() + # break + # except FileNotFoundError: + # break + # except PermissionError: + # if time.time() - t0 > 2.0: + # break + # time.sleep(0.02) + + + + + try: + filename.unlink() + except (FileNotFoundError, PermissionError): + self._pending_unlink.append(filename) + + + else: + with contextlib.suppress(FileNotFoundError): + filename.unlink() self._image_files = [] + # def makeWrapper(self): + # """ + # Start gnuplot and return wrapper around the REPL + # """ + # if pexpect.which("gnuplot"): + # program = "gnuplot" + # elif pexpect.which("gnuplot.exe"): + # program = "gnuplot.exe" + # else: + # raise Exception("gnuplot not found.") + + # # We don't want help commands getting stuck, + # # use a non interactive PAGER + # if pexpect.which("env") and pexpect.which("cat"): + # command = "env PAGER=cat {}".format(program) + # else: + # command = program + + # wrapper = GnuplotREPLWrapper( + # cmd_or_spawn=command, + # prompt_regex=PROMPT_RE, + # prompt_change_cmd=None, + # ) + # # No sleeping before sending commands to gnuplot + # wrapper.child.delaybeforesend = 0 + # return wrapper def makeWrapper(self): - """ - Start gnuplot and return wrapper around the REPL - """ if pexpect.which("gnuplot"): program = "gnuplot" elif pexpect.which("gnuplot.exe"): @@ -241,22 +621,29 @@ def makeWrapper(self): else: raise Exception("gnuplot not found.") - # We don't want help commands getting stuck, - # use a non interactive PAGER if pexpect.which("env") and pexpect.which("cat"): command = "env PAGER=cat {}".format(program) else: command = program - wrapper = GnuplotREPLWrapper( - cmd_or_spawn=command, - prompt_regex=PROMPT_RE, - prompt_change_cmd=None, - ) - # No sleeping before sending commands to gnuplot + if os.name == "nt": + READY = "__GPK_READY__" + wrapper = GnuplotREPLWrapper( + cmd_or_spawn=command, + prompt_regex=re.compile(re.escape(READY)), + prompt_change_cmd=None, + continuation_prompt_regex=re.compile(r"(?!)"), + prompt_emit_cmd=f'print "{READY}"', + ) + else: + wrapper = GnuplotREPLWrapper( + cmd_or_spawn=command, + prompt_regex=PROMPT_RE, + prompt_change_cmd=None, + ) + wrapper.child.delaybeforesend = 0 return wrapper - def do_shutdown(self, restart): """ Exit the gnuplot process and any other underlying stuff @@ -280,26 +667,80 @@ def reset_image_counter(self): cmd = f"{IMG_COUNTER}=0" self.do_execute_direct(cmd) - def handle_plot_settings(self): - """ - Handle the current plot settings + # def handle_plot_settings(self): + # """ + # Handle the current plot settings - This is used by the gnuplot line magic. The plot magic - is innadequate. - """ + # This is used by the gnuplot line magic. The plot magic + # is innadequate. + # """ + # settings = self.plot_settings + # if "termspec" not in settings or not settings["termspec"]: + # settings["termspec"] = 'pngcairo size 385, 256 font "Arial,10"' + # if "format" not in settings or not settings["format"]: + # settings["format"] = "png" + + # self.inline_plotting = settings["backend"] == "inline" + + # cmd = "set terminal {}".format(settings["termspec"]) + # self.do_execute_direct(cmd) + # self.reset_image_counter() + # def handle_plot_settings(self): + # settings = self.plot_settings + + # if "termspec" not in settings or not settings["termspec"]: + # if os.name == "nt": + # settings["termspec"] = 'png size 385, 256' + # else: + # settings["termspec"] = 'pngcairo size 385, 256 font "Arial,10"' + + # if "format" not in settings or not settings["format"]: + # settings["format"] = "png" + + # self.inline_plotting = settings["backend"] == "inline" + # cmd = "set terminal {}".format(settings["termspec"]) + # self.do_execute_direct(cmd) + # self.reset_image_counter() + + + + def handle_plot_settings(self): settings = self.plot_settings - if "termspec" not in settings or not settings["termspec"]: - settings["termspec"] = 'pngcairo size 385, 256 font "Arial,10"' + if "format" not in settings or not settings["format"]: settings["format"] = "png" - self.inline_plotting = settings["backend"] == "inline" + fmt = str(settings["format"]).lower().lstrip(".") + if fmt == "jpg": + fmt_for_term = "jpeg" + else: + fmt_for_term = fmt + + if "termspec" not in settings or not settings["termspec"]: + if os.name == "nt": + if fmt_for_term == "png": + settings["termspec"] = "png size 385, 256" + elif fmt_for_term == "jpeg": + settings["termspec"] = "jpeg size 385, 256" + elif fmt_for_term == "svg": + settings["termspec"] = "svg size 385, 256" + else: + settings["termspec"] = "png size 385, 256" + else: + if fmt_for_term == "png": + settings["termspec"] = 'pngcairo size 385, 256 font "Arial,10"' + elif fmt_for_term == "jpeg": + settings["termspec"] = 'jpeg size 385, 256' + elif fmt_for_term == "svg": + settings["termspec"] = 'svg size 385, 256' + else: + settings["termspec"] = 'pngcairo size 385, 256 font "Arial,10"' + self.inline_plotting = settings["backend"] == "inline" cmd = "set terminal {}".format(settings["termspec"]) self.do_execute_direct(cmd) self.reset_image_counter() - class StateMachine: """ Track context given gnuplot statements diff --git a/src/gnuplot_kernel/magics/gnuplot_magic.py b/src/gnuplot_kernel/magics/gnuplot_magic.py index b0bb4d2..f3ff5a5 100644 --- a/src/gnuplot_kernel/magics/gnuplot_magic.py +++ b/src/gnuplot_kernel/magics/gnuplot_magic.py @@ -117,16 +117,15 @@ def register_ipython_magics(): kernel.line_magics["gnuplot"] = magic kernel.cell_magics["gnuplot"] = magic - @register_line_magic - def _(line): + @register_line_magic("gnuplot") + def _gnuplot_line(line): magic.line_gnuplot(line) - @register_cell_magic - def _(line, cell): + @register_cell_magic("gnuplot") + def _gnuplot_cell(line, cell): magic.code = cell magic.cell_gnuplot() - def _parse_args(args): """ Process the gnuplot line magic arguments diff --git a/src/gnuplot_kernel/replwrap.py b/src/gnuplot_kernel/replwrap.py index eba32de..6436b99 100644 --- a/src/gnuplot_kernel/replwrap.py +++ b/src/gnuplot_kernel/replwrap.py @@ -83,7 +83,7 @@ def validate_input(self, code): return code def send(self, cmd): - self.child.send(cmd + "\r") + self.child.send(cmd + CRLF) def _force_prompt(self, timeout: float = 30, n=4): """ @@ -235,7 +235,9 @@ def run_command( # pyright: ignore[reportIncompatibleMethodOverride] # Removing any crlfs makes subsequent # processing cleaner - retval = cast("str", self.child.before).replace(CRLF, "\n") + + retval = cast("str", self.child.before).replace(CRLF, "\n") + self.prompt = self.child.after if self.is_error_output(retval): msg = "{}\n{}".format(line, textwrap.dedent(retval)) @@ -243,8 +245,9 @@ def run_command( # pyright: ignore[reportIncompatibleMethodOverride] # Sometimes block stmts like datablocks make the # the prompt leak into the return value - retval = PROMPT_REMOVE_RE.sub("", retval).strip(" ") + # retval = PROMPT_REMOVE_RE.sub("", retval).strip(" ") + retval = PROMPT_REMOVE_RE.sub("", retval).strip() # Some gnuplot installations return the input statements # We do not count those as output if retval.strip() != line.strip(): diff --git a/src/gnuplot_kernel/statement.py b/src/gnuplot_kernel/statement.py index 26a98ab..bcfc170 100644 --- a/src/gnuplot_kernel/statement.py +++ b/src/gnuplot_kernel/statement.py @@ -14,16 +14,25 @@ ) # plot statements +# PLOT_RE = re.compile( +# r"^\s*" +# r"(?P" +# r"plot|plo|pl|p|" +# r"splot|splo|spl|sp|" +# r"replot|replo|repl|rep" +# r")" +# r"\s?" +# ) PLOT_RE = re.compile( r"^\s*" - r"(?P" - r"plot|plo|pl|p|" + r"(?P" + r"(?:plot|plo|pl|p|" r"splot|splo|spl|sp|" - r"replot|replo|repl|rep" + r"replot|replo|repl|rep)" r")" - r"\s?" + r"\b" + r"(?:\s+|$)" ) - # "set multiplot" and abbreviated variants SET_MULTIPLE_RE = re.compile( r"\s*"