Ihor Radchenko <[email protected]> writes: > Jack, did you have a chance to look into this?
Sorry for the delay, and thank you Ihor and Kyle for the implementation suggestions. I'm attaching a patch here. It adds the directory etc/org-babel, and inside it a Python module ob_python_utils.py. ob-python.el then imports this file as a module, following the same assumptions as oc-csl for the location. This also reduces the number of modules polluting the global namespace in sessions (eg ast, matplotlib). I did not cleanup the Python code too much, but that could be a next step, eg based on Sam's suggestions, which should be easier now that it's a separate Python file. If it looks good, I might need some help getting it into upstream Emacs repo in the right place.
>From 6a722eada531731652da3f026b4b9bf7de9f7ae0 Mon Sep 17 00:00:00 2001 From: Jack Kamm <[email protected]> Date: Sun, 17 May 2026 16:10:42 -0700 Subject: [PATCH] ob-python: Move Python utilities into separate module * etc/org-babel/ob_python_utils.py: Python module containing utilities for `ob-python' * etc/org-babel/.gitignore: Ignore file for Babel external language modules * .dir-locals.el: Prevent tabs in python-mode * lisp/ob-python.el (org-babel-python--load-utils): New snippet to load Python utilities for `ob-python'. (org-babel-python--output-graphics-wrapper): Call Python utility functions for graphics, avoiding loading matpotlib into namespace. (org-babel-python--def-format-value): Remove Python function for formatting results; it has been migrated into ob_python_utils.py. (org-babel-python--setup-session): Replace `org-babel-python--def-format-value' with `org-babel-python--load-utils'. (org-babel-python-format-session-value): Python code to parse and evaluate source has been moved into ob_python_utils.py; call the new function from that module. (org-babel-python-evaluate-external-process): Replace `org-babel-python--def-format-value' with `org-babel-python--load-utils'. Replace function call that was migrated to ob_python_utils.py. (org-babel-python-evaluate-session): Python code was migrated to a new function in ob_python_utils.py; call that function. --- .dir-locals.el | 1 + etc/org-babel/.gitignore | 1 + etc/org-babel/ob_python_utils.py | 104 +++++++++++++++++++++++++++++++ lisp/ob-python.el | 98 +++++++++-------------------- 4 files changed, 137 insertions(+), 67 deletions(-) create mode 100644 etc/org-babel/.gitignore create mode 100644 etc/org-babel/ob_python_utils.py diff --git a/.dir-locals.el b/.dir-locals.el index 452ee0432..afc6b7e5d 100644 --- a/.dir-locals.el +++ b/.dir-locals.el @@ -15,6 +15,7 @@ ((nil . ((indent-tabs-mode . t) (org-footnote-define-inline . nil) (org-footnote-section . "Footnotes") (org-hide-emphasis-markers . nil))) + (python-mode . ((indent-tabs-mode . nil))) ("testing/lisp" . ((emacs-lisp-mode . ((elisp-flymake-byte-compile-load-path "./" "../../lisp/")))))) diff --git a/etc/org-babel/.gitignore b/etc/org-babel/.gitignore new file mode 100644 index 000000000..bee8a64b7 --- /dev/null +++ b/etc/org-babel/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/etc/org-babel/ob_python_utils.py b/etc/org-babel/ob_python_utils.py new file mode 100644 index 000000000..d4a781537 --- /dev/null +++ b/etc/org-babel/ob_python_utils.py @@ -0,0 +1,104 @@ +# Python utilities for ob-python.el +# +# Copyright (C) 2026 Free Software Foundation, Inc. +# +# This file is part of GNU Emacs. +# +# GNU Emacs is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# GNU Emacs is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>. + +import ast +import pprint + +def session_results_output( + src_file_name, context +): + with open(src_file_name) as f: + exec(compile(f.read(), f.name, 'exec'), context) + +def session_results_value( + src_file_name, result_file_name, result_params, context +): + with open(src_file_name) as f: + src_ast = ast.parse(f.read()) + + final_line = src_ast.body[-1] + + if isinstance(final_line, ast.Expr): + src_ast.body = src_ast.body[:-1] + exec(compile(src_ast, '<string>', 'exec'), context) + final_line = eval(compile(ast.Expression( + final_line.value), '<string>', 'eval'), context) + else: + exec(compile(src_ast, '<string>', 'exec'), context) + final_line = None + + format_results_value( + final_line, + result_file_name, + result_params + ) + +def clear_graphics(): + import matplotlib.pyplot + matplotlib.pyplot.gcf().clear() + +def save_graphics(file_name): + import matplotlib.pyplot + matplotlib.pyplot.savefig(file_name) + +def format_results_value(result, result_file_name, result_params): + with open(result_file_name, 'w') as result_file_obj: + if 'graphics' in result_params: + result.savefig(result_file_name) + elif 'pp' in result_params: + result_file_obj.write(pprint.pformat(result)) + elif 'list' in result_params and isinstance(result, dict): + result_file_obj.write( + str(['{} :: {}'.format(k, v) for k, v in result.items()]) + ) + else: + if not set(result_params).intersection(['scalar', 'verbatim', 'raw']): + def dict2table(res): + if isinstance(res, dict): + return [(k, dict2table(v)) for k, v in res.items()] + elif isinstance(res, list) or isinstance(res, tuple): + return [dict2table(x) for x in res] + else: + return res + if 'table' in result_params: + result = dict2table(result) + try: + import pandas + except ImportError: + pass + else: + if isinstance(result, pandas.DataFrame) and 'table' in result_params: + result = [ + [result.index.name or ''] + list(result.columns) + ] + [None] + [ + [i] + list(row) for i, row in result.iterrows() + ] + elif isinstance(result, pandas.Series) and 'table' in result_params: + result = list(result.items()) + try: + import numpy + except ImportError: + pass + else: + if isinstance(result, numpy.ndarray): + if 'table' in result_params: + result = result.tolist() + else: + result = repr(result) + result_file_obj.write(str(result)) diff --git a/lisp/ob-python.el b/lisp/ob-python.el index 1cb683492..60211cf14 100644 --- a/lisp/ob-python.el +++ b/lisp/ob-python.el @@ -149,60 +149,38 @@ (defun org-babel-load-session:python (session body params) ;; helper functions +(defconst org-babel-python--load-utils + (format "\ +def __OB_PYTHON_setup_path(dir): + import sys + if not dir in sys.path: + sys.path.append(dir) +__OB_PYTHON_setup_path('%s') +import ob_python_utils as __OB_PYTHON" + (let ((ob-root (file-name-directory (locate-library "ob")))) + (cond + ;; First check whether it looks like we're running from the main + ;; Org repository. + ((let ((etc-org (expand-file-name "../etc/org-babel/" ob-root))) + (and (file-directory-p etc-org) etc-org))) + ;; Next look for the directory alongside ob.el because package.el + ;; and straight will put all of org-mode/lisp/ in org-mode/. + ((let ((etc-pkg (expand-file-name "etc/org-babel/" ob-root))) + (and (file-directory-p etc-pkg) etc-pkg))) + ;; Finally fall back the location used by shared system installs + ;; and when running directly from Emacs repository. + (t + (expand-file-name "org/org-babel/" data-directory))))) + "Python code snippet to load utilities for `ob-python'.") + (defconst org-babel-python--output-graphics-wrapper "\ -import matplotlib.pyplot -matplotlib.pyplot.gcf().clear() +__OB_PYTHON.clear_graphics() %s -matplotlib.pyplot.savefig('%s')" +__OB_PYTHON.save_graphics('%s')" "Format string for saving Python graphical output. Has two %s escapes, for the Python code to be evaluated, and the file to save the graphics to.") -(defconst org-babel-python--def-format-value "\ -def __org_babel_python_format_value(result, result_file, result_params): - with open(result_file, 'w') as __org_babel_python_tmpfile: - if 'graphics' in result_params: - result.savefig(result_file) - elif 'pp' in result_params: - import pprint - __org_babel_python_tmpfile.write(pprint.pformat(result)) - elif 'list' in result_params and isinstance(result, dict): - __org_babel_python_tmpfile.write(str(['{} :: {}'.format(k, v) for k, v in result.items()])) - else: - if not set(result_params).intersection(\ -['scalar', 'verbatim', 'raw']): - def dict2table(res): - if isinstance(res, dict): - return [(k, dict2table(v)) for k, v in res.items()] - elif isinstance(res, list) or isinstance(res, tuple): - return [dict2table(x) for x in res] - else: - return res - if 'table' in result_params: - result = dict2table(result) - try: - import pandas - except ImportError: - pass - else: - if isinstance(result, pandas.DataFrame) and 'table' in result_params: - result = [[result.index.name or ''] + list(result.columns)] + \ -[None] + [[i] + list(row) for i, row in result.iterrows()] - elif isinstance(result, pandas.Series) and 'table' in result_params: - result = list(result.items()) - try: - import numpy - except ImportError: - pass - else: - if isinstance(result, numpy.ndarray): - if 'table' in result_params: - result = result.tolist() - else: - result = repr(result) - __org_babel_python_tmpfile.write(str(result))" - "Python function to format value result and save it to file.") - (defun org-babel-variable-assignments:python (params) "Return a list of Python statements assigning the block's variables. The assignments are defined in PARAMS." @@ -297,7 +275,7 @@ (defun org-babel-python--setup-session () Function should be run from within the Python session buffer. This is often run as a part of `python-shell-first-prompt-hook', unless the Python session was created outside Org." - (python-shell-send-string-no-output org-babel-python--def-format-value) + (python-shell-send-string-no-output org-babel-python--load-utils) (setq-local org-babel-python--initialized t)) (defun org-babel-python-initiate-session-by-key (&optional session) "Initiate a python session. @@ -374,19 +352,7 @@ (defun org-babel-python-format-session-value "Return Python code to evaluate SRC-FILE and write result to RESULT-FILE. RESULT-PARAMS defines the result type." (format "\ -import ast -with open('%s') as __org_babel_python_tmpfile: - __org_babel_python_ast = ast.parse(__org_babel_python_tmpfile.read()) -__org_babel_python_final = __org_babel_python_ast.body[-1] -if isinstance(__org_babel_python_final, ast.Expr): - __org_babel_python_ast.body = __org_babel_python_ast.body[:-1] - exec(compile(__org_babel_python_ast, '<string>', 'exec')) - __org_babel_python_final = eval(compile(ast.Expression( - __org_babel_python_final.value), '<string>', 'eval')) -else: - exec(compile(__org_babel_python_ast, '<string>', 'exec')) - __org_babel_python_final = None -__org_babel_python_format_value(__org_babel_python_final, '%s', %s)" +__OB_PYTHON.session_results_value('%s', '%s', %s, globals())" (org-babel-process-file-name src-file 'noquote) (org-babel-process-file-name result-file 'noquote) (org-babel-python-var-to-python result-params))) @@ -437,11 +403,11 @@ (defun org-babel-python-evaluate-external-process (concat preamble (and preamble "\n") (format - (concat org-babel-python--def-format-value " + (concat org-babel-python--load-utils " def main(): %s -__org_babel_python_format_value(main(), '%s', %s)") +__OB_PYTHON.format_results_value(main(), '%s', %s)") (org-babel-python--shift-right body) (org-babel-process-file-name results-file 'noquote) (org-babel-python-var-to-python result-params)))) @@ -487,9 +453,7 @@ (defun org-babel-python-evaluate-session body))) (pcase result-type (`output - (let ((body (format "\ -with open('%s') as __org_babel_python_tmpfile: - exec(compile(__org_babel_python_tmpfile.read(), __org_babel_python_tmpfile.name, 'exec'))" + (let ((body (format "__OB_PYTHON.session_results_output('%s', globals())" (org-babel-process-file-name tmp-src-file 'noquote)))) (org-babel-python-send-string session body))) -- 2.54.0
