Skip to content

Commit c13dbb5

Browse files
authored
fix(match): change match creation behavior to be multi-thread compliant (#213)
1 parent 3ebb352 commit c13dbb5

3 files changed

Lines changed: 35 additions & 24 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/),
1111
- Added `premium_key` property to `language_tool_python.server.LanguageTool` to attach a premium API key for LanguageTool API requests.
1212
- Added `premium_username` property to `language_tool_python.server.LanguageTool` to attach a username for premium LanguageTool API requests.
1313
- 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`).
14+
- Added `language_tool_python.match.four_byte_char_positions` public function returning the positions of 4-byte encoded characters in a string (previously private).
1415
- Added `language_tool_python.config_file.ConfigValue` public type alias representing all accepted value types for `LanguageToolConfig`.
1516

1617
### Changed
@@ -31,6 +32,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/),
3132
- Added `typing_extensions` as a dependency for Python < 3.13 (fallback for the stdlib `warnings.deprecated`).
3233

3334
### Fixed
35+
- **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.
3436
- Corrected a bug in `language_tool_python.config_file.LanguageToolConfig` where directory paths were incorrectly rejected by the path validator.
3537
- 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.
3638
- 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).

‎src/language_tool_python/match.py‎

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from ._internals.api_types import CheckMatch
1717

18-
__all__ = ["Match", "is_check_match"]
18+
__all__ = ["Match", "four_byte_char_positions", "is_check_match"]
1919

2020
logger = logging.getLogger(__name__)
2121

@@ -61,7 +61,7 @@ def _get_match_ordered_dict() -> OrderedDictType[str, type]:
6161
)
6262

6363

64-
def _four_byte_char_positions(text: str) -> list[int]:
64+
def four_byte_char_positions(text: str) -> list[int]:
6565
"""Identify positions of 4-byte encoded characters in a UTF-8 string.
6666
6767
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
123123
and ``text``), ``replacements`` (items with ``value``), ``length``, and
124124
``message``.
125125
:type attrib: CheckMatch
126-
:param text: The original text in which the error occurred (the whole text,
127-
not just the context).
128-
:type text: str
126+
:param four_byte_positions: The positions of 4-byte encoded characters in the
127+
original text in which the error occurred (the whole text, not just the
128+
context), as returned by :func:`four_byte_char_positions`.
129+
:type four_byte_positions: list[int]
129130
130131
Example of a match object received from the LanguageTool API :
131132
@@ -176,13 +177,6 @@ class Match: # noqa: PLW1641 # Doesn't implement hash because it's mutable
176177
177178
"""
178179

179-
PREVIOUS_MATCHES_TEXT: str | None = None
180-
"""The text of the previous match object."""
181-
182-
FOUR_BYTES_POSITIONS: list[int] | None = None
183-
"""The positions of 4-byte encoded characters in the text, registered by the
184-
previous match object (kept for optimization purposes if the text is the same)."""
185-
186180
rule_id: str
187181
"""The ID of the rule that was violated."""
188182

@@ -213,12 +207,24 @@ class Match: # noqa: PLW1641 # Doesn't implement hash because it's mutable
213207
sentence: str
214208
"""The sentence that contains the rule violation."""
215209

216-
def __init__(self, attrib: CheckMatch, text: str) -> None:
210+
def __init__(
211+
self,
212+
attrib: CheckMatch,
213+
four_byte_positions: list[int],
214+
) -> None:
217215
"""Initialize a Match object with the given attributes.
218216
219217
The method processes and normalizes the attributes before storing them on the
220218
object. This method adjusts the positions of 4-byte encoded characters in the
221219
text to ensure the offsets of the matches are correct.
220+
221+
:param attrib: The raw LanguageTool API match.
222+
:type attrib: CheckMatch
223+
:param four_byte_positions: The positions of 4-byte encoded characters in the
224+
original text (the whole text, not just the context), as returned by
225+
:func:`four_byte_char_positions`. Callers processing multiple matches for
226+
the same text should compute this once and reuse it across all matches.
227+
:type four_byte_positions: list[int]
222228
"""
223229
# Process rule.
224230
custom_match: dict[str, str | int | list[str]] = {}
@@ -242,15 +248,15 @@ def __init__(self, attrib: CheckMatch, text: str) -> None:
242248
for k, v in custom_match.items():
243249
setattr(self, k, v)
244250

245-
if text != Match.PREVIOUS_MATCHES_TEXT:
246-
Match.PREVIOUS_MATCHES_TEXT = text
247-
Match.FOUR_BYTES_POSITIONS = _four_byte_char_positions(text)
248-
# Get the positions of 4-byte encoded characters in the text because without
249-
# carrying out this step, the offsets of the matches could be incorrect.
250-
if Match.FOUR_BYTES_POSITIONS is not None:
251-
self.offset -= sum(
252-
1 for pos in Match.FOUR_BYTES_POSITIONS if pos < self.offset
253-
)
251+
# Adjust the offset for 4-byte encoded characters because without carrying out
252+
# this step, the offsets of the matches could be incorrect.
253+
offset = self.offset
254+
adjustment = 0
255+
for pos in four_byte_positions:
256+
if pos >= offset:
257+
break
258+
adjustment += 1
259+
self.offset = offset - adjustment
254260

255261
def _ordered_items(self) -> list[tuple[str, _MatchValue]]:
256262
"""Return public match attributes in the documented order."""

‎src/language_tool_python/server.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
ServerError,
4141
)
4242
from .language_tag import LanguageTag
43-
from .match import Match
43+
from .match import Match, four_byte_char_positions
4444
from .utils import correct
4545

4646
_startupinfo: object | None = None
@@ -732,7 +732,10 @@ def check(self, text: str) -> list[Match]:
732732
err = f"Invalid response received from the LanguageTool server: {response}"
733733
raise ServerError(err)
734734
matches = response["matches"]
735-
return [Match(match, text) for match in matches]
735+
if not matches:
736+
return []
737+
positions = four_byte_char_positions(text)
738+
return [Match(match, positions) for match in matches]
736739

737740
def check_matching_regions(
738741
self,

0 commit comments

Comments
 (0)