@@ -143,92 +143,106 @@ def extract_mermaid_blocks(content: str) -> List[Tuple[int, str]]:
143143 return mermaid_blocks
144144
145145
146+ _PYTHONMONKEY_BROKEN = False
147+
148+
149+ async def _try_pythonmonkey_parse (diagram_content : str ) -> str | None :
150+ """Attempt to parse via PythonMonkey/mermaid-parser-py.
151+
152+ Returns the extracted parse-error message, "" on success, or None when
153+ PythonMonkey itself is unusable (broken JS event loop binding on
154+ Python 3.13+) so the caller can fall back to mermaid-py.
155+ """
156+ global _PYTHONMONKEY_BROKEN
157+ if _PYTHONMONKEY_BROKEN :
158+ return None
159+
160+ import sys
161+ import os
162+
163+ try :
164+ from mermaid_parser .parser import parse_mermaid_py
165+ except Exception :
166+ _PYTHONMONKEY_BROKEN = True
167+ return None
168+
169+ old_stderr = sys .stderr
170+ sys .stderr = open (os .devnull , 'w' )
171+ try :
172+ if (
173+ _main_loop is not None
174+ and _main_loop .is_running ()
175+ and threading .get_ident () != _main_loop_thread_ident
176+ ):
177+ fut = asyncio .run_coroutine_threadsafe (
178+ parse_mermaid_py (diagram_content ), _main_loop
179+ )
180+ await asyncio .wrap_future (fut )
181+ else :
182+ await parse_mermaid_py (diagram_content )
183+ return ""
184+ except Exception as e :
185+ error_str = str (e )
186+ # PythonMonkey 1.3.1 only supports Python 3.8-3.11; on newer Pythons
187+ # every JS call raises this. Latch the failure once so subsequent
188+ # diagrams skip the broken path and go straight to mermaid-py.
189+ if "cannot find a running Python event-loop" in error_str :
190+ _PYTHONMONKEY_BROKEN = True
191+ return None
192+ match = re .search (r"Error:(.*?)(?=Stack Trace:|$)" , error_str , re .DOTALL )
193+ if match :
194+ return match .group (0 ).strip ()
195+ # Unknown error from the JS parser — fall back rather than surface it.
196+ return None
197+ finally :
198+ sys .stderr .close ()
199+ sys .stderr = old_stderr
200+
201+
202+ def _parse_via_mermaid_py (diagram_content : str ) -> str :
203+ """Validate via mermaid-py. Returns parse-error text, or "" if valid.
204+
205+ mermaid-py raises MermaidError on parse failure and returns an SVG body
206+ on success — we must drive the result off the exception, not the body
207+ text, otherwise a successful SVG gets reported as a parse error.
208+ """
209+ import mermaid as md
210+ try :
211+ md .Mermaid (diagram_content )
212+ return ""
213+ except Exception as e :
214+ return str (e )
215+
216+
146217async def validate_single_diagram (diagram_content : str , diagram_num : int , line_start : int ) -> str :
147218 """
148219 Validate a single mermaid diagram.
149-
220+
150221 Args:
151222 diagram_content: The mermaid diagram content
152223 diagram_num: Diagram number for error reporting
153224 line_start: Starting line number in the file
154-
225+
155226 Returns:
156227 Error message if invalid, empty string if valid
157228 """
158- import sys
159- import os
160- from io import StringIO
161-
162- core_error = ""
163-
164- try :
165- from mermaid_parser .parser import parse_mermaid_py
166- # logger.debug("Using mermaid-parser-py to validate mermaid diagrams")
167-
168- try :
169- # Redirect stderr to suppress mermaid parser JavaScript errors
170- old_stderr = sys .stderr
171- sys .stderr = open (os .devnull , 'w' )
172-
173- try :
174- if (
175- _main_loop is not None
176- and _main_loop .is_running ()
177- and threading .get_ident () != _main_loop_thread_ident
178- ):
179- # Caller is on a worker-thread loop (caw FastMCP path).
180- # Run the coroutine on the loop where PythonMonkey was
181- # bound so its asyncio.get_running_loop() succeeds.
182- fut = asyncio .run_coroutine_threadsafe (
183- parse_mermaid_py (diagram_content ), _main_loop
184- )
185- json_output = await asyncio .wrap_future (fut )
186- else :
187- json_output = await parse_mermaid_py (diagram_content )
188- finally :
189- # Restore stderr
190- sys .stderr .close ()
191- sys .stderr = old_stderr
192- except Exception as e :
193- error_str = str (e )
194-
195- # Extract the core error information from the exception message
196- # Look for the pattern that contains "Parse error on line X:"
197- error_pattern = r"Error:(.*?)(?=Stack Trace:|$)"
198- match = re .search (error_pattern , error_str , re .DOTALL )
199-
200- if match :
201- core_error = match .group (0 ).strip ()
202- core_error = core_error
203- else :
204- logger .error (f"No match found for error pattern, fallback to mermaid-py\n { error_str } " )
205- logger .error (f"Traceback: { traceback .format_exc ()} " )
206- raise Exception (error_str )
207-
208- except Exception as e :
209- logger .warning ("Using mermaid-py to validate mermaid diagrams" )
229+ core_error = await _try_pythonmonkey_parse (diagram_content )
230+ if core_error is None :
210231 try :
211- import mermaid as md
212- # Create Mermaid object and check response
213- render = md .Mermaid (diagram_content )
214- core_error = render .svg_response .text
215-
232+ core_error = _parse_via_mermaid_py (diagram_content )
216233 except Exception as e :
217234 return f" Diagram { diagram_num } : Exception during validation - { str (e )} "
218235
219- # Check if response indicates a parse error
220- if core_error :
221- # Extract line number from parse error and calculate actual line in markdown file
222- line_match = re .search (r'line (\d+)' , core_error )
223- if line_match :
224- error_line_in_diagram = int (line_match .group (1 ))
225- actual_line_in_file = line_start + error_line_in_diagram
226- newline = '\n '
227- return f"Diagram { diagram_num } : Parse error on line { actual_line_in_file } :{ newline } { newline .join (core_error .split (newline )[1 :])} "
228- else :
229- return f"Diagram { diagram_num } : { core_error } "
230-
231- return "" # No error
236+ if not core_error :
237+ return ""
238+
239+ line_match = re .search (r'line (\d+)' , core_error )
240+ if line_match :
241+ error_line_in_diagram = int (line_match .group (1 ))
242+ actual_line_in_file = line_start + error_line_in_diagram
243+ newline = '\n '
244+ return f"Diagram { diagram_num } : Parse error on line { actual_line_in_file } :{ newline } { newline .join (core_error .split (newline )[1 :])} "
245+ return f"Diagram { diagram_num } : { core_error } "
232246
233247
234248if __name__ == "__main__" :
0 commit comments