From a03e1226c1e80620387a65622b2e43ff5864c952 Mon Sep 17 00:00:00 2001 From: mdevolde Date: Wed, 1 Jul 2026 16:59:22 +0300 Subject: [PATCH] fix(match): change match creation behavior to be multi-thread compliant --- CHANGELOG.md | 2 ++ src/language_tool_python/match.py | 50 +++++++++++++++++------------- src/language_tool_python/server.py | 7 +++-- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b08bcb..b8a7a7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/), - Added `premium_key` property to `language_tool_python.server.LanguageTool` to attach a premium API key for LanguageTool API requests. - Added `premium_username` property to `language_tool_python.server.LanguageTool` to attach a username for premium LanguageTool API requests. - Added `language_tool_python.match.is_check_match` type guard function to verify that a value is a `CheckMatch` (type from `language_tool_python._internals`). +- Added `language_tool_python.match.four_byte_char_positions` public function returning the positions of 4-byte encoded characters in a string (previously private). - Added `language_tool_python.config_file.ConfigValue` public type alias representing all accepted value types for `LanguageToolConfig`. ### Changed @@ -31,6 +32,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/), - Added `typing_extensions` as a dependency for Python < 3.13 (fallback for the stdlib `warnings.deprecated`). ### Fixed +- **Breaking:** Fixed a bug in `language_tool_python.match.Match` where a shared class-level cache of 4-byte character positions could be overwritten by a concurrent `.check()` call from another thread on a different text, producing incorrect match offsets. `language_tool_python.match.Match.__init__` now takes a `four_byte_positions: list[int]` argument instead of `text: str`. Removed the `Match.PREVIOUS_MATCHES_TEXT` and `Match.FOUR_BYTES_POSITIONS` class attributes. - Corrected a bug in `language_tool_python.config_file.LanguageToolConfig` where directory paths were incorrectly rejected by the path validator. - Fixed a bug in `LanguageTool._start_server_on_free_port` where `_url` was not updated when retrying on a different port, causing all subsequent server requests to target the wrong (original) port. - Fixed a bug in `LanguageTool._query_server` where `RateLimitError` was only raised when the rate-limit response body was invalid JSON, a valid JSON body with status 426 was silently returned as data instead (for now, the body from LanguageTool for rate-limiting responses is "Upgrade Required", which is not valid JSON, but this may change in the future). diff --git a/src/language_tool_python/match.py b/src/language_tool_python/match.py index 162a5a9..c158a1e 100644 --- a/src/language_tool_python/match.py +++ b/src/language_tool_python/match.py @@ -15,7 +15,7 @@ from ._internals.api_types import CheckMatch -__all__ = ["Match", "is_check_match"] +__all__ = ["Match", "four_byte_char_positions", "is_check_match"] logger = logging.getLogger(__name__) @@ -61,7 +61,7 @@ def _get_match_ordered_dict() -> OrderedDictType[str, type]: ) -def _four_byte_char_positions(text: str) -> list[int]: +def four_byte_char_positions(text: str) -> list[int]: """Identify positions of 4-byte encoded characters in a UTF-8 string. This function scans through the input text and identifies the positions of @@ -123,9 +123,10 @@ class Match: # noqa: PLW1641 # Doesn't implement hash because it's mutable and ``text``), ``replacements`` (items with ``value``), ``length``, and ``message``. :type attrib: CheckMatch - :param text: The original text in which the error occurred (the whole text, - not just the context). - :type text: str + :param four_byte_positions: The positions of 4-byte encoded characters in the + original text in which the error occurred (the whole text, not just the + context), as returned by :func:`four_byte_char_positions`. + :type four_byte_positions: list[int] Example of a match object received from the LanguageTool API : @@ -176,13 +177,6 @@ class Match: # noqa: PLW1641 # Doesn't implement hash because it's mutable """ - PREVIOUS_MATCHES_TEXT: str | None = None - """The text of the previous match object.""" - - FOUR_BYTES_POSITIONS: list[int] | None = None - """The positions of 4-byte encoded characters in the text, registered by the - previous match object (kept for optimization purposes if the text is the same).""" - rule_id: str """The ID of the rule that was violated.""" @@ -213,12 +207,24 @@ class Match: # noqa: PLW1641 # Doesn't implement hash because it's mutable sentence: str """The sentence that contains the rule violation.""" - def __init__(self, attrib: CheckMatch, text: str) -> None: + def __init__( + self, + attrib: CheckMatch, + four_byte_positions: list[int], + ) -> None: """Initialize a Match object with the given attributes. The method processes and normalizes the attributes before storing them on the object. This method adjusts the positions of 4-byte encoded characters in the text to ensure the offsets of the matches are correct. + + :param attrib: The raw LanguageTool API match. + :type attrib: CheckMatch + :param four_byte_positions: The positions of 4-byte encoded characters in the + original text (the whole text, not just the context), as returned by + :func:`four_byte_char_positions`. Callers processing multiple matches for + the same text should compute this once and reuse it across all matches. + :type four_byte_positions: list[int] """ # Process rule. custom_match: dict[str, str | int | list[str]] = {} @@ -242,15 +248,15 @@ def __init__(self, attrib: CheckMatch, text: str) -> None: for k, v in custom_match.items(): setattr(self, k, v) - if text != Match.PREVIOUS_MATCHES_TEXT: - Match.PREVIOUS_MATCHES_TEXT = text - Match.FOUR_BYTES_POSITIONS = _four_byte_char_positions(text) - # Get the positions of 4-byte encoded characters in the text because without - # carrying out this step, the offsets of the matches could be incorrect. - if Match.FOUR_BYTES_POSITIONS is not None: - self.offset -= sum( - 1 for pos in Match.FOUR_BYTES_POSITIONS if pos < self.offset - ) + # Adjust the offset for 4-byte encoded characters because without carrying out + # this step, the offsets of the matches could be incorrect. + offset = self.offset + adjustment = 0 + for pos in four_byte_positions: + if pos >= offset: + break + adjustment += 1 + self.offset = offset - adjustment def _ordered_items(self) -> list[tuple[str, _MatchValue]]: """Return public match attributes in the documented order.""" diff --git a/src/language_tool_python/server.py b/src/language_tool_python/server.py index 5ea514c..023b06d 100644 --- a/src/language_tool_python/server.py +++ b/src/language_tool_python/server.py @@ -40,7 +40,7 @@ ServerError, ) from .language_tag import LanguageTag -from .match import Match +from .match import Match, four_byte_char_positions from .utils import correct _startupinfo: object | None = None @@ -732,7 +732,10 @@ def check(self, text: str) -> list[Match]: err = f"Invalid response received from the LanguageTool server: {response}" raise ServerError(err) matches = response["matches"] - return [Match(match, text) for match in matches] + if not matches: + return [] + positions = four_byte_char_positions(text) + return [Match(match, positions) for match in matches] def check_matching_regions( self,