oid-mcp 0.3.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.
- oid_mcp-0.3.0.dist-info/METADATA +8 -0
- oid_mcp-0.3.0.dist-info/RECORD +34 -0
- oid_mcp-0.3.0.dist-info/WHEEL +4 -0
- oid_mcp-0.3.0.dist-info/entry_points.txt +2 -0
- oidmcp/__init__.py +11 -0
- oidmcp/_wireframe.py +81 -0
- oidmcp/analysis.py +201 -0
- oidmcp/buffers.py +93 -0
- oidmcp/discovery.py +263 -0
- oidmcp/protocol.py +120 -0
- oidmcp/render.py +136 -0
- oidmcp/server.py +466 -0
- oidmcp/viewer_meta.py +35 -0
- oidscripts/__init__.py +0 -0
- oidscripts/agentendpoint.py +476 -0
- oidscripts/debuggers/__init__.py +0 -0
- oidscripts/debuggers/gdbbridge.py +180 -0
- oidscripts/debuggers/interfaces.py +182 -0
- oidscripts/debuggers/lldbbridge.py +277 -0
- oidscripts/debuggers/template_args.py +102 -0
- oidscripts/events.py +61 -0
- oidscripts/ides/__init__.py +0 -0
- oidscripts/ides/qtcreator.py +70 -0
- oidscripts/logger.py +18 -0
- oidscripts/oidtypes/__init__.py +0 -0
- oidscripts/oidtypes/eigen3.py +152 -0
- oidscripts/oidtypes/interface.py +88 -0
- oidscripts/oidtypes/opencv.py +181 -0
- oidscripts/oidwindow.py +220 -0
- oidscripts/symbols.py +13 -0
- oidscripts/sysinfo.py +146 -0
- oidscripts/test.py +205 -0
- oidscripts/typebridge.py +55 -0
- oidscripts/wireframe.py +94 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
oidmcp/__init__.py,sha256=wpWx6dgrfBtzt5P4RmrSn-sqzKF33m0miL_ed8R0jnc,446
|
|
2
|
+
oidmcp/_wireframe.py,sha256=LCEd7EVUfvHV6belQAUdewlCvO4qJTSrOQ8CR6rVqxw,3991
|
|
3
|
+
oidmcp/analysis.py,sha256=MQTpb4N8-pcgebZ-Rm7h4EjMEnPw0H8EJYE-u3Lk6wA,7228
|
|
4
|
+
oidmcp/buffers.py,sha256=ifY9YJIUwWiG2aqJRt-RdHPzuwMbFk-sF-MrCplMc7A,3191
|
|
5
|
+
oidmcp/discovery.py,sha256=5tHb3Cd3f3-oScDBhfFyBE6jbDKoz8lmgKJVzAz0ics,9089
|
|
6
|
+
oidmcp/protocol.py,sha256=j1A7PLvQzNlY6cBa_UnrfMEXDiUIm17sL-CmR7ZDzjk,5020
|
|
7
|
+
oidmcp/render.py,sha256=TfTlgS7QlasMxZaz-Gh6G0zN7U-cak_D1b1oa5_mBao,5006
|
|
8
|
+
oidmcp/server.py,sha256=dqsLeeT_lE8k3i-DmNBC2BYJSY1hDzx1tdq4nTS_bjA,18944
|
|
9
|
+
oidmcp/viewer_meta.py,sha256=cjblAAOZnL109BxXA9CGD9j-OR5g0Qmjj0eKOw6KS90,1505
|
|
10
|
+
oidscripts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
oidscripts/agentendpoint.py,sha256=hqciAuHk5J_KskIkpkNikW_v3pbSSKsgRoeMyL8oCWc,17075
|
|
12
|
+
oidscripts/events.py,sha256=LrjXd-JuCjNevbRpLbg6MjHeCzUJQ64032ILpjs3kTY,1897
|
|
13
|
+
oidscripts/logger.py,sha256=VV7uecLi7uZKJoyq5sJNoyfMiH-isAsI5WclpFY0h-U,403
|
|
14
|
+
oidscripts/oidwindow.py,sha256=8wzH8l_ms7MuHPQm84b7aGgcpHoMBg1OW56HI3cwCo8,7375
|
|
15
|
+
oidscripts/symbols.py,sha256=Rk5LrR32UwQTM5EQHhx17MZv1oVTQ_oCWdI01i4Q2rA,244
|
|
16
|
+
oidscripts/sysinfo.py,sha256=cfrW-ZsSP5KGFBDzqyx9a85U2J_6rnLQTHmSMzraXXs,4455
|
|
17
|
+
oidscripts/test.py,sha256=l6cQUZ1ON2Y2Vo1kSgskebL0IH3FTdB-b8Yeai5eyk4,5834
|
|
18
|
+
oidscripts/typebridge.py,sha256=aWTyyvl2zylCSsLXEVcUPB9QyHmXJuttNRH5ygk4wdU,1898
|
|
19
|
+
oidscripts/wireframe.py,sha256=1LzXlex4IVly9L3gad-SwIuD4xS_IDiW7plcASx49-A,3503
|
|
20
|
+
oidscripts/debuggers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
+
oidscripts/debuggers/gdbbridge.py,sha256=2sg4_78hWCXIRgF2MpXnRqp-sQm3NBp955i3a3SK-r8,6393
|
|
22
|
+
oidscripts/debuggers/interfaces.py,sha256=kUhL3kopVmsq6057a2vstFlG2gj422t4Arn3Q_dqjTg,5553
|
|
23
|
+
oidscripts/debuggers/lldbbridge.py,sha256=gKCxlwIt89nMIr5rEGYkfWVA8lW279eksQutNzqPHnA,10307
|
|
24
|
+
oidscripts/debuggers/template_args.py,sha256=23EHXaXY6QO_rjwRAXGnbbes1cNzWICYuXZ1xanLlDM,3509
|
|
25
|
+
oidscripts/ides/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
|
+
oidscripts/ides/qtcreator.py,sha256=50-RF_QMmWr2TY42Gg0OnOSIBWanMdNcRtJUtGn1v0w,2141
|
|
27
|
+
oidscripts/oidtypes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
28
|
+
oidscripts/oidtypes/eigen3.py,sha256=Eh1HwmMX5V23CpWGRgxHIMmbLA5nxBnpIoKQ0GTEkNU,6075
|
|
29
|
+
oidscripts/oidtypes/interface.py,sha256=juZ8UiK2QE6j-Gjl_PH69BsFnR-fMlgkQEjqRsN_Q-4,2937
|
|
30
|
+
oidscripts/oidtypes/opencv.py,sha256=AqHsgxUi0QWzYB3BPLigOj4OESexRpauUw0DlghwIZQ,5989
|
|
31
|
+
oid_mcp-0.3.0.dist-info/METADATA,sha256=DpWfOwoMOZDZ5RisLbpRf4qnSF2_Gmek7CPWc78fG0A,251
|
|
32
|
+
oid_mcp-0.3.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
33
|
+
oid_mcp-0.3.0.dist-info/entry_points.txt,sha256=6g2FzW2uUb8W6JbijKHRXZ31d83ZtDLl26-u8-tDJeg,47
|
|
34
|
+
oid_mcp-0.3.0.dist-info/RECORD,,
|
oidmcp/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""MCP server exposing OpenImageDebugger buffer inspection to AI agents.
|
|
2
|
+
|
|
3
|
+
The stdlib-only wire framing is defined once, in
|
|
4
|
+
``oidscripts.wireframe``, and shared with the in-debugger endpoint. The
|
|
5
|
+
``oidmcp._wireframe`` bridge re-exports it, appending the sibling
|
|
6
|
+
debugger-scripts tree to ``sys.path`` (idempotent and lowest-precedence)
|
|
7
|
+
so it imports as a normal named package rather than being loaded by
|
|
8
|
+
filesystem path.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.2.0"
|
oidmcp/_wireframe.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
"""Bridge to the single source of the wire framing: ``oidscripts.wireframe``.
|
|
4
|
+
|
|
5
|
+
oid-mcp and the in-debugger endpoint must agree byte-for-byte on the wire
|
|
6
|
+
format. Rather than keep a second copy in step by hand, both sides import
|
|
7
|
+
the one module that defines it. oid-mcp runs from the deployed OID
|
|
8
|
+
resources tree (``uv run --directory resources/oidmcp``), where
|
|
9
|
+
``resources/oidscripts`` is a sibling of this package, so this bridge puts
|
|
10
|
+
that directory on ``sys.path`` and re-exports the shared helpers as a
|
|
11
|
+
normal named-package import -- no code is loaded by filesystem path.
|
|
12
|
+
|
|
13
|
+
The append is idempotent and lowest-precedence, so it cannot shadow a
|
|
14
|
+
stdlib or site module; it mirrors what the test suite's conftest already
|
|
15
|
+
does to import from the debugger-scripts tree. That low precedence,
|
|
16
|
+
however, also means an unrelated ``oidscripts`` earlier on ``sys.path``
|
|
17
|
+
could win the import, and a foreign ``wireframe`` may carry an
|
|
18
|
+
incompatible wire format. So the resolved module is verified to be the one
|
|
19
|
+
shipped beside us and a mismatch is rejected loudly rather than used
|
|
20
|
+
silently.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import sysconfig
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
# Three supported layouts for the shared scripts tree:
|
|
29
|
+
# repo layout: resources/oidmcp/oidmcp/_wireframe.py with the tree at
|
|
30
|
+
# resources/oidscripts (parents[2] == resources/)
|
|
31
|
+
# installed layout: site-packages/oidmcp/_wireframe.py with oidscripts
|
|
32
|
+
# shipped in the same site-packages by the wheel
|
|
33
|
+
# (parents[1] == site-packages)
|
|
34
|
+
# editable-install layout: a local `uv run`/`pip install -e` dev sync
|
|
35
|
+
# redirects oidmcp/_wireframe.py's own __file__ back to
|
|
36
|
+
# this repo file (so parents[1] above is not the venv's
|
|
37
|
+
# site-packages), yet the build backend still has to
|
|
38
|
+
# materialize the force-included oidscripts tree as a
|
|
39
|
+
# real copy in the running interpreter's site-packages,
|
|
40
|
+
# since there is no editable/redirect mechanism for a
|
|
41
|
+
# force-include mapped from outside the project root.
|
|
42
|
+
_RESOURCES_DIR = Path(__file__).resolve().parents[2]
|
|
43
|
+
_SITE_DIR = Path(__file__).resolve().parents[1]
|
|
44
|
+
_PURELIB_DIR = Path(sysconfig.get_path('purelib'))
|
|
45
|
+
# Single-sourced to avoid repeating the path components below.
|
|
46
|
+
_OIDSCRIPTS_WIREFRAME = Path('oidscripts') / 'wireframe.py'
|
|
47
|
+
_EXPECTED_WIREFRAMES = (
|
|
48
|
+
_RESOURCES_DIR / _OIDSCRIPTS_WIREFRAME,
|
|
49
|
+
_SITE_DIR / _OIDSCRIPTS_WIREFRAME,
|
|
50
|
+
_PURELIB_DIR / _OIDSCRIPTS_WIREFRAME,
|
|
51
|
+
)
|
|
52
|
+
if str(_RESOURCES_DIR) not in sys.path:
|
|
53
|
+
sys.path.append(str(_RESOURCES_DIR))
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
from oidscripts import wireframe as _wireframe # noqa: E402
|
|
57
|
+
except ImportError as exc: # pragma: no cover - deployment misconfiguration
|
|
58
|
+
raise ImportError(
|
|
59
|
+
'oid-mcp requires the OpenImageDebugger scripts tree; expected '
|
|
60
|
+
'oidscripts alongside this package under resources/. Run oid-mcp '
|
|
61
|
+
'from the OID install (uv run --directory resources/oidmcp).'
|
|
62
|
+
) from exc
|
|
63
|
+
|
|
64
|
+
# Guard against an earlier sys.path entry resolving `oidscripts` to some
|
|
65
|
+
# other tree: confirm we imported the wireframe that ships beside us. A
|
|
66
|
+
# different module could encode frames incompatibly, so treat it as a
|
|
67
|
+
# misconfiguration to fix, not a protocol to trust.
|
|
68
|
+
_loaded = getattr(_wireframe, '__file__', None)
|
|
69
|
+
if _loaded is not None and os.path.realpath(_loaded) not in {
|
|
70
|
+
os.path.realpath(str(p)) for p in _EXPECTED_WIREFRAMES}:
|
|
71
|
+
raise ImportError( # pragma: no cover - conflicting oidscripts on path
|
|
72
|
+
'oid-mcp imported a foreign oidscripts.wireframe %s; expected '
|
|
73
|
+
'one of %s. Remove the conflicting oidscripts from sys.path / '
|
|
74
|
+
'PYTHONPATH.'
|
|
75
|
+
% (_loaded, [str(p) for p in _EXPECTED_WIREFRAMES]))
|
|
76
|
+
|
|
77
|
+
MAX_FRAME_BYTES = _wireframe.MAX_FRAME_BYTES
|
|
78
|
+
recv_frame = _wireframe.recv_frame
|
|
79
|
+
send_frame = _wireframe.send_frame
|
|
80
|
+
|
|
81
|
+
__all__ = ['MAX_FRAME_BYTES', 'recv_frame', 'send_frame']
|
oidmcp/analysis.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Exact-number inspection: stats, value crops, lossless dumps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from .buffers import crop_region
|
|
15
|
+
|
|
16
|
+
VALUES_CAP = 1024
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _finite_or_none(value):
|
|
20
|
+
value = float(value)
|
|
21
|
+
return value if math.isfinite(value) else None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _channel_stats(channel: np.ndarray, floating: bool, label) -> dict:
|
|
25
|
+
"""Nan-aware stat dict for one 2-D channel (all-NaN -> None stats)."""
|
|
26
|
+
finite = channel[np.isfinite(channel)] if floating else channel
|
|
27
|
+
empty = finite.size == 0
|
|
28
|
+
return {
|
|
29
|
+
'label': label,
|
|
30
|
+
'min': None if empty else _finite_or_none(finite.min()),
|
|
31
|
+
'max': None if empty else _finite_or_none(finite.max()),
|
|
32
|
+
'mean': None if empty else _finite_or_none(finite.mean()),
|
|
33
|
+
'std': None if empty else _finite_or_none(finite.std()),
|
|
34
|
+
'nan': int(np.isnan(channel).sum()) if floating else 0,
|
|
35
|
+
'inf': int(np.isinf(channel).sum()) if floating else 0,
|
|
36
|
+
'zeros': int((channel == 0).sum()),
|
|
37
|
+
'count': int(channel.size),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _as_hwc(arr: np.ndarray) -> np.ndarray:
|
|
42
|
+
"""
|
|
43
|
+
Normalize a buffer to (H, W, C). A bare (H, W) matrix is a
|
|
44
|
+
single-channel buffer, so promote it to (H, W, 1); reject anything
|
|
45
|
+
that is neither 2-D nor 3-D with a clear error.
|
|
46
|
+
"""
|
|
47
|
+
if arr.ndim == 2:
|
|
48
|
+
return arr[:, :, np.newaxis]
|
|
49
|
+
if arr.ndim != 3:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f'expected a 2-D or 3-D buffer, got {arr.ndim} dimensions')
|
|
52
|
+
return arr
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def compute_stats(arr: np.ndarray, meta: dict,
|
|
56
|
+
region: tuple | None = None) -> dict:
|
|
57
|
+
arr = _as_hwc(arr)
|
|
58
|
+
view = crop_region(arr, region) if region is not None else arr
|
|
59
|
+
floating = np.issubdtype(arr.dtype, np.floating)
|
|
60
|
+
layout = (meta.get('pixel_layout') or '')
|
|
61
|
+
if arr.shape[2] == 1:
|
|
62
|
+
# A single-channel buffer (an Eigen matrix, a mask) has no
|
|
63
|
+
# color-channel semantics; do not label it after the layout.
|
|
64
|
+
layout = ''
|
|
65
|
+
per_channel = []
|
|
66
|
+
for index in range(view.shape[2]):
|
|
67
|
+
channel = view[:, :, index]
|
|
68
|
+
label = layout[index] if index < len(layout) else None
|
|
69
|
+
stats = {'channel': index, **_channel_stats(channel, floating, label)}
|
|
70
|
+
per_channel.append(stats)
|
|
71
|
+
return {
|
|
72
|
+
'width': arr.shape[1],
|
|
73
|
+
'height': arr.shape[0],
|
|
74
|
+
'channels': arr.shape[2],
|
|
75
|
+
'dtype': str(arr.dtype),
|
|
76
|
+
'region': list(region) if region is not None else None,
|
|
77
|
+
'per_channel': per_channel,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _json_safe(value):
|
|
82
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
83
|
+
if math.isnan(value):
|
|
84
|
+
return 'NaN'
|
|
85
|
+
return 'Inf' if value > 0 else '-Inf'
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _sanitize_tree(node):
|
|
90
|
+
if isinstance(node, list):
|
|
91
|
+
return [_sanitize_tree(item) for item in node]
|
|
92
|
+
if isinstance(node, dict):
|
|
93
|
+
return {key: _sanitize_tree(value) for key, value in node.items()}
|
|
94
|
+
return _json_safe(node)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def extract_values(arr: np.ndarray, x: int, y: int, w: int, h: int,
|
|
98
|
+
channel: int | None = None) -> dict:
|
|
99
|
+
"""
|
|
100
|
+
Exact values for a crop; capped so results stay readable.
|
|
101
|
+
"""
|
|
102
|
+
per_pixel = 1 if channel is not None else arr.shape[2]
|
|
103
|
+
total = w * h * per_pixel
|
|
104
|
+
if total > VALUES_CAP:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f'{w}x{h} crop holds {total} values, over the {VALUES_CAP} '
|
|
107
|
+
f'cap; request a smaller region or a single channel')
|
|
108
|
+
view = crop_region(arr, (x, y, w, h))
|
|
109
|
+
if channel is not None:
|
|
110
|
+
if not 0 <= channel < arr.shape[2]:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f'channel {channel} out of range; buffer has '
|
|
113
|
+
f'{arr.shape[2]}')
|
|
114
|
+
view = view[:, :, channel]
|
|
115
|
+
return {
|
|
116
|
+
'x': x, 'y': y, 'w': w, 'h': h,
|
|
117
|
+
'channel': channel,
|
|
118
|
+
'dtype': str(arr.dtype),
|
|
119
|
+
'values': _sanitize_tree(view.tolist()),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _current_user() -> str:
|
|
124
|
+
try:
|
|
125
|
+
return getpass.getuser()
|
|
126
|
+
except Exception:
|
|
127
|
+
return str(os.getuid()) if hasattr(os, 'getuid') else 'user'
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _safe_dump_dir() -> Path:
|
|
131
|
+
"""
|
|
132
|
+
Per-user default dump directory, hardened against a shared tempdir:
|
|
133
|
+
reject a pre-created symlink or a directory owned by another user,
|
|
134
|
+
and force 0o700 (mkdir ignores the mode when the dir pre-exists).
|
|
135
|
+
"""
|
|
136
|
+
directory = Path(tempfile.gettempdir()) / ('oid-dumps-' + _current_user())
|
|
137
|
+
if directory.is_symlink():
|
|
138
|
+
raise RuntimeError('dump directory %s is a symlink' % directory)
|
|
139
|
+
directory.mkdir(mode=0o700, exist_ok=True)
|
|
140
|
+
if hasattr(os, 'getuid'):
|
|
141
|
+
info = os.stat(directory)
|
|
142
|
+
if info.st_uid != os.getuid():
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
'dump directory %s is owned by another user' % directory)
|
|
145
|
+
os.chmod(directory, 0o700)
|
|
146
|
+
return directory
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def dump_npy(arr: np.ndarray, symbol: str, stop_generation: int,
|
|
150
|
+
path: str | None = None, overwrite: bool = False) -> str:
|
|
151
|
+
"""
|
|
152
|
+
Save the full decoded buffer (padding already stripped) as .npy.
|
|
153
|
+
|
|
154
|
+
Every write is confined to the hardened per-user dump directory
|
|
155
|
+
(``_safe_dump_dir()``); ``path`` may only pick a bare filename
|
|
156
|
+
inside it, never an arbitrary location. The write itself is atomic
|
|
157
|
+
and symlink-safe.
|
|
158
|
+
|
|
159
|
+
Never clobbers an existing file: if the resolved target already
|
|
160
|
+
exists, raise unless ``overwrite`` is set, so an unintended ``path``
|
|
161
|
+
cannot silently destroy the caller's data.
|
|
162
|
+
"""
|
|
163
|
+
directory = _safe_dump_dir()
|
|
164
|
+
if path is None:
|
|
165
|
+
name = re.sub(r'[^A-Za-z0-9_.-]', '_', symbol)
|
|
166
|
+
filename = f'{name}_gen{stop_generation}.npy'
|
|
167
|
+
else:
|
|
168
|
+
# The client picks only a name INSIDE the hardened dump dir; a
|
|
169
|
+
# path separator, '..', or an absolute path could escape it.
|
|
170
|
+
if (os.path.isabs(path)
|
|
171
|
+
or os.path.basename(path) != path
|
|
172
|
+
or path in ('.', '..', '')):
|
|
173
|
+
raise ValueError(
|
|
174
|
+
'dump path must be a bare filename within the dump '
|
|
175
|
+
'directory')
|
|
176
|
+
filename = path if path.endswith('.npy') else f'{path}.npy'
|
|
177
|
+
target = os.path.join(str(directory), filename)
|
|
178
|
+
if os.path.islink(target):
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f'{target} is a symlink; refusing to write through it')
|
|
181
|
+
if not overwrite and os.path.lexists(target):
|
|
182
|
+
raise FileExistsError(
|
|
183
|
+
f'{target} already exists; pass overwrite=true to replace it')
|
|
184
|
+
# Atomic, symlink-safe write: fill a temp file in the same 0700 dir,
|
|
185
|
+
# then os.replace() the directory entry (which never follows a
|
|
186
|
+
# symlink at the target).
|
|
187
|
+
fd, tmp = tempfile.mkstemp(dir=str(directory), suffix='.npy.tmp')
|
|
188
|
+
try:
|
|
189
|
+
with os.fdopen(fd, 'wb') as handle:
|
|
190
|
+
np.save(handle, np.ascontiguousarray(arr))
|
|
191
|
+
os.replace(tmp, target)
|
|
192
|
+
except BaseException:
|
|
193
|
+
try:
|
|
194
|
+
os.unlink(tmp)
|
|
195
|
+
except OSError:
|
|
196
|
+
# Best-effort cleanup of the temp file; a failure to unlink it
|
|
197
|
+
# (already gone, or a transient FS error) must not mask the
|
|
198
|
+
# original write error we are about to re-raise.
|
|
199
|
+
pass
|
|
200
|
+
raise
|
|
201
|
+
return os.path.abspath(target)
|
oidmcp/buffers.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Decode raw endpoint payloads into numpy arrays."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
# Keep in sync with oidscripts/symbols.py (OID_TYPES_*). Value 1 is
|
|
10
|
+
# historically unused.
|
|
11
|
+
DTYPES = {
|
|
12
|
+
0: np.uint8,
|
|
13
|
+
2: np.uint16,
|
|
14
|
+
3: np.int16,
|
|
15
|
+
4: np.int32,
|
|
16
|
+
5: np.float32,
|
|
17
|
+
6: np.float64,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def decode_buffer(meta: dict, raw: bytes) -> np.ndarray:
|
|
22
|
+
"""
|
|
23
|
+
Return an (H, W, C) array in display orientation; channel order is
|
|
24
|
+
the storage order (pixel_layout is applied only when rendering).
|
|
25
|
+
|
|
26
|
+
Metadata semantics (matching the debugger bridges): width/height/
|
|
27
|
+
row_stride describe the buffer as stored; row_stride counts pixels,
|
|
28
|
+
including padding; transpose_buffer means the stored rows run along
|
|
29
|
+
the display's width axis.
|
|
30
|
+
"""
|
|
31
|
+
if meta['type'] not in DTYPES:
|
|
32
|
+
raise ValueError(f"unsupported buffer type {meta['type']}")
|
|
33
|
+
dtype = np.dtype(DTYPES[meta['type']])
|
|
34
|
+
height, width = int(meta['height']), int(meta['width'])
|
|
35
|
+
channels = int(meta['channels'])
|
|
36
|
+
stride = int(meta['row_stride'])
|
|
37
|
+
if height <= 0 or width <= 0 or channels <= 0:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f'invalid buffer dimensions: {width}x{height}x{channels}')
|
|
40
|
+
if stride < width:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f'row_stride {stride} is smaller than width {width}')
|
|
43
|
+
count = height * stride * channels
|
|
44
|
+
if len(raw) < count * dtype.itemsize:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f'buffer payload is {len(raw)} bytes; expected at least '
|
|
47
|
+
f'{count * dtype.itemsize}')
|
|
48
|
+
arr = np.frombuffer(raw, dtype=dtype, count=count)
|
|
49
|
+
arr = arr.reshape(height, stride, channels)[:, :width, :]
|
|
50
|
+
if meta.get('transpose_buffer'):
|
|
51
|
+
arr = arr.transpose(1, 0, 2)
|
|
52
|
+
return arr
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def crop_region(arr: np.ndarray,
|
|
56
|
+
region: tuple[int, int, int, int]) -> np.ndarray:
|
|
57
|
+
"""
|
|
58
|
+
Crop (x, y, w, h), in display coordinates (x = column, y = row).
|
|
59
|
+
"""
|
|
60
|
+
if len(region) != 4:
|
|
61
|
+
raise ValueError('region must be [x, y, w, h]')
|
|
62
|
+
x, y, w, h = (int(v) for v in region)
|
|
63
|
+
rows, cols = arr.shape[:2]
|
|
64
|
+
if w <= 0 or h <= 0 or x < 0 or y < 0 or x + w > cols or y + h > rows:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f'region (x={x}, y={y}, w={w}, h={h}) is outside the '
|
|
67
|
+
f'{cols}x{rows} buffer')
|
|
68
|
+
return arr[y:y + h, x:x + w, :]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# (session pid, token, symbol, stop generation) -> (metadata, decoded array)
|
|
72
|
+
CacheKey = tuple[int, str, str, int]
|
|
73
|
+
CacheValue = tuple[dict, np.ndarray]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class BufferCache:
|
|
77
|
+
"""Small LRU keyed by (session pid, token, symbol, stop generation)."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, capacity: int = 4):
|
|
80
|
+
self._capacity = capacity
|
|
81
|
+
self._entries: OrderedDict[CacheKey, CacheValue] = OrderedDict()
|
|
82
|
+
|
|
83
|
+
def get(self, key: CacheKey) -> CacheValue | None:
|
|
84
|
+
if key not in self._entries:
|
|
85
|
+
return None
|
|
86
|
+
self._entries.move_to_end(key)
|
|
87
|
+
return self._entries[key]
|
|
88
|
+
|
|
89
|
+
def put(self, key: CacheKey, value: CacheValue) -> None:
|
|
90
|
+
self._entries[key] = value
|
|
91
|
+
self._entries.move_to_end(key)
|
|
92
|
+
while len(self._entries) > self._capacity:
|
|
93
|
+
self._entries.popitem(last=False)
|