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
pycforge/ide/qt_state.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Bounded settings, file observation, dialogs, and shutdown for Qt."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from concurrent.futures import Future
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from PyQt5.QtCore import QByteArray, QTimer
|
|
11
|
+
|
|
12
|
+
from pycforge._version import __version__
|
|
13
|
+
|
|
14
|
+
from .action_contract import DynamicActionEntry, MAX_DYNAMIC_LABEL_CHARS
|
|
15
|
+
from .qt_contract import (
|
|
16
|
+
MAX_RECENT_PATHS,
|
|
17
|
+
MAX_SETTINGS_BLOB_BYTES,
|
|
18
|
+
MAX_SETTINGS_PATH_CHARS,
|
|
19
|
+
PRESENTATION_SETTING_KEYS,
|
|
20
|
+
SETTINGS_ORGANIZATION,
|
|
21
|
+
SETTINGS_SCHEMA_VERSION,
|
|
22
|
+
coerce_settings_schema_version,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class QtStateMixin:
|
|
27
|
+
"""Own presentation persistence and guarded filesystem observation."""
|
|
28
|
+
|
|
29
|
+
def _restore_workspace_state(self) -> None:
|
|
30
|
+
restorations = (
|
|
31
|
+
("window/geometry", self.restoreGeometry),
|
|
32
|
+
("window/state", self.restoreState),
|
|
33
|
+
("splitter/workspace", self.workspace_splitter.restoreState),
|
|
34
|
+
("splitter/editors", self.editor_splitter.restoreState),
|
|
35
|
+
("splitter/main", self.main_splitter.restoreState),
|
|
36
|
+
("splitter/source", self.source_splitter.restoreState),
|
|
37
|
+
)
|
|
38
|
+
for key, restore in restorations:
|
|
39
|
+
value = self._setting_blob(key)
|
|
40
|
+
if value is None:
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
restore(value)
|
|
44
|
+
except Exception:
|
|
45
|
+
# Presentation settings are untrusted, optional input and
|
|
46
|
+
# can never prevent construction of the workspace.
|
|
47
|
+
continue
|
|
48
|
+
self._set_navigator_visible(
|
|
49
|
+
self._setting_bool("view/bundle", True)
|
|
50
|
+
)
|
|
51
|
+
self._set_output_visible(
|
|
52
|
+
self._setting_bool("view/generated_c", False)
|
|
53
|
+
)
|
|
54
|
+
self._set_details_visible(
|
|
55
|
+
self._setting_bool("view/details", False)
|
|
56
|
+
)
|
|
57
|
+
self._set_source_split_visible(
|
|
58
|
+
self._setting_bool("view/source_split", False)
|
|
59
|
+
)
|
|
60
|
+
self._set_whitespace_visible(
|
|
61
|
+
self._setting_bool("view/whitespace", False)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def _persist_workspace_state(self) -> None:
|
|
65
|
+
values = {
|
|
66
|
+
"settings/schema_version": SETTINGS_SCHEMA_VERSION,
|
|
67
|
+
"window/geometry": self.saveGeometry(),
|
|
68
|
+
"window/state": self.saveState(),
|
|
69
|
+
"splitter/workspace": self.workspace_splitter.saveState(),
|
|
70
|
+
"splitter/editors": self.editor_splitter.saveState(),
|
|
71
|
+
"splitter/main": self.main_splitter.saveState(),
|
|
72
|
+
"splitter/source": self.source_splitter.saveState(),
|
|
73
|
+
"view/bundle": self.navigator.isVisible(),
|
|
74
|
+
"view/generated_c": self.output_panel.isVisible(),
|
|
75
|
+
"view/details": self.tabs.isVisible(),
|
|
76
|
+
"view/source_split": self.source_secondary.isVisible(),
|
|
77
|
+
"view/whitespace": self.source.whitespace_visible,
|
|
78
|
+
"workspace/last_directory": self._last_directory,
|
|
79
|
+
"workspace/recent_paths": self._recent_paths,
|
|
80
|
+
}
|
|
81
|
+
for key, value in values.items():
|
|
82
|
+
self._set_setting(key, value)
|
|
83
|
+
try:
|
|
84
|
+
self.settings.sync()
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
def _prepare_settings(self) -> None:
|
|
89
|
+
"""Validate stored presentation state or clear an incompatible schema."""
|
|
90
|
+
|
|
91
|
+
raw_version = self._settings_value(
|
|
92
|
+
"settings/schema_version", None
|
|
93
|
+
)
|
|
94
|
+
if raw_version is None:
|
|
95
|
+
# v0.12.1 had the same bounded presentation keys but no schema
|
|
96
|
+
# marker. Accept it once through the typed readers below.
|
|
97
|
+
version = SETTINGS_SCHEMA_VERSION
|
|
98
|
+
else:
|
|
99
|
+
version = coerce_settings_schema_version(raw_version)
|
|
100
|
+
if version != SETTINGS_SCHEMA_VERSION:
|
|
101
|
+
for key in PRESENTATION_SETTING_KEYS:
|
|
102
|
+
try:
|
|
103
|
+
self.settings.remove(key)
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
self._set_setting(
|
|
107
|
+
"settings/schema_version", SETTINGS_SCHEMA_VERSION
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def _settings_value(self, key: str, default: Any) -> Any:
|
|
111
|
+
try:
|
|
112
|
+
return self.settings.value(key, default)
|
|
113
|
+
except Exception:
|
|
114
|
+
return default
|
|
115
|
+
|
|
116
|
+
def _set_setting(self, key: str, value: Any) -> None:
|
|
117
|
+
try:
|
|
118
|
+
self.settings.setValue(key, value)
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
def _setting_blob(self, key: str) -> QByteArray | None:
|
|
123
|
+
value = self._settings_value(key, None)
|
|
124
|
+
if not isinstance(value, (QByteArray, bytes, bytearray)):
|
|
125
|
+
return None
|
|
126
|
+
if not 0 < len(value) <= MAX_SETTINGS_BLOB_BYTES:
|
|
127
|
+
return None
|
|
128
|
+
return QByteArray(value)
|
|
129
|
+
|
|
130
|
+
def _setting_bool(self, key: str, default: bool) -> bool:
|
|
131
|
+
value = self._settings_value(key, default)
|
|
132
|
+
if isinstance(value, bool):
|
|
133
|
+
return value
|
|
134
|
+
if isinstance(value, int) and value in (0, 1):
|
|
135
|
+
return bool(value)
|
|
136
|
+
if isinstance(value, str) and len(value) <= 16:
|
|
137
|
+
normalized = value.casefold()
|
|
138
|
+
if normalized in {"1", "true", "yes", "on"}:
|
|
139
|
+
return True
|
|
140
|
+
if normalized in {"0", "false", "no", "off"}:
|
|
141
|
+
return False
|
|
142
|
+
return default
|
|
143
|
+
|
|
144
|
+
def _setting_text(self, key: str, default: str) -> str:
|
|
145
|
+
value = self._settings_value(key, default)
|
|
146
|
+
if (
|
|
147
|
+
not isinstance(value, str)
|
|
148
|
+
or len(value) > MAX_SETTINGS_PATH_CHARS
|
|
149
|
+
or "\0" in value
|
|
150
|
+
):
|
|
151
|
+
return default
|
|
152
|
+
return value
|
|
153
|
+
|
|
154
|
+
def _load_recent_paths(self) -> list[str]:
|
|
155
|
+
value = self._settings_value("workspace/recent_paths", [])
|
|
156
|
+
if isinstance(value, str):
|
|
157
|
+
candidates = (value,)
|
|
158
|
+
elif isinstance(value, (list, tuple)):
|
|
159
|
+
candidates = value
|
|
160
|
+
else:
|
|
161
|
+
return []
|
|
162
|
+
paths: list[str] = []
|
|
163
|
+
for item in candidates[: MAX_RECENT_PATHS * 10]:
|
|
164
|
+
if (
|
|
165
|
+
not isinstance(item, str)
|
|
166
|
+
or not item
|
|
167
|
+
or len(item) > MAX_SETTINGS_PATH_CHARS
|
|
168
|
+
or "\0" in item
|
|
169
|
+
or item in paths
|
|
170
|
+
):
|
|
171
|
+
continue
|
|
172
|
+
paths.append(item)
|
|
173
|
+
if len(paths) == MAX_RECENT_PATHS:
|
|
174
|
+
break
|
|
175
|
+
return paths
|
|
176
|
+
|
|
177
|
+
def _remember_path(self, path: Path) -> None:
|
|
178
|
+
value = str(path)
|
|
179
|
+
self._recent_paths = [
|
|
180
|
+
value,
|
|
181
|
+
*(item for item in self._recent_paths if item != value),
|
|
182
|
+
][:MAX_RECENT_PATHS]
|
|
183
|
+
self._last_directory = str(path.parent)
|
|
184
|
+
self._set_setting(
|
|
185
|
+
"workspace/recent_paths", self._recent_paths
|
|
186
|
+
)
|
|
187
|
+
self._set_setting(
|
|
188
|
+
"workspace/last_directory", self._last_directory
|
|
189
|
+
)
|
|
190
|
+
self._rebuild_recent_menu()
|
|
191
|
+
|
|
192
|
+
def _rebuild_recent_menu(self) -> None:
|
|
193
|
+
if not hasattr(self, "action_registry"):
|
|
194
|
+
return
|
|
195
|
+
entries = []
|
|
196
|
+
for path in self._recent_paths:
|
|
197
|
+
label = path
|
|
198
|
+
if len(label) > MAX_DYNAMIC_LABEL_CHARS:
|
|
199
|
+
label = "…" + label[-(MAX_DYNAMIC_LABEL_CHARS - 1):]
|
|
200
|
+
name = Path(path).name or path
|
|
201
|
+
entries.append(
|
|
202
|
+
DynamicActionEntry(
|
|
203
|
+
key=path,
|
|
204
|
+
label=label,
|
|
205
|
+
tooltip=path,
|
|
206
|
+
accessible_name=(
|
|
207
|
+
f"Open recent Python document {name}"
|
|
208
|
+
),
|
|
209
|
+
payload=path,
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
self.action_registry.replace_dynamic(
|
|
213
|
+
"recent_python",
|
|
214
|
+
entries,
|
|
215
|
+
self._open_recent_path,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _open_recent_path(self, value: str) -> None:
|
|
219
|
+
self._open_path(Path(value))
|
|
220
|
+
|
|
221
|
+
def _refresh_watchers(self, snapshot) -> None:
|
|
222
|
+
key = (
|
|
223
|
+
snapshot.revision_generation,
|
|
224
|
+
snapshot.revision_authenticated,
|
|
225
|
+
tuple(item.path for item in snapshot.documents),
|
|
226
|
+
)
|
|
227
|
+
if key == self._watcher_projection_key:
|
|
228
|
+
return
|
|
229
|
+
revision = self.controller.committed_revision
|
|
230
|
+
if revision is None:
|
|
231
|
+
return
|
|
232
|
+
self._watcher_projection_key = key
|
|
233
|
+
linked = {
|
|
234
|
+
item.path: revision.index_for(
|
|
235
|
+
item.document_id
|
|
236
|
+
).utf8_sha256
|
|
237
|
+
for item in snapshot.documents
|
|
238
|
+
if item.path is not None
|
|
239
|
+
}
|
|
240
|
+
desired = set(linked)
|
|
241
|
+
self._known_file_fingerprints = {
|
|
242
|
+
path: fingerprint for path, fingerprint in linked.items()
|
|
243
|
+
}
|
|
244
|
+
current = set(self.file_watcher.files())
|
|
245
|
+
remove = sorted(current - desired)
|
|
246
|
+
add = sorted(desired - current)
|
|
247
|
+
if remove:
|
|
248
|
+
self.file_watcher.removePaths(remove)
|
|
249
|
+
if add:
|
|
250
|
+
self.file_watcher.addPaths(add)
|
|
251
|
+
|
|
252
|
+
def _external_file_changed(self, path: str) -> None:
|
|
253
|
+
# Atomic replacement can briefly remove the watched path. Assess
|
|
254
|
+
# decoded contents after the filesystem settles instead of using a
|
|
255
|
+
# timing-only self-save suppression window.
|
|
256
|
+
QTimer.singleShot(
|
|
257
|
+
120,
|
|
258
|
+
lambda value=path: self._assess_external_file_change(value),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def _assess_external_file_change(self, path: str) -> None:
|
|
262
|
+
expected = self._known_file_fingerprints.get(path)
|
|
263
|
+
try:
|
|
264
|
+
future = self.controller.observe_linked_file_async(path)
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
self._show_error(
|
|
267
|
+
"Could not inspect the linked Python file", exc
|
|
268
|
+
)
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
def assessed(completed: Future) -> None:
|
|
272
|
+
try:
|
|
273
|
+
observation = completed.result()
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
self.toast.show_message(
|
|
276
|
+
f"{path} became unavailable or unreadable: {exc}",
|
|
277
|
+
"warning",
|
|
278
|
+
)
|
|
279
|
+
else:
|
|
280
|
+
observed = observation.utf8_sha256
|
|
281
|
+
if expected is not None and observed != expected:
|
|
282
|
+
self.toast.show_message(
|
|
283
|
+
f"{path} changed outside PyCForge. "
|
|
284
|
+
"Reopen it explicitly to review the disk version.",
|
|
285
|
+
"warning",
|
|
286
|
+
)
|
|
287
|
+
self._watcher_projection_key = None
|
|
288
|
+
self._refresh_watchers(self.controller.snapshot)
|
|
289
|
+
|
|
290
|
+
self._dispatch_io(future, assessed)
|
|
291
|
+
|
|
292
|
+
def _unique_module_id(
|
|
293
|
+
self,
|
|
294
|
+
seed: str,
|
|
295
|
+
replacing_document_id: str | None = None,
|
|
296
|
+
) -> str:
|
|
297
|
+
base = re.sub(
|
|
298
|
+
r"[^a-z0-9_]+", "_", seed.casefold()
|
|
299
|
+
).strip("_")
|
|
300
|
+
if not base or not base[0].isalpha():
|
|
301
|
+
base = "module_" + (base or "source")
|
|
302
|
+
base = base[:55]
|
|
303
|
+
used = {
|
|
304
|
+
item.module_id for item in self.controller.snapshot.documents
|
|
305
|
+
if item.document_id != replacing_document_id
|
|
306
|
+
}
|
|
307
|
+
candidate = base
|
|
308
|
+
index = 2
|
|
309
|
+
while candidate in used:
|
|
310
|
+
candidate = f"{base[:59]}_{index}"
|
|
311
|
+
index += 1
|
|
312
|
+
return candidate
|
|
313
|
+
|
|
314
|
+
def _unique_logical_name(
|
|
315
|
+
self,
|
|
316
|
+
seed: str,
|
|
317
|
+
replacing_document_id: str | None = None,
|
|
318
|
+
) -> str:
|
|
319
|
+
safe = Path(seed).name or "source.py"
|
|
320
|
+
if not safe.endswith(".py"):
|
|
321
|
+
safe += ".py"
|
|
322
|
+
used = {
|
|
323
|
+
item.logical_name
|
|
324
|
+
for item in self.controller.snapshot.documents
|
|
325
|
+
if item.document_id != replacing_document_id
|
|
326
|
+
}
|
|
327
|
+
candidate = safe
|
|
328
|
+
index = 2
|
|
329
|
+
path = Path(safe)
|
|
330
|
+
while candidate in used:
|
|
331
|
+
candidate = f"{path.stem}_{index}{path.suffix}"
|
|
332
|
+
index += 1
|
|
333
|
+
return candidate
|
|
334
|
+
|
|
335
|
+
def _commit_pending_identity(self) -> bool:
|
|
336
|
+
if not self._flush_pending_source_sync():
|
|
337
|
+
return False
|
|
338
|
+
document_id = self.navigator.current_document_id
|
|
339
|
+
if document_id is None:
|
|
340
|
+
return True
|
|
341
|
+
module_id, logical_name = self.navigator.pending_identity()
|
|
342
|
+
try:
|
|
343
|
+
document = next(
|
|
344
|
+
item for item in self.controller.snapshot.documents
|
|
345
|
+
if item.document_id == document_id
|
|
346
|
+
)
|
|
347
|
+
except StopIteration:
|
|
348
|
+
self.toast.show_message(
|
|
349
|
+
"The selected module no longer exists.", "warning"
|
|
350
|
+
)
|
|
351
|
+
return False
|
|
352
|
+
if (
|
|
353
|
+
module_id == document.module_id
|
|
354
|
+
and logical_name == document.logical_name
|
|
355
|
+
):
|
|
356
|
+
return True
|
|
357
|
+
try:
|
|
358
|
+
self.controller.set_document_identity(
|
|
359
|
+
document_id,
|
|
360
|
+
module_id=module_id,
|
|
361
|
+
logical_name=logical_name,
|
|
362
|
+
)
|
|
363
|
+
except Exception as exc:
|
|
364
|
+
self._show_error(
|
|
365
|
+
"Document identity was not accepted", exc
|
|
366
|
+
)
|
|
367
|
+
self._navigator_key = None
|
|
368
|
+
self._apply_snapshot(self.controller.snapshot)
|
|
369
|
+
return False
|
|
370
|
+
return True
|
|
371
|
+
|
|
372
|
+
def _show_error(self, heading: str, exc: Exception) -> None:
|
|
373
|
+
message = str(exc).strip() or exc.__class__.__name__
|
|
374
|
+
self.toast.show_message(
|
|
375
|
+
f"{heading}: {message}", "error"
|
|
376
|
+
)
|
|
377
|
+
self.statusBar().showMessage(heading, 9000)
|
|
378
|
+
|
|
379
|
+
@staticmethod
|
|
380
|
+
def _linked_c_display(path: str | None) -> str:
|
|
381
|
+
"""Keep the destination filename visible; tooltip has full path."""
|
|
382
|
+
|
|
383
|
+
if not path:
|
|
384
|
+
return "not set"
|
|
385
|
+
return Path(path).name or path
|
|
386
|
+
|
|
387
|
+
def _update_window_title(self, active) -> None:
|
|
388
|
+
dirty = " •" if active.dirty else ""
|
|
389
|
+
self.setWindowTitle(
|
|
390
|
+
f"{active.logical_name}{dirty} — PyCForge {__version__}"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
__all__ = ["QtStateMixin"]
|