mrmd-python 0.3.3__tar.gz → 0.3.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mrmd-python
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: Python runtime server implementing the MRMD Runtime Protocol (MRP)
5
5
  Author: mrmd contributors
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mrmd-python"
3
- version = "0.3.3"
3
+ version = "0.3.5"
4
4
  description = "Python runtime server implementing the MRMD Runtime Protocol (MRP)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -12,5 +12,5 @@ Or programmatically:
12
12
  from .server import create_app, MRPServer
13
13
  from .worker import IPythonWorker
14
14
 
15
- __version__ = "0.1.0"
15
+ __version__ = "0.3.5"
16
16
  __all__ = ["create_app", "MRPServer", "IPythonWorker", "__version__"]
@@ -42,6 +42,7 @@ from .types import (
42
42
  StdinRequest,
43
43
  InputCancelledError,
44
44
  )
45
+ from .subprocess_manager import SubprocessWorker
45
46
 
46
47
 
47
48
  @dataclass
@@ -82,31 +83,25 @@ class IPythonWorker:
82
83
  self._input_event: threading.Event | None = None
83
84
  self._input_response: str | None = None
84
85
  self._execution_count = 0
85
- self._subprocess_globals: dict = {} # Simulated globals for subprocess mode
86
+ self._subprocess_worker: SubprocessWorker | None = None # Persistent subprocess for different venv
86
87
 
87
88
  def _get_venv_python(self) -> str | None:
88
89
  """Get the Python executable path for the configured venv."""
89
- print(f"[IPythonWorker._get_venv_python] self.venv={self.venv}", flush=True)
90
90
  if not self.venv:
91
- print("[IPythonWorker._get_venv_python] -> None (no venv)", flush=True)
92
91
  return None
93
92
  venv_path = Path(self.venv)
94
93
  if sys.platform == 'win32':
95
94
  candidates = [venv_path / 'Scripts' / 'python.exe']
96
95
  else:
97
- # Try both 'python' and 'python3'
98
96
  candidates = [
99
97
  venv_path / 'bin' / 'python',
100
98
  venv_path / 'bin' / 'python3',
101
99
  ]
102
100
 
103
101
  for python_exe in candidates:
104
- print(f"[IPythonWorker._get_venv_python] Checking {python_exe}...", flush=True)
105
102
  if python_exe.exists():
106
- print(f"[IPythonWorker._get_venv_python] -> {python_exe} (FOUND)", flush=True)
107
103
  return str(python_exe)
108
104
 
109
- print("[IPythonWorker._get_venv_python] -> None (no executable found)", flush=True)
110
105
  return None
111
106
 
112
107
  def _should_use_subprocess(self) -> bool:
@@ -117,33 +112,82 @@ class IPythonWorker:
117
112
  - The venv differs from the current Python's prefix
118
113
  - The venv's Python executable exists
119
114
  """
120
- # DEBUG LOGGING
121
- print(f"[IPythonWorker._should_use_subprocess] self.venv={self.venv}", flush=True)
122
- print(f"[IPythonWorker._should_use_subprocess] sys.prefix={sys.prefix}", flush=True)
123
-
124
115
  if not self.venv:
125
- print("[IPythonWorker._should_use_subprocess] -> False (no venv configured)", flush=True)
126
116
  return False
127
117
 
128
- # Compare venv path to current Python's prefix
129
- # Use realpath to handle symlinks
118
+ # Compare venv path to current Python's prefix (use realpath to handle symlinks)
130
119
  current_prefix = os.path.realpath(sys.prefix)
131
120
  target_venv = os.path.realpath(self.venv)
132
121
 
133
- print(f"[IPythonWorker._should_use_subprocess] current_prefix={current_prefix}", flush=True)
134
- print(f"[IPythonWorker._should_use_subprocess] target_venv={target_venv}", flush=True)
135
-
136
122
  if current_prefix == target_venv:
137
- print("[IPythonWorker._should_use_subprocess] -> False (same venv)", flush=True)
138
123
  return False
139
124
 
140
- # Also verify the target Python exists
125
+ # Verify the target Python exists
126
+ return self._get_venv_python() is not None
127
+
128
+ def _ensure_mrmd_python_in_venv(self) -> bool:
129
+ """Ensure mrmd-python is installed in the target venv.
130
+
131
+ Uses uv pip install to add mrmd-python to the venv so that
132
+ subprocess_worker can be invoked with -m mrmd_python.subprocess_worker.
133
+
134
+ Returns True if successful.
135
+ """
136
+ import subprocess as sp
137
+
141
138
  python_exe = self._get_venv_python()
142
- print(f"[IPythonWorker._should_use_subprocess] python_exe={python_exe}", flush=True)
139
+ if not python_exe:
140
+ return False
143
141
 
144
- result = python_exe is not None
145
- print(f"[IPythonWorker._should_use_subprocess] -> {result}", flush=True)
146
- return result
142
+ # Check if mrmd_python is already importable
143
+ check_result = sp.run(
144
+ [python_exe, "-c", "import mrmd_python"],
145
+ capture_output=True,
146
+ text=True,
147
+ )
148
+
149
+ if check_result.returncode == 0:
150
+ # Already installed
151
+ return True
152
+
153
+ # Install mrmd-python using uv pip
154
+ try:
155
+ install_result = sp.run(
156
+ ["uv", "pip", "install", "--python", python_exe, "mrmd-python"],
157
+ capture_output=True,
158
+ text=True,
159
+ timeout=120,
160
+ )
161
+ return install_result.returncode == 0
162
+ except (FileNotFoundError, sp.TimeoutExpired):
163
+ # uv not available or timeout - try pip directly
164
+ try:
165
+ install_result = sp.run(
166
+ [python_exe, "-m", "pip", "install", "mrmd-python"],
167
+ capture_output=True,
168
+ text=True,
169
+ timeout=120,
170
+ )
171
+ return install_result.returncode == 0
172
+ except (FileNotFoundError, sp.TimeoutExpired):
173
+ return False
174
+
175
+ def _get_subprocess_worker(self) -> SubprocessWorker:
176
+ """Get or create the subprocess worker for different-venv execution."""
177
+ if self._subprocess_worker is None or not self._subprocess_worker.is_alive():
178
+ # Ensure mrmd-python is installed in the target venv
179
+ if not self._ensure_mrmd_python_in_venv():
180
+ raise RuntimeError(
181
+ f"Could not install mrmd-python in venv {self.venv}. "
182
+ "Please install it manually: uv pip install mrmd-python"
183
+ )
184
+
185
+ self._subprocess_worker = SubprocessWorker(
186
+ venv=self.venv,
187
+ cwd=self.cwd,
188
+ assets_dir=self.assets_dir,
189
+ )
190
+ return self._subprocess_worker
147
191
 
148
192
  def _ensure_initialized(self):
149
193
  """Lazy initialization of IPython shell."""
@@ -638,111 +682,18 @@ class IPythonWorker:
638
682
  def _execute_subprocess(
639
683
  self, code: str, exec_id: str | None = None
640
684
  ) -> ExecuteResult:
641
- """Execute code in a subprocess using the configured venv's Python."""
642
- import subprocess
643
- import json
644
-
645
- python_exe = self._get_venv_python()
646
- if not python_exe:
647
- return ExecuteResult(
648
- success=False,
649
- error=ExecuteError(
650
- ename="VenvError",
651
- evalue="Could not find Python executable in venv",
652
- traceback=[]
653
- )
654
- )
655
-
656
- self._execution_count += 1
657
- start_time = time.time()
658
-
659
- # Create a wrapper script that executes the code and captures output
660
- # Uses AST to detect trailing expressions and print their results like IPython
661
- wrapper_code = '''
662
- import sys as _sys
663
- import ast as _ast
664
-
665
- _code = """ ''' + code.replace('\\', '\\\\').replace('"""', '\\"\\"\\"') + ''' """
666
-
667
- try:
668
- _tree = _ast.parse(_code)
669
- if _tree.body and isinstance(_tree.body[-1], _ast.Expr):
670
- # Last statement is an expression - capture its value
671
- if len(_tree.body) > 1:
672
- # Execute all but last statement
673
- _exec_code = _ast.Module(body=_tree.body[:-1], type_ignores=[])
674
- exec(compile(_exec_code, "<cell>", "exec"))
675
- # Evaluate and print last expression
676
- _expr_code = _ast.Expression(body=_tree.body[-1].value)
677
- _result = eval(compile(_expr_code, "<cell>", "eval"))
678
- if _result is not None:
679
- print(f"Out[''' + str(self._execution_count) + ''']: " + repr(_result))
680
- else:
681
- # No trailing expression, just exec everything
682
- exec(compile(_code, "<cell>", "exec"))
683
- except SyntaxError:
684
- # Fall back to simple exec
685
- exec(compile(_code, "<cell>", "exec"))
686
- except Exception as _e:
687
- import traceback as _tb
688
- print("".join(_tb.format_exception(type(_e), _e, _e.__traceback__)), file=_sys.stderr)
689
- _sys.exit(1)
690
- '''
691
-
685
+ """Execute code using the persistent SubprocessWorker (IPython-based)."""
692
686
  try:
693
- result = subprocess.run(
694
- [python_exe, "-c", wrapper_code],
695
- capture_output=True,
696
- text=True,
697
- timeout=300, # 5 minute timeout
698
- cwd=self.cwd,
699
- )
700
-
701
- duration = int((time.time() - start_time) * 1000)
702
-
703
- if result.returncode == 0:
704
- return ExecuteResult(
705
- success=True,
706
- stdout=result.stdout,
707
- stderr=result.stderr,
708
- executionCount=self._execution_count,
709
- duration=duration,
710
- )
711
- else:
712
- # Parse error from stderr if possible
713
- error_lines = result.stderr.strip().split('\n') if result.stderr else []
714
- return ExecuteResult(
715
- success=False,
716
- stdout=result.stdout,
717
- stderr=result.stderr,
718
- error=ExecuteError(
719
- ename="ExecutionError",
720
- evalue=error_lines[-1] if error_lines else "Unknown error",
721
- traceback=error_lines
722
- ),
723
- executionCount=self._execution_count,
724
- duration=duration,
725
- )
726
-
727
- except subprocess.TimeoutExpired:
728
- return ExecuteResult(
729
- success=False,
730
- error=ExecuteError(
731
- ename="TimeoutError",
732
- evalue="Execution timed out after 5 minutes",
733
- traceback=[]
734
- ),
735
- executionCount=self._execution_count,
736
- )
687
+ worker = self._get_subprocess_worker()
688
+ return worker.execute(code, store_history=True, exec_id=exec_id)
737
689
  except Exception as e:
738
690
  return ExecuteResult(
739
691
  success=False,
740
692
  error=ExecuteError(
741
- ename=type(e).__name__,
742
- evalue=str(e),
693
+ type=type(e).__name__,
694
+ message=str(e),
743
695
  traceback=[]
744
696
  ),
745
- executionCount=self._execution_count,
746
697
  )
747
698
 
748
699
  def _execute_subprocess_streaming(
@@ -751,149 +702,33 @@ except Exception as _e:
751
702
  on_output: Callable[[str, str, str], None],
752
703
  exec_id: str | None = None,
753
704
  ) -> ExecuteResult:
754
- """Execute code in subprocess with streaming output."""
755
- import subprocess
756
-
757
- print(f"[IPythonWorker._execute_subprocess_streaming] Starting subprocess execution", flush=True)
758
-
759
- python_exe = self._get_venv_python()
760
- print(f"[IPythonWorker._execute_subprocess_streaming] python_exe={python_exe}", flush=True)
761
-
762
- if not python_exe:
763
- return ExecuteResult(
764
- success=False,
765
- error=ExecuteError(
766
- ename="VenvError",
767
- evalue="Could not find Python executable in venv",
768
- traceback=[]
769
- )
770
- )
771
-
772
- self._execution_count += 1
773
- start_time = time.time()
774
- accumulated_stdout = ""
775
- accumulated_stderr = ""
776
-
777
- # Wrap code to capture expression results like IPython does
778
- # This handles cases like "import sys; sys.executable" which need to print
779
- wrapper_code = '''
780
- import sys as _sys
781
- import ast as _ast
782
-
783
- _code = """ ''' + code.replace('\\', '\\\\').replace('"""', '\\"\\"\\"') + ''' """
784
-
785
- # Parse to check if last statement is an expression
786
- try:
787
- _tree = _ast.parse(_code)
788
- if _tree.body and isinstance(_tree.body[-1], _ast.Expr):
789
- # Last statement is an expression - capture its value
790
- if len(_tree.body) > 1:
791
- # Execute all but last statement
792
- _exec_code = _ast.Module(body=_tree.body[:-1], type_ignores=[])
793
- exec(compile(_exec_code, "<cell>", "exec"))
794
- # Evaluate and print last expression
795
- _expr_code = _ast.Expression(body=_tree.body[-1].value)
796
- _result = eval(compile(_expr_code, "<cell>", "eval"))
797
- if _result is not None:
798
- print(f"Out[{''' + str(self._execution_count) + '''}]: " + repr(_result))
799
- else:
800
- # No trailing expression, just exec everything
801
- exec(compile(_code, "<cell>", "exec"))
802
- except SyntaxError as _e:
803
- # Fall back to simple exec for syntax errors in wrapper
804
- exec(compile(_code, "<cell>", "exec"))
805
- '''
806
-
807
- print(f"[IPythonWorker._execute_subprocess_streaming] Running: {python_exe} -c ...", flush=True)
808
-
705
+ """Execute code using the persistent SubprocessWorker with streaming."""
809
706
  try:
810
- # Start subprocess
811
- process = subprocess.Popen(
812
- [python_exe, "-c", wrapper_code],
813
- stdout=subprocess.PIPE,
814
- stderr=subprocess.PIPE,
815
- text=True,
816
- cwd=self.cwd,
707
+ worker = self._get_subprocess_worker()
708
+ return worker.execute_streaming(
709
+ code,
710
+ on_output=on_output,
711
+ store_history=True,
712
+ exec_id=exec_id,
817
713
  )
818
-
819
- # Read output in real-time
820
- import selectors
821
- sel = selectors.DefaultSelector()
822
- sel.register(process.stdout, selectors.EVENT_READ)
823
- sel.register(process.stderr, selectors.EVENT_READ)
824
-
825
- while process.poll() is None or sel.get_map():
826
- for key, _ in sel.select(timeout=0.1):
827
- data = key.fileobj.read(1)
828
- if not data:
829
- sel.unregister(key.fileobj)
830
- continue
831
- if key.fileobj == process.stdout:
832
- accumulated_stdout += data
833
- on_output("stdout", data, accumulated_stdout)
834
- else:
835
- accumulated_stderr += data
836
- on_output("stderr", data, accumulated_stderr)
837
-
838
- # Read any remaining output
839
- remaining_stdout = process.stdout.read()
840
- remaining_stderr = process.stderr.read()
841
- if remaining_stdout:
842
- accumulated_stdout += remaining_stdout
843
- on_output("stdout", remaining_stdout, accumulated_stdout)
844
- if remaining_stderr:
845
- accumulated_stderr += remaining_stderr
846
- on_output("stderr", remaining_stderr, accumulated_stderr)
847
-
848
- duration = int((time.time() - start_time) * 1000)
849
- success = process.returncode == 0
850
-
851
- if not success:
852
- error_lines = accumulated_stderr.strip().split('\n') if accumulated_stderr else []
853
- return ExecuteResult(
854
- success=False,
855
- stdout=accumulated_stdout,
856
- stderr=accumulated_stderr,
857
- error=ExecuteError(
858
- ename="ExecutionError",
859
- evalue=error_lines[-1] if error_lines else "Unknown error",
860
- traceback=error_lines
861
- ),
862
- executionCount=self._execution_count,
863
- duration=duration,
864
- )
865
-
866
- return ExecuteResult(
867
- success=True,
868
- stdout=accumulated_stdout,
869
- stderr=accumulated_stderr,
870
- executionCount=self._execution_count,
871
- duration=duration,
872
- )
873
-
874
714
  except Exception as e:
875
715
  return ExecuteResult(
876
716
  success=False,
877
717
  error=ExecuteError(
878
- ename=type(e).__name__,
879
- evalue=str(e),
718
+ type=type(e).__name__,
719
+ message=str(e),
880
720
  traceback=[]
881
721
  ),
882
- executionCount=self._execution_count,
883
722
  )
884
723
 
885
724
  def execute(
886
725
  self, code: str, store_history: bool = True, exec_id: str | None = None
887
726
  ) -> ExecuteResult:
888
727
  """Execute code and return result (non-streaming)."""
889
- print(f"[IPythonWorker.execute] Called with code length={len(code)}", flush=True)
890
-
891
728
  # Use subprocess execution if venv differs from current Python
892
729
  if self._should_use_subprocess():
893
- print("[IPythonWorker.execute] Using SUBPROCESS execution", flush=True)
894
730
  return self._execute_subprocess(code, exec_id)
895
731
 
896
- print("[IPythonWorker.execute] Using LOCAL execution", flush=True)
897
732
  self._ensure_initialized()
898
733
  self._captured_displays = []
899
734
  self._current_exec_id = exec_id
@@ -977,14 +812,10 @@ except SyntaxError as _e:
977
812
  Returns:
978
813
  ExecuteResult with final result
979
814
  """
980
- print(f"[IPythonWorker.execute_streaming] Called with code length={len(code)}", flush=True)
981
-
982
815
  # Use subprocess execution if venv differs from current Python
983
816
  if self._should_use_subprocess():
984
- print("[IPythonWorker.execute_streaming] Using SUBPROCESS execution", flush=True)
985
817
  return self._execute_subprocess_streaming(code, on_output, exec_id)
986
818
 
987
- print("[IPythonWorker.execute_streaming] Using LOCAL execution", flush=True)
988
819
  self._ensure_initialized()
989
820
  self._captured_displays = []
990
821
  self._current_exec_id = exec_id
@@ -1596,8 +1427,16 @@ except SyntaxError as _e:
1596
1427
 
1597
1428
  def reset(self):
1598
1429
  """Reset the namespace."""
1599
- self._ensure_initialized()
1600
- self.shell.reset()
1430
+ if self._subprocess_worker is not None:
1431
+ self._subprocess_worker.reset()
1432
+ elif self._initialized:
1433
+ self.shell.reset()
1434
+
1435
+ def shutdown(self):
1436
+ """Shutdown the worker, cleaning up subprocess if needed."""
1437
+ if self._subprocess_worker is not None:
1438
+ self._subprocess_worker.shutdown()
1439
+ self._subprocess_worker = None
1601
1440
 
1602
1441
  def get_info(self) -> dict:
1603
1442
  """Get info about this worker."""
File without changes
File without changes
File without changes