onec-interactive-runtime-core 0.1.19__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 (87) hide show
  1. onec_interactive_runtime_core-0.1.19.dist-info/METADATA +12 -0
  2. onec_interactive_runtime_core-0.1.19.dist-info/RECORD +87 -0
  3. onec_interactive_runtime_core-0.1.19.dist-info/WHEEL +4 -0
  4. onec_interactive_runtime_core-0.1.19.dist-info/licenses/COPYRIGHT +9 -0
  5. onec_interactive_runtime_core-0.1.19.dist-info/licenses/LICENSE +232 -0
  6. onec_interactive_runtime_core-0.1.19.dist-info/licenses/THIRD_PARTY_NOTICES.txt +32 -0
  7. onec_runtime/AGENTS.md +7 -0
  8. onec_runtime/__init__.py +3 -0
  9. onec_runtime/artifacts.py +59 -0
  10. onec_runtime/bootstrap.py +310 -0
  11. onec_runtime/breakpoint_workspace.py +249 -0
  12. onec_runtime/bsl/__init__.py +139 -0
  13. onec_runtime/bsl/diagnostics.py +1046 -0
  14. onec_runtime/bsl/full_ast_worker_projection.py +522 -0
  15. onec_runtime/bsl/generated_semantic_parser.py +6322 -0
  16. onec_runtime/bsl/lexer.py +164 -0
  17. onec_runtime/bsl/module_catalog.py +420 -0
  18. onec_runtime/bsl/module_delta.py +1079 -0
  19. onec_runtime/bsl/module_universe.py +1877 -0
  20. onec_runtime/bsl/notebook_cells.py +232 -0
  21. onec_runtime/bsl/notebook_method_globals.py +74 -0
  22. onec_runtime/bsl/notebook_methods.py +175 -0
  23. onec_runtime/bsl/parser_artifact_identity.py +119 -0
  24. onec_runtime/bsl/parser_target.py +388 -0
  25. onec_runtime/bsl/preprocessor.py +16 -0
  26. onec_runtime/bsl/semantic_lowering.py +1539 -0
  27. onec_runtime/bsl/source_maps.py +2411 -0
  28. onec_runtime/bsl/worker_dependency_resolver.py +289 -0
  29. onec_runtime/bsl/worker_preprocessor.py +247 -0
  30. onec_runtime/bsl/worker_projection_model.py +137 -0
  31. onec_runtime/bsl/worker_reload_source_map.py +1697 -0
  32. onec_runtime/capture.py +113 -0
  33. onec_runtime/capture_source.py +453 -0
  34. onec_runtime/compact_table.py +376 -0
  35. onec_runtime/compact_table_backend.py +500 -0
  36. onec_runtime/config.py +198 -0
  37. onec_runtime/configurator_agent.py +520 -0
  38. onec_runtime/controller_worker.py +400 -0
  39. onec_runtime/credentials.py +18 -0
  40. onec_runtime/epf_container.py +196 -0
  41. onec_runtime/errors.py +205 -0
  42. onec_runtime/experiment.py +130 -0
  43. onec_runtime/extension_bundle.py +944 -0
  44. onec_runtime/extension_lifecycle.py +402 -0
  45. onec_runtime/extension_state.py +222 -0
  46. onec_runtime/fault_injection.py +30 -0
  47. onec_runtime/kernel.py +179 -0
  48. onec_runtime/lease.py +123 -0
  49. onec_runtime/observation.py +287 -0
  50. onec_runtime/performance_profile.py +245 -0
  51. onec_runtime/privacy.py +263 -0
  52. onec_runtime/processes.py +265 -0
  53. onec_runtime/prototype_runtime.py +2644 -0
  54. onec_runtime/rdbg/__init__.py +1 -0
  55. onec_runtime/rdbg/models.py +130 -0
  56. onec_runtime/rdbg/reconnect.py +59 -0
  57. onec_runtime/rdbg/session.py +913 -0
  58. onec_runtime/rdbg/transport.py +138 -0
  59. onec_runtime/rdbg/xml_codec.py +980 -0
  60. onec_runtime/recovery.py +106 -0
  61. onec_runtime/recovery_journal.py +79 -0
  62. onec_runtime/resources/extension/OnecInteractiveRuntime.cfe +0 -0
  63. onec_runtime/resources/extension/extension-manifest.json +108 -0
  64. onec_runtime/runtime_api.py +5272 -0
  65. onec_runtime/runtime_contracts.py +196 -0
  66. onec_runtime/server_worker.py +2034 -0
  67. onec_runtime/session.py +2123 -0
  68. onec_runtime/startup_diagnostics.py +45 -0
  69. onec_runtime/stop_routing.py +65 -0
  70. onec_runtime/supervised_processes.py +232 -0
  71. onec_runtime/supervised_runtime.py +512 -0
  72. onec_runtime/supervisor.py +363 -0
  73. onec_runtime/supervisor_model.py +148 -0
  74. onec_runtime/supervisor_protocol.py +77 -0
  75. onec_runtime/table_materialization.py +471 -0
  76. onec_runtime/table_transfer_backend.py +203 -0
  77. onec_runtime/table_value.py +311 -0
  78. onec_runtime/toolchain.py +730 -0
  79. onec_runtime/value_materialization.py +432 -0
  80. onec_runtime/value_transfer_backend.py +199 -0
  81. onec_runtime/worker_breakpoints.py +1431 -0
  82. onec_runtime/worker_epf.py +313 -0
  83. onec_runtime/worker_epf_template.py +53 -0
  84. onec_runtime/worker_stage_protocol.py +516 -0
  85. onec_runtime/worker_universe.py +3772 -0
  86. onec_runtime_build/__init__.py +1 -0
  87. onec_runtime_build/parser_target_development.py +116 -0
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import re
5
+
6
+ from onec_runtime.bsl.source_maps import SourceSpan
7
+
8
+
9
+ KEYWORDS = {
10
+ "ПЕРЕМ": "ПЕРЕМ", "VAR": "ПЕРЕМ",
11
+ "ЭКСПОРТ": "ЭКСПОРТ", "EXPORT": "ЭКСПОРТ",
12
+ "АСИНХ": "АСИНХ", "ASYNC": "АСИНХ",
13
+ "ПРОЦЕДУРА": "ПРОЦЕДУРА", "PROCEDURE": "ПРОЦЕДУРА",
14
+ "КОНЕЦПРОЦЕДУРЫ": "КОНЕЦПРОЦЕДУРЫ", "ENDPROCEDURE": "КОНЕЦПРОЦЕДУРЫ",
15
+ "ФУНКЦИЯ": "ФУНКЦИЯ", "FUNCTION": "ФУНКЦИЯ",
16
+ "КОНЕЦФУНКЦИИ": "КОНЕЦФУНКЦИИ", "ENDFUNCTION": "КОНЕЦФУНКЦИИ",
17
+ "ЗНАЧ": "ЗНАЧ", "VAL": "ЗНАЧ",
18
+ "ЕСЛИ": "ЕСЛИ", "IF": "ЕСЛИ", "ТОГДА": "ТОГДА", "THEN": "ТОГДА",
19
+ "ИНАЧЕЕСЛИ": "ИНАЧЕЕСЛИ", "ELSIF": "ИНАЧЕЕСЛИ",
20
+ "ИНАЧЕ": "ИНАЧЕ", "ELSE": "ИНАЧЕ", "КОНЕЦЕСЛИ": "КОНЕЦЕСЛИ", "ENDIF": "КОНЕЦЕСЛИ",
21
+ "ПОКА": "ПОКА", "WHILE": "ПОКА", "ЦИКЛ": "ЦИКЛ", "DO": "ЦИКЛ",
22
+ "КОНЕЦЦИКЛА": "КОНЕЦЦИКЛА", "ENDDO": "КОНЕЦЦИКЛА",
23
+ "ДЛЯ": "ДЛЯ", "FOR": "ДЛЯ", "КАЖДОГО": "КАЖДОГО", "EACH": "КАЖДОГО",
24
+ "ИЗ": "ИЗ", "IN": "ИЗ", "ПО": "ПО", "TO": "ПО",
25
+ "ПОПЫТКА": "ПОПЫТКА", "TRY": "ПОПЫТКА", "ИСКЛЮЧЕНИЕ": "ИСКЛЮЧЕНИЕ", "EXCEPT": "ИСКЛЮЧЕНИЕ",
26
+ "КОНЕЦПОПЫТКИ": "КОНЕЦПОПЫТКИ", "ENDTRY": "КОНЕЦПОПЫТКИ",
27
+ "ВОЗВРАТ": "ВОЗВРАТ", "RETURN": "ВОЗВРАТ", "ПРОДОЛЖИТЬ": "ПРОДОЛЖИТЬ", "CONTINUE": "ПРОДОЛЖИТЬ",
28
+ "ПРЕРВАТЬ": "ПРЕРВАТЬ", "BREAK": "ПРЕРВАТЬ", "ВЫЗВАТЬИСКЛЮЧЕНИЕ": "ВЫЗВАТЬИСКЛЮЧЕНИЕ", "RAISE": "ВЫЗВАТЬИСКЛЮЧЕНИЕ",
29
+ "ВЫПОЛНИТЬ": "ВЫПОЛНИТЬ", "EXECUTE": "ВЫПОЛНИТЬ", "ПЕРЕЙТИ": "ПЕРЕЙТИ", "GOTO": "ПЕРЕЙТИ",
30
+ "ДОБАВИТЬОБРАБОТЧИК": "ДОБАВИТЬОБРАБОТЧИК", "ADDHANDLER": "ДОБАВИТЬОБРАБОТЧИК",
31
+ "УДАЛИТЬОБРАБОТЧИК": "УДАЛИТЬОБРАБОТЧИК", "REMOVEHANDLER": "УДАЛИТЬОБРАБОТЧИК",
32
+ "ЖДАТЬ": "ЖДАТЬ", "AWAIT": "ЖДАТЬ", "И": "И", "AND": "И", "ИЛИ": "ИЛИ", "OR": "ИЛИ", "НЕ": "НЕ", "NOT": "НЕ",
33
+ "НОВЫЙ": "НОВЫЙ", "NEW": "НОВЫЙ", "ИСТИНА": "ИСТИНА", "TRUE": "ИСТИНА", "ЛОЖЬ": "ЛОЖЬ", "FALSE": "ЛОЖЬ",
34
+ "НЕОПРЕДЕЛЕНО": "НЕОПРЕДЕЛЕНО", "UNDEFINED": "НЕОПРЕДЕЛЕНО", "NULL": "NULL",
35
+ "ОБЛАСТЬ": "ОБЛАСТЬ", "REGION": "ОБЛАСТЬ",
36
+ "КОНЕЦОБЛАСТИ": "КОНЕЦОБЛАСТИ", "ENDREGION": "КОНЕЦОБЛАСТИ",
37
+ "ИСПОЛЬЗОВАТЬ": "ИСПОЛЬЗОВАТЬ", "USE": "ИСПОЛЬЗОВАТЬ",
38
+ "NATIVE": "NATIVE", "STACK": "STACK",
39
+ }
40
+
41
+ _PREPROCESSOR_ONLY_KEYWORDS = frozenset(
42
+ {
43
+ "ОБЛАСТЬ",
44
+ "REGION",
45
+ "КОНЕЦОБЛАСТИ",
46
+ "ENDREGION",
47
+ "ИСПОЛЬЗОВАТЬ",
48
+ "USE",
49
+ "NATIVE",
50
+ "STACK",
51
+ }
52
+ )
53
+
54
+ _ID = re.compile(r"[A-Za-zА-Яа-яЁё_][0-9A-Za-zА-Яа-яЁё_]*")
55
+ _NUMBER = re.compile(r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?")
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class Token:
60
+ type: str
61
+ text: str
62
+ start: int
63
+ end: int
64
+
65
+
66
+ class BslLexError(ValueError):
67
+ def __init__(
68
+ self,
69
+ message: str,
70
+ *,
71
+ span: SourceSpan = SourceSpan(0, 0),
72
+ code: str = "lexical_error",
73
+ ) -> None:
74
+ super().__init__(message)
75
+ self.span = span
76
+ self.code = code
77
+
78
+
79
+ def tokenize(source: str) -> list[Token]:
80
+ result: list[Token] = []
81
+ position = 0
82
+ force_identifier = False
83
+ while position < len(source):
84
+ if source[position].isspace():
85
+ position += 1
86
+ continue
87
+ if source.startswith("//", position):
88
+ newline = source.find("\n", position)
89
+ position = len(source) if newline < 0 else newline + 1
90
+ continue
91
+ start = position
92
+ character = source[position]
93
+ if character == '"':
94
+ position += 1
95
+ while position < len(source):
96
+ if source[position] == '"':
97
+ if position + 1 < len(source) and source[position + 1] == '"':
98
+ position += 2
99
+ continue
100
+ position += 1
101
+ break
102
+ position += 1
103
+ else:
104
+ raise BslLexError(
105
+ f"Unterminated string at {start}",
106
+ span=SourceSpan(start, len(source)),
107
+ code="unterminated_string",
108
+ )
109
+ result.append(Token("STRING", source[start:position], start, position))
110
+ force_identifier = False
111
+ continue
112
+ if character == "'":
113
+ position = source.find("'", position + 1)
114
+ if position < 0:
115
+ raise BslLexError(
116
+ f"Unterminated date literal at {start}",
117
+ span=SourceSpan(start, len(source)),
118
+ code="unterminated_date_literal",
119
+ )
120
+ position += 1
121
+ result.append(Token("DATETIME", source[start:position], start, position))
122
+ force_identifier = False
123
+ continue
124
+ match = _NUMBER.match(source, position)
125
+ if match:
126
+ position = match.end()
127
+ result.append(Token("NUMBER", match.group(), start, position))
128
+ force_identifier = False
129
+ continue
130
+ match = _ID.match(source, position)
131
+ if match:
132
+ position = match.end()
133
+ text = match.group()
134
+ keyword = text.upper()
135
+ preprocessor_only = keyword in _PREPROCESSOR_ONLY_KEYWORDS
136
+ follows_hash = bool(result and result[-1].type == "#")
137
+ token_type = (
138
+ "ID"
139
+ if force_identifier or (preprocessor_only and not follows_hash)
140
+ else KEYWORDS.get(keyword, "ID")
141
+ )
142
+ result.append(Token(token_type, text, start, position))
143
+ force_identifier = False
144
+ continue
145
+ operator = next(
146
+ (value for value in ("<=", ">=", "<>") if source.startswith(value, position)),
147
+ None,
148
+ )
149
+ if operator:
150
+ position += len(operator)
151
+ result.append(Token(operator, operator, start, position))
152
+ force_identifier = False
153
+ continue
154
+ if character in ";,().[]+-*/%=<>?:~#&":
155
+ position += 1
156
+ result.append(Token(character, character, start, position))
157
+ force_identifier = character in ".&"
158
+ continue
159
+ raise BslLexError(
160
+ f"Unexpected character {character!r} at {position}",
161
+ span=SourceSpan(position, position + 1),
162
+ code="unexpected_character",
163
+ )
164
+ return result
@@ -0,0 +1,420 @@
1
+ """Lazy Designer/EDT common-module metadata catalog for one runtime session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from hashlib import sha256
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import re
13
+ from threading import RLock
14
+ import xml.etree.ElementTree as ElementTree
15
+
16
+ from onec_runtime.errors import ModuleUniverseAdmissionError, ProtocolError
17
+
18
+
19
+ _BSL_IDENTIFIER_RE = re.compile(r"[A-Za-zА-Яа-яЁё_][0-9A-Za-zА-Яа-яЁё_]*\Z")
20
+
21
+
22
+ class CommonModuleScope(str, Enum):
23
+ SERVER = "server"
24
+ CLIENT_SERVER = "client_server"
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class CommonModuleDescriptor:
29
+ canonical_name: str
30
+ scope: CommonModuleScope
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class CommonModuleCatalogSnapshot:
35
+ profile: str
36
+ preprocessor_profile: str
37
+ revision: int
38
+ modules: tuple[CommonModuleDescriptor, ...]
39
+ sha256: str
40
+
41
+ @classmethod
42
+ def create(
43
+ cls,
44
+ *,
45
+ profile: str,
46
+ preprocessor_profile: str,
47
+ revision: int,
48
+ modules: Iterable[CommonModuleDescriptor],
49
+ ) -> "CommonModuleCatalogSnapshot":
50
+ ordered = tuple(sorted(modules, key=lambda item: item.canonical_name.casefold()))
51
+ payload = json.dumps(
52
+ {
53
+ "profile": profile,
54
+ "preprocessor_profile": preprocessor_profile,
55
+ "modules": [
56
+ [item.canonical_name.casefold(), item.scope.value]
57
+ for item in ordered
58
+ ],
59
+ },
60
+ ensure_ascii=False,
61
+ sort_keys=True,
62
+ separators=(",", ":"),
63
+ ).encode("utf-8")
64
+ return cls(
65
+ profile,
66
+ preprocessor_profile,
67
+ revision,
68
+ ordered,
69
+ sha256(payload).hexdigest(),
70
+ )
71
+
72
+ def require(self, name: str) -> CommonModuleDescriptor:
73
+ by_name = {item.canonical_name.casefold(): item for item in self.modules}
74
+ try:
75
+ return by_name[name.casefold()]
76
+ except (AttributeError, KeyError):
77
+ raise ModuleUniverseAdmissionError(
78
+ "common module is absent from catalog"
79
+ ) from None
80
+
81
+
82
+ class SessionCommonModuleCatalog:
83
+ def __init__(
84
+ self,
85
+ source_root: Path,
86
+ *,
87
+ profile: str,
88
+ preprocessor_profile: str = "server",
89
+ ) -> None:
90
+ supplied = Path(source_root)
91
+ if _is_link(supplied):
92
+ raise ProtocolError("common-module source root is unsafe")
93
+ try:
94
+ resolved = supplied.resolve(strict=True)
95
+ configured_root = resolved
96
+ direct = resolved / "CommonModules"
97
+ nested_root = resolved / "src"
98
+ nested = nested_root / "CommonModules"
99
+ if _is_link(direct) or _is_link(nested_root) or _is_link(nested):
100
+ raise ProtocolError("common-module source root is unsafe")
101
+ if direct.is_dir() and nested.is_dir():
102
+ raise ProtocolError("common-module source root is ambiguous")
103
+ if not direct.is_dir() and nested.is_dir():
104
+ resolved = nested_root.resolve(strict=True)
105
+ supplied_common_modules = resolved / "CommonModules"
106
+ if _is_link(supplied_common_modules):
107
+ raise ProtocolError("common-module source root is unsafe")
108
+ common_modules = supplied_common_modules.resolve(strict=True)
109
+ except (FileNotFoundError, OSError) as error:
110
+ raise ProtocolError("common-module source root is unsafe") from error
111
+ if (
112
+ not resolved.is_dir()
113
+ or _is_link(common_modules)
114
+ or not common_modules.is_dir()
115
+ or not common_modules.is_relative_to(resolved)
116
+ or common_modules.parent != resolved
117
+ ):
118
+ raise ProtocolError("common-module source root is unsafe")
119
+ self._source_root = resolved
120
+ self._configured_root = configured_root
121
+ self._common_modules = common_modules
122
+ self._profile = profile
123
+ self._preprocessor_profile = preprocessor_profile
124
+ self._snapshot: CommonModuleCatalogSnapshot | None = None
125
+ self._path_index: dict[str, Path] | None = None
126
+ self._resolved: dict[str, CommonModuleDescriptor | None] = {}
127
+ self._lock = RLock()
128
+
129
+ @property
130
+ def initialized(self) -> bool:
131
+ with self._lock:
132
+ return self._snapshot is not None
133
+
134
+ @property
135
+ def source_root(self) -> Path:
136
+ """Normalized Designer root or EDT src directory."""
137
+ return self._source_root
138
+
139
+ def read_worker_module_source(self, path: Path | str) -> tuple[str, str]:
140
+ """Read one canonical Designer or EDT module under the bound root.
141
+
142
+ A relative path may start at the configured EDT project root or its src.
143
+ """
144
+ if not isinstance(path, (Path, str)):
145
+ raise ProtocolError("Worker module source path is invalid")
146
+ supplied = Path(path)
147
+ if supplied.is_absolute():
148
+ candidate = supplied
149
+ elif (
150
+ self._configured_root != self._source_root
151
+ and supplied.parts
152
+ and supplied.parts[0].casefold() == "src"
153
+ ):
154
+ candidate = self._configured_root / supplied
155
+ else:
156
+ candidate = self._source_root / supplied
157
+ try:
158
+ relative = candidate.relative_to(self._source_root)
159
+ except ValueError:
160
+ raise ProtocolError("Worker module source path is outside source root") from None
161
+ parts = relative.parts
162
+ if (
163
+ len(parts) == 4
164
+ and parts[0].casefold() == "commonmodules"
165
+ and parts[2].casefold() == "ext"
166
+ and parts[3].casefold() == "module.bsl"
167
+ ):
168
+ name = parts[1]
169
+ metadata = self._common_modules / f"{name}.xml"
170
+ elif (
171
+ len(parts) == 3
172
+ and parts[0].casefold() == "commonmodules"
173
+ and parts[2].casefold() == "module.bsl"
174
+ ):
175
+ name = parts[1]
176
+ metadata = self._common_modules / name / f"{name}.mdo"
177
+ else:
178
+ raise ProtocolError("Worker module source path is invalid")
179
+ if not _is_bsl_identifier(name):
180
+ raise ProtocolError("Worker module source path is invalid")
181
+ component = self._source_root
182
+ for part in parts:
183
+ component = component / part
184
+ if _is_link(component):
185
+ raise ProtocolError("Worker module source path is unsafe")
186
+ try:
187
+ resolved = candidate.resolve(strict=True)
188
+ if (
189
+ resolved != candidate
190
+ or not resolved.is_file()
191
+ or not metadata.is_file()
192
+ or _is_link(metadata)
193
+ ):
194
+ raise ProtocolError("Worker module source path is invalid")
195
+ return name, resolved.read_bytes().decode("utf-8-sig")
196
+ except (OSError, UnicodeError) as error:
197
+ raise ProtocolError("Worker module source path is unreadable") from error
198
+
199
+ def ensure_initialized(self) -> CommonModuleCatalogSnapshot:
200
+ with self._lock:
201
+ if self._snapshot is None:
202
+ self._path_index = self._enumerate_path_index()
203
+ self._snapshot = CommonModuleCatalogSnapshot.create(
204
+ profile=self._profile,
205
+ preprocessor_profile=self._preprocessor_profile,
206
+ revision=1,
207
+ modules=(),
208
+ )
209
+ return self._snapshot
210
+
211
+ def resolve_candidates(self, names: Iterable[str]) -> CommonModuleCatalogSnapshot:
212
+ """Resolve permissive bare roots, ignoring missing/non-server modules."""
213
+
214
+ return self._resolve_names(names, required=False)
215
+
216
+ def ensure_modules(self, names: Iterable[str]) -> CommonModuleCatalogSnapshot:
217
+ """Resolve required loaded-module identities, failing closed."""
218
+
219
+ return self._resolve_names(names, required=True)
220
+
221
+ def _resolve_names(
222
+ self,
223
+ names: Iterable[str],
224
+ *,
225
+ required: bool,
226
+ ) -> CommonModuleCatalogSnapshot:
227
+ with self._lock:
228
+ current = self.ensure_initialized()
229
+ requested: dict[str, str] = {}
230
+ for name in names:
231
+ if not _is_bsl_identifier(name):
232
+ raise ProtocolError("common-module batch identity is invalid")
233
+ requested.setdefault(name.casefold(), name)
234
+ if not requested:
235
+ return current
236
+
237
+ staged_resolved = dict(self._resolved)
238
+ staged_index = self._path_index or {}
239
+ if any(
240
+ normalized not in staged_index and normalized not in staged_resolved
241
+ for normalized in requested
242
+ ):
243
+ staged_index = self._enumerate_path_index()
244
+ staged_modules = {
245
+ item.canonical_name.casefold(): item for item in current.modules
246
+ }
247
+ changed = False
248
+ for normalized in requested:
249
+ if normalized in staged_resolved:
250
+ descriptor = staged_resolved[normalized]
251
+ else:
252
+ metadata_path = staged_index.get(normalized)
253
+ if metadata_path is None:
254
+ if required:
255
+ raise ProtocolError("missing common module is unsupported")
256
+ continue
257
+ descriptor = self._read_descriptor(metadata_path)
258
+ staged_resolved[normalized] = descriptor
259
+
260
+ if descriptor is None:
261
+ if required:
262
+ raise ProtocolError("missing common module is unsupported")
263
+ continue
264
+ if normalized not in staged_modules:
265
+ staged_modules[normalized] = descriptor
266
+ changed = True
267
+
268
+ self._resolved = staged_resolved
269
+ self._path_index = staged_index
270
+ if not changed:
271
+ return current
272
+ candidate = CommonModuleCatalogSnapshot.create(
273
+ profile=current.profile,
274
+ preprocessor_profile=current.preprocessor_profile,
275
+ revision=current.revision + 1,
276
+ modules=staged_modules.values(),
277
+ )
278
+ self._snapshot = candidate
279
+ return candidate
280
+
281
+ def _enumerate_path_index(self) -> dict[str, Path]:
282
+ index: dict[str, Path] = {}
283
+ paths = list(self._common_modules.glob("*.xml"))
284
+ with os.scandir(self._common_modules) as entries:
285
+ for entry in entries:
286
+ if entry.name.casefold().endswith(".xml"):
287
+ continue
288
+ if entry.is_symlink():
289
+ raise ProtocolError("common-module source root is unsafe")
290
+ if not entry.is_dir(follow_symlinks=False):
291
+ continue
292
+ folder = Path(entry.path)
293
+ if folder.is_junction():
294
+ raise ProtocolError("common-module source root is unsafe")
295
+ # One level only: EDT metadata is Name/Name.mdo. Do not scan
296
+ # module source files or descend into Designer's Ext directory.
297
+ for path in folder.glob("*.mdo"):
298
+ if path.stem != folder.name:
299
+ raise ProtocolError("common-module metadata identity is invalid")
300
+ paths.append(path)
301
+ for path in sorted(paths, key=lambda item: item.name.casefold()):
302
+ if not _is_bsl_identifier(path.stem):
303
+ raise ProtocolError("common-module metadata identity is invalid")
304
+ normalized = path.stem.casefold()
305
+ if normalized in index:
306
+ raise ProtocolError("duplicate common module identity")
307
+ index[normalized] = path
308
+ return index
309
+
310
+ def _read_descriptor(self, metadata_path: Path) -> CommonModuleDescriptor | None:
311
+ return self._read_metadata(metadata_path)[1]
312
+
313
+ def _read_metadata(
314
+ self, metadata_path: Path
315
+ ) -> tuple[str, CommonModuleDescriptor | None]:
316
+ resolved = self._safe_metadata_path(metadata_path)
317
+ try:
318
+ document = ElementTree.fromstring(resolved.read_bytes())
319
+ except (OSError, ElementTree.ParseError) as error:
320
+ raise ProtocolError("common-module metadata is invalid") from error
321
+ edt = resolved.suffix.casefold() == ".mdo"
322
+ properties = (
323
+ document if _local_name(document.tag) == "CommonModule" else None
324
+ ) if edt else _properties_element(document)
325
+ if properties is None:
326
+ raise ProtocolError("common-module metadata is invalid")
327
+ name = _text_property(properties, "name" if edt else "Name")
328
+ if (
329
+ name is None
330
+ or not _is_bsl_identifier(name)
331
+ or resolved.stem != name
332
+ ):
333
+ raise ProtocolError("common-module metadata identity is invalid")
334
+ if edt:
335
+ # EDT omits boolean properties at their default false value.
336
+ server = _bool_property(properties, "server", default=False)
337
+ global_module = _bool_property(properties, "global", default=False)
338
+ managed = _bool_property(properties, "clientManagedApplication", default=False)
339
+ ordinary = _bool_property(properties, "clientOrdinaryApplication", default=False)
340
+ client = managed or ordinary
341
+ else:
342
+ server = _bool_property(properties, "Server")
343
+ global_module = _bool_property(properties, "Global")
344
+ client = _bool_property(properties, "ClientManagedApplication") or _bool_property(
345
+ properties, "ClientOrdinaryApplication"
346
+ )
347
+ if not server or global_module:
348
+ return name, None
349
+ return name, CommonModuleDescriptor(
350
+ canonical_name=name,
351
+ scope=(
352
+ CommonModuleScope.CLIENT_SERVER
353
+ if client
354
+ else CommonModuleScope.SERVER
355
+ ),
356
+ )
357
+
358
+ def _safe_metadata_path(self, metadata_path: Path) -> Path:
359
+ path = Path(metadata_path)
360
+ designer_path = path.suffix.casefold() == ".xml" and path.parent == self._common_modules
361
+ edt_path = (
362
+ path.suffix.casefold() == ".mdo"
363
+ and path.parent.parent == self._common_modules
364
+ and path.stem == path.parent.name
365
+ )
366
+ if _is_link(path) or _is_link(path.parent) or not (designer_path or edt_path):
367
+ raise ProtocolError("common-module source root is unsafe")
368
+ try:
369
+ resolved = path.resolve(strict=True)
370
+ except (FileNotFoundError, OSError) as error:
371
+ raise ProtocolError("common-module source root is unsafe") from error
372
+ if resolved.parent != path.parent or not resolved.is_file():
373
+ raise ProtocolError("common-module source root is unsafe")
374
+ return resolved
375
+
376
+
377
+ def _local_name(tag: object) -> str:
378
+ if not isinstance(tag, str):
379
+ return ""
380
+ return tag.rsplit("}", 1)[-1]
381
+
382
+
383
+ def _properties_element(document: ElementTree.Element) -> ElementTree.Element | None:
384
+ for common_module in document.iter():
385
+ if _local_name(common_module.tag) != "CommonModule":
386
+ continue
387
+ for child in common_module:
388
+ if _local_name(child.tag) == "Properties":
389
+ return child
390
+ return None
391
+
392
+
393
+ def _text_property(properties: ElementTree.Element, name: str) -> str | None:
394
+ values = [child.text or "" for child in properties if _local_name(child.tag) == name]
395
+ if len(values) > 1:
396
+ raise ProtocolError("common-module metadata is invalid")
397
+ return values[0] if values else None
398
+
399
+
400
+ def _bool_property(
401
+ properties: ElementTree.Element, name: str, *, default: bool | None = None,
402
+ ) -> bool:
403
+ value = _text_property(properties, name)
404
+ if value is None:
405
+ if default is not None:
406
+ return default
407
+ raise ProtocolError("common-module metadata is invalid")
408
+ if value.strip().casefold() == "true":
409
+ return True
410
+ if value.strip().casefold() == "false":
411
+ return False
412
+ raise ProtocolError("common-module metadata is invalid")
413
+
414
+
415
+ def _is_link(path: Path) -> bool:
416
+ return path.is_symlink() or path.is_junction()
417
+
418
+
419
+ def _is_bsl_identifier(value: object) -> bool:
420
+ return isinstance(value, str) and bool(_BSL_IDENTIFIER_RE.fullmatch(value))