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

Reply via email to