pycforge 0.15.2__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.
Files changed (221) hide show
  1. pycforge/__init__.py +8 -0
  2. pycforge/__main__.py +2 -0
  3. pycforge/_version.py +1 -0
  4. pycforge/converter/__init__.py +0 -0
  5. pycforge/converter/analysis/__init__.py +5 -0
  6. pycforge/converter/analysis/functions.py +723 -0
  7. pycforge/converter/analysis/model.py +158 -0
  8. pycforge/converter/analysis/planning.py +464 -0
  9. pycforge/converter/analysis/stage.py +1976 -0
  10. pycforge/converter/analysis/symbols.py +237 -0
  11. pycforge/converter/analysis/validation.py +2166 -0
  12. pycforge/converter/c_output/__init__.py +2 -0
  13. pycforge/converter/c_output/conformance.py +297 -0
  14. pycforge/converter/c_output/renderer.py +220 -0
  15. pycforge/converter/conditional_regions/__init__.py +71 -0
  16. pycforge/converter/conditional_regions/analysis.py +481 -0
  17. pycforge/converter/conditional_regions/lowering.py +579 -0
  18. pycforge/converter/conditional_regions/model.py +243 -0
  19. pycforge/converter/conditional_regions/validation.py +690 -0
  20. pycforge/converter/containers/__init__.py +19 -0
  21. pycforge/converter/containers/analysis.py +542 -0
  22. pycforge/converter/containers/lowering.py +290 -0
  23. pycforge/converter/containers/model.py +146 -0
  24. pycforge/converter/contracts/__init__.py +0 -0
  25. pycforge/converter/contracts/configuration.py +178 -0
  26. pycforge/converter/contracts/identifiers.py +68 -0
  27. pycforge/converter/contracts/versions.py +71 -0
  28. pycforge/converter/core/__init__.py +0 -0
  29. pycforge/converter/core/artifact_io.py +87 -0
  30. pycforge/converter/core/cancellation.py +7 -0
  31. pycforge/converter/core/canonicalization.py +254 -0
  32. pycforge/converter/core/context.py +13 -0
  33. pycforge/converter/core/diagnostics.py +95 -0
  34. pycforge/converter/core/enums.py +23 -0
  35. pycforge/converter/core/fingerprint.py +22 -0
  36. pycforge/converter/core/pipeline.py +8 -0
  37. pycforge/converter/core/progress.py +25 -0
  38. pycforge/converter/core/request.py +50 -0
  39. pycforge/converter/core/resource_policy.py +31 -0
  40. pycforge/converter/core/result.py +23 -0
  41. pycforge/converter/core/serialization.py +36 -0
  42. pycforge/converter/core/stage.py +10 -0
  43. pycforge/converter/core/stage_artifact.py +80 -0
  44. pycforge/converter/core/stage_outcome.py +15 -0
  45. pycforge/converter/decision_trace/__init__.py +0 -0
  46. pycforge/converter/decision_trace/recorder.py +52 -0
  47. pycforge/converter/facade.py +459 -0
  48. pycforge/converter/frontend/__init__.py +4 -0
  49. pycforge/converter/frontend/normalizer.py +59 -0
  50. pycforge/converter/frontend/parser.py +20 -0
  51. pycforge/converter/frontend/source_document.py +93 -0
  52. pycforge/converter/frontend/stages.py +518 -0
  53. pycforge/converter/frontend/validation.py +76 -0
  54. pycforge/converter/io/__init__.py +0 -0
  55. pycforge/converter/io/atomic_writer.py +33 -0
  56. pycforge/converter/ir/__init__.py +1 -0
  57. pycforge/converter/ir/c_ir/__init__.py +4 -0
  58. pycforge/converter/ir/c_ir/builder.py +26 -0
  59. pycforge/converter/ir/c_ir/model.py +318 -0
  60. pycforge/converter/ir/c_ir/serialization.py +88 -0
  61. pycforge/converter/ir/c_ir/validation.py +834 -0
  62. pycforge/converter/ir/python_ir/__init__.py +17 -0
  63. pycforge/converter/ir/python_ir/nodes.py +86 -0
  64. pycforge/converter/keyword_calls/__init__.py +68 -0
  65. pycforge/converter/keyword_calls/analysis.py +458 -0
  66. pycforge/converter/keyword_calls/lowering.py +271 -0
  67. pycforge/converter/keyword_calls/model.py +304 -0
  68. pycforge/converter/keyword_calls/validation.py +802 -0
  69. pycforge/converter/keyword_only_calls/__init__.py +68 -0
  70. pycforge/converter/keyword_only_calls/analysis.py +640 -0
  71. pycforge/converter/keyword_only_calls/lowering.py +376 -0
  72. pycforge/converter/keyword_only_calls/model.py +328 -0
  73. pycforge/converter/keyword_only_calls/validation.py +1357 -0
  74. pycforge/converter/lowering.py +1000 -0
  75. pycforge/converter/modules/__init__.py +13 -0
  76. pycforge/converter/modules/analysis.py +1250 -0
  77. pycforge/converter/modules/lowering.py +200 -0
  78. pycforge/converter/modules/model.py +73 -0
  79. pycforge/converter/modules/stage.py +192 -0
  80. pycforge/converter/numeric_semantics/__init__.py +23 -0
  81. pycforge/converter/numeric_semantics/analysis.py +420 -0
  82. pycforge/converter/numeric_semantics/lowering.py +153 -0
  83. pycforge/converter/numeric_semantics/model.py +123 -0
  84. pycforge/converter/records/__init__.py +34 -0
  85. pycforge/converter/records/analysis.py +1394 -0
  86. pycforge/converter/records/lowering.py +393 -0
  87. pycforge/converter/records/model.py +319 -0
  88. pycforge/converter/rules/__init__.py +3 -0
  89. pycforge/converter/support_templates/__init__.py +49 -0
  90. pycforge/converter/support_templates/assembly.py +106 -0
  91. pycforge/converter/support_templates/factories.py +348 -0
  92. pycforge/converter/support_templates/model.py +159 -0
  93. pycforge/converter/support_templates/registry.py +388 -0
  94. pycforge/converter/telemetry/__init__.py +0 -0
  95. pycforge/converter/telemetry/sink.py +39 -0
  96. pycforge/ide/__init__.py +19 -0
  97. pycforge/ide/__main__.py +5 -0
  98. pycforge/ide/_worker_event_codec.py +276 -0
  99. pycforge/ide/_worker_protocol_json.py +197 -0
  100. pycforge/ide/_worker_protocol_types.py +90 -0
  101. pycforge/ide/_worker_request_codec.py +200 -0
  102. pycforge/ide/_worker_request_data.py +260 -0
  103. pycforge/ide/_worker_result_data.py +343 -0
  104. pycforge/ide/action_contract.py +469 -0
  105. pycforge/ide/action_validation.py +229 -0
  106. pycforge/ide/command_palette.py +223 -0
  107. pycforge/ide/controller.py +904 -0
  108. pycforge/ide/controller_conversion.py +546 -0
  109. pycforge/ide/controller_io.py +205 -0
  110. pycforge/ide/editor.py +951 -0
  111. pycforge/ide/editor_commands_qt.py +274 -0
  112. pycforge/ide/editor_lexical.py +43 -0
  113. pycforge/ide/editor_sidebars.py +236 -0
  114. pycforge/ide/editor_syntax.py +243 -0
  115. pycforge/ide/find_replace.py +535 -0
  116. pycforge/ide/icons.py +91 -0
  117. pycforge/ide/io_service.py +196 -0
  118. pycforge/ide/model.py +164 -0
  119. pycforge/ide/panels.py +903 -0
  120. pycforge/ide/positions.py +220 -0
  121. pycforge/ide/process_worker.py +130 -0
  122. pycforge/ide/qt.py +230 -0
  123. pycforge/ide/qt_actions.py +418 -0
  124. pycforge/ide/qt_close.py +169 -0
  125. pycforge/ide/qt_command_palette.py +297 -0
  126. pycforge/ide/qt_contract.py +220 -0
  127. pycforge/ide/qt_documents.py +590 -0
  128. pycforge/ide/qt_editor_buffers.py +113 -0
  129. pycforge/ide/qt_editor_surfaces.py +112 -0
  130. pycforge/ide/qt_menus.py +324 -0
  131. pycforge/ide/qt_projection.py +500 -0
  132. pycforge/ide/qt_shell.py +348 -0
  133. pycforge/ide/qt_shell_interactions.py +320 -0
  134. pycforge/ide/qt_state.py +393 -0
  135. pycforge/ide/qt_workspace_features.py +398 -0
  136. pycforge/ide/qt_workspace_navigation.py +208 -0
  137. pycforge/ide/qt_workspace_observers.py +173 -0
  138. pycforge/ide/qt_workspace_panels.py +545 -0
  139. pycforge/ide/qt_workspace_widgets.py +228 -0
  140. pycforge/ide/resources/icons/about.svg +1 -0
  141. pycforge/ide/resources/icons/add-document.svg +1 -0
  142. pycforge/ide/resources/icons/brand-mark.svg +1 -0
  143. pycforge/ide/resources/icons/bundle-search.svg +1 -0
  144. pycforge/ide/resources/icons/cancel.svg +1 -0
  145. pycforge/ide/resources/icons/check.svg +1 -0
  146. pycforge/ide/resources/icons/chevron-right.svg +1 -0
  147. pycforge/ide/resources/icons/close.svg +1 -0
  148. pycforge/ide/resources/icons/collapse-all.svg +1 -0
  149. pycforge/ide/resources/icons/command-palette.svg +1 -0
  150. pycforge/ide/resources/icons/convert.svg +1 -0
  151. pycforge/ide/resources/icons/copy.svg +1 -0
  152. pycforge/ide/resources/icons/cut.svg +1 -0
  153. pycforge/ide/resources/icons/decision-trace.svg +1 -0
  154. pycforge/ide/resources/icons/details.svg +1 -0
  155. pycforge/ide/resources/icons/diagnostics.svg +1 -0
  156. pycforge/ide/resources/icons/duplicate-line.svg +1 -0
  157. pycforge/ide/resources/icons/exit.svg +1 -0
  158. pycforge/ide/resources/icons/expand-all.svg +1 -0
  159. pycforge/ide/resources/icons/export.svg +1 -0
  160. pycforge/ide/resources/icons/find.svg +1 -0
  161. pycforge/ide/resources/icons/go-to-line.svg +1 -0
  162. pycforge/ide/resources/icons/go-to-output.svg +1 -0
  163. pycforge/ide/resources/icons/go-to-source.svg +1 -0
  164. pycforge/ide/resources/icons/history.svg +1 -0
  165. pycforge/ide/resources/icons/indent.svg +1 -0
  166. pycforge/ide/resources/icons/link-c.svg +1 -0
  167. pycforge/ide/resources/icons/mappings.svg +1 -0
  168. pycforge/ide/resources/icons/module.svg +1 -0
  169. pycforge/ide/resources/icons/move-down.svg +1 -0
  170. pycforge/ide/resources/icons/move-line-down.svg +1 -0
  171. pycforge/ide/resources/icons/move-line-up.svg +1 -0
  172. pycforge/ide/resources/icons/move-up.svg +1 -0
  173. pycforge/ide/resources/icons/next.svg +1 -0
  174. pycforge/ide/resources/icons/open.svg +1 -0
  175. pycforge/ide/resources/icons/outdent.svg +1 -0
  176. pycforge/ide/resources/icons/outline.svg +1 -0
  177. pycforge/ide/resources/icons/paste.svg +1 -0
  178. pycforge/ide/resources/icons/previous.svg +1 -0
  179. pycforge/ide/resources/icons/primary-module.svg +1 -0
  180. pycforge/ide/resources/icons/redo.svg +1 -0
  181. pycforge/ide/resources/icons/remove-document.svg +1 -0
  182. pycforge/ide/resources/icons/replace.svg +1 -0
  183. pycforge/ide/resources/icons/save-as.svg +1 -0
  184. pycforge/ide/resources/icons/save.svg +1 -0
  185. pycforge/ide/resources/icons/select-all.svg +1 -0
  186. pycforge/ide/resources/icons/settings.svg +1 -0
  187. pycforge/ide/resources/icons/show-c.svg +1 -0
  188. pycforge/ide/resources/icons/split-view.svg +1 -0
  189. pycforge/ide/resources/icons/summary.svg +1 -0
  190. pycforge/ide/resources/icons/telemetry.svg +1 -0
  191. pycforge/ide/resources/icons/toggle-comment.svg +1 -0
  192. pycforge/ide/resources/icons/toggle-fold.svg +1 -0
  193. pycforge/ide/resources/icons/undo.svg +1 -0
  194. pycforge/ide/resources/icons/whitespace.svg +1 -0
  195. pycforge/ide/revisions.py +400 -0
  196. pycforge/ide/search_service.py +280 -0
  197. pycforge/ide/session_history.py +150 -0
  198. pycforge/ide/source_editing.py +417 -0
  199. pycforge/ide/source_structure.py +558 -0
  200. pycforge/ide/source_structure_async.py +191 -0
  201. pycforge/ide/supervisor.py +503 -0
  202. pycforge/ide/theme.py +119 -0
  203. pycforge/ide/theme_stylesheet.py +598 -0
  204. pycforge/ide/theme_workspace_stylesheet.py +155 -0
  205. pycforge/ide/visual_tokens.py +142 -0
  206. pycforge/ide/worker_protocol.py +80 -0
  207. pycforge/ide/workspace_search.py +462 -0
  208. pycforge/ide/workspace_session.py +256 -0
  209. pycforge/laboratory/__init__.py +6 -0
  210. pycforge/laboratory/audits.py +2244 -0
  211. pycforge/laboratory/checkpoint_e.py +2685 -0
  212. pycforge/laboratory/cli.py +119 -0
  213. pycforge/laboratory/exit_codes.py +11 -0
  214. pycforge/laboratory/keyword_audit.py +661 -0
  215. pycforge/laboratory/keyword_only_audit.py +481 -0
  216. pycforge-0.15.2.dist-info/METADATA +209 -0
  217. pycforge-0.15.2.dist-info/RECORD +221 -0
  218. pycforge-0.15.2.dist-info/WHEEL +5 -0
  219. pycforge-0.15.2.dist-info/entry_points.txt +5 -0
  220. pycforge-0.15.2.dist-info/licenses/LICENSE +674 -0
  221. pycforge-0.15.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,462 @@
1
+ """Bounded literal search over an explicitly captured source bundle.
2
+
3
+ This module is independent of Qt and performs no file-system access. A search
4
+ request owns immutable strings supplied by the caller; linked document paths
5
+ are deliberately absent from the input contract.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ import re
12
+ from threading import Condition, Thread, current_thread
13
+ from time import monotonic
14
+ from typing import Callable, Iterable
15
+
16
+
17
+ MAX_BUNDLE_DOCUMENTS = 64
18
+ MAX_BUNDLE_MATCHES = 5_000
19
+ MAX_QUERY_CHARS = 4_096
20
+ MAX_PREVIEW_CHARS = 512
21
+ MAX_DOCUMENT_ID_CHARS = 128
22
+ MAX_LOGICAL_NAME_CHARS = 4_096
23
+ DEFAULT_PREVIEW_CHARS = 240
24
+ DEFAULT_SCAN_CHUNK = 64 * 1024
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class WorkspaceSearchDocument:
29
+ """One already-open document captured for a search request."""
30
+
31
+ document_id: str
32
+ logical_name: str
33
+ text: str
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class BundleSearchMatch:
38
+ """One literal match with explicit Python and Qt position contracts.
39
+
40
+ ``start`` and ``end`` are Python code-point offsets. ``qt_start`` and
41
+ ``qt_end`` are UTF-16 code-unit offsets suitable for a Qt text cursor.
42
+ """
43
+
44
+ document_id: str
45
+ logical_name: str
46
+ document_ordinal: int
47
+ start: int
48
+ end: int
49
+ qt_start: int
50
+ qt_end: int
51
+ preview: str
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class BundleSearchResult:
56
+ """A globally bounded search result.
57
+
58
+ When ``truncated`` is true, ``total_count`` is a lower bound of
59
+ ``MAX_BUNDLE_MATCHES + 1``. Scanning stops at that first omitted match so
60
+ a broad query cannot create unbounded background work merely to count.
61
+ """
62
+
63
+ generation: int
64
+ matches: tuple[BundleSearchMatch, ...]
65
+ total_count: int
66
+ truncated: bool
67
+ elapsed_seconds: float
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class _BundleSearchRequest:
72
+ generation: int
73
+ documents: tuple[WorkspaceSearchDocument, ...]
74
+ query: str
75
+ match_case: bool
76
+ whole_word: bool
77
+ callback: Callable[[BundleSearchResult], None]
78
+
79
+
80
+ def _captured_documents(
81
+ documents: Iterable[WorkspaceSearchDocument],
82
+ ) -> tuple[WorkspaceSearchDocument, ...]:
83
+ records = tuple(documents)
84
+ if not 1 <= len(records) <= MAX_BUNDLE_DOCUMENTS:
85
+ raise ValueError("search requires between 1 and 64 open documents")
86
+ document_ids: list[str] = []
87
+ logical_names: list[str] = []
88
+ for document in records:
89
+ if not isinstance(document, WorkspaceSearchDocument):
90
+ raise TypeError(
91
+ "search documents must be WorkspaceSearchDocument values"
92
+ )
93
+ if (
94
+ not isinstance(document.document_id, str)
95
+ or not document.document_id
96
+ or len(document.document_id) > MAX_DOCUMENT_ID_CHARS
97
+ ):
98
+ raise ValueError("search document ID must be non-empty text")
99
+ if (
100
+ not isinstance(document.logical_name, str)
101
+ or not document.logical_name
102
+ or len(document.logical_name) > MAX_LOGICAL_NAME_CHARS
103
+ ):
104
+ raise ValueError("search logical name must be non-empty text")
105
+ if not isinstance(document.text, str):
106
+ raise TypeError("search document text must be a string")
107
+ document_ids.append(document.document_id)
108
+ logical_names.append(document.logical_name)
109
+ if len(document_ids) != len(set(document_ids)):
110
+ raise ValueError("search document IDs must be unique")
111
+ if len(logical_names) != len(set(logical_names)):
112
+ raise ValueError("search logical names must be unique")
113
+ return records
114
+
115
+
116
+ def _positive_bounded_int(
117
+ value: object,
118
+ *,
119
+ name: str,
120
+ maximum: int,
121
+ ) -> int:
122
+ if isinstance(value, bool) or not isinstance(value, int):
123
+ raise TypeError(f"{name} must be an integer")
124
+ if not 1 <= value <= maximum:
125
+ raise ValueError(f"{name} must be between 1 and {maximum}")
126
+ return value
127
+
128
+
129
+ def _utf16_units(value: str) -> int:
130
+ return len(value.encode("utf-16-le", errors="surrogatepass")) // 2
131
+
132
+
133
+ def _bounded_preview(
134
+ text: str,
135
+ start: int,
136
+ end: int,
137
+ budget: int,
138
+ ) -> str:
139
+ """Return one bounded, single-line context window."""
140
+
141
+ left = max(0, start - budget // 3)
142
+ right = min(len(text), left + budget)
143
+ if right - left < budget:
144
+ left = max(0, right - budget)
145
+ value = text[left:right].replace("\r", " ").replace("\n", " ")
146
+ if left:
147
+ value = "\N{HORIZONTAL ELLIPSIS}" + value[1:]
148
+ if right < len(text) and value:
149
+ value = value[:-1] + "\N{HORIZONTAL ELLIPSIS}"
150
+ # Replacements above preserve length, but retain a final fail-closed cap.
151
+ return value[:budget]
152
+
153
+
154
+ def search_closed_documents(
155
+ documents: Iterable[WorkspaceSearchDocument],
156
+ query: str,
157
+ *,
158
+ generation: int = 0,
159
+ match_case: bool = False,
160
+ whole_word: bool = False,
161
+ match_limit: int = MAX_BUNDLE_MATCHES,
162
+ preview_chars: int = DEFAULT_PREVIEW_CHARS,
163
+ scan_chunk: int = DEFAULT_SCAN_CHUNK,
164
+ cancelled: Callable[[], bool] | None = None,
165
+ ) -> BundleSearchResult | None:
166
+ """Synchronously search captured documents without consulting the host.
167
+
168
+ The returned match sequence is in bundle order and then source order.
169
+ ``None`` means the caller's generation was cancelled before publication.
170
+ """
171
+
172
+ started = monotonic()
173
+ records = _captured_documents(documents)
174
+ if not isinstance(query, str):
175
+ raise TypeError("search query must be a string")
176
+ if len(query) > MAX_QUERY_CHARS:
177
+ raise ValueError(
178
+ f"search query exceeds the {MAX_QUERY_CHARS}-character limit"
179
+ )
180
+ if isinstance(generation, bool) or not isinstance(generation, int):
181
+ raise TypeError("search generation must be an integer")
182
+ if generation < 0:
183
+ raise ValueError("search generation must be non-negative")
184
+ match_limit = _positive_bounded_int(
185
+ match_limit,
186
+ name="match_limit",
187
+ maximum=MAX_BUNDLE_MATCHES,
188
+ )
189
+ preview_chars = _positive_bounded_int(
190
+ preview_chars,
191
+ name="preview_chars",
192
+ maximum=MAX_PREVIEW_CHARS,
193
+ )
194
+ scan_chunk = _positive_bounded_int(
195
+ scan_chunk,
196
+ name="scan_chunk",
197
+ maximum=4 * 1024 * 1024,
198
+ )
199
+ is_cancelled = cancelled or (lambda: False)
200
+ if is_cancelled():
201
+ return None
202
+ if not query:
203
+ return BundleSearchResult(
204
+ generation, (), 0, False, monotonic() - started
205
+ )
206
+
207
+ expression = re.escape(query)
208
+ if whole_word:
209
+ expression = rf"(?<!\w){expression}(?!\w)"
210
+ pattern = re.compile(expression, 0 if match_case else re.IGNORECASE)
211
+ matches: list[BundleSearchMatch] = []
212
+ total_count = 0
213
+
214
+ for ordinal, document in enumerate(records):
215
+ if is_cancelled():
216
+ return None
217
+ text = document.text
218
+ if len(query) > len(text):
219
+ continue
220
+ ownership_width = max(scan_chunk, len(query) + 1)
221
+ ownership_start = 0
222
+ last_match_end = 0
223
+ codepoint_position = 0
224
+ qt_position = 0
225
+ matches_since_check = 0
226
+
227
+ while ownership_start < len(text):
228
+ if is_cancelled():
229
+ return None
230
+ ownership_end = min(
231
+ len(text), ownership_start + ownership_width
232
+ )
233
+ search_start = max(ownership_start, last_match_end)
234
+ search_end = min(
235
+ len(text),
236
+ ownership_end + len(query) + (1 if whole_word else 0),
237
+ )
238
+ for found in pattern.finditer(text, search_start, search_end):
239
+ start, end = found.span()
240
+ if start >= ownership_end:
241
+ break
242
+ total_count += 1
243
+ if total_count > match_limit:
244
+ return BundleSearchResult(
245
+ generation,
246
+ tuple(matches),
247
+ total_count,
248
+ True,
249
+ monotonic() - started,
250
+ )
251
+ qt_position += _utf16_units(
252
+ text[codepoint_position:start]
253
+ )
254
+ qt_start = qt_position
255
+ qt_position += _utf16_units(text[start:end])
256
+ matches.append(
257
+ BundleSearchMatch(
258
+ document.document_id,
259
+ document.logical_name,
260
+ ordinal,
261
+ start,
262
+ end,
263
+ qt_start,
264
+ qt_position,
265
+ _bounded_preview(
266
+ text, start, end, preview_chars
267
+ ),
268
+ )
269
+ )
270
+ codepoint_position = end
271
+ last_match_end = end
272
+ matches_since_check += 1
273
+ if matches_since_check >= 256:
274
+ matches_since_check = 0
275
+ if is_cancelled():
276
+ return None
277
+ ownership_start = ownership_end
278
+
279
+ return BundleSearchResult(
280
+ generation,
281
+ tuple(matches),
282
+ total_count,
283
+ False,
284
+ monotonic() - started,
285
+ )
286
+
287
+
288
+ class AsyncBundleSearchService:
289
+ """One-active, one-latest-pending search with stale-result suppression."""
290
+
291
+ def __init__(
292
+ self,
293
+ *,
294
+ match_limit: int = MAX_BUNDLE_MATCHES,
295
+ preview_chars: int = DEFAULT_PREVIEW_CHARS,
296
+ scan_chunk: int = DEFAULT_SCAN_CHUNK,
297
+ ) -> None:
298
+ self._match_limit = _positive_bounded_int(
299
+ match_limit,
300
+ name="match_limit",
301
+ maximum=MAX_BUNDLE_MATCHES,
302
+ )
303
+ self._preview_chars = _positive_bounded_int(
304
+ preview_chars,
305
+ name="preview_chars",
306
+ maximum=MAX_PREVIEW_CHARS,
307
+ )
308
+ self._scan_chunk = _positive_bounded_int(
309
+ scan_chunk,
310
+ name="scan_chunk",
311
+ maximum=4 * 1024 * 1024,
312
+ )
313
+ self._condition = Condition()
314
+ self._generation = 0
315
+ self._active_generation: int | None = None
316
+ self._pending: _BundleSearchRequest | None = None
317
+ self._closed = False
318
+ self._worker = Thread(
319
+ target=self._worker_main,
320
+ name="pycforge-bundle-search",
321
+ daemon=True,
322
+ )
323
+ self._worker.start()
324
+
325
+ @property
326
+ def worker_is_daemon(self) -> bool:
327
+ return self._worker.daemon
328
+
329
+ @property
330
+ def active_generation(self) -> int | None:
331
+ with self._condition:
332
+ return self._active_generation
333
+
334
+ @property
335
+ def pending_generation(self) -> int | None:
336
+ with self._condition:
337
+ return (
338
+ self._pending.generation
339
+ if self._pending is not None
340
+ else None
341
+ )
342
+
343
+ @property
344
+ def is_closed(self) -> bool:
345
+ with self._condition:
346
+ return self._closed
347
+
348
+ def submit(
349
+ self,
350
+ documents: Iterable[WorkspaceSearchDocument],
351
+ query: str,
352
+ *,
353
+ match_case: bool = False,
354
+ whole_word: bool = False,
355
+ callback: Callable[[BundleSearchResult], None],
356
+ ) -> int:
357
+ records = _captured_documents(documents)
358
+ if not isinstance(query, str):
359
+ raise TypeError("search query must be a string")
360
+ if len(query) > MAX_QUERY_CHARS:
361
+ raise ValueError(
362
+ f"search query exceeds the {MAX_QUERY_CHARS}-character limit"
363
+ )
364
+ if not callable(callback):
365
+ raise TypeError("search callback must be callable")
366
+ with self._condition:
367
+ if self._closed:
368
+ raise RuntimeError("bundle search service is closed")
369
+ self._generation += 1
370
+ generation = self._generation
371
+ self._pending = _BundleSearchRequest(
372
+ generation,
373
+ records,
374
+ query,
375
+ bool(match_case),
376
+ bool(whole_word),
377
+ callback,
378
+ )
379
+ self._condition.notify()
380
+ return generation
381
+
382
+ def cancel(self) -> int:
383
+ with self._condition:
384
+ if self._closed:
385
+ return self._generation
386
+ self._generation += 1
387
+ self._pending = None
388
+ self._condition.notify()
389
+ return self._generation
390
+
391
+ def close(self, *, wait_seconds: float = 0.05) -> None:
392
+ with self._condition:
393
+ if self._closed:
394
+ return
395
+ self._closed = True
396
+ self._generation += 1
397
+ self._pending = None
398
+ self._condition.notify_all()
399
+ if wait_seconds > 0 and current_thread() is not self._worker:
400
+ self._worker.join(timeout=wait_seconds)
401
+
402
+ def _is_cancelled(self, generation: int) -> bool:
403
+ with self._condition:
404
+ return self._closed or generation != self._generation
405
+
406
+ def _worker_main(self) -> None:
407
+ while True:
408
+ with self._condition:
409
+ while self._pending is None and not self._closed:
410
+ self._condition.wait()
411
+ if self._closed:
412
+ return
413
+ request = self._pending
414
+ self._pending = None
415
+ assert request is not None
416
+ self._active_generation = request.generation
417
+
418
+ try:
419
+ result = search_closed_documents(
420
+ request.documents,
421
+ request.query,
422
+ generation=request.generation,
423
+ match_case=request.match_case,
424
+ whole_word=request.whole_word,
425
+ match_limit=self._match_limit,
426
+ preview_chars=self._preview_chars,
427
+ scan_chunk=self._scan_chunk,
428
+ cancelled=lambda: self._is_cancelled(
429
+ request.generation
430
+ ),
431
+ )
432
+ except Exception:
433
+ # A non-authoritative observer defect must not retire the
434
+ # service or publish a plausible partial result.
435
+ result = None
436
+
437
+ with self._condition:
438
+ if self._active_generation == request.generation:
439
+ self._active_generation = None
440
+ deliver = (
441
+ result is not None
442
+ and not self._closed
443
+ and request.generation == self._generation
444
+ )
445
+ if deliver:
446
+ try:
447
+ request.callback(result)
448
+ except Exception:
449
+ # Presentation observers cannot retire the sole worker.
450
+ pass
451
+
452
+
453
+ __all__ = [
454
+ "AsyncBundleSearchService",
455
+ "BundleSearchMatch",
456
+ "BundleSearchResult",
457
+ "DEFAULT_PREVIEW_CHARS",
458
+ "MAX_BUNDLE_DOCUMENTS",
459
+ "MAX_BUNDLE_MATCHES",
460
+ "WorkspaceSearchDocument",
461
+ "search_closed_documents",
462
+ ]