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.
- pycforge/__init__.py +8 -0
- pycforge/__main__.py +2 -0
- pycforge/_version.py +1 -0
- pycforge/converter/__init__.py +0 -0
- pycforge/converter/analysis/__init__.py +5 -0
- pycforge/converter/analysis/functions.py +723 -0
- pycforge/converter/analysis/model.py +158 -0
- pycforge/converter/analysis/planning.py +464 -0
- pycforge/converter/analysis/stage.py +1976 -0
- pycforge/converter/analysis/symbols.py +237 -0
- pycforge/converter/analysis/validation.py +2166 -0
- pycforge/converter/c_output/__init__.py +2 -0
- pycforge/converter/c_output/conformance.py +297 -0
- pycforge/converter/c_output/renderer.py +220 -0
- pycforge/converter/conditional_regions/__init__.py +71 -0
- pycforge/converter/conditional_regions/analysis.py +481 -0
- pycforge/converter/conditional_regions/lowering.py +579 -0
- pycforge/converter/conditional_regions/model.py +243 -0
- pycforge/converter/conditional_regions/validation.py +690 -0
- pycforge/converter/containers/__init__.py +19 -0
- pycforge/converter/containers/analysis.py +542 -0
- pycforge/converter/containers/lowering.py +290 -0
- pycforge/converter/containers/model.py +146 -0
- pycforge/converter/contracts/__init__.py +0 -0
- pycforge/converter/contracts/configuration.py +178 -0
- pycforge/converter/contracts/identifiers.py +68 -0
- pycforge/converter/contracts/versions.py +71 -0
- pycforge/converter/core/__init__.py +0 -0
- pycforge/converter/core/artifact_io.py +87 -0
- pycforge/converter/core/cancellation.py +7 -0
- pycforge/converter/core/canonicalization.py +254 -0
- pycforge/converter/core/context.py +13 -0
- pycforge/converter/core/diagnostics.py +95 -0
- pycforge/converter/core/enums.py +23 -0
- pycforge/converter/core/fingerprint.py +22 -0
- pycforge/converter/core/pipeline.py +8 -0
- pycforge/converter/core/progress.py +25 -0
- pycforge/converter/core/request.py +50 -0
- pycforge/converter/core/resource_policy.py +31 -0
- pycforge/converter/core/result.py +23 -0
- pycforge/converter/core/serialization.py +36 -0
- pycforge/converter/core/stage.py +10 -0
- pycforge/converter/core/stage_artifact.py +80 -0
- pycforge/converter/core/stage_outcome.py +15 -0
- pycforge/converter/decision_trace/__init__.py +0 -0
- pycforge/converter/decision_trace/recorder.py +52 -0
- pycforge/converter/facade.py +459 -0
- pycforge/converter/frontend/__init__.py +4 -0
- pycforge/converter/frontend/normalizer.py +59 -0
- pycforge/converter/frontend/parser.py +20 -0
- pycforge/converter/frontend/source_document.py +93 -0
- pycforge/converter/frontend/stages.py +518 -0
- pycforge/converter/frontend/validation.py +76 -0
- pycforge/converter/io/__init__.py +0 -0
- pycforge/converter/io/atomic_writer.py +33 -0
- pycforge/converter/ir/__init__.py +1 -0
- pycforge/converter/ir/c_ir/__init__.py +4 -0
- pycforge/converter/ir/c_ir/builder.py +26 -0
- pycforge/converter/ir/c_ir/model.py +318 -0
- pycforge/converter/ir/c_ir/serialization.py +88 -0
- pycforge/converter/ir/c_ir/validation.py +834 -0
- pycforge/converter/ir/python_ir/__init__.py +17 -0
- pycforge/converter/ir/python_ir/nodes.py +86 -0
- pycforge/converter/keyword_calls/__init__.py +68 -0
- pycforge/converter/keyword_calls/analysis.py +458 -0
- pycforge/converter/keyword_calls/lowering.py +271 -0
- pycforge/converter/keyword_calls/model.py +304 -0
- pycforge/converter/keyword_calls/validation.py +802 -0
- pycforge/converter/keyword_only_calls/__init__.py +68 -0
- pycforge/converter/keyword_only_calls/analysis.py +640 -0
- pycforge/converter/keyword_only_calls/lowering.py +376 -0
- pycforge/converter/keyword_only_calls/model.py +328 -0
- pycforge/converter/keyword_only_calls/validation.py +1357 -0
- pycforge/converter/lowering.py +1000 -0
- pycforge/converter/modules/__init__.py +13 -0
- pycforge/converter/modules/analysis.py +1250 -0
- pycforge/converter/modules/lowering.py +200 -0
- pycforge/converter/modules/model.py +73 -0
- pycforge/converter/modules/stage.py +192 -0
- pycforge/converter/numeric_semantics/__init__.py +23 -0
- pycforge/converter/numeric_semantics/analysis.py +420 -0
- pycforge/converter/numeric_semantics/lowering.py +153 -0
- pycforge/converter/numeric_semantics/model.py +123 -0
- pycforge/converter/records/__init__.py +34 -0
- pycforge/converter/records/analysis.py +1394 -0
- pycforge/converter/records/lowering.py +393 -0
- pycforge/converter/records/model.py +319 -0
- pycforge/converter/rules/__init__.py +3 -0
- pycforge/converter/support_templates/__init__.py +49 -0
- pycforge/converter/support_templates/assembly.py +106 -0
- pycforge/converter/support_templates/factories.py +348 -0
- pycforge/converter/support_templates/model.py +159 -0
- pycforge/converter/support_templates/registry.py +388 -0
- pycforge/converter/telemetry/__init__.py +0 -0
- pycforge/converter/telemetry/sink.py +39 -0
- pycforge/ide/__init__.py +19 -0
- pycforge/ide/__main__.py +5 -0
- pycforge/ide/_worker_event_codec.py +276 -0
- pycforge/ide/_worker_protocol_json.py +197 -0
- pycforge/ide/_worker_protocol_types.py +90 -0
- pycforge/ide/_worker_request_codec.py +200 -0
- pycforge/ide/_worker_request_data.py +260 -0
- pycforge/ide/_worker_result_data.py +343 -0
- pycforge/ide/action_contract.py +469 -0
- pycforge/ide/action_validation.py +229 -0
- pycforge/ide/command_palette.py +223 -0
- pycforge/ide/controller.py +904 -0
- pycforge/ide/controller_conversion.py +546 -0
- pycforge/ide/controller_io.py +205 -0
- pycforge/ide/editor.py +951 -0
- pycforge/ide/editor_commands_qt.py +274 -0
- pycforge/ide/editor_lexical.py +43 -0
- pycforge/ide/editor_sidebars.py +236 -0
- pycforge/ide/editor_syntax.py +243 -0
- pycforge/ide/find_replace.py +535 -0
- pycforge/ide/icons.py +91 -0
- pycforge/ide/io_service.py +196 -0
- pycforge/ide/model.py +164 -0
- pycforge/ide/panels.py +903 -0
- pycforge/ide/positions.py +220 -0
- pycforge/ide/process_worker.py +130 -0
- pycforge/ide/qt.py +230 -0
- pycforge/ide/qt_actions.py +418 -0
- pycforge/ide/qt_close.py +169 -0
- pycforge/ide/qt_command_palette.py +297 -0
- pycforge/ide/qt_contract.py +220 -0
- pycforge/ide/qt_documents.py +590 -0
- pycforge/ide/qt_editor_buffers.py +113 -0
- pycforge/ide/qt_editor_surfaces.py +112 -0
- pycforge/ide/qt_menus.py +324 -0
- pycforge/ide/qt_projection.py +500 -0
- pycforge/ide/qt_shell.py +348 -0
- pycforge/ide/qt_shell_interactions.py +320 -0
- pycforge/ide/qt_state.py +393 -0
- pycforge/ide/qt_workspace_features.py +398 -0
- pycforge/ide/qt_workspace_navigation.py +208 -0
- pycforge/ide/qt_workspace_observers.py +173 -0
- pycforge/ide/qt_workspace_panels.py +545 -0
- pycforge/ide/qt_workspace_widgets.py +228 -0
- pycforge/ide/resources/icons/about.svg +1 -0
- pycforge/ide/resources/icons/add-document.svg +1 -0
- pycforge/ide/resources/icons/brand-mark.svg +1 -0
- pycforge/ide/resources/icons/bundle-search.svg +1 -0
- pycforge/ide/resources/icons/cancel.svg +1 -0
- pycforge/ide/resources/icons/check.svg +1 -0
- pycforge/ide/resources/icons/chevron-right.svg +1 -0
- pycforge/ide/resources/icons/close.svg +1 -0
- pycforge/ide/resources/icons/collapse-all.svg +1 -0
- pycforge/ide/resources/icons/command-palette.svg +1 -0
- pycforge/ide/resources/icons/convert.svg +1 -0
- pycforge/ide/resources/icons/copy.svg +1 -0
- pycforge/ide/resources/icons/cut.svg +1 -0
- pycforge/ide/resources/icons/decision-trace.svg +1 -0
- pycforge/ide/resources/icons/details.svg +1 -0
- pycforge/ide/resources/icons/diagnostics.svg +1 -0
- pycforge/ide/resources/icons/duplicate-line.svg +1 -0
- pycforge/ide/resources/icons/exit.svg +1 -0
- pycforge/ide/resources/icons/expand-all.svg +1 -0
- pycforge/ide/resources/icons/export.svg +1 -0
- pycforge/ide/resources/icons/find.svg +1 -0
- pycforge/ide/resources/icons/go-to-line.svg +1 -0
- pycforge/ide/resources/icons/go-to-output.svg +1 -0
- pycforge/ide/resources/icons/go-to-source.svg +1 -0
- pycforge/ide/resources/icons/history.svg +1 -0
- pycforge/ide/resources/icons/indent.svg +1 -0
- pycforge/ide/resources/icons/link-c.svg +1 -0
- pycforge/ide/resources/icons/mappings.svg +1 -0
- pycforge/ide/resources/icons/module.svg +1 -0
- pycforge/ide/resources/icons/move-down.svg +1 -0
- pycforge/ide/resources/icons/move-line-down.svg +1 -0
- pycforge/ide/resources/icons/move-line-up.svg +1 -0
- pycforge/ide/resources/icons/move-up.svg +1 -0
- pycforge/ide/resources/icons/next.svg +1 -0
- pycforge/ide/resources/icons/open.svg +1 -0
- pycforge/ide/resources/icons/outdent.svg +1 -0
- pycforge/ide/resources/icons/outline.svg +1 -0
- pycforge/ide/resources/icons/paste.svg +1 -0
- pycforge/ide/resources/icons/previous.svg +1 -0
- pycforge/ide/resources/icons/primary-module.svg +1 -0
- pycforge/ide/resources/icons/redo.svg +1 -0
- pycforge/ide/resources/icons/remove-document.svg +1 -0
- pycforge/ide/resources/icons/replace.svg +1 -0
- pycforge/ide/resources/icons/save-as.svg +1 -0
- pycforge/ide/resources/icons/save.svg +1 -0
- pycforge/ide/resources/icons/select-all.svg +1 -0
- pycforge/ide/resources/icons/settings.svg +1 -0
- pycforge/ide/resources/icons/show-c.svg +1 -0
- pycforge/ide/resources/icons/split-view.svg +1 -0
- pycforge/ide/resources/icons/summary.svg +1 -0
- pycforge/ide/resources/icons/telemetry.svg +1 -0
- pycforge/ide/resources/icons/toggle-comment.svg +1 -0
- pycforge/ide/resources/icons/toggle-fold.svg +1 -0
- pycforge/ide/resources/icons/undo.svg +1 -0
- pycforge/ide/resources/icons/whitespace.svg +1 -0
- pycforge/ide/revisions.py +400 -0
- pycforge/ide/search_service.py +280 -0
- pycforge/ide/session_history.py +150 -0
- pycforge/ide/source_editing.py +417 -0
- pycforge/ide/source_structure.py +558 -0
- pycforge/ide/source_structure_async.py +191 -0
- pycforge/ide/supervisor.py +503 -0
- pycforge/ide/theme.py +119 -0
- pycforge/ide/theme_stylesheet.py +598 -0
- pycforge/ide/theme_workspace_stylesheet.py +155 -0
- pycforge/ide/visual_tokens.py +142 -0
- pycforge/ide/worker_protocol.py +80 -0
- pycforge/ide/workspace_search.py +462 -0
- pycforge/ide/workspace_session.py +256 -0
- pycforge/laboratory/__init__.py +6 -0
- pycforge/laboratory/audits.py +2244 -0
- pycforge/laboratory/checkpoint_e.py +2685 -0
- pycforge/laboratory/cli.py +119 -0
- pycforge/laboratory/exit_codes.py +11 -0
- pycforge/laboratory/keyword_audit.py +661 -0
- pycforge/laboratory/keyword_only_audit.py +481 -0
- pycforge-0.15.2.dist-info/METADATA +209 -0
- pycforge-0.15.2.dist-info/RECORD +221 -0
- pycforge-0.15.2.dist-info/WHEEL +5 -0
- pycforge-0.15.2.dist-info/entry_points.txt +5 -0
- pycforge-0.15.2.dist-info/licenses/LICENSE +674 -0
- 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
|
+
]
|