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,400 @@
1
+ from __future__ import annotations
2
+
3
+ from concurrent.futures import Future
4
+ from dataclasses import dataclass
5
+ from hashlib import sha256
6
+ from threading import Condition, RLock, Thread, current_thread
7
+ from time import monotonic
8
+ from typing import Callable, Sequence
9
+
10
+ from pycforge.converter.contracts.configuration import DEFAULT_RENDERER, DEFAULT_RULE_SET
11
+ from pycforge.converter.core.canonicalization import canonicalize
12
+ from pycforge.converter.core.diagnostics import Diagnostic
13
+ from pycforge.converter.core.fingerprint import Fingerprint, fingerprint
14
+ from pycforge.converter.core.request import (
15
+ ConversionRequest,
16
+ SourceBundle,
17
+ SourceDocumentInput,
18
+ )
19
+
20
+ from .model import WorkspaceDocument
21
+ from .positions import TextPositionIndex, build_text_position_index
22
+
23
+
24
+ RevisionListener = Callable[["WorkspaceRevision"], None]
25
+ RevisionErrorListener = Callable[[int, BaseException], None]
26
+
27
+
28
+ class RevisionBuildError(ValueError):
29
+ """Raised when an immutable workspace revision cannot be canonicalized."""
30
+
31
+ def __init__(self, diagnostics: tuple[Diagnostic, ...]) -> None:
32
+ self.diagnostics = diagnostics
33
+ detail = "; ".join(
34
+ f"{diagnostic.code}: {diagnostic.message}" for diagnostic in diagnostics
35
+ )
36
+ super().__init__(detail or "workspace revision could not be canonicalized")
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class DocumentIndex:
41
+ """Cached source-position facts for one immutable document text.
42
+
43
+ ``line_starts`` contains Python string offsets. ``utf16_line_starts``
44
+ contains offsets in UTF-16 code units, matching Qt cursor positions.
45
+ Both tuples include the first line and add a new entry after every LF.
46
+ """
47
+
48
+ document_id: str
49
+ module_id: str
50
+ logical_name: str
51
+ text_fingerprint: str
52
+ source_fingerprint: str
53
+ utf8_sha256: str
54
+ utf8_size: int
55
+ line_starts: tuple[int, ...]
56
+ utf16_line_starts: tuple[int, ...]
57
+ utf16_compatible: bool
58
+ position_index: TextPositionIndex
59
+
60
+ @property
61
+ def line_count(self) -> int:
62
+ return len(self.line_starts)
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class WorkspaceRevision:
67
+ """Fully authenticated, immutable source state ready for conversion."""
68
+
69
+ generation: int
70
+ active_document_id: str
71
+ source_fingerprint: str
72
+ bundle_fingerprint: str
73
+ source_bundle: SourceBundle
74
+ request: ConversionRequest
75
+ request_fingerprint: Fingerprint
76
+ resource_fingerprint: Fingerprint
77
+ document_indexes: tuple[DocumentIndex, ...]
78
+ total_utf8_size: int
79
+
80
+ @property
81
+ def active_index(self) -> DocumentIndex:
82
+ for index in self.document_indexes:
83
+ if index.document_id == self.active_document_id:
84
+ return index
85
+ raise LookupError("active document index is missing")
86
+
87
+ @property
88
+ def line_starts(self) -> tuple[int, ...]:
89
+ """Cached Python offsets for the active document's line starts."""
90
+
91
+ return self.active_index.line_starts
92
+
93
+ @property
94
+ def utf16_line_starts(self) -> tuple[int, ...]:
95
+ """Cached Qt-compatible offsets for the active document's lines."""
96
+
97
+ return self.active_index.utf16_line_starts
98
+
99
+ @property
100
+ def source_utf8_size(self) -> int:
101
+ return self.active_index.utf8_size
102
+
103
+ def index_for(self, document_id: str) -> DocumentIndex:
104
+ for index in self.document_indexes:
105
+ if index.document_id == document_id:
106
+ return index
107
+ raise KeyError(document_id)
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class RevisionInput:
112
+ """A cheap immutable capture; expensive facts are added by the service."""
113
+
114
+ generation: int
115
+ documents: tuple[WorkspaceDocument, ...]
116
+ active_document_id: str
117
+
118
+
119
+ def source_fingerprint(text: str) -> str:
120
+ """Return the exact workspace-source identity used by the controller."""
121
+
122
+ return fingerprint("workspace-source", text).value
123
+
124
+
125
+ def conversion_order(
126
+ documents: Sequence[WorkspaceDocument],
127
+ ) -> tuple[WorkspaceDocument, ...]:
128
+ primary = tuple(document for document in documents if document.is_primary)
129
+ if len(primary) != 1:
130
+ raise ValueError("workspace must contain exactly one primary document")
131
+ return primary + tuple(document for document in documents if not document.is_primary)
132
+
133
+
134
+ def source_bundle_for(
135
+ documents: Sequence[WorkspaceDocument],
136
+ ) -> SourceBundle:
137
+ ordered = conversion_order(documents)
138
+
139
+ def source(document: WorkspaceDocument) -> SourceDocumentInput:
140
+ return SourceDocumentInput(
141
+ logical_name=document.logical_name,
142
+ text=document.text,
143
+ module_id=document.module_id,
144
+ )
145
+
146
+ return SourceBundle(
147
+ primary=source(ordered[0]),
148
+ companions=tuple(source(document) for document in ordered[1:]),
149
+ )
150
+
151
+
152
+ def workspace_bundle_fingerprint(
153
+ documents: Sequence[WorkspaceDocument],
154
+ ) -> str:
155
+ """Return the exact semantic bundle identity used by WorkspaceController."""
156
+
157
+ ordered = conversion_order(documents)
158
+ semantic = [
159
+ {
160
+ "module_id": document.module_id,
161
+ "logical_name": document.logical_name,
162
+ "text": document.text,
163
+ "is_primary": index == 0,
164
+ }
165
+ for index, document in enumerate(ordered)
166
+ ]
167
+ return fingerprint("workspace-source-bundle", semantic).value
168
+
169
+
170
+ def _document_index(document: WorkspaceDocument) -> DocumentIndex:
171
+ text = document.text
172
+ encoded = text.encode("utf-8")
173
+ positions = build_text_position_index(text)
174
+ return DocumentIndex(
175
+ document_id=document.document_id,
176
+ module_id=document.module_id,
177
+ logical_name=document.logical_name,
178
+ text_fingerprint=fingerprint("workspace-document-text", text).value,
179
+ source_fingerprint=source_fingerprint(text),
180
+ utf8_sha256=sha256(encoded).hexdigest(),
181
+ utf8_size=len(encoded),
182
+ line_starts=positions.line_starts,
183
+ utf16_line_starts=positions.utf16_line_starts,
184
+ utf16_compatible=positions.utf16_compatible,
185
+ position_index=positions,
186
+ )
187
+
188
+
189
+ def build_workspace_revision(revision_input: RevisionInput) -> WorkspaceRevision:
190
+ """Build all proportional revision facts on a non-GUI worker thread."""
191
+
192
+ generation = revision_input.generation
193
+ if not isinstance(generation, int) or isinstance(generation, bool) or generation < 0:
194
+ raise ValueError("revision generation must be a non-negative integer")
195
+ documents = revision_input.documents
196
+ if not documents:
197
+ raise ValueError("workspace revision must contain at least one document")
198
+ if any(not isinstance(document, WorkspaceDocument) for document in documents):
199
+ raise TypeError("workspace revision documents must be WorkspaceDocument values")
200
+ document_ids = tuple(document.document_id for document in documents)
201
+ if len(document_ids) != len(set(document_ids)):
202
+ raise ValueError("workspace revision document IDs must be unique")
203
+ if revision_input.active_document_id not in document_ids:
204
+ raise ValueError("active document must belong to the workspace revision")
205
+
206
+ active = next(
207
+ document
208
+ for document in documents
209
+ if document.document_id == revision_input.active_document_id
210
+ )
211
+ bundle = source_bundle_for(documents)
212
+ request = ConversionRequest(
213
+ bundle,
214
+ rule_set_version=DEFAULT_RULE_SET,
215
+ renderer_version=DEFAULT_RENDERER,
216
+ )
217
+ canonical, diagnostics = canonicalize(request)
218
+ if canonical is None:
219
+ raise RevisionBuildError(diagnostics)
220
+
221
+ indexes = tuple(_document_index(document) for document in documents)
222
+ return WorkspaceRevision(
223
+ generation=generation,
224
+ active_document_id=revision_input.active_document_id,
225
+ source_fingerprint=source_fingerprint(active.text),
226
+ bundle_fingerprint=workspace_bundle_fingerprint(documents),
227
+ source_bundle=canonical.request.source_bundle,
228
+ request=canonical.request,
229
+ request_fingerprint=canonical.request_fingerprint,
230
+ resource_fingerprint=canonical.resource_fingerprint,
231
+ document_indexes=indexes,
232
+ total_utf8_size=sum(index.utf8_size for index in indexes),
233
+ )
234
+
235
+
236
+ @dataclass(slots=True)
237
+ class _RevisionJob:
238
+ submission: int
239
+ revision_input: RevisionInput
240
+ future: Future[WorkspaceRevision]
241
+
242
+
243
+ class WorkspaceRevisionService:
244
+ """One-active, one-latest-pending immutable revision builder.
245
+
246
+ The service deliberately owns one dedicated thread. Submissions only
247
+ capture the already-immutable document tuple; source-proportional work is
248
+ performed by the service thread. A newer submission replaces the pending
249
+ job and retires an active job's publication authority.
250
+ """
251
+
252
+ def __init__(
253
+ self,
254
+ on_revision: RevisionListener | None = None,
255
+ *,
256
+ on_error: RevisionErrorListener | None = None,
257
+ builder: Callable[[RevisionInput], WorkspaceRevision] = build_workspace_revision,
258
+ thread_name: str = "pycforge-revision-index",
259
+ ) -> None:
260
+ self._on_revision = on_revision
261
+ self._on_error = on_error
262
+ self._builder = builder
263
+ self._condition = Condition(RLock())
264
+ self._active: _RevisionJob | None = None
265
+ self._pending: _RevisionJob | None = None
266
+ self._latest_submission = 0
267
+ self._closed = False
268
+ self._thread = Thread(
269
+ target=self._run,
270
+ name=thread_name,
271
+ daemon=True,
272
+ )
273
+ self._thread.start()
274
+
275
+ def submit(
276
+ self,
277
+ generation: int,
278
+ documents: Sequence[WorkspaceDocument],
279
+ active_document_id: str,
280
+ ) -> Future[WorkspaceRevision]:
281
+ """Capture a revision request without traversing any document text."""
282
+
283
+ revision_input = RevisionInput(
284
+ generation=generation,
285
+ documents=tuple(documents),
286
+ active_document_id=active_document_id,
287
+ )
288
+ future: Future[WorkspaceRevision] = Future()
289
+ with self._condition:
290
+ if self._closed:
291
+ raise RuntimeError("workspace revision service is closed")
292
+ self._latest_submission += 1
293
+ job = _RevisionJob(self._latest_submission, revision_input, future)
294
+ replaced = self._pending
295
+ self._pending = job
296
+ if replaced is not None:
297
+ replaced.future.cancel()
298
+ self._condition.notify()
299
+ return future
300
+
301
+ @property
302
+ def active_generation(self) -> int | None:
303
+ with self._condition:
304
+ return (
305
+ None
306
+ if self._active is None
307
+ else self._active.revision_input.generation
308
+ )
309
+
310
+ @property
311
+ def pending_generation(self) -> int | None:
312
+ with self._condition:
313
+ return (
314
+ None
315
+ if self._pending is None
316
+ else self._pending.revision_input.generation
317
+ )
318
+
319
+ @property
320
+ def is_closed(self) -> bool:
321
+ with self._condition:
322
+ return self._closed
323
+
324
+ def wait_idle(self, timeout: float | None = None) -> bool:
325
+ """Wait until neither an active nor pending computation remains."""
326
+
327
+ deadline = None if timeout is None else monotonic() + timeout
328
+ with self._condition:
329
+ while self._active is not None or self._pending is not None:
330
+ remaining = None if deadline is None else deadline - monotonic()
331
+ if remaining is not None and remaining <= 0:
332
+ return False
333
+ self._condition.wait(remaining)
334
+ return True
335
+
336
+ def close(self, *, wait: bool = True, timeout: float | None = None) -> None:
337
+ """Retire publication immediately and optionally join the worker."""
338
+
339
+ with self._condition:
340
+ if not self._closed:
341
+ self._closed = True
342
+ pending = self._pending
343
+ self._pending = None
344
+ if pending is not None:
345
+ pending.future.cancel()
346
+ self._condition.notify_all()
347
+ if wait and current_thread() is not self._thread:
348
+ self._thread.join(timeout)
349
+
350
+ def _run(self) -> None:
351
+ while True:
352
+ with self._condition:
353
+ while self._pending is None and not self._closed:
354
+ self._condition.wait()
355
+ if self._pending is None and self._closed:
356
+ self._condition.notify_all()
357
+ return
358
+ job = self._pending
359
+ self._pending = None
360
+ self._active = job
361
+ self._condition.notify_all()
362
+
363
+ if not job.future.set_running_or_notify_cancel():
364
+ with self._condition:
365
+ if self._active is job:
366
+ self._active = None
367
+ self._condition.notify_all()
368
+ continue
369
+
370
+ revision: WorkspaceRevision | None = None
371
+ failure: BaseException | None = None
372
+ try:
373
+ revision = self._builder(job.revision_input)
374
+ except BaseException as exc:
375
+ failure = exc
376
+
377
+ with self._condition:
378
+ if self._active is job:
379
+ self._active = None
380
+ publishable = (
381
+ not self._closed
382
+ and job.submission == self._latest_submission
383
+ and self._pending is None
384
+ )
385
+ self._condition.notify_all()
386
+ if failure is None:
387
+ assert revision is not None
388
+ if publishable and self._on_revision is not None:
389
+ try:
390
+ self._on_revision(revision)
391
+ except Exception:
392
+ pass
393
+ job.future.set_result(revision)
394
+ else:
395
+ if publishable and self._on_error is not None:
396
+ try:
397
+ self._on_error(job.revision_input.generation, failure)
398
+ except Exception:
399
+ pass
400
+ job.future.set_exception(failure)
@@ -0,0 +1,280 @@
1
+ """Bounded background literal search for the optional PyQt workspace.
2
+
3
+ The service in this module is deliberately independent of Qt. It owns one
4
+ daemon worker thread and one replaceable pending request, so rapid editor or
5
+ query changes cannot build an unbounded queue. Results contain Qt-compatible
6
+ UTF-16 positions, but only a bounded prefix of the matches is retained.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ import re
13
+ from threading import Condition, Thread
14
+ from time import monotonic
15
+ from typing import Callable
16
+
17
+
18
+ DEFAULT_MATCH_LIMIT = 5_000
19
+ DEFAULT_SCAN_CHUNK = 64 * 1024
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class LiteralSearchResult:
24
+ """The bounded result of one immutable literal-search request."""
25
+
26
+ generation: int
27
+ ranges: tuple[tuple[int, int], ...]
28
+ total_count: int
29
+ elapsed_seconds: float
30
+
31
+ @property
32
+ def truncated(self) -> bool:
33
+ return self.total_count > len(self.ranges)
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class _SearchRequest:
38
+ generation: int
39
+ text: str
40
+ query: str
41
+ match_case: bool
42
+ whole_word: bool
43
+ callback: Callable[[LiteralSearchResult], None]
44
+
45
+
46
+ def _qt_ranges(
47
+ text: str, ranges: tuple[tuple[int, int], ...]
48
+ ) -> tuple[tuple[int, int], ...]:
49
+ """Convert ordered Python code-point ranges to Qt UTF-16 positions."""
50
+
51
+ converted: list[tuple[int, int]] = []
52
+ codepoint_position = 0
53
+ qt_position = 0
54
+ for start, end in ranges:
55
+ qt_position += len(text[codepoint_position:start].encode("utf-16-le")) // 2
56
+ qt_start = qt_position
57
+ qt_position += len(text[start:end].encode("utf-16-le")) // 2
58
+ converted.append((qt_start, qt_position))
59
+ codepoint_position = end
60
+ return tuple(converted)
61
+
62
+
63
+ def _bounded_literal_search(
64
+ request: _SearchRequest,
65
+ *,
66
+ match_limit: int,
67
+ scan_chunk: int,
68
+ cancelled: Callable[[], bool],
69
+ ) -> LiteralSearchResult | None:
70
+ """Scan an immutable request in cancellable windows.
71
+
72
+ Searching bounded windows instead of invoking one full-document regex
73
+ search gives the supervisor a cancellation point even when the document
74
+ contains no matches. Window ownership is based on match start positions;
75
+ an overlap large enough for the literal and its word-boundary lookahead
76
+ preserves the same non-overlapping semantics as ``re.finditer``.
77
+ """
78
+
79
+ started = monotonic()
80
+ if cancelled():
81
+ return None
82
+ if not request.query or len(request.query) > len(request.text):
83
+ return LiteralSearchResult(request.generation, (), 0, monotonic() - started)
84
+
85
+ expression = re.escape(request.query)
86
+ if request.whole_word:
87
+ expression = rf"(?<!\w){expression}(?!\w)"
88
+ flags = 0 if request.match_case else re.IGNORECASE
89
+ pattern = re.compile(expression, flags)
90
+
91
+ stored: list[tuple[int, int]] = []
92
+ total_count = 0
93
+ text_length = len(request.text)
94
+ # Avoid repeatedly scanning a very large literal through small overlapping
95
+ # windows. Ordinary queries retain the 64 KiB cancellation granularity.
96
+ ownership_width = max(scan_chunk, len(request.query) + 1)
97
+ ownership_start = 0
98
+ last_match_end = 0
99
+ matches_since_cancel_check = 0
100
+
101
+ while ownership_start < text_length:
102
+ if cancelled():
103
+ return None
104
+ ownership_end = min(text_length, ownership_start + ownership_width)
105
+ search_start = max(ownership_start, last_match_end)
106
+ # Include enough right context for a match that begins in this window
107
+ # plus one code point for the whole-word lookahead.
108
+ search_end = min(
109
+ text_length,
110
+ ownership_end + len(request.query) + (1 if request.whole_word else 0),
111
+ )
112
+ for match in pattern.finditer(request.text, search_start, search_end):
113
+ start, end = match.span()
114
+ if start >= ownership_end:
115
+ break
116
+ total_count += 1
117
+ if len(stored) < match_limit:
118
+ stored.append((start, end))
119
+ last_match_end = end
120
+ matches_since_cancel_check += 1
121
+ if matches_since_cancel_check >= 256:
122
+ matches_since_cancel_check = 0
123
+ if cancelled():
124
+ return None
125
+ ownership_start = ownership_end
126
+
127
+ return LiteralSearchResult(
128
+ request.generation,
129
+ _qt_ranges(request.text, tuple(stored)),
130
+ total_count,
131
+ monotonic() - started,
132
+ )
133
+
134
+
135
+ class AsyncLiteralSearchService:
136
+ """Latest-wins literal search with one daemon worker and bounded results."""
137
+
138
+ def __init__(
139
+ self,
140
+ *,
141
+ match_limit: int = DEFAULT_MATCH_LIMIT,
142
+ scan_chunk: int = DEFAULT_SCAN_CHUNK,
143
+ ) -> None:
144
+ if isinstance(match_limit, bool) or not isinstance(match_limit, int):
145
+ raise TypeError("match_limit must be an integer")
146
+ if isinstance(scan_chunk, bool) or not isinstance(scan_chunk, int):
147
+ raise TypeError("scan_chunk must be an integer")
148
+ if match_limit <= 0:
149
+ raise ValueError("match_limit must be positive")
150
+ if scan_chunk <= 0:
151
+ raise ValueError("scan_chunk must be positive")
152
+ self._match_limit = match_limit
153
+ self._scan_chunk = scan_chunk
154
+ self._condition = Condition()
155
+ self._generation = 0
156
+ self._active_generation: int | None = None
157
+ self._pending: _SearchRequest | None = None
158
+ self._closed = False
159
+ self._worker = Thread(
160
+ target=self._worker_main,
161
+ name="pycforge-literal-search",
162
+ daemon=True,
163
+ )
164
+ self._worker.start()
165
+
166
+ @property
167
+ def worker_is_daemon(self) -> bool:
168
+ return self._worker.daemon
169
+
170
+ @property
171
+ def active_generation(self) -> int | None:
172
+ with self._condition:
173
+ return self._active_generation
174
+
175
+ @property
176
+ def pending_generation(self) -> int | None:
177
+ with self._condition:
178
+ return self._pending.generation if self._pending is not None else None
179
+
180
+ def submit(
181
+ self,
182
+ text: str,
183
+ query: str,
184
+ *,
185
+ match_case: bool = False,
186
+ whole_word: bool = False,
187
+ callback: Callable[[LiteralSearchResult], None],
188
+ ) -> int:
189
+ """Replace the pending request and return its monotonic generation."""
190
+
191
+ if not isinstance(text, str) or not isinstance(query, str):
192
+ raise TypeError("text and query must be strings")
193
+ if not callable(callback):
194
+ raise TypeError("callback must be callable")
195
+ with self._condition:
196
+ if self._closed:
197
+ raise RuntimeError("search service is closed")
198
+ self._generation += 1
199
+ generation = self._generation
200
+ self._pending = _SearchRequest(
201
+ generation,
202
+ text,
203
+ query,
204
+ bool(match_case),
205
+ bool(whole_word),
206
+ callback,
207
+ )
208
+ self._condition.notify()
209
+ return generation
210
+
211
+ def cancel(self) -> int:
212
+ """Cancel the active generation and discard the pending request."""
213
+
214
+ with self._condition:
215
+ if self._closed:
216
+ return self._generation
217
+ self._generation += 1
218
+ self._pending = None
219
+ self._condition.notify()
220
+ return self._generation
221
+
222
+ def close(self, *, wait_seconds: float = 0.05) -> None:
223
+ """Stop accepting work and cooperatively end the daemon worker."""
224
+
225
+ with self._condition:
226
+ if self._closed:
227
+ return
228
+ self._closed = True
229
+ self._generation += 1
230
+ self._pending = None
231
+ self._condition.notify()
232
+ if wait_seconds > 0:
233
+ self._worker.join(timeout=wait_seconds)
234
+
235
+ def _is_cancelled(self, generation: int) -> bool:
236
+ with self._condition:
237
+ return self._closed or generation != self._generation
238
+
239
+ def _worker_main(self) -> None:
240
+ while True:
241
+ with self._condition:
242
+ while self._pending is None and not self._closed:
243
+ self._condition.wait()
244
+ if self._closed:
245
+ return
246
+ request = self._pending
247
+ self._pending = None
248
+ assert request is not None
249
+ self._active_generation = request.generation
250
+
251
+ result = _bounded_literal_search(
252
+ request,
253
+ match_limit=self._match_limit,
254
+ scan_chunk=self._scan_chunk,
255
+ cancelled=lambda: self._is_cancelled(request.generation),
256
+ )
257
+
258
+ with self._condition:
259
+ if self._active_generation == request.generation:
260
+ self._active_generation = None
261
+ deliver = (
262
+ result is not None
263
+ and not self._closed
264
+ and request.generation == self._generation
265
+ )
266
+ if deliver:
267
+ try:
268
+ request.callback(result)
269
+ except Exception:
270
+ # A disappearing Qt receiver or a consumer callback must
271
+ # not retire the sole search worker.
272
+ continue
273
+
274
+
275
+ __all__ = [
276
+ "AsyncLiteralSearchService",
277
+ "DEFAULT_MATCH_LIMIT",
278
+ "DEFAULT_SCAN_CHUNK",
279
+ "LiteralSearchResult",
280
+ ]