11"""LanguageTool server management module."""
22
33import atexit
4+ import contextlib
45import http .client
56import json
67import os
7- import re
8+ import random
89import socket
910import subprocess
10- import threading
11+ import time
1112import urllib .parse
1213from typing import Any , Dict , List , Optional , Set
1314
4243RUNNING_SERVER_PROCESSES : List [subprocess .Popen ] = []
4344
4445
46+ def _kill_processes (processes : List [subprocess .Popen ]) -> None :
47+ """
48+ Kill all running server processes.
49+ This function iterates over the list of running server processes and
50+ forcefully kills each process by its PID.
51+
52+ :param processes: A list of subprocess.Popen objects representing the running server processes.
53+ :type processes: List[subprocess.Popen]
54+ """
55+ for pid in [p .pid for p in processes ]:
56+ with contextlib .suppress (psutil .NoSuchProcess ):
57+ kill_process_force (pid = pid )
58+
59+
4560class LanguageTool :
4661 """
4762 A class to interact with the LanguageTool server for text checking and correction.
@@ -64,21 +79,17 @@ class LanguageTool:
6479 :type language_tool_download_version: Optional[str]
6580
6681 Attributes:
67- _MIN_PORT (int): The minimum port number to use for the server.
68- _MAX_PORT (int): The maximum port number to use for the server.
82+ _AVAILABLE_PORTS (List[int]): A list of available ports for the server, shuffled randomly.
6983 _TIMEOUT (int): The timeout for server requests.
7084 _remote (bool): A flag to indicate if the server is remote.
7185 _port (int): The port number to use for the server.
7286 _server (subprocess.Popen): The server process.
73- _consumer_thread (threading.Thread): The thread to consume server output.
74- _PORT_RE (re.Pattern): A compiled regular expression pattern to match the server port.
7587 language_tool_download_version (str): The version of LanguageTool to download.
7688 _new_spellings (List[str]): A list of new spellings to register.
7789 _new_spellings_persist (bool): A flag to indicate if new spellings should persist.
7890 _host (str): The host to use for the server.
7991 config (LanguageToolConfig): The configuration to use for the server.
8092 _url (str): The URL of the server if remote.
81- _stop_consume_event (threading.Event): An event to signal the consumer thread to stop.
8293 motherTongue (str): The user's mother tongue (used in requests to the server).
8394 disabled_rules (Set[str]): A set of disabled rules (used in requests to the server).
8495 enabled_rules (Set[str]): A set of enabled rules (used in requests to the server).
@@ -91,15 +102,6 @@ class LanguageTool:
91102 _spell_checking_categories (Set[str]): A set of spell-checking categories.
92103 """
93104
94- _MIN_PORT = 8081
95- _MAX_PORT = 8999
96- _TIMEOUT = 5 * 60
97- _remote = False
98- _port = _MIN_PORT
99- _server : subprocess .Popen = None
100- _consumer_thread : threading .Thread = None
101- _PORT_RE = re .compile (r"(?:https?://.*:|port\s+)(\d+)" , re .I )
102-
103105 def __init__ (
104106 self ,
105107 language = None ,
@@ -114,10 +116,16 @@ def __init__(
114116 """
115117 Initialize the LanguageTool server.
116118 """
119+ self ._remote = False
120+ self ._TIMEOUT = 5 * 60
117121 self .language_tool_download_version = language_tool_download_version
118122 self ._new_spellings = None
119123 self ._new_spellings_persist = new_spellings_persist
120124 self ._host = host or socket .gethostbyname ("localhost" )
125+ self ._AVAILABLE_PORTS = list (range (8081 , 8999 ))
126+ random .shuffle (self ._AVAILABLE_PORTS )
127+ self ._port = self ._AVAILABLE_PORTS .pop ()
128+ self ._server : subprocess .Popen = None
121129
122130 if remote_server and config is not None :
123131 raise ValueError ("Cannot use both remote_server and config parameters." )
@@ -129,7 +137,7 @@ def __init__(
129137 self ._url = urllib .parse .urljoin (self ._url , "v2/" )
130138 self ._update_remote_server_config (self ._url )
131139 elif not self ._server_is_alive ():
132- self ._stop_consume_event = threading . Event ()
140+ self ._url = f"http:// { self . _host } : { self . _port } /v2/"
133141 self ._start_server_on_free_port ()
134142 if language is None :
135143 try :
@@ -541,13 +549,12 @@ def _start_server_on_free_port(self) -> None:
541549 :raises ServerError: If the server cannot be started and the maximum port number is reached.
542550 """
543551 while True :
544- self ._url = f"http://{ self ._host } :{ self ._port } /v2/"
545552 try :
546553 self ._start_local_server ()
547554 break
548555 except ServerError :
549- if self ._MIN_PORT <= self . _port < self . _MAX_PORT :
550- self ._port += 1
556+ if len ( self ._AVAILABLE_PORTS ) > 0 :
557+ self ._port = self . _AVAILABLE_PORTS . pop ()
551558 else :
552559 raise
553560
@@ -559,18 +566,11 @@ def _start_local_server(self) -> None:
559566 version. It handles the server initialization, including setting up
560567 the server command and managing the server process.
561568
562- Notes:
563- - This method uses subprocess to start the server and reads the server
564- output to determine the port it is running on.
565- - It also starts a consumer thread to handle the server's stdout.
566-
567569 :raises PathError: If the path to LanguageTool cannot be found.
568- :raises LanguageToolError: If the server starts on a different port than requested.
569- :raises ServerError: If the server is already running or cannot be started.
570+ :raises ServerError: If the server fails to start or exits early.
570571 """
571572 # Before starting local server, download language tool if needed.
572573 download_lt (self .language_tool_download_version )
573- err = None
574574 try :
575575 if DEBUG_MODE :
576576 if self ._port :
@@ -582,64 +582,62 @@ def _start_local_server(self) -> None:
582582 )
583583 server_cmd = get_server_cmd (self ._port , self .config )
584584 except PathError as e :
585- # Can't find path to LanguageTool.
586- err = e
585+ raise PathError (
586+ "Failed to find LanguageTool. Please ensure it is downloaded correctly." ,
587+ ) from e
587588 else :
588- # Need to PIPE all handles: http://bugs.python.org/issue3905
589589 self ._server = subprocess .Popen (
590590 server_cmd ,
591591 stdin = subprocess .PIPE ,
592- stdout = subprocess .PIPE ,
593- stderr = subprocess .PIPE ,
594- universal_newlines = True ,
592+ stdout = subprocess .DEVNULL ,
593+ stderr = subprocess .DEVNULL ,
594+ text = True ,
595595 startupinfo = startupinfo ,
596596 )
597597 global RUNNING_SERVER_PROCESSES
598598 RUNNING_SERVER_PROCESSES .append (self ._server )
599599
600- match = None
601- while True :
602- line = self ._server .stdout .readline ()
603- if not line :
604- break
605- match = self ._PORT_RE .search (line )
606- if match :
607- port = int (match .group (1 ))
608- if port != self ._port :
609- raise LanguageToolError (
610- f"requested port { self ._port } , but got { port } " ,
611- )
612- break
613- if not match :
614- err_msg = self ._terminate_server ()
615- match = self ._PORT_RE .search (err_msg )
616- if not match :
617- raise LanguageToolError (err_msg )
618- port = int (match .group (1 ))
619- if port != self ._port :
620- raise LanguageToolError (err_msg )
600+ self ._wait_for_server_ready ()
621601
622- if self ._server :
623- self ._consumer_thread = threading .Thread (
624- target = lambda : self ._consume (self ._server .stdout ),
625- )
626- self ._consumer_thread .daemon = True
627- self ._consumer_thread .start ()
628- else :
629- # Couldn't start the server, so maybe there is already one running.
630- if err :
631- raise Exception (err )
602+ if not self ._server :
632603 raise ServerError ("Server running; don't start a server here." )
633604
634- def _consume (self , stdout : Any ) -> None :
605+ def _wait_for_server_ready (self , timeout : int = 15 ) -> None :
635606 """
636- Continuously reads from the provided stdout until a stop event is set.
607+ Wait for the LanguageTool server to become ready and responsive.
608+ This method polls the server's ``/languages`` endpoint until it responds
609+ successfully or until the timeout is reached. It also monitors the server
610+ process to detect early exits.
637611
638- :param stdout: The output stream to read from.
639- :type stdout: Any
612+ :param timeout: Maximum time in seconds to wait for the server to become ready.
613+ Defaults to 15 seconds.
614+ :type timeout: int
615+ :raises ServerError: If the server process exits early with a non-zero code,
616+ or if the server does not become ready within the specified
617+ timeout period.
640618 """
641- while not self ._stop_consume_event .is_set () and stdout .readline ():
642- pass
619+ url = urllib .parse .urljoin (self ._url , "languages" )
620+ start = time .time ()
621+
622+ while time .time () - start < timeout :
623+ # Early exit check
624+ ret = self ._server .poll ()
625+ if ret is not None :
626+ raise ServerError (f"LanguageTool server exited early with code { ret } " )
627+
628+ # Attempt to connect
629+ with contextlib .suppress (requests .RequestException ):
630+ r = requests .get (url , timeout = 2 )
631+ if r .ok :
632+ return
633+
634+ time .sleep (0.2 )
635+
636+ # Timeout without response
637+ raise ServerError (
638+ f"LanguageTool server did not become ready on { self ._host } :{ self ._port } "
639+ f"within { timeout } seconds"
640+ )
643641
644642 def _server_is_alive (self ) -> bool :
645643 """
@@ -651,65 +649,22 @@ def _server_is_alive(self) -> bool:
651649 """
652650 return self ._server and self ._server .poll () is None
653651
654- def _terminate_server (self ) -> str :
652+ def _terminate_server (self ) -> None :
655653 """
656- Terminates the server process and associated consumer thread .
654+ Terminates the server process.
657655 This method performs the following steps:
658- 1. Signals the consumer thread to stop consuming stdout.
659- 2. Waits for the consumer thread to finish.
660- 3. Attempts to terminate the server process gracefully.
661- 4. If the server process does not terminate within the timeout, force kills it.
662- 5. Closes all associated file descriptors (stdin, stdout, stderr).
663- 6. Captures any error messages from stderr, if available.
664-
665- :return: Error message from stderr, if any, for further logging or debugging.
666- :rtype: str
656+ 1. Attempts to terminate the server process gracefully.
657+ 2. Closes associated file descriptor (stdin).
667658 """
668- # Signal the consumer thread to stop consuming stdout
669- self ._stop_consume_event .set ()
670- if self ._consumer_thread :
671- # Wait for the consumer thread to finish
672- self ._consumer_thread .join (timeout = 5 )
673-
674- error_message = ""
675659 if self ._server :
676- try :
677- try :
678- # Get the main server process using psutil
679- proc = psutil .Process (self ._server .pid )
680- except psutil .NoSuchProcess :
681- # If the process doesn't exist, set proc to None
682- proc = None
683-
684- # Attempt to terminate the process gracefully
685- self ._server .terminate ()
686- # Wait for the process to terminate and capture any stderr output
687- _ , stderr = self ._server .communicate (timeout = 5 )
688-
689- except subprocess .TimeoutExpired :
690- # If the process does not terminate within the timeout, force kill it
691- kill_process_force (proc = proc )
692- # Capture remaining stderr output after force termination
693- _ , stderr = self ._server .communicate ()
694-
695- finally :
696- # Close all associated file descriptors (stdin, stdout, stderr)
697- if self ._server .stdin :
698- self ._server .stdin .close ()
699- if self ._server .stdout :
700- self ._server .stdout .close ()
701- if self ._server .stderr :
702- self ._server .stderr .close ()
703-
704- # Release the server process object
705- self ._server = None
706-
707- # Capture any error messages from stderr, if available
708- if stderr :
709- error_message = stderr .strip ()
710-
711- # Return the error message, if any, for further logging or debugging
712- return error_message
660+ _kill_processes ([self ._server ])
661+ RUNNING_SERVER_PROCESSES .remove (self ._server )
662+
663+ if self ._server .stdin :
664+ self ._server .stdin .close ()
665+
666+ # Release the server process object
667+ self ._server = None
713668
714669
715670class LanguageToolPublicAPI (LanguageTool ):
@@ -738,5 +693,4 @@ def terminate_server() -> None:
738693 This function iterates over the list of running server processes and
739694 forcefully kills each process by its PID.
740695 """
741- for pid in [p .pid for p in RUNNING_SERVER_PROCESSES ]:
742- kill_process_force (pid = pid )
696+ _kill_processes (RUNNING_SERVER_PROCESSES )
0 commit comments