answer42 0.2.0__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.
- answer42-0.2.0.dist-info/METADATA +388 -0
- answer42-0.2.0.dist-info/RECORD +28 -0
- answer42-0.2.0.dist-info/WHEEL +4 -0
- answer42-0.2.0.dist-info/entry_points.txt +2 -0
- answer42-0.2.0.dist-info/licenses/LICENSE +21 -0
- mcp_1c/__init__.py +4 -0
- mcp_1c/assets/MCPTestClient.cf +0 -0
- mcp_1c/assets/MCPTestManager.cf +0 -0
- mcp_1c/assets/__init__.py +1 -0
- mcp_1c/assets/skills/answer42/SKILL.md +170 -0
- mcp_1c/assets/skills/answer42-rag/SKILL.md +58 -0
- mcp_1c/bridge.py +136 -0
- mcp_1c/credentials.py +147 -0
- mcp_1c/os_support.py +224 -0
- mcp_1c/platform.py +187 -0
- mcp_1c/protocol.py +35 -0
- mcp_1c/rag/__init__.py +5 -0
- mcp_1c/rag/detect.py +23 -0
- mcp_1c/rag/model.py +114 -0
- mcp_1c/rag/parsers.py +387 -0
- mcp_1c/rag/service.py +375 -0
- mcp_1c/rag/store.py +228 -0
- mcp_1c/recorder.py +239 -0
- mcp_1c/release_helper.py +83 -0
- mcp_1c/runtime.py +636 -0
- mcp_1c/server.py +3285 -0
- mcp_1c/skill_installer.py +127 -0
- mcp_1c/window_control.py +276 -0
mcp_1c/runtime.py
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"""1C test infrastructure launchers for the MCP bridge.
|
|
2
|
+
|
|
3
|
+
This module owns:
|
|
4
|
+
* file infobase creation via `ibcmd infobase create`
|
|
5
|
+
* .cf configuration loading (`ibcmd config load`)
|
|
6
|
+
* the autonomous 1C server (`ibsrv`) HTTP-gate lifecycle
|
|
7
|
+
* thin-client (`1cv8c /WS ws://host:port`) launch in /TESTMANAGER mode
|
|
8
|
+
* test-manager (thin client, `/TESTMANAGER`) launch and log wiring
|
|
9
|
+
* test-client (`/TESTCLIENT`) argv construction and Python-side launch
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import shlex
|
|
17
|
+
import shutil
|
|
18
|
+
import socket
|
|
19
|
+
import subprocess
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from . import os_support
|
|
26
|
+
|
|
27
|
+
LOGGER = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
DEFAULT_ONEC_VERSION = "8.3.27.1606"
|
|
30
|
+
DEFAULT_WS_PORT = 8765
|
|
31
|
+
DEFAULT_IBSRV_HTTP_PORT = 8314
|
|
32
|
+
DEFAULT_TEST_CLIENT_PORT = 1538
|
|
33
|
+
DEFAULT_IBSRV_DIRECT_PORT = 1541
|
|
34
|
+
DEFAULT_DISPLAY_START = 99
|
|
35
|
+
DEFAULT_DISPLAY_RES = "1280x1024x24"
|
|
36
|
+
DEFAULT_PLATFORM_DIR = f"/opt/1cv8/x86_64/{DEFAULT_ONEC_VERSION}"
|
|
37
|
+
DEFAULT_THICK_CLIENT = f"{DEFAULT_PLATFORM_DIR}/1cv8"
|
|
38
|
+
DEFAULT_THIN_CLIENT = f"{DEFAULT_PLATFORM_DIR}/1cv8c"
|
|
39
|
+
DEFAULT_IBCMD = f"{DEFAULT_PLATFORM_DIR}/ibcmd"
|
|
40
|
+
DEFAULT_IBSRV = f"{DEFAULT_PLATFORM_DIR}/ibsrv"
|
|
41
|
+
DEFAULT_DATA_DIR = str(os_support.default_data_dir())
|
|
42
|
+
DEFAULT_IB_PATH = str(os_support.default_data_dir() / "ib")
|
|
43
|
+
DEFAULT_IBSRV_DATA = str(os_support.default_data_dir() / "ibsrv-data")
|
|
44
|
+
DEFAULT_CF_BUILT = "build/MCPTestManager.cf"
|
|
45
|
+
DEFAULT_CLIENT_CF_BUILT = "build/MCPTestClient.cf"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _default_inbox_dir() -> Path:
|
|
49
|
+
return os_support.default_data_dir()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(slots=True)
|
|
53
|
+
class InfobaseSpec:
|
|
54
|
+
"""File infobase used by the test-manager and the ibsrv HTTP-gate."""
|
|
55
|
+
|
|
56
|
+
db_path: Path
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(slots=True)
|
|
60
|
+
class StandaloneServerSpec:
|
|
61
|
+
"""Standalone 1C server (ibsrv) HTTP-gate connection parameters.
|
|
62
|
+
|
|
63
|
+
ibsrv publishes a single TCP port that the thin client reaches via
|
|
64
|
+
`/WS"http://host:port"` (note: the *http://* form, not `ws://`). The
|
|
65
|
+
URL must NOT carry a trailing slash; the HTTP-base path is configured
|
|
66
|
+
inside ibsrv itself.
|
|
67
|
+
|
|
68
|
+
The standalone server requires that the infobase it serves has a
|
|
69
|
+
stable name (`--name=`) — the thin client must refer to the same
|
|
70
|
+
name when connecting.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
address: str = "127.0.0.1"
|
|
74
|
+
port: int = DEFAULT_IBSRV_HTTP_PORT
|
|
75
|
+
name: str = "mcp"
|
|
76
|
+
data_dir: Path = field(default_factory=lambda: Path(DEFAULT_IBSRV_DATA))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(slots=True)
|
|
80
|
+
class TestManagerSpec:
|
|
81
|
+
"""Test-manager (thin client) connection to the standalone server."""
|
|
82
|
+
|
|
83
|
+
onec_path: str = DEFAULT_THIN_CLIENT
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(slots=True)
|
|
87
|
+
class ManagerRuntime:
|
|
88
|
+
"""Tracks the live processes owned by the MCP server."""
|
|
89
|
+
|
|
90
|
+
infobase: InfobaseSpec
|
|
91
|
+
server: StandaloneServerSpec
|
|
92
|
+
manager: TestManagerSpec
|
|
93
|
+
server_process: subprocess.Popen | None = None
|
|
94
|
+
manager_process: subprocess.Popen | None = None
|
|
95
|
+
log_dir: Path = field(default_factory=lambda: _default_inbox_dir() / "logs")
|
|
96
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def find_free_display(start: int = DEFAULT_DISPLAY_START) -> str:
|
|
100
|
+
"""Find the first free Xvfb display starting from :99."""
|
|
101
|
+
for n in range(start, 200):
|
|
102
|
+
path = f"/tmp/.X11-unix/X{n}"
|
|
103
|
+
if not os.path.exists(path):
|
|
104
|
+
return f":{n}"
|
|
105
|
+
raise RuntimeError("No free X display found")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def find_free_port(start: int = DEFAULT_WS_PORT, end: int | None = None) -> int:
|
|
109
|
+
"""Find a free TCP port in the range start..start+500 (or start..end)."""
|
|
110
|
+
end = end or start + 500
|
|
111
|
+
for port in range(start, end):
|
|
112
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
113
|
+
try:
|
|
114
|
+
s.bind(("", port))
|
|
115
|
+
return port
|
|
116
|
+
except OSError:
|
|
117
|
+
continue
|
|
118
|
+
raise RuntimeError(f"No free port found in range {start}..{end}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def start_xvfb(display: str, resolution: str = DEFAULT_DISPLAY_RES) -> subprocess.Popen:
|
|
122
|
+
"""Start Xvfb for the given display and return the Popen handle."""
|
|
123
|
+
if os_support.IS_WINDOWS:
|
|
124
|
+
raise RuntimeError("Xvfb is not available on Windows")
|
|
125
|
+
xvfb_bin = shutil.which("Xvfb") or "/usr/bin/Xvfb"
|
|
126
|
+
if not Path(xvfb_bin).exists():
|
|
127
|
+
raise FileNotFoundError("Xvfb not found; install it to run headless sessions")
|
|
128
|
+
proc = subprocess.Popen(
|
|
129
|
+
[xvfb_bin, display, "-screen", "0", resolution, "-ac", "+extension", "GLX", "+render", "-noreset"],
|
|
130
|
+
stdout=subprocess.DEVNULL,
|
|
131
|
+
stderr=subprocess.DEVNULL,
|
|
132
|
+
stdin=subprocess.DEVNULL,
|
|
133
|
+
**os_support.subprocess_creation_kwargs(),
|
|
134
|
+
)
|
|
135
|
+
# Give Xvfb a moment; caller should poll DISPLAY if needed.
|
|
136
|
+
time.sleep(0.5)
|
|
137
|
+
LOGGER.info("Xvfb started on display %s (pid=%s)", display, proc.pid)
|
|
138
|
+
return proc
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def stop_xvfb(proc: subprocess.Popen | None) -> None:
|
|
142
|
+
"""Stop the Xvfb process started by start_xvfb."""
|
|
143
|
+
kill_process(proc, "Xvfb")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def is_port_listening(host: str, port: int, timeout: float = 1.0) -> bool:
|
|
147
|
+
"""Return True if something accepts TCP connections on host:port."""
|
|
148
|
+
try:
|
|
149
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
150
|
+
return True
|
|
151
|
+
except (OSError, socket.timeout):
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def wait_for_port(host: str, port: int, timeout: float = 30.0, poll: float = 0.5) -> bool:
|
|
156
|
+
"""Block until host:port accepts TCP connections or timeout expires."""
|
|
157
|
+
deadline = time.monotonic() + timeout
|
|
158
|
+
while time.monotonic() < deadline:
|
|
159
|
+
if is_port_listening(host, port):
|
|
160
|
+
return True
|
|
161
|
+
time.sleep(poll)
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def kill_process(proc: subprocess.Popen | None, label: str) -> None:
|
|
166
|
+
"""Terminate a subprocess tree, escalating when needed."""
|
|
167
|
+
os_support.kill_process_tree(proc, label, LOGGER)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _decode_process_output(data: bytes) -> str:
|
|
171
|
+
encodings = ["utf-8-sig", "utf-8", "cp866", "cp1251"] if os_support.IS_WINDOWS else ["utf-8-sig", "utf-8", "cp866"]
|
|
172
|
+
for encoding in encodings:
|
|
173
|
+
try:
|
|
174
|
+
return data.decode(encoding)
|
|
175
|
+
except UnicodeDecodeError:
|
|
176
|
+
continue
|
|
177
|
+
return data.decode(encodings[-1], errors="replace")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _run_capture(argv: list[str], *, timeout: float, input_text: str | None = None) -> subprocess.CompletedProcess[str]:
|
|
181
|
+
input_bytes = input_text.encode("utf-8") if input_text is not None else None
|
|
182
|
+
completed = subprocess.run(argv, input=input_bytes, capture_output=True, text=False, timeout=timeout)
|
|
183
|
+
return subprocess.CompletedProcess(
|
|
184
|
+
args=completed.args,
|
|
185
|
+
returncode=completed.returncode,
|
|
186
|
+
stdout=_decode_process_output(completed.stdout or b""),
|
|
187
|
+
stderr=_decode_process_output(completed.stderr or b""),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def create_file_infobase(
|
|
192
|
+
ibcmd: str,
|
|
193
|
+
db_path: Path,
|
|
194
|
+
timeout: float = 60.0,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Create a fresh file infobase via `ibcmd infobase create --create-database`.
|
|
197
|
+
|
|
198
|
+
Wipes any existing directory at db_path first. The DB is created
|
|
199
|
+
without a configuration — a .cf is loaded into it next via
|
|
200
|
+
`load_configuration`.
|
|
201
|
+
"""
|
|
202
|
+
if db_path.exists():
|
|
203
|
+
shutil.rmtree(db_path, ignore_errors=True)
|
|
204
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
argv = [
|
|
206
|
+
ibcmd,
|
|
207
|
+
"infobase",
|
|
208
|
+
"create",
|
|
209
|
+
"--db-path=" + str(db_path),
|
|
210
|
+
"--create-database",
|
|
211
|
+
]
|
|
212
|
+
LOGGER.info("Creating file infobase at %s via %s", db_path, ibcmd)
|
|
213
|
+
completed = _run_capture(argv, timeout=timeout)
|
|
214
|
+
if completed.returncode != 0:
|
|
215
|
+
raise RuntimeError(
|
|
216
|
+
f"ibcmd infobase create failed (rc={completed.returncode}): "
|
|
217
|
+
f"stdout={completed.stdout!r} stderr={completed.stderr!r}"
|
|
218
|
+
)
|
|
219
|
+
LOGGER.info("File infobase created: %s", db_path)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def load_configuration(
|
|
223
|
+
ibcmd: str,
|
|
224
|
+
db_path: Path,
|
|
225
|
+
cf_path: str | Path,
|
|
226
|
+
timeout: float = 120.0,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Load a `.cf` configuration into an existing file infobase.
|
|
229
|
+
|
|
230
|
+
The default flow uses `MCPTestManager.cf` which embeds the MCP
|
|
231
|
+
processor in the configuration.
|
|
232
|
+
"""
|
|
233
|
+
if not Path(cf_path).exists():
|
|
234
|
+
raise FileNotFoundError(f"Configuration file not found: {cf_path}")
|
|
235
|
+
argv = [
|
|
236
|
+
ibcmd,
|
|
237
|
+
"config",
|
|
238
|
+
"load",
|
|
239
|
+
f"--db-path={db_path}",
|
|
240
|
+
str(cf_path),
|
|
241
|
+
]
|
|
242
|
+
LOGGER.info("Loading configuration %s into %s", cf_path, db_path)
|
|
243
|
+
completed = _run_capture(argv, timeout=timeout)
|
|
244
|
+
if completed.returncode != 0:
|
|
245
|
+
raise RuntimeError(
|
|
246
|
+
f"ibcmd config load failed (rc={completed.returncode}): "
|
|
247
|
+
f"stdout={completed.stdout!r} stderr={completed.stderr!r}"
|
|
248
|
+
)
|
|
249
|
+
LOGGER.info("Configuration loaded from %s into %s", cf_path, db_path)
|
|
250
|
+
apply_configuration(ibcmd=ibcmd, db_path=db_path, timeout=timeout)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def apply_configuration(ibcmd: str, db_path: Path, timeout: float = 120.0) -> None:
|
|
254
|
+
"""Apply the loaded configuration to the infobase database configuration."""
|
|
255
|
+
argv = [
|
|
256
|
+
ibcmd,
|
|
257
|
+
"config",
|
|
258
|
+
"apply",
|
|
259
|
+
"--db-path=" + str(db_path),
|
|
260
|
+
"--force",
|
|
261
|
+
"--dynamic=force",
|
|
262
|
+
"--session-terminate=force",
|
|
263
|
+
]
|
|
264
|
+
LOGGER.info("Applying configuration to %s", db_path)
|
|
265
|
+
completed = _run_capture(argv, input_text="3\n", timeout=timeout)
|
|
266
|
+
if completed.returncode != 0:
|
|
267
|
+
raise RuntimeError(
|
|
268
|
+
f"ibcmd config apply failed (rc={completed.returncode}): "
|
|
269
|
+
f"stdout={completed.stdout!r} stderr={completed.stderr!r}"
|
|
270
|
+
)
|
|
271
|
+
LOGGER.info("Configuration applied to %s", db_path)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def ensure_standalone_config(
|
|
275
|
+
data_dir: Path,
|
|
276
|
+
db_path: Path,
|
|
277
|
+
address: str,
|
|
278
|
+
port: int,
|
|
279
|
+
name: str,
|
|
280
|
+
) -> Path:
|
|
281
|
+
"""Write a minimal standalone-server YAML config for the MCP infobase."""
|
|
282
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
283
|
+
config_path = data_dir / "standalone.yaml"
|
|
284
|
+
config_path.write_text(
|
|
285
|
+
"server:\n"
|
|
286
|
+
f" address: {address}\n"
|
|
287
|
+
f" port: {port}\n"
|
|
288
|
+
"database:\n"
|
|
289
|
+
f" path: {db_path}\n"
|
|
290
|
+
"infobase:\n"
|
|
291
|
+
f" name: {name}\n"
|
|
292
|
+
"http:\n"
|
|
293
|
+
" base: /\n",
|
|
294
|
+
encoding="utf-8",
|
|
295
|
+
)
|
|
296
|
+
return config_path
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def start_standalone_server(
|
|
300
|
+
ibsrv: str,
|
|
301
|
+
address: str,
|
|
302
|
+
port: int,
|
|
303
|
+
name: str,
|
|
304
|
+
data_dir: Path,
|
|
305
|
+
log_dir: Path,
|
|
306
|
+
db_path: Path | None = None,
|
|
307
|
+
timeout: float = 30.0,
|
|
308
|
+
direct_regport: int | None = None,
|
|
309
|
+
direct_range: tuple[int, int] | None = None,
|
|
310
|
+
) -> subprocess.Popen:
|
|
311
|
+
"""Start the autonomous 1C server (ibsrv) and wait for the HTTP-gate.
|
|
312
|
+
|
|
313
|
+
`direct_regport` overrides the direct-connect gateway port (default
|
|
314
|
+
1541 for current platform builds). When running two ibsrv instances on the same
|
|
315
|
+
host, each must use a different direct-regport.
|
|
316
|
+
|
|
317
|
+
`direct_range` must also be unique for concurrently running autonomous
|
|
318
|
+
servers. The platform default 1560:1591 is shared, and otherwise several
|
|
319
|
+
ibsrv processes can fail with "Ошибка открытия порта '156x' шлюза
|
|
320
|
+
Конфигуратора" while the HTTP port itself looks free.
|
|
321
|
+
"""
|
|
322
|
+
if not Path(ibsrv).exists():
|
|
323
|
+
raise FileNotFoundError(f"ibsrv not found: {ibsrv}")
|
|
324
|
+
if data_dir.exists():
|
|
325
|
+
shutil.rmtree(data_dir)
|
|
326
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
327
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
328
|
+
log_path = log_dir / "ibsrv.log"
|
|
329
|
+
log_handle = open(log_path, "ab", buffering=0)
|
|
330
|
+
LOGGER.info("Starting ibsrv (data=%s, name=%s, address=%s, port=%s, direct_regport=%s)", data_dir, name, address, port, direct_regport or "default")
|
|
331
|
+
config_path = ensure_standalone_config(
|
|
332
|
+
data_dir=data_dir,
|
|
333
|
+
db_path=db_path or Path(DEFAULT_IB_PATH),
|
|
334
|
+
address=address,
|
|
335
|
+
port=port,
|
|
336
|
+
name=name,
|
|
337
|
+
)
|
|
338
|
+
argv = [
|
|
339
|
+
ibsrv,
|
|
340
|
+
f"--data={data_dir}",
|
|
341
|
+
f"--config={config_path}",
|
|
342
|
+
f"--name={name}",
|
|
343
|
+
]
|
|
344
|
+
if direct_regport is not None:
|
|
345
|
+
argv.append(f"--direct-regport={direct_regport}")
|
|
346
|
+
if direct_range is not None:
|
|
347
|
+
argv.append(f"--direct-range={direct_range[0]}:{direct_range[1]}")
|
|
348
|
+
proc = subprocess.Popen(
|
|
349
|
+
argv,
|
|
350
|
+
stdout=log_handle,
|
|
351
|
+
stderr=log_handle,
|
|
352
|
+
stdin=subprocess.DEVNULL,
|
|
353
|
+
**os_support.subprocess_creation_kwargs(),
|
|
354
|
+
)
|
|
355
|
+
if not wait_for_port(address, port, timeout=timeout):
|
|
356
|
+
kill_process(proc, "ibsrv")
|
|
357
|
+
raise RuntimeError(
|
|
358
|
+
f"ibsrv did not start listening on {address}:{port} within {timeout}s. "
|
|
359
|
+
f"See log: {log_path}"
|
|
360
|
+
)
|
|
361
|
+
LOGGER.info("ibsrv is listening on %s:%s (pid=%s, name=%s)", address, port, proc.pid, name)
|
|
362
|
+
return proc
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def export_eventlog(
|
|
366
|
+
ibcmd: str,
|
|
367
|
+
eventlog_dir: Path,
|
|
368
|
+
out_path: Path,
|
|
369
|
+
from_time: str | None = None,
|
|
370
|
+
to_time: str | None = None,
|
|
371
|
+
fmt: str = "json",
|
|
372
|
+
timeout: float = 30.0,
|
|
373
|
+
) -> Path:
|
|
374
|
+
"""Export the 1C autonomous-server event log to a file.
|
|
375
|
+
|
|
376
|
+
The `ibcmd eventlog export` command works directly with the
|
|
377
|
+
event-log directory and does not connect to the infobase. This is
|
|
378
|
+
the preferred diagnostic path for manager/client startup errors in
|
|
379
|
+
the autonomous-server scenario.
|
|
380
|
+
"""
|
|
381
|
+
if not Path(ibcmd).exists():
|
|
382
|
+
raise FileNotFoundError(f"ibcmd not found: {ibcmd}")
|
|
383
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
384
|
+
argv = [
|
|
385
|
+
ibcmd,
|
|
386
|
+
"eventlog",
|
|
387
|
+
"export",
|
|
388
|
+
f"--format={fmt}",
|
|
389
|
+
f"--out={out_path}",
|
|
390
|
+
]
|
|
391
|
+
if from_time:
|
|
392
|
+
argv.append(f"--from={from_time}")
|
|
393
|
+
if to_time:
|
|
394
|
+
argv.append(f"--to={to_time}")
|
|
395
|
+
argv.append(str(eventlog_dir))
|
|
396
|
+
LOGGER.info("Exporting 1C event log: %s", " ".join(argv))
|
|
397
|
+
completed = _run_capture(argv, timeout=timeout)
|
|
398
|
+
if completed.returncode != 0:
|
|
399
|
+
raise RuntimeError(
|
|
400
|
+
f"ibcmd eventlog export failed (rc={completed.returncode}): "
|
|
401
|
+
f"stdout={completed.stdout!r} stderr={completed.stderr!r}"
|
|
402
|
+
)
|
|
403
|
+
return out_path
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def build_thin_client_ws_url(address: str, port: int) -> str:
|
|
407
|
+
"""Build the `/WS` URL value for `1cv8c` against the ibsrv HTTP-gate.
|
|
408
|
+
|
|
409
|
+
Return the raw URL without embedded quotes. Callers pass it as a separate
|
|
410
|
+
argv element after `/WS`; this avoids Windows `subprocess.list2cmdline`
|
|
411
|
+
escaping embedded quotes as `/WS\"http://...\"`, which 1C can parse as
|
|
412
|
+
invalid connection parameters.
|
|
413
|
+
"""
|
|
414
|
+
return f"http://{address}:{port}"
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def build_thin_test_manager_argv(
|
|
418
|
+
onec_path: str,
|
|
419
|
+
ws_url: str,
|
|
420
|
+
websocket_url: str,
|
|
421
|
+
) -> list[str]:
|
|
422
|
+
"""Compose argv for 1cv8c /WS <ws-url> /TESTMANAGER /C <ws-bridge>.
|
|
423
|
+
|
|
424
|
+
The MCP test-manager infobase is intentionally built without any
|
|
425
|
+
user accounts (see the configuration's application module), so
|
|
426
|
+
`/N`/`/P` are not passed: the thin client starts directly in the
|
|
427
|
+
test-manager form. The MCP processor is opened automatically by
|
|
428
|
+
the application module's `ПриНачалеРаботыСистемы` handler.
|
|
429
|
+
|
|
430
|
+
`/Execute` is intentionally not used here — the processor lives
|
|
431
|
+
inside the configuration and is started by the application module
|
|
432
|
+
rather than by the client command line.
|
|
433
|
+
"""
|
|
434
|
+
return [
|
|
435
|
+
onec_path,
|
|
436
|
+
"ENTERPRISE",
|
|
437
|
+
"/DisableStartupMessages",
|
|
438
|
+
"/DisableStartupDialogs",
|
|
439
|
+
"/WS",
|
|
440
|
+
ws_url,
|
|
441
|
+
"/Lru",
|
|
442
|
+
"/VLru_RU",
|
|
443
|
+
"/TESTMANAGER",
|
|
444
|
+
"/C",
|
|
445
|
+
websocket_url,
|
|
446
|
+
]
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def build_thin_test_manager_file_argv(
|
|
450
|
+
onec_path: str,
|
|
451
|
+
db_path: Path,
|
|
452
|
+
websocket_url: str,
|
|
453
|
+
) -> list[str]:
|
|
454
|
+
"""Compose argv for 1cv8c /F <db-path> /TESTMANAGER /C <ws-bridge>."""
|
|
455
|
+
return [
|
|
456
|
+
onec_path,
|
|
457
|
+
"ENTERPRISE",
|
|
458
|
+
"/DisableStartupMessages",
|
|
459
|
+
"/DisableStartupDialogs",
|
|
460
|
+
"/F",
|
|
461
|
+
str(db_path),
|
|
462
|
+
"/Lru",
|
|
463
|
+
"/VLru_RU",
|
|
464
|
+
"/TESTMANAGER",
|
|
465
|
+
"/C",
|
|
466
|
+
websocket_url,
|
|
467
|
+
]
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def add_1c_out_args_before_testmanager(argv: list[str], onec_out_path: Path | str) -> list[str]:
|
|
471
|
+
"""Add `/Out <path>` before `/TESTMANAGER` so it is never consumed by `/C`."""
|
|
472
|
+
startup_args = ["/DisableUnrecoverableErrorMessage", "/Out", str(onec_out_path)]
|
|
473
|
+
try:
|
|
474
|
+
testmanager_index = argv.index("/TESTMANAGER")
|
|
475
|
+
except ValueError:
|
|
476
|
+
return [*argv, *startup_args]
|
|
477
|
+
return [*argv[:testmanager_index], *startup_args, *argv[testmanager_index:]]
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _start_thin_test_manager_argv(
|
|
481
|
+
argv: list[str],
|
|
482
|
+
log_dir: Path,
|
|
483
|
+
display: str = "",
|
|
484
|
+
log_name: str = "test_manager.log",
|
|
485
|
+
) -> subprocess.Popen:
|
|
486
|
+
if not Path(argv[0]).exists():
|
|
487
|
+
raise FileNotFoundError(f"1cv8c (thin) not found: {argv[0]}")
|
|
488
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
489
|
+
log_path = log_dir / log_name
|
|
490
|
+
onec_out_path = log_dir / f"{Path(log_name).stem}_1c_out.log"
|
|
491
|
+
argv = add_1c_out_args_before_testmanager(argv, onec_out_path)
|
|
492
|
+
log_handle = open(log_path, "ab", buffering=0)
|
|
493
|
+
LOGGER.info("Starting thin test manager: %s", " ".join(argv))
|
|
494
|
+
env = os.environ.copy()
|
|
495
|
+
if display:
|
|
496
|
+
env["DISPLAY"] = display
|
|
497
|
+
proc = subprocess.Popen(
|
|
498
|
+
argv,
|
|
499
|
+
stdout=log_handle,
|
|
500
|
+
stderr=log_handle,
|
|
501
|
+
stdin=subprocess.DEVNULL,
|
|
502
|
+
**os_support.subprocess_creation_kwargs(),
|
|
503
|
+
env=env,
|
|
504
|
+
)
|
|
505
|
+
proc.answer42_stdout_log_path = str(log_path) # type: ignore[attr-defined]
|
|
506
|
+
proc.answer42_out_log_path = str(onec_out_path) # type: ignore[attr-defined]
|
|
507
|
+
proc.answer42_argv = argv # type: ignore[attr-defined]
|
|
508
|
+
LOGGER.info("Test manager launched (pid=%s); log=%s out=%s", proc.pid, log_path, onec_out_path)
|
|
509
|
+
return proc
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def start_thin_test_manager(
|
|
513
|
+
onec_path: str,
|
|
514
|
+
ws_url: str,
|
|
515
|
+
websocket_url: str,
|
|
516
|
+
log_dir: Path,
|
|
517
|
+
display: str = ":99",
|
|
518
|
+
) -> subprocess.Popen:
|
|
519
|
+
"""Start the 1C test-manager (thin client, /WS HTTP gate) and return Popen."""
|
|
520
|
+
argv = build_thin_test_manager_argv(onec_path, ws_url, websocket_url)
|
|
521
|
+
return _start_thin_test_manager_argv(argv, log_dir=log_dir, display=display)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def start_thin_test_manager_file_direct(
|
|
525
|
+
onec_path: str,
|
|
526
|
+
db_path: Path,
|
|
527
|
+
websocket_url: str,
|
|
528
|
+
log_dir: Path,
|
|
529
|
+
display: str = "",
|
|
530
|
+
) -> subprocess.Popen:
|
|
531
|
+
"""Start the 1C test-manager directly against a file infobase via /F."""
|
|
532
|
+
argv = build_thin_test_manager_file_argv(onec_path, db_path, websocket_url)
|
|
533
|
+
return _start_thin_test_manager_argv(argv, log_dir=log_dir, display=display)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _split_extra_args(value: str) -> list[str]:
|
|
537
|
+
value = (value or "").strip()
|
|
538
|
+
if not value:
|
|
539
|
+
return []
|
|
540
|
+
try:
|
|
541
|
+
parts = shlex.split(value, posix=not os_support.IS_WINDOWS)
|
|
542
|
+
except ValueError:
|
|
543
|
+
parts = value.split()
|
|
544
|
+
return [part[1:-1] if len(part) >= 2 and part[0] == part[-1] == '"' else part for part in parts]
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _merge_command_parameter(argv: list[str], command_parameter: str) -> list[str]:
|
|
548
|
+
command_parameter = (command_parameter or "").strip()
|
|
549
|
+
if not command_parameter:
|
|
550
|
+
return argv
|
|
551
|
+
lowered = [item.lower() for item in argv]
|
|
552
|
+
if "/c" in lowered:
|
|
553
|
+
index = lowered.index("/c")
|
|
554
|
+
if index + 1 < len(argv):
|
|
555
|
+
argv[index + 1] = f"{argv[index + 1]};{command_parameter}"
|
|
556
|
+
else:
|
|
557
|
+
argv.append(command_parameter)
|
|
558
|
+
return argv
|
|
559
|
+
return [*argv, "/C", command_parameter]
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def build_test_client_argv(
|
|
563
|
+
onec_path: str,
|
|
564
|
+
*,
|
|
565
|
+
connection_mode: str,
|
|
566
|
+
connection_value: str,
|
|
567
|
+
port: int,
|
|
568
|
+
username: str = "",
|
|
569
|
+
password: str = "",
|
|
570
|
+
extra_args: str = "",
|
|
571
|
+
execute: str = "",
|
|
572
|
+
command_parameter: str = "",
|
|
573
|
+
out_log: Path | str | None = None,
|
|
574
|
+
) -> list[str]:
|
|
575
|
+
"""Compose argv for 1cv8c /TESTCLIENT without going through a shell."""
|
|
576
|
+
mode = connection_mode.lower()
|
|
577
|
+
if mode in {"ws", "http", "url"}:
|
|
578
|
+
connection_arg = "/WS"
|
|
579
|
+
elif mode == "server":
|
|
580
|
+
connection_arg = "/S"
|
|
581
|
+
elif mode in {"file-direct", "direct-file", "file_direct", "direct_file"}:
|
|
582
|
+
connection_arg = "/F"
|
|
583
|
+
else:
|
|
584
|
+
raise ValueError(f"Unsupported test-client connection mode: {connection_mode!r}")
|
|
585
|
+
|
|
586
|
+
argv = [
|
|
587
|
+
onec_path,
|
|
588
|
+
"ENTERPRISE",
|
|
589
|
+
connection_arg,
|
|
590
|
+
connection_value,
|
|
591
|
+
"/Lru",
|
|
592
|
+
"/VLru_RU",
|
|
593
|
+
"/DisableStartupMessages",
|
|
594
|
+
"/DisableStartupDialogs",
|
|
595
|
+
"/DisableUnrecoverableErrorMessage",
|
|
596
|
+
]
|
|
597
|
+
if out_log:
|
|
598
|
+
argv.extend(["/Out", str(out_log)])
|
|
599
|
+
argv.extend(_split_extra_args(extra_args))
|
|
600
|
+
if execute:
|
|
601
|
+
argv.extend(["/Execute", execute])
|
|
602
|
+
argv = _merge_command_parameter(argv, command_parameter)
|
|
603
|
+
argv.extend(["/TESTCLIENT", f"-TPort{port}"])
|
|
604
|
+
if username:
|
|
605
|
+
argv.extend(["/N", username, "/P", password])
|
|
606
|
+
return argv
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def start_test_client(
|
|
610
|
+
argv: list[str],
|
|
611
|
+
log_dir: Path,
|
|
612
|
+
display: str = "",
|
|
613
|
+
log_name: str = "test_client_stdout.log",
|
|
614
|
+
) -> subprocess.Popen:
|
|
615
|
+
"""Start 1C /TESTCLIENT from Python so argv quoting and PID are controlled."""
|
|
616
|
+
if not Path(argv[0]).exists():
|
|
617
|
+
raise FileNotFoundError(f"1cv8c (thin) not found: {argv[0]}")
|
|
618
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
619
|
+
stdout_log_path = log_dir / log_name
|
|
620
|
+
log_handle = open(stdout_log_path, "ab", buffering=0)
|
|
621
|
+
LOGGER.info("Starting test client: %s", " ".join(argv))
|
|
622
|
+
env = os.environ.copy()
|
|
623
|
+
if display:
|
|
624
|
+
env["DISPLAY"] = display
|
|
625
|
+
proc = subprocess.Popen(
|
|
626
|
+
argv,
|
|
627
|
+
stdout=log_handle,
|
|
628
|
+
stderr=log_handle,
|
|
629
|
+
stdin=subprocess.DEVNULL,
|
|
630
|
+
**os_support.subprocess_creation_kwargs(),
|
|
631
|
+
env=env,
|
|
632
|
+
)
|
|
633
|
+
proc.answer42_stdout_log_path = str(stdout_log_path) # type: ignore[attr-defined]
|
|
634
|
+
proc.answer42_argv = argv # type: ignore[attr-defined]
|
|
635
|
+
LOGGER.info("Test client launched (pid=%s); stdout=%s", proc.pid, stdout_log_path)
|
|
636
|
+
return proc
|