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,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)
|