mrmd-python 0.3.4__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.4
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.4"
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,103 +682,10 @@ 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
- type="VenvError",
651
- message="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
- # Use .strip() to remove any leading/trailing whitespace from the embedded code
662
- wrapper_code = '''
663
- import sys as _sys
664
- import ast as _ast
665
-
666
- _code = """''' + code.replace('\\', '\\\\').replace('"""', '\\"\\"\\"') + '''""".strip()
667
-
668
- try:
669
- _tree = _ast.parse(_code)
670
- if _tree.body and isinstance(_tree.body[-1], _ast.Expr):
671
- # Last statement is an expression - capture its value
672
- if len(_tree.body) > 1:
673
- # Execute all but last statement
674
- _exec_code = _ast.Module(body=_tree.body[:-1], type_ignores=[])
675
- exec(compile(_exec_code, "<cell>", "exec"))
676
- # Evaluate and print last expression
677
- _expr_code = _ast.Expression(body=_tree.body[-1].value)
678
- _result = eval(compile(_expr_code, "<cell>", "eval"))
679
- if _result is not None:
680
- print(f"Out[''' + str(self._execution_count) + ''']: " + repr(_result))
681
- else:
682
- # No trailing expression, just exec everything
683
- exec(compile(_code, "<cell>", "exec"))
684
- except SyntaxError:
685
- # Fall back to simple exec
686
- exec(compile(_code, "<cell>", "exec"))
687
- except Exception as _e:
688
- import traceback as _tb
689
- print("".join(_tb.format_exception(type(_e), _e, _e.__traceback__)), file=_sys.stderr)
690
- _sys.exit(1)
691
- '''
692
-
685
+ """Execute code using the persistent SubprocessWorker (IPython-based)."""
693
686
  try:
694
- result = subprocess.run(
695
- [python_exe, "-c", wrapper_code],
696
- capture_output=True,
697
- text=True,
698
- timeout=300, # 5 minute timeout
699
- cwd=self.cwd,
700
- )
701
-
702
- duration = int((time.time() - start_time) * 1000)
703
-
704
- if result.returncode == 0:
705
- return ExecuteResult(
706
- success=True,
707
- stdout=result.stdout,
708
- stderr=result.stderr,
709
- executionCount=self._execution_count,
710
- duration=duration,
711
- )
712
- else:
713
- # Parse error from stderr if possible
714
- error_lines = result.stderr.strip().split('\n') if result.stderr else []
715
- return ExecuteResult(
716
- success=False,
717
- stdout=result.stdout,
718
- stderr=result.stderr,
719
- error=ExecuteError(
720
- type="ExecutionError",
721
- message=error_lines[-1] if error_lines else "Unknown error",
722
- traceback=error_lines
723
- ),
724
- executionCount=self._execution_count,
725
- duration=duration,
726
- )
727
-
728
- except subprocess.TimeoutExpired:
729
- return ExecuteResult(
730
- success=False,
731
- error=ExecuteError(
732
- type="TimeoutError",
733
- message="Execution timed out after 5 minutes",
734
- traceback=[]
735
- ),
736
- executionCount=self._execution_count,
737
- )
687
+ worker = self._get_subprocess_worker()
688
+ return worker.execute(code, store_history=True, exec_id=exec_id)
738
689
  except Exception as e:
739
690
  return ExecuteResult(
740
691
  success=False,
@@ -743,7 +694,6 @@ except Exception as _e:
743
694
  message=str(e),
744
695
  traceback=[]
745
696
  ),
746
- executionCount=self._execution_count,
747
697
  )
748
698
 
749
699
  def _execute_subprocess_streaming(
@@ -752,127 +702,15 @@ except Exception as _e:
752
702
  on_output: Callable[[str, str, str], None],
753
703
  exec_id: str | None = None,
754
704
  ) -> ExecuteResult:
755
- """Execute code in subprocess with streaming output."""
756
- import subprocess
757
-
758
- print(f"[IPythonWorker._execute_subprocess_streaming] Starting subprocess execution", flush=True)
759
-
760
- python_exe = self._get_venv_python()
761
- print(f"[IPythonWorker._execute_subprocess_streaming] python_exe={python_exe}", flush=True)
762
-
763
- if not python_exe:
764
- return ExecuteResult(
765
- success=False,
766
- error=ExecuteError(
767
- type="VenvError",
768
- message="Could not find Python executable in venv",
769
- traceback=[]
770
- )
771
- )
772
-
773
- self._execution_count += 1
774
- start_time = time.time()
775
- accumulated_stdout = ""
776
- accumulated_stderr = ""
777
-
778
- # Wrap code to capture expression results like IPython does
779
- # This handles cases like "import sys; sys.executable" which need to print
780
- # Use .strip() to remove any leading/trailing whitespace from the embedded code
781
- wrapper_code = '''
782
- import sys as _sys
783
- import ast as _ast
784
-
785
- _code = """''' + code.replace('\\', '\\\\').replace('"""', '\\"\\"\\"') + '''""".strip()
786
-
787
- # Parse to check if last statement is an expression
788
- try:
789
- _tree = _ast.parse(_code)
790
- if _tree.body and isinstance(_tree.body[-1], _ast.Expr):
791
- # Last statement is an expression - capture its value
792
- if len(_tree.body) > 1:
793
- # Execute all but last statement
794
- _exec_code = _ast.Module(body=_tree.body[:-1], type_ignores=[])
795
- exec(compile(_exec_code, "<cell>", "exec"))
796
- # Evaluate and print last expression
797
- _expr_code = _ast.Expression(body=_tree.body[-1].value)
798
- _result = eval(compile(_expr_code, "<cell>", "eval"))
799
- if _result is not None:
800
- print(f"Out[{''' + str(self._execution_count) + '''}]: " + repr(_result))
801
- else:
802
- # No trailing expression, just exec everything
803
- exec(compile(_code, "<cell>", "exec"))
804
- except SyntaxError as _e:
805
- # Fall back to simple exec for syntax errors in wrapper
806
- exec(compile(_code, "<cell>", "exec"))
807
- '''
808
-
809
- print(f"[IPythonWorker._execute_subprocess_streaming] Running: {python_exe} -c ...", flush=True)
810
-
705
+ """Execute code using the persistent SubprocessWorker with streaming."""
811
706
  try:
812
- # Start subprocess
813
- process = subprocess.Popen(
814
- [python_exe, "-c", wrapper_code],
815
- stdout=subprocess.PIPE,
816
- stderr=subprocess.PIPE,
817
- text=True,
818
- 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,
819
713
  )
820
-
821
- # Read output in real-time
822
- import selectors
823
- sel = selectors.DefaultSelector()
824
- sel.register(process.stdout, selectors.EVENT_READ)
825
- sel.register(process.stderr, selectors.EVENT_READ)
826
-
827
- while process.poll() is None or sel.get_map():
828
- for key, _ in sel.select(timeout=0.1):
829
- data = key.fileobj.read(1)
830
- if not data:
831
- sel.unregister(key.fileobj)
832
- continue
833
- if key.fileobj == process.stdout:
834
- accumulated_stdout += data
835
- on_output("stdout", data, accumulated_stdout)
836
- else:
837
- accumulated_stderr += data
838
- on_output("stderr", data, accumulated_stderr)
839
-
840
- # Read any remaining output
841
- remaining_stdout = process.stdout.read()
842
- remaining_stderr = process.stderr.read()
843
- if remaining_stdout:
844
- accumulated_stdout += remaining_stdout
845
- on_output("stdout", remaining_stdout, accumulated_stdout)
846
- if remaining_stderr:
847
- accumulated_stderr += remaining_stderr
848
- on_output("stderr", remaining_stderr, accumulated_stderr)
849
-
850
- duration = int((time.time() - start_time) * 1000)
851
- success = process.returncode == 0
852
-
853
- if not success:
854
- error_lines = accumulated_stderr.strip().split('\n') if accumulated_stderr else []
855
- return ExecuteResult(
856
- success=False,
857
- stdout=accumulated_stdout,
858
- stderr=accumulated_stderr,
859
- error=ExecuteError(
860
- type="ExecutionError",
861
- message=error_lines[-1] if error_lines else "Unknown error",
862
- traceback=error_lines
863
- ),
864
- executionCount=self._execution_count,
865
- duration=duration,
866
- )
867
-
868
- return ExecuteResult(
869
- success=True,
870
- stdout=accumulated_stdout,
871
- stderr=accumulated_stderr,
872
- executionCount=self._execution_count,
873
- duration=duration,
874
- )
875
-
876
714
  except Exception as e:
877
715
  return ExecuteResult(
878
716
  success=False,
@@ -881,21 +719,16 @@ except SyntaxError as _e:
881
719
  message=str(e),
882
720
  traceback=[]
883
721
  ),
884
- executionCount=self._execution_count,
885
722
  )
886
723
 
887
724
  def execute(
888
725
  self, code: str, store_history: bool = True, exec_id: str | None = None
889
726
  ) -> ExecuteResult:
890
727
  """Execute code and return result (non-streaming)."""
891
- print(f"[IPythonWorker.execute] Called with code length={len(code)}", flush=True)
892
-
893
728
  # Use subprocess execution if venv differs from current Python
894
729
  if self._should_use_subprocess():
895
- print("[IPythonWorker.execute] Using SUBPROCESS execution", flush=True)
896
730
  return self._execute_subprocess(code, exec_id)
897
731
 
898
- print("[IPythonWorker.execute] Using LOCAL execution", flush=True)
899
732
  self._ensure_initialized()
900
733
  self._captured_displays = []
901
734
  self._current_exec_id = exec_id
@@ -979,14 +812,10 @@ except SyntaxError as _e:
979
812
  Returns:
980
813
  ExecuteResult with final result
981
814
  """
982
- print(f"[IPythonWorker.execute_streaming] Called with code length={len(code)}", flush=True)
983
-
984
815
  # Use subprocess execution if venv differs from current Python
985
816
  if self._should_use_subprocess():
986
- print("[IPythonWorker.execute_streaming] Using SUBPROCESS execution", flush=True)
987
817
  return self._execute_subprocess_streaming(code, on_output, exec_id)
988
818
 
989
- print("[IPythonWorker.execute_streaming] Using LOCAL execution", flush=True)
990
819
  self._ensure_initialized()
991
820
  self._captured_displays = []
992
821
  self._current_exec_id = exec_id
@@ -1598,8 +1427,16 @@ except SyntaxError as _e:
1598
1427
 
1599
1428
  def reset(self):
1600
1429
  """Reset the namespace."""
1601
- self._ensure_initialized()
1602
- 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
1603
1440
 
1604
1441
  def get_info(self) -> dict:
1605
1442
  """Get info about this worker."""
File without changes
File without changes
File without changes