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,904 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import re
5
+ from concurrent.futures import Future
6
+ from dataclasses import replace
7
+ from pathlib import Path, PurePosixPath
8
+ from threading import RLock
9
+ from typing import Callable, Mapping, Sequence
10
+
11
+ from pycforge.converter.core.stage_artifact import freeze_value
12
+ from pycforge.converter.io.atomic_writer import AtomicWriter
13
+
14
+ from .model import (
15
+ MAX_WORKSPACE_DOCUMENTS,
16
+ PreferenceValue,
17
+ WorkspaceDocument,
18
+ WorkspaceSnapshot,
19
+ WorkspaceState,
20
+ WorkspaceStore,
21
+ )
22
+ from .io_service import WorkspaceIOService
23
+ from .positions import TextPositionIndex, TextPositionIndexService
24
+ from .controller_conversion import ConversionControllerMixin
25
+ from .controller_io import AsyncIOControllerMixin
26
+ from .revisions import (
27
+ RevisionBuildError,
28
+ RevisionInput,
29
+ WorkspaceRevision,
30
+ WorkspaceRevisionService,
31
+ build_workspace_revision,
32
+ source_fingerprint,
33
+ workspace_bundle_fingerprint,
34
+ )
35
+ from .supervisor import (
36
+ ProcessConversionSupervisor,
37
+ )
38
+
39
+
40
+ SnapshotListener = Callable[[WorkspaceSnapshot], None]
41
+ _MODULE_SEGMENT = re.compile(r"^[a-z][a-z0-9_]{0,62}$")
42
+ _DOCUMENT_ID = re.compile(r"^[A-Za-z][A-Za-z0-9._:-]{0,127}$")
43
+
44
+
45
+ class WorkspaceController(
46
+ ConversionControllerMixin,
47
+ AsyncIOControllerMixin,
48
+ ):
49
+ """Headless, race-safe controller for a bounded Phase 12 source bundle."""
50
+
51
+ def __init__(
52
+ self,
53
+ *,
54
+ supervisor: ProcessConversionSupervisor | None = None,
55
+ io_service: WorkspaceIOService | None = None,
56
+ writer: AtomicWriter | None = None,
57
+ ) -> None:
58
+ self._supervisor = supervisor or ProcessConversionSupervisor()
59
+ self._owns_supervisor = supervisor is None
60
+ self._io_service = io_service or WorkspaceIOService()
61
+ self._owns_io_service = io_service is None
62
+ self._writer = writer or AtomicWriter()
63
+ self._store = WorkspaceStore()
64
+ initial = self._store.snapshot
65
+ initial_revision = build_workspace_revision(
66
+ RevisionInput(
67
+ generation=0,
68
+ documents=initial.documents,
69
+ active_document_id=initial.active_document_id,
70
+ )
71
+ )
72
+ self._store.replace(
73
+ replace(
74
+ initial,
75
+ source_fingerprint=initial_revision.source_fingerprint,
76
+ bundle_fingerprint=initial_revision.bundle_fingerprint,
77
+ revision_generation=initial_revision.generation,
78
+ revision_authenticated=True,
79
+ )
80
+ )
81
+ self._listeners: list[SnapshotListener] = []
82
+ self._lock = RLock()
83
+ self._request_sequence = 0
84
+ self._revision_generation = 0
85
+ self._document_sequence = 1
86
+ self._current_revision = initial_revision
87
+ self._result_output_index: (
88
+ tuple[str, TextPositionIndex] | None
89
+ ) = None
90
+ self._output_index_generation = 0
91
+ self._closed = False
92
+ self._output_index_service = TextPositionIndexService(
93
+ self._accept_output_index
94
+ )
95
+ self._revision_future: Future[WorkspaceRevision] | None = None
96
+ self._revision_service = WorkspaceRevisionService(
97
+ self._accept_revision,
98
+ on_error=self._reject_revision,
99
+ )
100
+
101
+ @property
102
+ def snapshot(self) -> WorkspaceSnapshot:
103
+ return self._store.snapshot
104
+
105
+ @property
106
+ def committed_revision(self) -> WorkspaceRevision | None:
107
+ with self._lock:
108
+ if (
109
+ not self.snapshot.revision_authenticated
110
+ or self._current_revision.generation
111
+ != self.snapshot.revision_generation
112
+ ):
113
+ return None
114
+ return self._current_revision
115
+
116
+ @property
117
+ def result_output_index(self) -> TextPositionIndex | None:
118
+ """Return cached positions for the exact retained generated-C string."""
119
+
120
+ with self._lock:
121
+ cached = self._result_output_index
122
+ if (
123
+ cached is None
124
+ or cached[0] is not self.snapshot.generated_c
125
+ ):
126
+ return None
127
+ return cached[1]
128
+
129
+ def _accept_output_index(
130
+ self,
131
+ generation: int,
132
+ text: str,
133
+ index: TextPositionIndex,
134
+ ) -> None:
135
+ with self._lock:
136
+ if (
137
+ self._closed
138
+ or generation != self._output_index_generation
139
+ or text is not self.snapshot.generated_c
140
+ ):
141
+ return
142
+ self._result_output_index = (text, index)
143
+ # The immutable snapshot remains exact; republishing only tells
144
+ # presentation observers that deferred position facts are ready.
145
+ self._publish(self.snapshot)
146
+
147
+ def subscribe(self, listener: SnapshotListener) -> None:
148
+ with self._lock:
149
+ if listener not in self._listeners:
150
+ self._listeners.append(listener)
151
+
152
+ def unsubscribe(self, listener: SnapshotListener) -> None:
153
+ with self._lock:
154
+ if listener in self._listeners:
155
+ self._listeners.remove(listener)
156
+
157
+ def _publish(self, snapshot: WorkspaceSnapshot) -> None:
158
+ self._store.replace(snapshot)
159
+ for listener in tuple(self._listeners):
160
+ try:
161
+ listener(snapshot)
162
+ except Exception:
163
+ # Views are observers and cannot compromise conversion custody.
164
+ pass
165
+
166
+ @staticmethod
167
+ def source_fingerprint(text: str) -> str:
168
+ return source_fingerprint(text)
169
+
170
+ @classmethod
171
+ def workspace_bundle_fingerprint(
172
+ cls,
173
+ documents: Sequence[WorkspaceDocument],
174
+ ) -> str:
175
+ return workspace_bundle_fingerprint(documents)
176
+
177
+ @staticmethod
178
+ def _validate_module_id(value: object) -> bool:
179
+ if not isinstance(value, str) or not value:
180
+ return False
181
+ try:
182
+ encoded = value.encode("utf-8")
183
+ except UnicodeEncodeError:
184
+ return False
185
+ parts = value.split(".")
186
+ return bool(
187
+ len(encoded) <= 255
188
+ and 1 <= len(parts) <= 16
189
+ and all(_MODULE_SEGMENT.fullmatch(part) for part in parts)
190
+ )
191
+
192
+ @staticmethod
193
+ def _validate_logical_name(value: object) -> bool:
194
+ if not isinstance(value, str) or not value:
195
+ return False
196
+ try:
197
+ value.encode("utf-8")
198
+ except UnicodeEncodeError:
199
+ return False
200
+ path = PurePosixPath(value)
201
+ return bool(
202
+ path.parts
203
+ and path.as_posix() == value
204
+ and not value.startswith("/")
205
+ and "\\" not in value
206
+ and ".." not in path.parts
207
+ and not any(ord(character) < 32 for character in value)
208
+ )
209
+
210
+ @classmethod
211
+ def _validate_documents(cls, documents: Sequence[WorkspaceDocument]) -> None:
212
+ if not 1 <= len(documents) <= MAX_WORKSPACE_DOCUMENTS:
213
+ raise ValueError("workspace must contain between 1 and 64 documents")
214
+ if sum(item.is_primary for item in documents) != 1:
215
+ raise ValueError("workspace must contain exactly one primary document")
216
+
217
+ document_ids: list[str] = []
218
+ module_ids: list[str] = []
219
+ logical_names: list[str] = []
220
+ paths: list[str] = []
221
+ for document in documents:
222
+ if not isinstance(document, WorkspaceDocument):
223
+ raise TypeError("workspace documents must be WorkspaceDocument values")
224
+ if not _DOCUMENT_ID.fullmatch(document.document_id):
225
+ raise ValueError("document ID is not a canonical workspace identifier")
226
+ if not cls._validate_module_id(document.module_id):
227
+ raise ValueError("module ID must be a canonical lowercase dotted identifier")
228
+ if not cls._validate_logical_name(document.logical_name):
229
+ raise ValueError("logical name must be a canonical relative logical path")
230
+ if not isinstance(document.text, str):
231
+ raise TypeError("document text must be a string")
232
+ if document.path is not None:
233
+ if not isinstance(document.path, str) or not document.path or "\0" in document.path:
234
+ raise ValueError("document path must be a non-empty path string")
235
+ paths.append(document.path)
236
+ document_ids.append(document.document_id)
237
+ module_ids.append(document.module_id)
238
+ logical_names.append(document.logical_name)
239
+
240
+ if len(document_ids) != len(set(document_ids)):
241
+ raise ValueError("workspace document IDs must be unique")
242
+ if len(module_ids) != len(set(module_ids)):
243
+ raise ValueError("workspace module IDs must be unique")
244
+ if len(logical_names) != len(set(logical_names)):
245
+ raise ValueError("workspace logical names must be unique")
246
+ if len(paths) != len(set(paths)):
247
+ raise ValueError("workspace file paths must be unique")
248
+
249
+ @staticmethod
250
+ def _path_text(path: Path | str) -> str:
251
+ value = str(Path(path))
252
+ if not value or "\0" in value:
253
+ raise ValueError("path must be non-empty")
254
+ return value
255
+
256
+ def _next_document_id(self) -> str:
257
+ existing = {item.document_id for item in self.snapshot.documents}
258
+ while True:
259
+ self._document_sequence += 1
260
+ candidate = f"doc-{self._document_sequence:04d}"
261
+ if candidate not in existing:
262
+ return candidate
263
+
264
+ @staticmethod
265
+ def _semantic_documents_changed(
266
+ old: Sequence[WorkspaceDocument],
267
+ new: Sequence[WorkspaceDocument],
268
+ ) -> bool:
269
+ if len(old) != len(new):
270
+ return True
271
+ return any(
272
+ before.document_id != after.document_id
273
+ or before.module_id != after.module_id
274
+ or before.logical_name != after.logical_name
275
+ or before.is_primary != after.is_primary
276
+ or before.text is not after.text
277
+ for before, after in zip(old, new)
278
+ )
279
+
280
+ def _mutate_documents(
281
+ self,
282
+ documents: Sequence[WorkspaceDocument],
283
+ *,
284
+ active_document_id: str,
285
+ reason: str,
286
+ linked_c_path: str | None | object = Ellipsis,
287
+ linked_c_path_is_default: bool | object = Ellipsis,
288
+ ) -> WorkspaceSnapshot:
289
+ candidate = tuple(documents)
290
+ self._validate_documents(candidate)
291
+ if active_document_id not in {item.document_id for item in candidate}:
292
+ raise ValueError("active document must belong to the workspace")
293
+
294
+ old = self.snapshot
295
+ active = next(item for item in candidate if item.document_id == active_document_id)
296
+ semantic_changed = self._semantic_documents_changed(old.documents, candidate)
297
+ if not semantic_changed:
298
+ state = old.state
299
+ stale_reason = old.stale_reason
300
+ elif old.generated_c is not None:
301
+ state = WorkspaceState.STALE
302
+ stale_reason = reason
303
+ else:
304
+ # A semantic edit starts a new, not-yet-converted revision. Prior
305
+ # rejection or observer records must not remain authoritative for
306
+ # the new text merely because no generated C exists yet.
307
+ state = WorkspaceState.EMPTY
308
+ stale_reason = None
309
+
310
+ if semantic_changed:
311
+ # A request is identified by both its source fingerprint and its
312
+ # generation. Advancing the generation on every semantic edit
313
+ # prevents an A -> B -> A edit from making an obsolete result for
314
+ # A look current again, even when a converter ignores cancellation.
315
+ self._revision_generation += 1
316
+ if old.state in {
317
+ WorkspaceState.CONVERTING,
318
+ WorkspaceState.CANCEL_REQUESTED,
319
+ }:
320
+ self._supervisor.cancel(old.request_sequence)
321
+
322
+ if semantic_changed:
323
+ current_source_fingerprint = ""
324
+ else:
325
+ try:
326
+ current_source_fingerprint = self._current_revision.index_for(
327
+ active.document_id
328
+ ).source_fingerprint
329
+ except KeyError:
330
+ current_source_fingerprint = old.source_fingerprint
331
+
332
+ updated = replace(
333
+ old,
334
+ documents=candidate,
335
+ active_document_id=active_document_id,
336
+ source_text=active.text,
337
+ source_fingerprint=current_source_fingerprint,
338
+ bundle_fingerprint=("" if semantic_changed else old.bundle_fingerprint),
339
+ revision_generation=self._revision_generation,
340
+ revision_authenticated=(
341
+ False if semantic_changed else old.revision_authenticated
342
+ ),
343
+ state=state,
344
+ stale_reason=stale_reason,
345
+ worker_failure_reason=None if semantic_changed else old.worker_failure_reason,
346
+ diagnostics=() if semantic_changed else old.diagnostics,
347
+ summary=() if semantic_changed else old.summary,
348
+ decision_trace=None if semantic_changed else old.decision_trace,
349
+ telemetry=None if semantic_changed else old.telemetry,
350
+ conversion_summary=None if semantic_changed else old.conversion_summary,
351
+ mappings=(
352
+ old.mappings
353
+ if not semantic_changed or old.generated_c is not None
354
+ else ()
355
+ ),
356
+ active_stage=None if semantic_changed else old.active_stage,
357
+ completed_stages=0 if semantic_changed else old.completed_stages,
358
+ total_stages=0 if semantic_changed else old.total_stages,
359
+ linked_c_path=(
360
+ old.linked_c_path if linked_c_path is Ellipsis else linked_c_path
361
+ ),
362
+ linked_c_path_is_default=(
363
+ old.linked_c_path_is_default
364
+ if linked_c_path_is_default is Ellipsis
365
+ else bool(linked_c_path_is_default)
366
+ ),
367
+ )
368
+ self._publish(updated)
369
+ if semantic_changed:
370
+ self._revision_future = self._revision_service.submit(
371
+ self._revision_generation,
372
+ candidate,
373
+ active_document_id,
374
+ )
375
+ return updated
376
+
377
+ def _accept_revision(self, revision: WorkspaceRevision) -> None:
378
+ with self._lock:
379
+ current = self.snapshot
380
+ if (
381
+ self._closed
382
+ or revision.generation != self._revision_generation
383
+ or revision.generation != current.revision_generation
384
+ ):
385
+ return
386
+ self._current_revision = revision
387
+ try:
388
+ active_source = revision.index_for(
389
+ current.active_document_id
390
+ ).source_fingerprint
391
+ except KeyError:
392
+ return
393
+ self._publish(
394
+ replace(
395
+ current,
396
+ source_fingerprint=active_source,
397
+ bundle_fingerprint=revision.bundle_fingerprint,
398
+ revision_authenticated=True,
399
+ worker_failure_reason=None,
400
+ )
401
+ )
402
+
403
+ def _reject_revision(self, generation: int, error: BaseException) -> None:
404
+ with self._lock:
405
+ current = self.snapshot
406
+ if self._closed or generation != current.revision_generation:
407
+ return
408
+ diagnostics: tuple[dict, ...] = ()
409
+ if isinstance(error, RevisionBuildError):
410
+ diagnostics = tuple(
411
+ freeze_value(item.to_dict()) for item in error.diagnostics
412
+ )
413
+ self._publish(
414
+ replace(
415
+ current,
416
+ revision_authenticated=False,
417
+ state=WorkspaceState.REJECTED,
418
+ diagnostics=diagnostics,
419
+ stale_reason=(
420
+ "revision-authentication-failed"
421
+ if current.generated_c is not None
422
+ else None
423
+ ),
424
+ worker_failure_reason="revision-authentication-failed",
425
+ )
426
+ )
427
+
428
+ def _replace_document(
429
+ self,
430
+ document_id: str,
431
+ replacement: WorkspaceDocument,
432
+ *,
433
+ reason: str,
434
+ active_document_id: str | None = None,
435
+ linked_c_path: str | None | object = Ellipsis,
436
+ linked_c_path_is_default: bool | object = Ellipsis,
437
+ ) -> WorkspaceSnapshot:
438
+ documents = list(self.snapshot.documents)
439
+ for index, document in enumerate(documents):
440
+ if document.document_id == document_id:
441
+ documents[index] = replacement
442
+ break
443
+ else:
444
+ raise KeyError(f"unknown workspace document: {document_id}")
445
+ return self._mutate_documents(
446
+ documents,
447
+ active_document_id=active_document_id or self.snapshot.active_document_id,
448
+ reason=reason,
449
+ linked_c_path=linked_c_path,
450
+ linked_c_path_is_default=linked_c_path_is_default,
451
+ )
452
+
453
+ def set_source(self, text: str) -> None:
454
+ """Compatibility alias: update the active document's source text."""
455
+
456
+ with self._lock:
457
+ active = self.snapshot.active_document
458
+ self.update_document(active.document_id, text)
459
+
460
+ def update_document(self, document_id: str, text: str) -> WorkspaceDocument:
461
+ with self._lock:
462
+ document = self._document(document_id)
463
+ if not isinstance(text, str):
464
+ raise TypeError("document text must be a string")
465
+ if text == document.text:
466
+ return document
467
+ replacement = replace(document, text=text, dirty=True)
468
+ self._replace_document(
469
+ document_id,
470
+ replacement,
471
+ reason="source-changed",
472
+ )
473
+ return replacement
474
+
475
+ def add_document(
476
+ self,
477
+ module_id: str,
478
+ logical_name: str,
479
+ text: str = "",
480
+ *,
481
+ path: Path | str | None = None,
482
+ document_id: str | None = None,
483
+ make_active: bool = True,
484
+ is_primary: bool = False,
485
+ dirty: bool | None = None,
486
+ ) -> WorkspaceDocument:
487
+ with self._lock:
488
+ if len(self.snapshot.documents) >= MAX_WORKSPACE_DOCUMENTS:
489
+ raise ValueError("workspace already contains the maximum 64 documents")
490
+ identifier = document_id or self._next_document_id()
491
+ path_text = None if path is None else self._path_text(path)
492
+ document = WorkspaceDocument(
493
+ document_id=identifier,
494
+ module_id=module_id,
495
+ logical_name=logical_name,
496
+ text=text,
497
+ path=path_text,
498
+ is_primary=is_primary,
499
+ dirty=bool(text) if dirty is None and path is None else bool(dirty),
500
+ )
501
+ documents = list(self.snapshot.documents)
502
+ if is_primary:
503
+ documents = [replace(item, is_primary=False) for item in documents]
504
+ documents.append(document)
505
+ linked: str | None | object = Ellipsis
506
+ linked_default: bool | object = Ellipsis
507
+ if is_primary and self.snapshot.linked_c_path_is_default:
508
+ linked = self._default_c_path(document)
509
+ linked_default = True
510
+ self._mutate_documents(
511
+ documents,
512
+ active_document_id=(
513
+ document.document_id if make_active else self.snapshot.active_document_id
514
+ ),
515
+ reason="document-added",
516
+ linked_c_path=linked,
517
+ linked_c_path_is_default=linked_default,
518
+ )
519
+ return document
520
+
521
+ def remove_document(self, document_id: str) -> WorkspaceDocument:
522
+ with self._lock:
523
+ if len(self.snapshot.documents) == 1:
524
+ raise ValueError("the final workspace document cannot be removed")
525
+ removed = self._document(document_id)
526
+ old_documents = list(self.snapshot.documents)
527
+ removed_index = old_documents.index(removed)
528
+ documents = [item for item in old_documents if item.document_id != document_id]
529
+ if removed.is_primary:
530
+ documents[0] = replace(documents[0], is_primary=True)
531
+ if self.snapshot.active_document_id == document_id:
532
+ active_index = min(removed_index, len(documents) - 1)
533
+ active_document_id = documents[active_index].document_id
534
+ else:
535
+ active_document_id = self.snapshot.active_document_id
536
+ linked: str | None | object = Ellipsis
537
+ linked_default: bool | object = Ellipsis
538
+ if removed.is_primary and self.snapshot.linked_c_path_is_default:
539
+ linked = self._default_c_path(documents[0])
540
+ linked_default = True
541
+ self._mutate_documents(
542
+ documents,
543
+ active_document_id=active_document_id,
544
+ reason="document-removed",
545
+ linked_c_path=linked,
546
+ linked_c_path_is_default=linked_default,
547
+ )
548
+ return removed
549
+
550
+ def select_document(self, document_id: str) -> WorkspaceDocument:
551
+ with self._lock:
552
+ selected = self._document(document_id)
553
+ old = self.snapshot
554
+ self._publish(
555
+ replace(
556
+ old,
557
+ active_document_id=document_id,
558
+ source_text=selected.text,
559
+ source_fingerprint=(
560
+ self._current_revision.index_for(
561
+ selected.document_id
562
+ ).source_fingerprint
563
+ if old.revision_authenticated
564
+ else ""
565
+ ),
566
+ )
567
+ )
568
+ return selected
569
+
570
+ def reorder_documents(self, document_ids: Sequence[str]) -> None:
571
+ with self._lock:
572
+ identifiers = tuple(document_ids)
573
+ current = {item.document_id: item for item in self.snapshot.documents}
574
+ if len(identifiers) != len(current) or set(identifiers) != set(current):
575
+ raise ValueError("document order must be an exact workspace permutation")
576
+ if len(identifiers) != len(set(identifiers)):
577
+ raise ValueError("document order contains duplicate document IDs")
578
+ self._mutate_documents(
579
+ [current[item] for item in identifiers],
580
+ active_document_id=self.snapshot.active_document_id,
581
+ reason="documents-reordered",
582
+ )
583
+
584
+ def set_document_identity(
585
+ self,
586
+ document_id: str,
587
+ *,
588
+ module_id: str | None = None,
589
+ logical_name: str | None = None,
590
+ is_primary: bool | None = None,
591
+ ) -> WorkspaceDocument:
592
+ with self._lock:
593
+ document = self._document(document_id)
594
+ replacement = replace(
595
+ document,
596
+ module_id=document.module_id if module_id is None else module_id,
597
+ logical_name=(
598
+ document.logical_name if logical_name is None else logical_name
599
+ ),
600
+ is_primary=document.is_primary if is_primary is None else is_primary,
601
+ )
602
+ documents = list(self.snapshot.documents)
603
+ if is_primary is True:
604
+ documents = [replace(item, is_primary=False) for item in documents]
605
+ elif is_primary is False and document.is_primary:
606
+ raise ValueError("select another primary document before clearing this one")
607
+ for index, item in enumerate(documents):
608
+ if item.document_id == document_id:
609
+ documents[index] = replacement
610
+ break
611
+ linked: str | None | object = Ellipsis
612
+ linked_default: bool | object = Ellipsis
613
+ if is_primary is True and self.snapshot.linked_c_path_is_default:
614
+ linked = self._default_c_path(replacement)
615
+ linked_default = True
616
+ self._mutate_documents(
617
+ documents,
618
+ active_document_id=self.snapshot.active_document_id,
619
+ reason=(
620
+ "primary-document-changed"
621
+ if is_primary is not None and replacement.is_primary != document.is_primary
622
+ else "document-identity-changed"
623
+ ),
624
+ linked_c_path=linked,
625
+ linked_c_path_is_default=linked_default,
626
+ )
627
+ return replacement
628
+
629
+ def set_primary_document(self, document_id: str) -> WorkspaceDocument:
630
+ return self.set_document_identity(document_id, is_primary=True)
631
+
632
+ def _document(self, document_id: str) -> WorkspaceDocument:
633
+ for document in self.snapshot.documents:
634
+ if document.document_id == document_id:
635
+ return document
636
+ raise KeyError(f"unknown workspace document: {document_id}")
637
+
638
+ @staticmethod
639
+ def _module_id_for_path(path: Path) -> str:
640
+ candidate = re.sub(r"[^a-z0-9_]+", "_", path.stem.lower()).strip("_")
641
+ if not candidate or not candidate[0].isalpha():
642
+ candidate = f"module_{candidate}" if candidate else "module"
643
+ return candidate[:63]
644
+
645
+ def open_document(
646
+ self,
647
+ path: Path | str,
648
+ *,
649
+ module_id: str | None = None,
650
+ logical_name: str | None = None,
651
+ document_id: str | None = None,
652
+ make_active: bool = True,
653
+ is_primary: bool = False,
654
+ ) -> WorkspaceDocument:
655
+ source_path = Path(path)
656
+ text = source_path.read_text(encoding="utf-8")
657
+ return self._apply_opened_document(
658
+ source_path,
659
+ text,
660
+ module_id=module_id,
661
+ logical_name=logical_name,
662
+ document_id=document_id,
663
+ make_active=make_active,
664
+ is_primary=is_primary,
665
+ )
666
+
667
+ def _apply_opened_document(
668
+ self,
669
+ source_path: Path,
670
+ text: str,
671
+ *,
672
+ module_id: str | None,
673
+ logical_name: str | None,
674
+ document_id: str | None,
675
+ make_active: bool,
676
+ is_primary: bool,
677
+ ) -> WorkspaceDocument:
678
+ if document_id is None:
679
+ # Opening the first file replaces the pristine untitled document;
680
+ # subsequent opens extend the bundle. This keeps the common
681
+ # single-file path unsurprising without weakening explicit tabs.
682
+ with self._lock:
683
+ pristine = (
684
+ len(self.snapshot.documents) == 1
685
+ and not self.snapshot.active_document.text
686
+ and self.snapshot.active_document.path is None
687
+ and not self.snapshot.active_document.dirty
688
+ )
689
+ if pristine:
690
+ document_id = self.snapshot.active_document_id
691
+ module_id = module_id or self._module_id_for_path(source_path)
692
+ logical_name = logical_name or source_path.name
693
+ if document_id is None:
694
+ return self.add_document(
695
+ module_id or self._module_id_for_path(source_path),
696
+ logical_name or source_path.name,
697
+ text,
698
+ path=source_path,
699
+ make_active=make_active,
700
+ is_primary=is_primary,
701
+ dirty=False,
702
+ )
703
+
704
+ with self._lock:
705
+ document = self._document(document_id)
706
+ replacement = replace(
707
+ document,
708
+ module_id=document.module_id if module_id is None else module_id,
709
+ logical_name=(
710
+ document.logical_name if logical_name is None else logical_name
711
+ ),
712
+ text=text,
713
+ path=self._path_text(source_path),
714
+ dirty=False,
715
+ )
716
+ linked: str | None | object = Ellipsis
717
+ linked_default: bool | object = Ellipsis
718
+ if document.is_primary and self.snapshot.linked_c_path_is_default:
719
+ linked = self._default_c_path(replacement)
720
+ linked_default = True
721
+ self._replace_document(
722
+ document_id,
723
+ replacement,
724
+ reason="document-opened",
725
+ active_document_id=(
726
+ document_id if make_active else self.snapshot.active_document_id
727
+ ),
728
+ linked_c_path=linked,
729
+ linked_c_path_is_default=linked_default,
730
+ )
731
+ return replacement
732
+
733
+ def open_python(self, path: Path) -> str:
734
+ """Compatibility operation: open into the active document."""
735
+
736
+ opened = self.open_document(
737
+ path,
738
+ document_id=self.snapshot.active_document_id,
739
+ make_active=True,
740
+ )
741
+ return opened.text
742
+
743
+ def save_document(
744
+ self,
745
+ document_id: str | None = None,
746
+ path: Path | str | None = None,
747
+ ) -> str:
748
+ with self._lock:
749
+ identifier = document_id or self.snapshot.active_document_id
750
+ document = self._document(identifier)
751
+ destination = (
752
+ self._path_text(path)
753
+ if path is not None
754
+ else document.path
755
+ )
756
+ if destination is None:
757
+ raise ValueError("document has no linked Python path")
758
+ replacement = replace(document, path=destination, dirty=False)
759
+ candidate = tuple(
760
+ replacement if item.document_id == identifier else item
761
+ for item in self.snapshot.documents
762
+ )
763
+ # Save As must be transactional with respect to workspace path
764
+ # ownership. Validate the prospective snapshot before the atomic
765
+ # writer is allowed to create a temporary or replace a destination.
766
+ self._validate_documents(candidate)
767
+ self._writer.write_text(Path(destination), document.text)
768
+ linked: str | None | object = Ellipsis
769
+ linked_default: bool | object = Ellipsis
770
+ if document.is_primary and self.snapshot.linked_c_path_is_default:
771
+ linked = self._default_c_path(replacement)
772
+ linked_default = True
773
+ self._replace_document(
774
+ identifier,
775
+ replacement,
776
+ reason="document-saved",
777
+ linked_c_path=linked,
778
+ linked_c_path_is_default=linked_default,
779
+ )
780
+ return destination
781
+
782
+ def save_python(self, path: Path) -> None:
783
+ self.save_document(self.snapshot.active_document_id, path)
784
+
785
+ @staticmethod
786
+ def _default_c_path(document: WorkspaceDocument) -> str | None:
787
+ if document.path is None:
788
+ return None
789
+ return str(Path(document.path).with_suffix(".c"))
790
+
791
+ def link_generated_c(self, path: Path | str | None = None) -> str:
792
+ with self._lock:
793
+ if path is None:
794
+ destination = self._default_c_path(self.snapshot.primary_document)
795
+ if destination is None:
796
+ raise ValueError("primary document has no linked Python path")
797
+ is_default = True
798
+ else:
799
+ destination = self._path_text(path)
800
+ is_default = False
801
+ self._publish(
802
+ replace(
803
+ self.snapshot,
804
+ linked_c_path=destination,
805
+ linked_c_path_is_default=is_default,
806
+ )
807
+ )
808
+ return destination
809
+
810
+ def _require_publishable_c(self) -> str:
811
+ snapshot = self.snapshot
812
+ if snapshot.generated_c is None:
813
+ raise ValueError("no complete generated C result is available")
814
+ if not snapshot.can_save_c:
815
+ raise ValueError("generated C is stale or not publishable for the current bundle")
816
+ return snapshot.generated_c
817
+
818
+ def save_generated_c(self, path: Path) -> None:
819
+ with self._lock:
820
+ generated_c = self._require_publishable_c()
821
+ destination = self._path_text(path)
822
+ self._writer.write_text(Path(destination), generated_c)
823
+ self._publish(
824
+ replace(
825
+ self.snapshot,
826
+ linked_c_path=destination,
827
+ linked_c_path_is_default=False,
828
+ )
829
+ )
830
+
831
+ def save_generated_c_linked(self) -> str:
832
+ with self._lock:
833
+ generated_c = self._require_publishable_c()
834
+ destination = self.snapshot.linked_c_path
835
+ is_default = self.snapshot.linked_c_path_is_default
836
+ if destination is None:
837
+ destination = self._default_c_path(self.snapshot.primary_document)
838
+ if destination is None:
839
+ raise ValueError("generated C has no linked output path")
840
+ is_default = True
841
+ self._writer.write_text(Path(destination), generated_c)
842
+ self._publish(
843
+ replace(
844
+ self.snapshot,
845
+ linked_c_path=destination,
846
+ linked_c_path_is_default=is_default,
847
+ )
848
+ )
849
+ return destination
850
+
851
+ def set_preference(self, key: str, value: PreferenceValue) -> None:
852
+ with self._lock:
853
+ self._validate_preference(key, value)
854
+ values = self.snapshot.preference_data()
855
+ values[key] = value
856
+ self._publish(replace(self.snapshot, preferences=tuple(sorted(values.items()))))
857
+
858
+ def restore_preferences(
859
+ self,
860
+ values: Mapping[str, PreferenceValue],
861
+ ) -> None:
862
+ if not isinstance(values, Mapping):
863
+ raise TypeError("workspace preferences must be a mapping")
864
+ normalized: dict[str, PreferenceValue] = {}
865
+ for key, value in values.items():
866
+ self._validate_preference(key, value)
867
+ normalized[key] = value
868
+ with self._lock:
869
+ self._publish(
870
+ replace(self.snapshot, preferences=tuple(sorted(normalized.items())))
871
+ )
872
+
873
+ @staticmethod
874
+ def _validate_preference(key: object, value: object) -> None:
875
+ if not isinstance(key, str) or not key or any(ord(char) < 32 for char in key):
876
+ raise ValueError("preference key must be a non-empty printable string")
877
+ if not isinstance(value, (str, int, float, bool, type(None))):
878
+ raise TypeError("preference value must be a JSON scalar")
879
+ if isinstance(value, float) and not math.isfinite(value):
880
+ raise ValueError("preference float must be finite")
881
+
882
+ def navigate_source_to_output(self, source_node_id: str) -> tuple[dict, ...]:
883
+ return tuple(
884
+ mapping
885
+ for mapping in self.snapshot.mappings
886
+ if source_node_id in tuple(mapping.get("source_node_ids", ()))
887
+ or mapping.get("source_node_id") == source_node_id
888
+ )
889
+
890
+ def close(self, *, wait: bool = True) -> None:
891
+ with self._lock:
892
+ if self._closed:
893
+ return
894
+ self._closed = True
895
+ self.cancel()
896
+ self._revision_service.close(wait=wait, timeout=2.0 if wait else None)
897
+ self._output_index_service.close(
898
+ wait=wait,
899
+ timeout=2.0 if wait else None,
900
+ )
901
+ if self._owns_io_service:
902
+ self._io_service.close(wait=wait, timeout=2.0 if wait else None)
903
+ if self._owns_supervisor:
904
+ self._supervisor.close(wait=wait, timeout=2.25 if wait else None)