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.
mcp_1c/os_support.py ADDED
@@ -0,0 +1,224 @@
1
+ """Cross-platform OS helpers for Answer42 runtime.
2
+
3
+ Keep platform-specific process, lock and filesystem defaults here so the
4
+ higher-level MCP tools do not import POSIX-only modules on Windows.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import subprocess
13
+ import tempfile
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+ from typing import Iterator
17
+
18
+ IS_WINDOWS = os.name == "nt"
19
+ IS_POSIX = os.name == "posix"
20
+
21
+
22
+ def default_data_dir() -> Path:
23
+ """Return the default Answer42 runtime data directory for this OS."""
24
+ override = os.getenv("ONEC_MCP_DATA_DIR")
25
+ if override:
26
+ return Path(override)
27
+ return Path(tempfile.gettempdir()) / "onec-mcp"
28
+
29
+
30
+ def runtime_path(env_name: str, *parts: str) -> Path:
31
+ """Return an env-overridable path under the runtime data directory."""
32
+ override = os.getenv(env_name)
33
+ if override:
34
+ return Path(override)
35
+ return default_data_dir().joinpath(*parts)
36
+
37
+
38
+ @contextmanager
39
+ def file_lock(path: Path) -> Iterator[None]:
40
+ """Acquire an exclusive file lock on POSIX and Windows.
41
+
42
+ The implementation imports fcntl/msvcrt lazily so importing Answer42 on
43
+ Windows does not fail on the POSIX-only ``fcntl`` module.
44
+ """
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ with path.open("a+b") as lock_file:
47
+ lock_file.seek(0)
48
+ if not lock_file.read(1):
49
+ lock_file.write(b"0")
50
+ lock_file.flush()
51
+ lock_file.seek(0)
52
+ if IS_WINDOWS:
53
+ import msvcrt # type: ignore
54
+
55
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
56
+ try:
57
+ yield
58
+ finally:
59
+ lock_file.seek(0)
60
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
61
+ else:
62
+ import fcntl # type: ignore
63
+
64
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
65
+ try:
66
+ yield
67
+ finally:
68
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
69
+
70
+
71
+ def subprocess_creation_kwargs(*, detached: bool = True) -> dict[str, object]:
72
+ """Return Popen kwargs for a separately controllable child process."""
73
+ if IS_WINDOWS:
74
+ flags = 0
75
+ if detached:
76
+ flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
77
+ return {"creationflags": flags} if flags else {}
78
+ return {"start_new_session": True} if detached else {}
79
+
80
+
81
+ def kill_process_tree(proc: subprocess.Popen | None, label: str, logger: logging.Logger | None = None) -> None:
82
+ """Terminate a subprocess tree in a cross-platform way."""
83
+ log = logger or logging.getLogger(__name__)
84
+ if proc is None:
85
+ return
86
+ try:
87
+ if proc.poll() is not None:
88
+ return
89
+ except Exception:
90
+ return
91
+
92
+ if IS_WINDOWS:
93
+ try:
94
+ subprocess.run(
95
+ ["taskkill", "/PID", str(proc.pid), "/T", "/F"],
96
+ stdout=subprocess.DEVNULL,
97
+ stderr=subprocess.DEVNULL,
98
+ check=False,
99
+ timeout=10,
100
+ )
101
+ try:
102
+ proc.wait(timeout=5)
103
+ except subprocess.TimeoutExpired:
104
+ pass
105
+ log.info("Stopped %s (pid=%s) via taskkill", label, proc.pid)
106
+ return
107
+ except Exception as exc:
108
+ log.debug("taskkill failed for %s (pid=%s): %s", label, proc.pid, exc)
109
+ try:
110
+ proc.terminate()
111
+ proc.wait(timeout=5)
112
+ log.info("Stopped %s (pid=%s) via terminate", label, proc.pid)
113
+ return
114
+ except Exception:
115
+ pass
116
+ try:
117
+ proc.kill()
118
+ proc.wait(timeout=5)
119
+ log.warning("Killed %s (pid=%s)", label, proc.pid)
120
+ except Exception as exc:
121
+ log.error("Failed to stop %s (pid=%s): %s", label, proc.pid, exc)
122
+ return
123
+
124
+ import signal
125
+
126
+ try:
127
+ os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
128
+ except ProcessLookupError:
129
+ return
130
+ except Exception:
131
+ try:
132
+ proc.terminate()
133
+ except Exception:
134
+ pass
135
+ try:
136
+ proc.wait(timeout=5)
137
+ log.info("Stopped %s (pid=%s) via SIGTERM", label, proc.pid)
138
+ return
139
+ except subprocess.TimeoutExpired:
140
+ pass
141
+ except Exception:
142
+ pass
143
+ try:
144
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
145
+ proc.wait(timeout=5)
146
+ log.warning("Killed %s (pid=%s) via SIGKILL", label, proc.pid)
147
+ except ProcessLookupError:
148
+ return
149
+ except subprocess.TimeoutExpired:
150
+ log.error("Failed to stop %s (pid=%s)", label, proc.pid)
151
+
152
+
153
+ def kill_processes_by_command_substrings(
154
+ substrings: list[str],
155
+ label: str,
156
+ logger: logging.Logger | None = None,
157
+ ) -> None:
158
+ """Best-effort cleanup of stale child processes matching command-line substrings."""
159
+ patterns = [s for s in substrings if s]
160
+ if not patterns:
161
+ return
162
+ log = logger or logging.getLogger(__name__)
163
+ if IS_WINDOWS:
164
+ patterns_json = json.dumps(patterns, ensure_ascii=False)
165
+ script = r"""
166
+ $patterns = ConvertFrom-Json @'
167
+ %s
168
+ '@
169
+ $own = %d
170
+ Get-CimInstance Win32_Process | Where-Object {
171
+ if ($_.ProcessId -eq $own -or -not $_.CommandLine) { return $false }
172
+ $cmd = $_.CommandLine
173
+ foreach ($p in $patterns) {
174
+ if ($p -and $cmd -like ('*' + $p + '*')) { return $true }
175
+ }
176
+ return $false
177
+ } | ForEach-Object {
178
+ try { Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop } catch {}
179
+ }
180
+ """ % (patterns_json, os.getpid())
181
+ try:
182
+ subprocess.run(
183
+ ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
184
+ stdout=subprocess.DEVNULL,
185
+ stderr=subprocess.DEVNULL,
186
+ check=False,
187
+ timeout=15,
188
+ )
189
+ log.info("Requested cleanup of stale %s processes by command substrings", label)
190
+ except Exception as exc:
191
+ log.debug("Failed to cleanup stale %s processes: %s", label, exc)
192
+ return
193
+
194
+ try:
195
+ completed = subprocess.run(
196
+ ["pgrep", "-f", "|".join(patterns)],
197
+ capture_output=True,
198
+ text=True,
199
+ check=False,
200
+ timeout=10,
201
+ )
202
+ except Exception as exc:
203
+ log.debug("pgrep failed for stale %s cleanup: %s", label, exc)
204
+ return
205
+ own = {os.getpid(), os.getppid()}
206
+ for raw_pid in completed.stdout.split():
207
+ try:
208
+ pid = int(raw_pid)
209
+ except ValueError:
210
+ continue
211
+ if pid in own:
212
+ continue
213
+ try:
214
+ os.kill(pid, 9)
215
+ log.info("Killed stale %s process pid=%s", label, pid)
216
+ except ProcessLookupError:
217
+ pass
218
+ except Exception as exc:
219
+ log.debug("Failed to kill stale %s process pid=%s: %s", label, pid, exc)
220
+
221
+
222
+ def executable_name(base: str) -> str:
223
+ """Return executable name with .exe suffix on Windows."""
224
+ return f"{base}.exe" if IS_WINDOWS and not base.lower().endswith(".exe") else base
mcp_1c/platform.py ADDED
@@ -0,0 +1,187 @@
1
+ """1С platform discovery helpers.
2
+
3
+ * detect_version_from_url - fetch the public login page and parse VERSION.
4
+ * find_platform_dir - locate the requested (or latest) platform dir.
5
+ * require_websocket_support - reject unsupported 1C platform versions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import socket
13
+ import urllib.error
14
+ import urllib.request
15
+ from pathlib import Path
16
+ from typing import Optional
17
+ from urllib.parse import urlsplit, urlunsplit
18
+
19
+ from . import os_support
20
+
21
+
22
+ def find_free_display(start: int = 99) -> str:
23
+ """Find the first free Xvfb display starting from `start`."""
24
+ for n in range(start, 200):
25
+ path = f"/tmp/.X11-unix/X{n}"
26
+ if not Path(path).exists():
27
+ return f":{n}"
28
+ raise RuntimeError("No free X display found")
29
+
30
+
31
+ def find_free_port(start: int = 8765, end: int | None = None) -> int:
32
+ """Find a free TCP port in the range `start..start+500` (or `start..end`)."""
33
+ end = end or start + 500
34
+ for port in range(start, end):
35
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
36
+ try:
37
+ s.bind(("", port))
38
+ return port
39
+ except OSError:
40
+ continue
41
+ raise RuntimeError(f"No free port found in range {start}..{end}")
42
+
43
+
44
+ _LOCALE_RE = re.compile(r"^[a-z]{2}_[A-Z]{2}$")
45
+
46
+
47
+ def normalize_infobase_url(url: str) -> str:
48
+ """Return a canonical web infobase URL: no language suffix, trailing slash."""
49
+ value = str(url or "").strip()
50
+ parts = urlsplit(value)
51
+ if parts.scheme.lower() not in {"http", "https"}:
52
+ return value
53
+ segments = [segment for segment in parts.path.split("/") if segment]
54
+ if segments and _LOCALE_RE.match(segments[-1]):
55
+ segments.pop()
56
+ path = "/" + "/".join(segments) + "/" if segments else "/"
57
+ return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
58
+
59
+
60
+ def detect_version_from_url(url: str) -> Optional[str]:
61
+ """Fetch a public base_url and extract VERSION from the HTML.
62
+
63
+ If the page is behind Basic Auth or simply unavailable, this is not an
64
+ error; we return None and let the caller pick a default platform version.
65
+ """
66
+ try:
67
+ with urllib.request.urlopen(normalize_infobase_url(url), timeout=10) as resp:
68
+ html = resp.read().decode("utf-8", errors="ignore")
69
+ m = re.search(r'var\s+VERSION\s*=\s*"(\d+\.\d+\.\d+\.\d+)"', html)
70
+ if m:
71
+ return m.group(1)
72
+ except (urllib.error.URLError, OSError):
73
+ pass
74
+ except Exception:
75
+ pass
76
+ return None
77
+
78
+
79
+ def _missing_required_tools(path: Path) -> list[str]:
80
+ return [
81
+ tool for tool in ("1cv8c", "ibcmd")
82
+ if not (path / os_support.executable_name(tool)).exists()
83
+ ]
84
+
85
+
86
+ def _is_platform_candidate(path: Path) -> bool:
87
+ return not _missing_required_tools(path)
88
+
89
+
90
+ def _platform_component_score(path: Path) -> tuple[int, int]:
91
+ """Prefer fuller installations when the same version exists in several roots."""
92
+ return (
93
+ 1 if (path / os_support.executable_name("ibsrv")).exists() else 0,
94
+ 1 if (path / os_support.executable_name("1cv8")).exists() else 0,
95
+ )
96
+
97
+
98
+ def find_platform_dir(version: Optional[str] = None) -> Path:
99
+ """Locate the platform directory for a given version.
100
+
101
+ If `version` is omitted the maximum available version is selected.
102
+ Searches standard Linux and Windows installation locations. The
103
+ ``ONEC_PLATFORM_DIR`` environment variable can point directly to the
104
+ platform ``bin`` directory and takes precedence.
105
+ """
106
+ override = os.getenv("ONEC_PLATFORM_DIR")
107
+ if override:
108
+ path = Path(override)
109
+ missing = _missing_required_tools(path)
110
+ if not missing:
111
+ return path
112
+ raise ValueError(f"ONEC_PLATFORM_DIR does not contain required 1C tools {missing}: {path}")
113
+
114
+ candidates: list[Path] = []
115
+ if os_support.IS_WINDOWS:
116
+ bases = []
117
+ for env_name in ("ProgramFiles", "ProgramFiles(x86)"):
118
+ root = os.getenv(env_name)
119
+ if root:
120
+ bases.append(Path(root) / "1cv8")
121
+ for base in bases:
122
+ if not base.exists():
123
+ continue
124
+ for sub in base.iterdir():
125
+ bin_dir = sub / "bin"
126
+ if bin_dir.is_dir() and _is_platform_candidate(bin_dir):
127
+ candidates.append(bin_dir)
128
+ elif sub.is_dir() and _is_platform_candidate(sub):
129
+ candidates.append(sub)
130
+ else:
131
+ for base in [Path("/opt/1cv8/x86_64"), Path("/opt/1cv8/x86"), Path("/opt/1c/x86_64"), Path("/opt/1c/x86")]:
132
+ if not base.exists():
133
+ continue
134
+ for sub in base.iterdir():
135
+ if sub.is_dir() and _is_platform_candidate(sub):
136
+ candidates.append(sub)
137
+ if not candidates:
138
+ raise RuntimeError(
139
+ "No suitable 1C platform directories found. "
140
+ "Answer42 requires 1cv8c and ibcmd in the platform bin directory. "
141
+ "Set ONEC_PLATFORM_DIR to a full platform installation."
142
+ )
143
+ if version:
144
+ matching = [c for c in candidates if platform_version_from_dir(c) == version]
145
+ if matching:
146
+ return max(matching, key=_platform_component_score)
147
+ raise ValueError(f"Версия {version} не найдена среди установленных платформ 1С")
148
+ # Numeric compare using the first three components to avoid treating the
149
+ # build number as the most significant part. Windows candidates usually
150
+ # point to .../<version>/bin, so the version is in the parent directory.
151
+ return max(
152
+ candidates,
153
+ key=lambda p: (_version_tuple(platform_version_from_dir(p)), _platform_component_score(p)),
154
+ )
155
+
156
+
157
+ def platform_version_from_dir(platform_dir: Path) -> str:
158
+ """Return the 1C version represented by a platform directory.
159
+
160
+ Linux installs typically use /opt/1cv8/x86_64/<version>/1cv8c, while
161
+ Windows installs use C:\\Program Files\\1cv8\\<version>\\bin\\1cv8c.exe.
162
+ For Windows bin directories, the version is therefore the parent name.
163
+ """
164
+ path = Path(platform_dir)
165
+ if path.name.lower() == "bin" and path.parent.name:
166
+ return path.parent.name
167
+ return path.name
168
+
169
+
170
+ def _version_tuple(version: str) -> tuple[int, int, int, int]:
171
+ parts = version.split(".")
172
+ nums: list[int] = []
173
+ for p in parts[:4]:
174
+ try:
175
+ nums.append(int(p))
176
+ except ValueError:
177
+ nums.append(0)
178
+ while len(nums) < 4:
179
+ nums.append(0)
180
+ return (nums[0], nums[1], nums[2], nums[3])
181
+
182
+
183
+ def require_websocket_support(version: str) -> None:
184
+ """Answer42 requires 1С:Предприятие 8.3.27+."""
185
+ major, minor, patch, _ = _version_tuple(version)
186
+ if (major, minor, patch) < (8, 3, 27):
187
+ raise ValueError(f"Версия {version} не поддерживается. Требуется 1С:Предприятие 8.3.27+.")
mcp_1c/protocol.py ADDED
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class BridgeRequest:
9
+ """Command sent from the Python MCP side to the 1C bridge over WebSocket."""
10
+
11
+ id: str
12
+ method: str
13
+ params: dict[str, Any]
14
+
15
+ def to_json(self) -> dict[str, Any]:
16
+ return {"type": "request", "id": self.id, "method": self.method, "params": self.params}
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class BridgeResponse:
21
+ """Response returned by the 1C bridge."""
22
+
23
+ id: str
24
+ ok: bool
25
+ result: Any = None
26
+ error: str | None = None
27
+
28
+ @classmethod
29
+ def from_json(cls, data: dict[str, Any]) -> "BridgeResponse":
30
+ return cls(
31
+ id=str(data.get("id", "")),
32
+ ok=bool(data.get("ok", False)),
33
+ result=data.get("result"),
34
+ error=data.get("error"),
35
+ )
mcp_1c/rag/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Local 1C configuration RAG index helpers."""
2
+
3
+ from .service import RagService
4
+
5
+ __all__ = ["RagService"]
mcp_1c/rag/detect.py ADDED
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from .model import SourceFormat
6
+
7
+
8
+ def detect_source_format(path: str | Path) -> SourceFormat:
9
+ root = Path(path)
10
+ if not root.exists() or not root.is_dir():
11
+ return "unknown"
12
+
13
+ # EDT-style project: Configuration/Configuration.mdo and object folders with *.mdo.
14
+ if (root / "Configuration" / "Configuration.mdo").exists():
15
+ return "edt"
16
+ if any(root.glob("Catalogs/*/*.mdo")) or any(root.glob("Documents/*/*.mdo")) or any(root.glob("DataProcessors/*/*.mdo")):
17
+ return "edt"
18
+
19
+ # Designer XML dump: Configuration.xml, object XML at root folders, Ext/Form.xml.
20
+ if (root / "Configuration.xml").exists() or any(root.glob("*/**/Ext/Form.xml")) or any(root.glob("Catalogs/*.xml")):
21
+ return "designer_xml"
22
+
23
+ return "unknown"
mcp_1c/rag/model.py ADDED
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any, Literal
6
+
7
+ SourceFormat = Literal["edt", "designer_xml", "unknown"]
8
+ SourceKind = Literal["base", "extension", "configuration", "standalone"]
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class SourceInfo:
13
+ name: str
14
+ path: Path
15
+ kind: SourceKind = "standalone"
16
+ format: SourceFormat = "unknown"
17
+ repo_url: str | None = None
18
+ branch: str | None = None
19
+ commit: str | None = None
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class MetadataObject:
24
+ full_name: str
25
+ kind: str
26
+ name: str
27
+ synonym: str | None = None
28
+ source_path: str | None = None
29
+ source_format: SourceFormat = "unknown"
30
+ is_hierarchical: bool | None = None
31
+ owner_types: list[str] = field(default_factory=list)
32
+ raw: dict[str, Any] = field(default_factory=dict)
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class Attribute:
37
+ object_full_name: str
38
+ name: str
39
+ type_name: str | None = None
40
+ synonym: str | None = None
41
+ required: bool | None = None
42
+ source_path: str | None = None
43
+ role: str = "attribute"
44
+ raw: dict[str, Any] = field(default_factory=dict)
45
+
46
+
47
+ @dataclass(slots=True)
48
+ class TablePart:
49
+ object_full_name: str
50
+ name: str
51
+ synonym: str | None = None
52
+ source_path: str | None = None
53
+ raw: dict[str, Any] = field(default_factory=dict)
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class TablePartColumn:
58
+ object_full_name: str
59
+ table_part: str
60
+ name: str
61
+ type_name: str | None = None
62
+ synonym: str | None = None
63
+ required: bool | None = None
64
+ source_path: str | None = None
65
+ raw: dict[str, Any] = field(default_factory=dict)
66
+
67
+
68
+ @dataclass(slots=True)
69
+ class Form:
70
+ object_full_name: str
71
+ name: str
72
+ kind: str | None = None
73
+ source_path: str | None = None
74
+ raw: dict[str, Any] = field(default_factory=dict)
75
+
76
+
77
+ @dataclass(slots=True)
78
+ class FormElement:
79
+ object_full_name: str
80
+ form_name: str
81
+ name: str
82
+ element_type: str | None = None
83
+ title: str | None = None
84
+ data_path: str | None = None
85
+ command_name: str | None = None
86
+ parent_name: str | None = None
87
+ source_path: str | None = None
88
+ raw: dict[str, Any] = field(default_factory=dict)
89
+
90
+
91
+ @dataclass(slots=True)
92
+ class DataCompositionField:
93
+ object_full_name: str
94
+ schema_name: str
95
+ data_path: str
96
+ source_field: str | None = None
97
+ title: str | None = None
98
+ expression: str | None = None
99
+ dataset_name: str | None = None
100
+ field_type: str | None = None
101
+ source_path: str | None = None
102
+ raw: dict[str, Any] = field(default_factory=dict)
103
+
104
+
105
+ @dataclass(slots=True)
106
+ class ParsedSource:
107
+ source: SourceInfo
108
+ objects: list[MetadataObject] = field(default_factory=list)
109
+ attributes: list[Attribute] = field(default_factory=list)
110
+ table_parts: list[TablePart] = field(default_factory=list)
111
+ table_part_columns: list[TablePartColumn] = field(default_factory=list)
112
+ forms: list[Form] = field(default_factory=list)
113
+ form_elements: list[FormElement] = field(default_factory=list)
114
+ data_composition_fields: list[DataCompositionField] = field(default_factory=list)