xai-review 0.8.0__py3-none-any.whl → 0.10.0__py3-none-any.whl

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.

Potentially problematic release.


This version of xai-review might be problematic. Click here for more details.

ai_review/libs/json.py ADDED
@@ -0,0 +1,19 @@
1
+ import re
2
+
3
+ CONTROL_CHARS_RE = re.compile(r"[\x00-\x1F]")
4
+
5
+
6
+ def sanitize_json_string(raw: str) -> str:
7
+ def replace(match: re.Match) -> str:
8
+ char = match.group()
9
+ match char:
10
+ case "\n":
11
+ return "\\n"
12
+ case "\r":
13
+ return "\\r"
14
+ case "\t":
15
+ return "\\t"
16
+ case _:
17
+ return f"\\u{ord(char):04x}"
18
+
19
+ return CONTROL_CHARS_RE.sub(replace, raw)
@@ -13,7 +13,7 @@ Supported build modes:
13
13
  - ADDED_AND_REMOVED_WITH_CONTEXT added + removed + surrounding unchanged lines
14
14
  """
15
15
  from enum import Enum
16
- from typing import Iterable, Optional
16
+ from typing import Iterable
17
17
 
18
18
  from ai_review.libs.diff.models import DiffFile, DiffLineType
19
19
  from ai_review.services.diff.tools import normalize_file_path, marker_for_line, read_snapshot
@@ -24,7 +24,7 @@ class MarkerType(Enum):
24
24
  REMOVED = "removed"
25
25
 
26
26
 
27
- def build_full_file_current(file: Optional[DiffFile], file_path: str, head_sha: str | None) -> str:
27
+ def build_full_file_current(file: DiffFile | None, file_path: str, head_sha: str | None) -> str:
28
28
  text = read_snapshot(file_path, head_sha=head_sha)
29
29
  if text is None:
30
30
  return f"# Failed to read current snapshot for {file_path}"
@@ -33,7 +33,7 @@ def build_full_file_current(file: Optional[DiffFile], file_path: str, head_sha:
33
33
  return render_plain_numbered(text.splitlines(), added_new, marker_type=MarkerType.ADDED)
34
34
 
35
35
 
36
- def build_full_file_previous(file: Optional[DiffFile], file_path: str, base_sha: str | None) -> str:
36
+ def build_full_file_previous(file: DiffFile | None, file_path: str, base_sha: str | None) -> str:
37
37
  text = read_snapshot(file_path, base_sha=base_sha)
38
38
  if text is None:
39
39
  return f"# Failed to read previous snapshot for {file_path} (base_sha missing or file absent)"
@@ -42,31 +42,31 @@ def build_full_file_previous(file: Optional[DiffFile], file_path: str, base_sha:
42
42
  return render_plain_numbered(text.splitlines(), removed_old, marker_type=MarkerType.REMOVED)
43
43
 
44
44
 
45
- def build_full_file_diff(file: DiffFile) -> str:
45
+ def build_full_file_diff(file: DiffFile | None) -> str:
46
46
  return render_unified(file, include_added=True, include_removed=True, include_unchanged=True, context=0)
47
47
 
48
48
 
49
- def build_only_added(file: DiffFile) -> str:
49
+ def build_only_added(file: DiffFile | None) -> str:
50
50
  return render_unified(file, include_added=True, include_removed=False, include_unchanged=False, context=0)
51
51
 
52
52
 
53
- def build_only_removed(file: DiffFile) -> str:
53
+ def build_only_removed(file: DiffFile | None) -> str:
54
54
  return render_unified(file, include_added=False, include_removed=True, include_unchanged=False, context=0)
55
55
 
56
56
 
57
- def build_added_and_removed(file: DiffFile) -> str:
57
+ def build_added_and_removed(file: DiffFile | None) -> str:
58
58
  return render_unified(file, include_added=True, include_removed=True, include_unchanged=False, context=0)
59
59
 
60
60
 
61
- def build_only_added_with_context(file: DiffFile, context: int) -> str:
61
+ def build_only_added_with_context(file: DiffFile | None, context: int) -> str:
62
62
  return render_unified(file, include_added=True, include_removed=False, include_unchanged=True, context=context)
63
63
 
64
64
 
65
- def build_only_removed_with_context(file: DiffFile, context: int) -> str:
65
+ def build_only_removed_with_context(file: DiffFile | None, context: int) -> str:
66
66
  return render_unified(file, include_added=False, include_removed=True, include_unchanged=True, context=context)
67
67
 
68
68
 
69
- def build_added_and_removed_with_context(file: DiffFile, context: int) -> str:
69
+ def build_added_and_removed_with_context(file: DiffFile | None, context: int) -> str:
70
70
  return render_unified(file, include_added=True, include_removed=True, include_unchanged=True, context=context)
71
71
 
72
72
 
@@ -87,7 +87,7 @@ def render_plain_numbered(lines: Iterable[str], changed: set[int], marker_type:
87
87
 
88
88
 
89
89
  def render_unified(
90
- file: DiffFile,
90
+ file: DiffFile | None,
91
91
  *,
92
92
  include_added: bool,
93
93
  include_removed: bool,
@@ -104,12 +104,19 @@ def render_unified(
104
104
 
105
105
  Context controls how many unchanged lines around modifications are shown.
106
106
  """
107
+ if file is None:
108
+ return "# Diff target not found"
109
+
110
+ if not file.hunks:
111
+ header = normalize_file_path(file.new_name or file.orig_name)
112
+ return f"# No matching lines for mode in {header}"
113
+
107
114
  lines_out: list[str] = []
108
115
 
109
116
  added_new_positions = file.added_line_numbers()
110
117
  removed_old_positions = file.removed_line_numbers()
111
118
 
112
- def in_context(old_no: Optional[int], new_no: Optional[int]) -> bool:
119
+ def in_context(old_no: int | None, new_no: int | None) -> bool:
113
120
  """Check if an unchanged line falls within context radius."""
114
121
  if context <= 0:
115
122
  return False
@@ -2,6 +2,7 @@ import re
2
2
 
3
3
  from pydantic import ValidationError
4
4
 
5
+ from ai_review.libs.json import sanitize_json_string
5
6
  from ai_review.libs.logger import get_logger
6
7
  from ai_review.services.review.inline.schema import InlineCommentListSchema
7
8
 
@@ -12,6 +13,19 @@ CLEAN_JSON_BLOCK_RE = re.compile(r"```(?:json)?(.*?)```", re.DOTALL | re.IGNOREC
12
13
 
13
14
 
14
15
  class InlineCommentService:
16
+ @classmethod
17
+ def try_parse_model_output(cls, raw: str) -> InlineCommentListSchema | None:
18
+ try:
19
+ return InlineCommentListSchema.model_validate_json(raw)
20
+ except ValidationError as error:
21
+ logger.debug(f"Parse failed, trying sanitized JSON: {raw[:200]=}, {error=}")
22
+ try:
23
+ cleaned = sanitize_json_string(raw)
24
+ return InlineCommentListSchema.model_validate_json(cleaned)
25
+ except ValidationError as error:
26
+ logger.debug(f"Sanitized JSON still invalid: {raw[:200]=}, {error=}")
27
+ return None
28
+
15
29
  @classmethod
16
30
  def parse_model_output(cls, output: str) -> InlineCommentListSchema:
17
31
  output = (output or "").strip()
@@ -22,17 +36,18 @@ class InlineCommentService:
22
36
  if match := CLEAN_JSON_BLOCK_RE.search(output):
23
37
  output = match.group(1).strip()
24
38
 
25
- try:
26
- return InlineCommentListSchema.model_validate_json(output)
27
- except ValidationError:
28
- logger.warning("LLM output is not valid JSON, trying to extract first JSON array...")
39
+ if parsed := cls.try_parse_model_output(output):
40
+ return parsed
41
+
42
+ logger.warning("Failed to parse LLM output as JSON, trying to extract first JSON array...")
29
43
 
30
44
  if json_array_match := FIRST_JSON_ARRAY_RE.search(output):
31
- try:
32
- return InlineCommentListSchema.model_validate_json(json_array_match.group(0))
33
- except ValidationError:
34
- logger.exception("JSON array found but still invalid")
45
+ if parsed := cls.try_parse_model_output(json_array_match.group(0)):
46
+ logger.info("Successfully parsed JSON after extracting array from output")
47
+ return parsed
48
+ else:
49
+ logger.error("Extracted JSON array is still invalid after sanitization")
35
50
  else:
36
- logger.exception("No JSON array found in LLM output")
51
+ logger.error("No JSON array found in LLM output")
37
52
 
38
53
  return InlineCommentListSchema(root=[])
@@ -0,0 +1,41 @@
1
+ import pytest
2
+
3
+ from ai_review.libs.json import sanitize_json_string
4
+
5
+
6
+ @pytest.mark.parametrize(
7
+ ("actual", "expected"),
8
+ [
9
+ ("hello world", "hello world"),
10
+ ("line1\nline2", "line1\\nline2"),
11
+ ("foo\rbar", "foo\\rbar"),
12
+ ("a\tb", "a\\tb"),
13
+ ("abc\0def", "abc\\u0000def"),
14
+ ("x\n\ry\t\0z", "x\\n\\ry\\t\\u0000z"),
15
+ ],
16
+ )
17
+ def test_sanitize_basic_cases(actual: str, expected: str) -> None:
18
+ assert sanitize_json_string(actual) == expected
19
+
20
+
21
+ def test_sanitize_multiple_control_chars() -> None:
22
+ raw = "A\nB\rC\tD\0E"
23
+ result = sanitize_json_string(raw)
24
+ assert result == "A\\nB\\rC\\tD\\u0000E"
25
+
26
+
27
+ def test_sanitize_idempotent() -> None:
28
+ raw = "foo\nbar"
29
+ once = sanitize_json_string(raw)
30
+ twice = sanitize_json_string(once)
31
+ assert once == twice
32
+
33
+
34
+ def test_sanitize_empty_string() -> None:
35
+ assert sanitize_json_string("") == ""
36
+
37
+
38
+ def test_sanitize_only_control_chars() -> None:
39
+ raw = "\n\r\t\0"
40
+ result = sanitize_json_string(raw)
41
+ assert result == "\\n\\r\\t\\u0000"
@@ -166,3 +166,26 @@ def test_build_added_and_removed_with_context(sample_diff_file: DiffFile) -> Non
166
166
  " 2: keep B\n"
167
167
  "+3: added me # added"
168
168
  )
169
+
170
+
171
+ def test_build_full_file_diff_empty_file() -> None:
172
+ """
173
+ Should handle new empty file (mode=NEW, no hunks).
174
+ """
175
+ file = DiffFile(
176
+ header="diff --git a/LICENSE b/LICENSE",
177
+ mode=FileMode.NEW,
178
+ orig_name="",
179
+ new_name="LICENSE",
180
+ hunks=[],
181
+ )
182
+ out = renderers.build_full_file_diff(file)
183
+ assert "New empty file: LICENSE" in out or "No matching lines" in out
184
+
185
+
186
+ def test_build_full_file_diff_none() -> None:
187
+ """
188
+ Should handle case when diff target is None.
189
+ """
190
+ out = renderers.build_full_file_diff(None)
191
+ assert "Diff target not found" in out or out == ""
@@ -47,3 +47,55 @@ def test_no_json_array_found_logs_and_returns_empty():
47
47
  output = "this is not json at all"
48
48
  result = InlineCommentService.parse_model_output(output)
49
49
  assert result.root == []
50
+
51
+
52
+ def test_json_with_raw_newline_sanitized():
53
+ output = '[{"file": "e.py", "line": 3, "message": "line1\nline2"}]'
54
+ result = InlineCommentService.parse_model_output(output)
55
+ assert len(result.root) == 1
56
+ assert result.root[0].message == "line1\nline2"
57
+
58
+
59
+ def test_json_with_tab_character_sanitized():
60
+ output = '[{"file": "f.py", "line": 4, "message": "a\tb"}]'
61
+ result = InlineCommentService.parse_model_output(output)
62
+ assert len(result.root) == 1
63
+ assert result.root[0].message == "a\tb"
64
+
65
+
66
+ def test_json_with_null_byte_sanitized():
67
+ raw = "abc\0def"
68
+ output = f'[{{"file": "g.py", "line": 5, "message": "{raw}"}}]'
69
+ result = InlineCommentService.parse_model_output(output)
70
+ assert len(result.root) == 1
71
+ assert result.root[0].message == "abc\0def"
72
+
73
+
74
+ def test_json_with_multiple_control_chars():
75
+ raw = "x\n\ry\t\0z"
76
+ output = f'[{{"file": "h.py", "line": 6, "message": "{raw}"}}]'
77
+ result = InlineCommentService.parse_model_output(output)
78
+ assert len(result.root) == 1
79
+ assert result.root[0].message == "x\n\ry\t\0z"
80
+
81
+
82
+ def test_try_parse_valid_json():
83
+ raw = '[{"file": "ok.py", "line": 1, "message": "all good"}]'
84
+ result = InlineCommentService.try_parse_model_output(raw)
85
+ assert isinstance(result, InlineCommentListSchema)
86
+ assert len(result.root) == 1
87
+ assert result.root[0].file == "ok.py"
88
+
89
+
90
+ def test_try_parse_needs_sanitization():
91
+ raw = '[{"file": "bad.py", "line": 2, "message": "line1\nline2"}]'
92
+ result = InlineCommentService.try_parse_model_output(raw)
93
+ assert result is not None
94
+ assert result.root[0].file == "bad.py"
95
+ assert "line1" in result.root[0].message
96
+
97
+
98
+ def test_try_parse_totally_invalid_returns_none():
99
+ raw = "this is not json at all"
100
+ result = InlineCommentService.try_parse_model_output(raw)
101
+ assert result is None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xai-review
3
- Version: 0.8.0
3
+ Version: 0.10.0
4
4
  Summary: AI-powered code review tool
5
5
  Author-email: Nikita Filonov <nikita.filonov@example.com>
6
6
  Maintainer-email: Nikita Filonov <nikita.filonov@example.com>
@@ -26,6 +26,7 @@ ai_review/clients/openai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZ
26
26
  ai_review/clients/openai/client.py,sha256=N07ZRnbptOtab8imMUZbGL-kRoOwIZmNYbHySumD3IU,1707
27
27
  ai_review/clients/openai/schema.py,sha256=glxwMtBrDA6W0BQgH-ruKe0bKH3Ps1P-Y1-2jGdqaUM,764
28
28
  ai_review/libs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
29
+ ai_review/libs/json.py,sha256=koGnjcPgHBq3DHvzr090pM1ZCPtM9TjAoqWhnZJIo1I,460
29
30
  ai_review/libs/logger.py,sha256=LbXR2Zk1btJ-83I-vHee7cUETgT1mHToSsqEI_8uM0U,370
30
31
  ai_review/libs/resources.py,sha256=s9taAbL1Shl_GiGkPpkkivUcM1Yh6d_IQAG97gffsJU,748
31
32
  ai_review/libs/asynchronous/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -78,7 +79,7 @@ ai_review/services/cost/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG
78
79
  ai_review/services/cost/schema.py,sha256=K3uCIMMxGL8AaIPh4a-d0mT5uIJuk3f805DkP8o8DtY,1323
79
80
  ai_review/services/cost/service.py,sha256=rK-jw0lDszv_O13CRZAGK7R-fB-Y7xakX8aVb86zcEk,2103
80
81
  ai_review/services/diff/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
81
- ai_review/services/diff/renderers.py,sha256=L9SEnkrYjt0kfoKl8qBH1dUDUrnA32CzCx0YI9IPvco,5611
82
+ ai_review/services/diff/renderers.py,sha256=tEUml-uqsi5FNoU2NYjxehsZHU61dTPR2VnFi7QVzV4,5861
82
83
  ai_review/services/diff/schema.py,sha256=17GAQY1-ySwREJ1-NKNKgBcstMJ5Hb42FcFG2p7i6Rs,94
83
84
  ai_review/services/diff/service.py,sha256=FDuMw_y2QVQcfSbkVv3H2uGf1sIMeQ0_KHYCPhCU24g,3498
84
85
  ai_review/services/diff/tools.py,sha256=YHmH6Ult_rucCd563UhG0geMzqrPhqKFZKyug79xNuA,1963
@@ -102,7 +103,7 @@ ai_review/services/review/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJW
102
103
  ai_review/services/review/service.py,sha256=8YhRFqhZAk2pAnkDaytKSCENlOeOti1brAJq3R9tVMY,8394
103
104
  ai_review/services/review/inline/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
104
105
  ai_review/services/review/inline/schema.py,sha256=ry8sJdTgusQvFW51neRiapzgzVGwswwJzdYhaV3hbT0,1545
105
- ai_review/services/review/inline/service.py,sha256=2joeGCoPLacQAsmEtEKzmqPTQNYFYW8Dq3Cyfn3HZ_I,1408
106
+ ai_review/services/review/inline/service.py,sha256=qJqtjLY1En07_ZiI_wnhJHZDFfUSUHDqkMXutd1ZuMc,2131
106
107
  ai_review/services/review/policy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
107
108
  ai_review/services/review/policy/service.py,sha256=yGDePLxAEF3N1Pkh47jGVd-4dGEESyxDXIXxV7KQfuY,2027
108
109
  ai_review/services/review/summary/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -130,6 +131,7 @@ ai_review/tests/suites/clients/openai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCe
130
131
  ai_review/tests/suites/clients/openai/test_client.py,sha256=Ox5ifP1C_gSeDRacBa9DnXhe__Z8-WcbzR2JH_V3xKo,1050
131
132
  ai_review/tests/suites/clients/openai/test_schema.py,sha256=x1tamS4GC9pOTpjieKDbK2D73CVV4BkATppytwMevLo,1599
132
133
  ai_review/tests/suites/libs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
134
+ ai_review/tests/suites/libs/test_json.py,sha256=vmbkzRRyPuP-0uyLLlj-Tf_0MAWI_V2wDev9sM8tAHw,1054
133
135
  ai_review/tests/suites/libs/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
134
136
  ai_review/tests/suites/libs/config/test_prompt.py,sha256=kDMTnykC54tTPfE6cqYRBbV8d5jzAKucXdJfwtqUybM,2242
135
137
  ai_review/tests/suites/libs/diff/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -140,7 +142,7 @@ ai_review/tests/suites/libs/template/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeu
140
142
  ai_review/tests/suites/libs/template/test_render.py,sha256=n-ss5bd_hwc-RzYmqWmFM6KSlP1zLSnlsW1Yki12Bpw,1890
141
143
  ai_review/tests/suites/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
142
144
  ai_review/tests/suites/services/diff/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
143
- ai_review/tests/suites/services/diff/test_renderers.py,sha256=XFXaOTGAdPOJRb8w-1BOX-eci1C49mKvH4vckybrJSg,5641
145
+ ai_review/tests/suites/services/diff/test_renderers.py,sha256=IKOpsGedONNW8ZfYTAk0Vq0hfFi7L6TpWs8vVVQroj0,6273
144
146
  ai_review/tests/suites/services/diff/test_service.py,sha256=iFkGX9Vj2X44JU3eFsoHsg9o9353eKX-QCv_J9KxfzU,3162
145
147
  ai_review/tests/suites/services/diff/test_tools.py,sha256=HBQ3eCn-kLeb_k5iTgyr09x0VwLYSegSbxm0Qk9ZrCc,3543
146
148
  ai_review/tests/suites/services/prompt/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -149,15 +151,15 @@ ai_review/tests/suites/services/prompt/test_service.py,sha256=M8vvBhEbyHnXCSiIRu
149
151
  ai_review/tests/suites/services/review/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
150
152
  ai_review/tests/suites/services/review/inline/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
151
153
  ai_review/tests/suites/services/review/inline/test_schema.py,sha256=tIz-1UA_GgwcdsyUqgrodiiVVmd_jhoOVmtEwzRVWiY,2474
152
- ai_review/tests/suites/services/review/inline/test_service.py,sha256=5YP5xoijfn39jdAlmL5fEWBNjD4XhsgKnCPBUZDMZfw,1729
154
+ ai_review/tests/suites/services/review/inline/test_service.py,sha256=ZcrEWKyD-Fsu3tqAOzT1Et1bxLbX6Hn0_Jc9xE_1_78,3566
153
155
  ai_review/tests/suites/services/review/policy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
154
156
  ai_review/tests/suites/services/review/policy/test_service.py,sha256=kRWT550OjWYQ7ZfsihBRc-tx-NMkhlynEsqur55RK0M,3687
155
157
  ai_review/tests/suites/services/review/summary/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
156
158
  ai_review/tests/suites/services/review/summary/test_schema.py,sha256=xSoydvABZldHaVDa0OBFvYrj8wMuZqUDN3MO-XdvxOI,819
157
159
  ai_review/tests/suites/services/review/summary/test_service.py,sha256=8UMvi_NL9frm280vD6Q1NCDrdI7K8YbXzoViIus-I2g,541
158
- xai_review-0.8.0.dist-info/licenses/LICENSE,sha256=p-v8m7Kmz4KKc7PcvsGiGEmCw9AiSXY4_ylOPy_u--Y,11343
159
- xai_review-0.8.0.dist-info/METADATA,sha256=d7MEYbJjP_FPZpuZdbHmtaak6gyDbk72-BV07n3u67o,9617
160
- xai_review-0.8.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
161
- xai_review-0.8.0.dist-info/entry_points.txt,sha256=JyC5URanMi5io5P_PXQf7H_I1OGIpk5cZQhaPQ0g4Zs,53
162
- xai_review-0.8.0.dist-info/top_level.txt,sha256=sTsZbfzLoqvRZKdKa-BcxWvjlHdrpbeJ6DrGY0EuR0E,10
163
- xai_review-0.8.0.dist-info/RECORD,,
160
+ xai_review-0.10.0.dist-info/licenses/LICENSE,sha256=p-v8m7Kmz4KKc7PcvsGiGEmCw9AiSXY4_ylOPy_u--Y,11343
161
+ xai_review-0.10.0.dist-info/METADATA,sha256=eiLnNDKQiqcKlXpaAL2-rkF8mHENCWJtUAUliMzeVic,9618
162
+ xai_review-0.10.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
163
+ xai_review-0.10.0.dist-info/entry_points.txt,sha256=JyC5URanMi5io5P_PXQf7H_I1OGIpk5cZQhaPQ0g4Zs,53
164
+ xai_review-0.10.0.dist-info/top_level.txt,sha256=sTsZbfzLoqvRZKdKa-BcxWvjlHdrpbeJ6DrGY0EuR0E,10
165
+ xai_review-0.10.0.dist-info/RECORD,,