refigure 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- refigure-0.2.0/README.md → refigure-0.3.0/PKG-INFO +78 -0
- refigure-0.2.0/PKG-INFO → refigure-0.3.0/README.md +46 -30
- {refigure-0.2.0 → refigure-0.3.0}/pyproject.toml +10 -2
- {refigure-0.2.0 → refigure-0.3.0}/refigure/__init__.py +2 -0
- {refigure-0.2.0 → refigure-0.3.0}/refigure/api.py +35 -0
- {refigure-0.2.0 → refigure-0.3.0}/refigure/cli.py +21 -1
- refigure-0.3.0/refigure/mcp/__init__.py +30 -0
- refigure-0.3.0/refigure/mcp/_lru.py +159 -0
- refigure-0.3.0/refigure/mcp/auth.py +102 -0
- refigure-0.3.0/refigure/mcp/cli.py +373 -0
- refigure-0.3.0/refigure/mcp/exceptions.py +19 -0
- refigure-0.3.0/refigure/mcp/server.py +1252 -0
- refigure-0.3.0/refigure/mcp/state.py +197 -0
- refigure-0.3.0/refigure/mcp/vlm_cache.py +104 -0
- {refigure-0.2.0 → refigure-0.3.0}/refigure/vlm/__init__.py +179 -52
- refigure-0.3.0/refigure/vlm/cache.py +106 -0
- refigure-0.3.0/tests/unit/mcp/conftest.py +20 -0
- refigure-0.3.0/tests/unit/mcp/test_auth.py +142 -0
- refigure-0.3.0/tests/unit/mcp/test_batch.py +450 -0
- refigure-0.3.0/tests/unit/mcp/test_bridge.py +126 -0
- refigure-0.3.0/tests/unit/mcp/test_cli.py +445 -0
- refigure-0.3.0/tests/unit/mcp/test_http.py +197 -0
- refigure-0.3.0/tests/unit/mcp/test_lru.py +162 -0
- refigure-0.3.0/tests/unit/mcp/test_prompts.py +197 -0
- refigure-0.3.0/tests/unit/mcp/test_resources.py +115 -0
- refigure-0.3.0/tests/unit/mcp/test_schema_pin.py +207 -0
- refigure-0.3.0/tests/unit/mcp/test_state.py +244 -0
- refigure-0.3.0/tests/unit/mcp/test_tools.py +441 -0
- refigure-0.3.0/tests/unit/mcp/test_vlm_cache.py +85 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_cli.py +51 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_optional_dependency_guards.py +1 -0
- refigure-0.3.0/tests/unit/test_vlm_max_markers.py +155 -0
- refigure-0.3.0/tests/unit/vlm/test_soffice_profile_isolation.py +188 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/vlm/test_vlm.py +43 -14
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/vlm/test_vlm_cache.py +59 -0
- refigure-0.3.0/tests/unit/xlsx/__init__.py +0 -0
- refigure-0.2.0/refigure/vlm/cache.py +0 -70
- {refigure-0.2.0 → refigure-0.3.0}/.gitignore +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/ATTRIBUTION.md +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/LICENSE +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/NOTICE +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/refigure/__main__.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/refigure/_io.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/refigure/core/__init__.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/refigure/core/chart_data.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/refigure/core/chart_render.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/refigure/core/zipsafe.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/refigure/docx/__init__.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/refigure/docx_groups.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/refigure/py.typed +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/refigure/vlm/client.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/refigure/xlsx/__init__.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/refigure/xlsx/charts.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/__init__.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/support.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/__init__.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/core/__init__.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/core/test_chart_data.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/core/test_chart_render.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/core/test_chart_render_missing_mermaidx.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/core/test_chart_render_visual.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/docx/__init__.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/docx/test_docx.py +0 -0
- {refigure-0.2.0/tests/unit/vlm → refigure-0.3.0/tests/unit/mcp}/__init__.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_concurrency.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_docx_chart_group_coexistence.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_docx_groups.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_io.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_property_based.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_robustness.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/test_xml_security.py +0 -0
- {refigure-0.2.0/tests/unit/xlsx → refigure-0.3.0/tests/unit/vlm}/__init__.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/vlm/test_vlm_client.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/xlsx/test_xlsx.py +0 -0
- {refigure-0.2.0 → refigure-0.3.0}/tests/unit/xlsx/test_xlsx_charts.py +0 -0
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: refigure
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: DOCX/XLSX -> Markdown conversion with native OOXML chart-data extraction (no rasterize/OCR/VLM needed) + optional VLM interpretation for figures with no native chart data
|
|
5
|
+
Project-URL: Homepage, https://github.com/HelgDemidov/refigure
|
|
6
|
+
Project-URL: Repository, https://github.com/HelgDemidov/refigure
|
|
7
|
+
Author-email: HelgDemidov <helg.demidov88@gmail.com>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
License-File: NOTICE
|
|
11
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: lxml<7,>=6.1
|
|
18
|
+
Provides-Extra: docx
|
|
19
|
+
Requires-Dist: mammoth<2,>=1.12; extra == 'docx'
|
|
20
|
+
Requires-Dist: markdownify<2,>=1.2; extra == 'docx'
|
|
21
|
+
Provides-Extra: mcp
|
|
22
|
+
Requires-Dist: mcp<3,>=2.0; extra == 'mcp'
|
|
23
|
+
Provides-Extra: vlm
|
|
24
|
+
Requires-Dist: pdfplumber<0.12,>=0.11; extra == 'vlm'
|
|
25
|
+
Provides-Extra: vlm-direct
|
|
26
|
+
Requires-Dist: anthropic<1,>=0.120; extra == 'vlm-direct'
|
|
27
|
+
Requires-Dist: openai<4,>=3.1.0; extra == 'vlm-direct'
|
|
28
|
+
Requires-Dist: pdfplumber<0.12,>=0.11; extra == 'vlm-direct'
|
|
29
|
+
Provides-Extra: xlsx
|
|
30
|
+
Requires-Dist: openpyxl<4,>=3.1; extra == 'xlsx'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
1
33
|
# refigure
|
|
2
34
|
|
|
3
35
|
**Converters where figures survive.**
|
|
@@ -100,6 +132,9 @@ refigure report.docx --vlm # needs the system soffice/LibreOf
|
|
|
100
132
|
chart/group counts + `vlm_used`), not a bare string.
|
|
101
133
|
- **CLI included** — `refigure` console command, stdin/stdout-first, native
|
|
102
134
|
batch mode, typed exit codes (see below).
|
|
135
|
+
- **MCP server included** — `refigure-mcp` console command (`[mcp]` extra),
|
|
136
|
+
stdio or Streamable HTTP, tools/resources/prompts, batch conversion with
|
|
137
|
+
per-file isolation (see below).
|
|
103
138
|
|
|
104
139
|
## CLI
|
|
105
140
|
|
|
@@ -133,6 +168,49 @@ Exit codes:
|
|
|
133
168
|
| 5 | the format's extra (`[docx]`/`[xlsx]`) isn't installed |
|
|
134
169
|
| 6 | unexpected internal error |
|
|
135
170
|
|
|
171
|
+
## MCP server
|
|
172
|
+
|
|
173
|
+
`refigure-mcp` — the same converters as an
|
|
174
|
+
[MCP](https://modelcontextprotocol.io) server, for agents/IDEs that speak
|
|
175
|
+
the protocol directly instead of shelling out to a CLI or importing the
|
|
176
|
+
library:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
pip install "refigure[mcp,docx,xlsx]"
|
|
180
|
+
refigure-mcp # stdio — the MCP client launches it
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
```json
|
|
184
|
+
{
|
|
185
|
+
"mcpServers": {
|
|
186
|
+
"refigure": { "command": "refigure-mcp" }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Three tools — `convert_docx`, `convert_xlsx`, and `convert_batch` (multiple
|
|
192
|
+
files in one call: one bad file reports its own error without aborting the
|
|
193
|
+
rest) — each registered only if its format extra is actually installed.
|
|
194
|
+
`use_vlm`/`--vlm-provider` and friends work the same as the CLI. A result
|
|
195
|
+
too large to inline is stored and handed back as a
|
|
196
|
+
`refigure://conversion/{id}` resource instead of inflating the tool
|
|
197
|
+
response. Two prompts (`ingest_for_rag`, `explain_conversion_warnings`)
|
|
198
|
+
help a client pick the right tool/VLM settings for the job.
|
|
199
|
+
|
|
200
|
+
Streamable HTTP is opt-in, for a shared/remote deployment — bearer-token
|
|
201
|
+
auth is required, not optional:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
echo "sk-... = alice" > tokens.txt
|
|
205
|
+
refigure-mcp --transport http --mcp-auth-token-file tokens.txt
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Per-caller rate-limiting (protects the operator's own spend from a
|
|
209
|
+
leaked/runaway token) applies automatically over HTTP, together with a
|
|
210
|
+
fairness soft-cap once 2+ callers are configured; `refigure-mcp --help`
|
|
211
|
+
covers every tuning flag (concurrency, timeouts, resource-store limits,
|
|
212
|
+
batch size, VLM ceiling).
|
|
213
|
+
|
|
136
214
|
## Real examples
|
|
137
215
|
|
|
138
216
|
Full `convert()` output on real, openly-licensed documents — not
|
|
@@ -1,33 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: refigure
|
|
3
|
-
Version: 0.2.0
|
|
4
|
-
Summary: DOCX/XLSX -> Markdown conversion with native OOXML chart-data extraction, no rasterize/OCR/VLM
|
|
5
|
-
Project-URL: Homepage, https://github.com/HelgDemidov/refigure
|
|
6
|
-
Project-URL: Repository, https://github.com/HelgDemidov/refigure
|
|
7
|
-
Author-email: HelgDemidov <helg.demidov88@gmail.com>
|
|
8
|
-
License-Expression: Apache-2.0
|
|
9
|
-
License-File: LICENSE
|
|
10
|
-
License-File: NOTICE
|
|
11
|
-
Classifier: Development Status :: 2 - Pre-Alpha
|
|
12
|
-
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
-
Classifier: Programming Language :: Python :: 3
|
|
15
|
-
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
16
|
-
Requires-Python: >=3.10
|
|
17
|
-
Requires-Dist: lxml<7,>=6.1
|
|
18
|
-
Provides-Extra: docx
|
|
19
|
-
Requires-Dist: mammoth<2,>=1.12; extra == 'docx'
|
|
20
|
-
Requires-Dist: markdownify<2,>=1.2; extra == 'docx'
|
|
21
|
-
Provides-Extra: vlm
|
|
22
|
-
Requires-Dist: pdfplumber<0.12,>=0.11; extra == 'vlm'
|
|
23
|
-
Provides-Extra: vlm-direct
|
|
24
|
-
Requires-Dist: anthropic<1,>=0.120; extra == 'vlm-direct'
|
|
25
|
-
Requires-Dist: openai<4,>=3.1.0; extra == 'vlm-direct'
|
|
26
|
-
Requires-Dist: pdfplumber<0.12,>=0.11; extra == 'vlm-direct'
|
|
27
|
-
Provides-Extra: xlsx
|
|
28
|
-
Requires-Dist: openpyxl<4,>=3.1; extra == 'xlsx'
|
|
29
|
-
Description-Content-Type: text/markdown
|
|
30
|
-
|
|
31
1
|
# refigure
|
|
32
2
|
|
|
33
3
|
**Converters where figures survive.**
|
|
@@ -130,6 +100,9 @@ refigure report.docx --vlm # needs the system soffice/LibreOf
|
|
|
130
100
|
chart/group counts + `vlm_used`), not a bare string.
|
|
131
101
|
- **CLI included** — `refigure` console command, stdin/stdout-first, native
|
|
132
102
|
batch mode, typed exit codes (see below).
|
|
103
|
+
- **MCP server included** — `refigure-mcp` console command (`[mcp]` extra),
|
|
104
|
+
stdio or Streamable HTTP, tools/resources/prompts, batch conversion with
|
|
105
|
+
per-file isolation (see below).
|
|
133
106
|
|
|
134
107
|
## CLI
|
|
135
108
|
|
|
@@ -163,6 +136,49 @@ Exit codes:
|
|
|
163
136
|
| 5 | the format's extra (`[docx]`/`[xlsx]`) isn't installed |
|
|
164
137
|
| 6 | unexpected internal error |
|
|
165
138
|
|
|
139
|
+
## MCP server
|
|
140
|
+
|
|
141
|
+
`refigure-mcp` — the same converters as an
|
|
142
|
+
[MCP](https://modelcontextprotocol.io) server, for agents/IDEs that speak
|
|
143
|
+
the protocol directly instead of shelling out to a CLI or importing the
|
|
144
|
+
library:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
pip install "refigure[mcp,docx,xlsx]"
|
|
148
|
+
refigure-mcp # stdio — the MCP client launches it
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{
|
|
153
|
+
"mcpServers": {
|
|
154
|
+
"refigure": { "command": "refigure-mcp" }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Three tools — `convert_docx`, `convert_xlsx`, and `convert_batch` (multiple
|
|
160
|
+
files in one call: one bad file reports its own error without aborting the
|
|
161
|
+
rest) — each registered only if its format extra is actually installed.
|
|
162
|
+
`use_vlm`/`--vlm-provider` and friends work the same as the CLI. A result
|
|
163
|
+
too large to inline is stored and handed back as a
|
|
164
|
+
`refigure://conversion/{id}` resource instead of inflating the tool
|
|
165
|
+
response. Two prompts (`ingest_for_rag`, `explain_conversion_warnings`)
|
|
166
|
+
help a client pick the right tool/VLM settings for the job.
|
|
167
|
+
|
|
168
|
+
Streamable HTTP is opt-in, for a shared/remote deployment — bearer-token
|
|
169
|
+
auth is required, not optional:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
echo "sk-... = alice" > tokens.txt
|
|
173
|
+
refigure-mcp --transport http --mcp-auth-token-file tokens.txt
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Per-caller rate-limiting (protects the operator's own spend from a
|
|
177
|
+
leaked/runaway token) applies automatically over HTTP, together with a
|
|
178
|
+
fairness soft-cap once 2+ callers are configured; `refigure-mcp --help`
|
|
179
|
+
covers every tuning flag (concurrency, timeouts, resource-store limits,
|
|
180
|
+
batch size, VLM ceiling).
|
|
181
|
+
|
|
166
182
|
## Real examples
|
|
167
183
|
|
|
168
184
|
Full `convert()` output on real, openly-licensed documents — not
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "refigure"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "DOCX/XLSX -> Markdown conversion with native OOXML chart-data extraction
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "DOCX/XLSX -> Markdown conversion with native OOXML chart-data extraction (no rasterize/OCR/VLM needed) + optional VLM interpretation for figures with no native chart data"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
11
11
|
license-files = ["LICENSE", "NOTICE"]
|
|
@@ -57,9 +57,17 @@ vlm = ["pdfplumber>=0.11,<0.12"]
|
|
|
57
57
|
# [vlm] here reflects a genuine dependency, not a bug to route around by
|
|
58
58
|
# flattening client.py out of the package.
|
|
59
59
|
vlm-direct = ["refigure[vlm]", "openai>=3.1.0,<4", "anthropic>=0.120,<1"]
|
|
60
|
+
# MCP server (v2, stage 10) — thin protocol wrapper over docx.convert()/
|
|
61
|
+
# xlsx.convert(), no self-referential coupling to [docx]/[xlsx]/[vlm]
|
|
62
|
+
# (unlike vlm-direct above): refigure/mcp/__init__.py's guard is the only
|
|
63
|
+
# module-level dependency, a bare `refigure[mcp]` is valid with 0 tools
|
|
64
|
+
# registered — see docs/mcp-server/mcp-server-architecture/
|
|
65
|
+
# mcp-server-architecture.md §2.
|
|
66
|
+
mcp = ["mcp>=2.0,<3"]
|
|
60
67
|
|
|
61
68
|
[project.scripts]
|
|
62
69
|
refigure = "refigure.cli:main"
|
|
70
|
+
refigure-mcp = "refigure.mcp.cli:main"
|
|
63
71
|
|
|
64
72
|
[project.urls]
|
|
65
73
|
Homepage = "https://github.com/HelgDemidov/refigure"
|
|
@@ -6,6 +6,7 @@ from .api import (
|
|
|
6
6
|
CorruptArchiveError,
|
|
7
7
|
MissingOptionalDependencyError,
|
|
8
8
|
UnsupportedFormatError,
|
|
9
|
+
VlmMarkerLimitExceededError,
|
|
9
10
|
)
|
|
10
11
|
|
|
11
12
|
try:
|
|
@@ -24,4 +25,5 @@ __all__ = [
|
|
|
24
25
|
"CorruptArchiveError",
|
|
25
26
|
"MissingOptionalDependencyError",
|
|
26
27
|
"UnsupportedFormatError",
|
|
28
|
+
"VlmMarkerLimitExceededError",
|
|
27
29
|
]
|
|
@@ -255,6 +255,35 @@ class Config:
|
|
|
255
255
|
language-sensitivity — see ``vlm_verify``'s own docstring and
|
|
256
256
|
``witness_defects``'s."""
|
|
257
257
|
|
|
258
|
+
vlm_max_markers: int | None = None
|
|
259
|
+
"""Maximum number of VLM markers this call may resolve with a PAID
|
|
260
|
+
call (docx-only — XLSX has no VLM path). ``None`` (default): no
|
|
261
|
+
ceiling, current behavior. A caller that sets this gets a pre-flight
|
|
262
|
+
check in ``vlm.enhance_docx_markdown``, before any paid call: every
|
|
263
|
+
marker that would incur at least one paid call — a cache-miss, OR (
|
|
264
|
+
when ``vlm_verify=True``) a cache-hit whose ``judge_verdict`` is still
|
|
265
|
+
unset (the judge-backfill pass is itself a paid call; without counting
|
|
266
|
+
it, a fully-cached document under ``vlm_verify=True`` would silently
|
|
267
|
+
bypass the ceiling) — is counted once, via a single memoized cache
|
|
268
|
+
probe reused by the main resolution loops (no doubled reads against a
|
|
269
|
+
networked ``VlmCacheBackend``, no TOCTOU window between the count and
|
|
270
|
+
the paid calls). Exceeding the count raises
|
|
271
|
+
``VlmMarkerLimitExceededError`` immediately, before the first paid
|
|
272
|
+
call — no partial processing.
|
|
273
|
+
|
|
274
|
+
Motivation: input size alone does not bound the number of paid VLM
|
|
275
|
+
calls a document can trigger — a 1.46 MB real-world document carrying
|
|
276
|
+
9990 VLM-eligible markers was the concrete case that motivated this
|
|
277
|
+
field. This is the one lever specifically for VLM cost/latency
|
|
278
|
+
escalation; it is orthogonal to ``strict`` (which narrows exactly one
|
|
279
|
+
unrelated failure mode, a missing ``soffice`` binary) and to the
|
|
280
|
+
zero-loss-on-failure contract every other VLM failure path already
|
|
281
|
+
has (PR #16's security-audit contract) — an unavailable cache backend
|
|
282
|
+
still degrades every PROBE to "treat as a miss" (``vlm.
|
|
283
|
+
_cache_get_safely``'s existing behavior), which can trigger this
|
|
284
|
+
ceiling honestly (those calls really would have been paid), not a
|
|
285
|
+
weakening of that contract."""
|
|
286
|
+
|
|
258
287
|
|
|
259
288
|
@dataclass
|
|
260
289
|
class ConversionResult:
|
|
@@ -287,3 +316,9 @@ class MissingOptionalDependencyError(Exception):
|
|
|
287
316
|
binary a pip extra can't provide (``soffice``/LibreOffice, needed to
|
|
288
317
|
render a composite DOCX group under ``Config(use_vlm=True,
|
|
289
318
|
strict=True)`` — see ``Config.strict``'s docstring)."""
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class VlmMarkerLimitExceededError(Exception):
|
|
322
|
+
"""The document carries more VLM-eligible markers requiring a paid call
|
|
323
|
+
than ``Config.vlm_max_markers`` allows (see its docstring) — raised
|
|
324
|
+
before any paid call, not after partial processing."""
|
|
@@ -33,6 +33,7 @@ EXIT_UNSUPPORTED_FORMAT = 3
|
|
|
33
33
|
EXIT_CORRUPT_ARCHIVE = 4
|
|
34
34
|
EXIT_MISSING_DEPENDENCY = 5
|
|
35
35
|
EXIT_INTERNAL_ERROR = 6
|
|
36
|
+
EXIT_VLM_MARKER_LIMIT = 7
|
|
36
37
|
|
|
37
38
|
_SUFFIX_TO_FORMAT = {".docx": "docx", ".xlsx": "xlsx"}
|
|
38
39
|
|
|
@@ -67,7 +68,12 @@ def _exit_code_for(exc: Exception) -> int:
|
|
|
67
68
|
# Imported lazily too — importing refigure.api at module level is cheap
|
|
68
69
|
# (lxml-only, see api.py), but the exception CLASSES only need to exist
|
|
69
70
|
# once we actually have an exception to classify.
|
|
70
|
-
from .api import
|
|
71
|
+
from .api import (
|
|
72
|
+
CorruptArchiveError,
|
|
73
|
+
MissingOptionalDependencyError,
|
|
74
|
+
UnsupportedFormatError,
|
|
75
|
+
VlmMarkerLimitExceededError,
|
|
76
|
+
)
|
|
71
77
|
|
|
72
78
|
if isinstance(exc, UnsupportedFormatError):
|
|
73
79
|
return EXIT_UNSUPPORTED_FORMAT
|
|
@@ -75,6 +81,8 @@ def _exit_code_for(exc: Exception) -> int:
|
|
|
75
81
|
return EXIT_CORRUPT_ARCHIVE
|
|
76
82
|
if isinstance(exc, MissingOptionalDependencyError):
|
|
77
83
|
return EXIT_MISSING_DEPENDENCY
|
|
84
|
+
if isinstance(exc, VlmMarkerLimitExceededError):
|
|
85
|
+
return EXIT_VLM_MARKER_LIMIT
|
|
78
86
|
return EXIT_INTERNAL_ERROR
|
|
79
87
|
|
|
80
88
|
|
|
@@ -200,6 +208,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
200
208
|
"putting a secret in argv/shell history."
|
|
201
209
|
),
|
|
202
210
|
)
|
|
211
|
+
vlm_group.add_argument(
|
|
212
|
+
"--vlm-max-markers",
|
|
213
|
+
metavar="N",
|
|
214
|
+
type=int,
|
|
215
|
+
help=(
|
|
216
|
+
"Cap on VLM markers requiring a paid call (Config(vlm_max_markers=...)). "
|
|
217
|
+
"Exceeding it exits with code 7 before any paid call. Unset: no cap "
|
|
218
|
+
"(the library default)."
|
|
219
|
+
),
|
|
220
|
+
)
|
|
203
221
|
verbosity = parser.add_mutually_exclusive_group()
|
|
204
222
|
verbosity.add_argument(
|
|
205
223
|
"-v",
|
|
@@ -271,6 +289,8 @@ def _build_config(args: argparse.Namespace, parser: argparse.ArgumentParser) ->
|
|
|
271
289
|
kwargs["vlm_model"] = args.vlm_model
|
|
272
290
|
if args.vlm_judge_mode is not None:
|
|
273
291
|
kwargs["vlm_judge_mode"] = args.vlm_judge_mode
|
|
292
|
+
if args.vlm_max_markers is not None:
|
|
293
|
+
kwargs["vlm_max_markers"] = args.vlm_max_markers
|
|
274
294
|
if args.vlm:
|
|
275
295
|
# Provider/key-file resolution only matters when VLM is actually
|
|
276
296
|
# enabled — otherwise --vlm-provider openai with no --vlm would
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""MCP-server subpackage (v2, stage 10) — a thin protocol wrapper over
|
|
2
|
+
``refigure.docx.convert()``/``refigure.xlsx.convert()``, not new
|
|
3
|
+
conversion logic. See ``docs/mcp-server/mcp-server-architecture/
|
|
4
|
+
mcp-server-architecture.md`` for the full design.
|
|
5
|
+
|
|
6
|
+
Guard is the first same-package-import-adjacent statement in this file —
|
|
7
|
+
same discipline as ``refigure/vlm/__init__.py``'s own guard docstring
|
|
8
|
+
explains: a module-level ``try/except ImportError`` is only effective if
|
|
9
|
+
it runs before any OTHER same-package import that could itself
|
|
10
|
+
transitively raise an unguarded ``ImportError``. This subpackage currently
|
|
11
|
+
has no such transitive risk (``server.py``/``cli.py`` only import
|
|
12
|
+
``..api`` at module level, itself ``lxml``-only), but the ordering
|
|
13
|
+
discipline holds regardless of today's safety being circumstantial.
|
|
14
|
+
|
|
15
|
+
No self-referential coupling to ``[docx]``/``[xlsx]``/``[vlm]`` (unlike
|
|
16
|
+
``vlm-direct``, see ``pyproject.toml``'s comment there) — a bare
|
|
17
|
+
``refigure[mcp]`` install is valid, it just registers 0 tools (``docx``/
|
|
18
|
+
``xlsx`` are imported lazily, per-tool, inside ``server.py``).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from ..api import MissingOptionalDependencyError
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import mcp as _mcp_sdk # noqa: F401 - import-for-guard, see module docstring
|
|
27
|
+
except ImportError as exc: # pragma: no cover - see tests/unit/test_optional_dependency_guards.py
|
|
28
|
+
raise MissingOptionalDependencyError(
|
|
29
|
+
"refigure[mcp] is required to use the refigure-mcp server"
|
|
30
|
+
) from exc
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Generic bounded LRU + byte-budget store, shared by ``ServerState``
|
|
2
|
+
(``state.py``) and ``BoundedLruVlmCache`` (``vlm_cache.py``) — architecture
|
|
3
|
+
doc §7-bis: "the same LRU utility as the resource store", not two
|
|
4
|
+
independent implementations of the same eviction policy.
|
|
5
|
+
|
|
6
|
+
Byte-budget eviction is O(1) per operation: every entry carries its OWN
|
|
7
|
+
precomputed size (this class never (re)computes it — the caller does, and
|
|
8
|
+
is expected to do so OUTSIDE any lock it holds), and a running total is
|
|
9
|
+
adjusted by plain addition/subtraction on insert/evict. Recomputing every
|
|
10
|
+
remaining entry's size on each eviction (e.g. re-encoding/re-hashing the
|
|
11
|
+
whole store under the lock) would reproduce the exact stall the
|
|
12
|
+
architecture review measured for that approach: 34-60ms of event-loop
|
|
13
|
+
blocking vs. <1ms for this incremental counter.
|
|
14
|
+
|
|
15
|
+
Thread-safe via a plain ``threading.Lock`` — correct for both of this
|
|
16
|
+
class's callers even though they run in different execution contexts:
|
|
17
|
+
``ServerState`` is only ever touched from the event loop (architecture
|
|
18
|
+
doc §4's "mutations only from the event loop" invariant), while
|
|
19
|
+
``BoundedLruVlmCache`` is written from worker threads (core's
|
|
20
|
+
``enhance_docx_markdown`` calls ``VlmCacheBackend.set()`` synchronously,
|
|
21
|
+
inside the ``anyio.to_thread`` bridge). A plain lock protects either
|
|
22
|
+
caller identically — no async-specific primitive needed here.
|
|
23
|
+
|
|
24
|
+
Phase 3 (``docs/mcp-server/mcp-server-phase3-http-auth/
|
|
25
|
+
mcp-server-phase3-http-auth-2026-08-21.md`` §1) adds two additive members —
|
|
26
|
+
existing callers that don't pass ``on_evict``/don't call ``remove()`` see
|
|
27
|
+
no behavior change: an optional ``on_evict`` callback, invoked for entries
|
|
28
|
+
evicted by ``insert()``'s own count/byte-budget loop (``ServerState``'s
|
|
29
|
+
per-caller soft-cap accounting needs to know which OTHER caller's entry a
|
|
30
|
+
given ``insert()`` just evicted — this class has no other way to tell it),
|
|
31
|
+
and ``remove()``, an explicit point-eviction the caller already knows
|
|
32
|
+
about (deliberately NOT routed through ``on_evict`` — the caller doing the
|
|
33
|
+
removing already updates its own bookkeeping at the call site, invoking
|
|
34
|
+
the callback too would double-count).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import threading
|
|
40
|
+
from collections import OrderedDict
|
|
41
|
+
from typing import Callable, Generic, TypeVar
|
|
42
|
+
|
|
43
|
+
# Not PEP 695 generics (`class BoundedLru[T]`) — this repo's floor is
|
|
44
|
+
# Python 3.10 (`pyproject.toml`'s `requires-python = ">=3.10"`), that
|
|
45
|
+
# syntax is 3.12+.
|
|
46
|
+
_T = TypeVar("_T")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BoundedLru(Generic[_T]):
|
|
50
|
+
"""A dict-like LRU store bounded by BOTH entry count and total byte
|
|
51
|
+
size — whichever limit is hit first evicts the oldest entries until
|
|
52
|
+
neither is exceeded. Has no notion of time itself; a TTL (if the
|
|
53
|
+
caller wants one, as ``ServerState`` does) is layered on top by
|
|
54
|
+
storing a timestamp inside the value and checking it after ``get()``,
|
|
55
|
+
not something this class enforces.
|
|
56
|
+
|
|
57
|
+
A single entry larger than ``max_bytes`` on its own is kept, not
|
|
58
|
+
evicted-on-arrival: eviction never drops the store to zero entries
|
|
59
|
+
just because the one remaining entry is large — a caller calling
|
|
60
|
+
``get()`` right after ``insert()`` must always find what it just
|
|
61
|
+
inserted. This only matters for a pathologically oversized single
|
|
62
|
+
entry; every real caller in this codebase keeps individual entries
|
|
63
|
+
well under their respective budgets (see ``state.py``/
|
|
64
|
+
``vlm_cache.py``)."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
*,
|
|
69
|
+
max_entries: int,
|
|
70
|
+
max_bytes: int,
|
|
71
|
+
on_evict: Callable[[str, _T, int], None] | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
self._max_entries = max_entries
|
|
74
|
+
self._max_bytes = max_bytes
|
|
75
|
+
self._on_evict = on_evict
|
|
76
|
+
self._lock = threading.Lock()
|
|
77
|
+
self._data: OrderedDict[str, tuple[_T, int]] = OrderedDict()
|
|
78
|
+
self._total_bytes = 0
|
|
79
|
+
|
|
80
|
+
def insert(self, key: str, value: _T, size_bytes: int) -> None:
|
|
81
|
+
"""Insert (or replace) ``key``, then evict oldest entries while
|
|
82
|
+
over either limit. ``size_bytes`` must be precomputed by the
|
|
83
|
+
caller BEFORE calling this — see the module docstring; computing
|
|
84
|
+
it here (e.g. from ``value``) would put arbitrary caller-defined
|
|
85
|
+
serialization cost back under the lock, exactly what this class
|
|
86
|
+
exists to avoid.
|
|
87
|
+
|
|
88
|
+
``on_evict`` (if set) is called once per entry evicted by THIS
|
|
89
|
+
call's own count/byte-budget loop — AFTER ``self._lock`` has
|
|
90
|
+
already been released (never while held: a lock this class
|
|
91
|
+
doesn't own must never be acquired while this one is, and
|
|
92
|
+
``ServerState``'s own callback does exactly that with its own
|
|
93
|
+
``self._lock`` — see the inline comment below for the full
|
|
94
|
+
lock-ordering rationale). A replacement of ``key`` itself
|
|
95
|
+
(already present, same key re-inserted) is NOT reported via
|
|
96
|
+
``on_evict`` — that's a value update, not an eviction, and the
|
|
97
|
+
caller performing the ``insert()`` already knows about it
|
|
98
|
+
directly."""
|
|
99
|
+
evicted: list[tuple[str, _T, int]] = []
|
|
100
|
+
with self._lock:
|
|
101
|
+
if key in self._data:
|
|
102
|
+
_, old_size = self._data.pop(key)
|
|
103
|
+
self._total_bytes -= old_size
|
|
104
|
+
self._data[key] = (value, size_bytes)
|
|
105
|
+
self._total_bytes += size_bytes
|
|
106
|
+
while len(self._data) > 1 and (
|
|
107
|
+
len(self._data) > self._max_entries or self._total_bytes > self._max_bytes
|
|
108
|
+
):
|
|
109
|
+
evicted_key, (evicted_value, evicted_size) = self._data.popitem(last=False)
|
|
110
|
+
self._total_bytes -= evicted_size
|
|
111
|
+
if self._on_evict is not None:
|
|
112
|
+
evicted.append((evicted_key, evicted_value, evicted_size))
|
|
113
|
+
# Callbacks fire AFTER releasing the lock (not each iteration inside
|
|
114
|
+
# the loop above): a lock this class doesn't own must never be
|
|
115
|
+
# acquired while this one is held, and ServerState's own callback
|
|
116
|
+
# (§4 of the phase-3 spec) does exactly that (its own `self._lock`).
|
|
117
|
+
# Reporting collected evictions post-unlock avoids any risk of a
|
|
118
|
+
# lock-ordering cycle, at the cost of a caller theoretically
|
|
119
|
+
# observing `on_evict` fire slightly after `insert()`'s own state
|
|
120
|
+
# change is externally visible — irrelevant here, every real
|
|
121
|
+
# `on_evict` caller is on the same single-threaded event loop.
|
|
122
|
+
if self._on_evict is not None:
|
|
123
|
+
for evicted_key, evicted_value, evicted_size in evicted:
|
|
124
|
+
self._on_evict(evicted_key, evicted_value, evicted_size)
|
|
125
|
+
|
|
126
|
+
def remove(self, key: str) -> _T | None:
|
|
127
|
+
"""Explicit point-eviction of ``key`` — the value it held, or
|
|
128
|
+
``None`` if ``key`` wasn't present. Deliberately does NOT invoke
|
|
129
|
+
``on_evict`` (see the module/class docstrings): the caller
|
|
130
|
+
removing a specific key already knows it's doing so and updates
|
|
131
|
+
its own bookkeeping at the call site — ``ServerState``'s soft-cap
|
|
132
|
+
self-eviction (phase-3 spec §4) is the one real caller."""
|
|
133
|
+
with self._lock:
|
|
134
|
+
entry = self._data.pop(key, None)
|
|
135
|
+
if entry is None:
|
|
136
|
+
return None
|
|
137
|
+
value, size_bytes = entry
|
|
138
|
+
self._total_bytes -= size_bytes
|
|
139
|
+
return value
|
|
140
|
+
|
|
141
|
+
def get(self, key: str) -> _T | None:
|
|
142
|
+
"""The value for ``key``, or ``None`` on a miss. A hit touches the
|
|
143
|
+
entry as most-recently-used (moves it to the end) — a genuine LRU
|
|
144
|
+
read, not just a lookup."""
|
|
145
|
+
with self._lock:
|
|
146
|
+
if key not in self._data:
|
|
147
|
+
return None
|
|
148
|
+
self._data.move_to_end(key)
|
|
149
|
+
value, _ = self._data[key]
|
|
150
|
+
return value
|
|
151
|
+
|
|
152
|
+
def __len__(self) -> int:
|
|
153
|
+
with self._lock:
|
|
154
|
+
return len(self._data)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def total_bytes(self) -> int:
|
|
158
|
+
with self._lock:
|
|
159
|
+
return self._total_bytes
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Token-file loading + the static-token ``TokenVerifier`` implementation
|
|
2
|
+
behind ``refigure-mcp --transport http``. See
|
|
3
|
+
``docs/mcp-server/mcp-server-phase3-http-auth/
|
|
4
|
+
mcp-server-phase3-http-auth-2026-08-21.md`` §2 for the design this module
|
|
5
|
+
implements, and architecture doc §6 for the requirements it comes from.
|
|
6
|
+
|
|
7
|
+
This module is a config-loading/auth-verification concern, not a
|
|
8
|
+
conversion-tool concern — its own ``ValueError``s are handled at CLI
|
|
9
|
+
startup (``cli.py``, before ``build_server()`` is even called), never
|
|
10
|
+
routed through ``server.py``'s ``_TYPED_EXCEPTIONS``/``_call_and_wrap_errors``
|
|
11
|
+
(that's for exceptions a live tool call can raise, which this isn't).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hmac
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
|
20
|
+
|
|
21
|
+
from .state import _LOCAL_CALLER_ID
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _normalize(value: str) -> str:
|
|
25
|
+
"""The one normalization function used both when loading the token
|
|
26
|
+
file and (indirectly, via the tuples ``_StaticTokenVerifier`` is built
|
|
27
|
+
from) when verifying a request's bearer token — architecture doc §6:
|
|
28
|
+
"одна функция нормализации ... на стартовую дедупликацию и
|
|
29
|
+
рантайм-проверку". Case-sensitive, ``strip()`` only — no case-folding,
|
|
30
|
+
a bearer token is opaque data, not a human-typed identifier."""
|
|
31
|
+
return value.strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_token_file(path: Path) -> dict[str, str]:
|
|
35
|
+
"""Parse a ``--mcp-auth-token-file``: one ``token = caller_id`` pair
|
|
36
|
+
per non-blank line (exactly one ``=``; no comment syntax — the
|
|
37
|
+
architecture doc doesn't call for one, and this format doesn't need
|
|
38
|
+
it). Returns ``{token: caller_id}``.
|
|
39
|
+
|
|
40
|
+
Raises ``ValueError`` (line number included in the message) on: a
|
|
41
|
+
line with no ``=`` or more than one; an empty token or ``caller_id``
|
|
42
|
+
after normalization; ``caller_id == "__local__"`` (reserved for the
|
|
43
|
+
stdio/in-memory sentinel, architecture doc §6 — accepting it here
|
|
44
|
+
would let a configured HTTP caller collide with the unauthenticated
|
|
45
|
+
local identity); the SAME token value mapped to two DIFFERENT
|
|
46
|
+
``caller_id``s (almost certainly a typo — silently letting the last
|
|
47
|
+
line win would hide a real misconfiguration). The same token mapped
|
|
48
|
+
to the SAME ``caller_id`` twice is a harmless idempotent duplicate,
|
|
49
|
+
not an error. DIFFERENT tokens mapped to the same ``caller_id`` is
|
|
50
|
+
the normal token-rotation case (architecture doc §6) and needs no
|
|
51
|
+
special handling at all — it's just two distinct dict keys."""
|
|
52
|
+
token_map: dict[str, str] = {}
|
|
53
|
+
for lineno, raw_line in enumerate(path.read_text().splitlines(), start=1):
|
|
54
|
+
line = raw_line.strip()
|
|
55
|
+
if not line:
|
|
56
|
+
continue
|
|
57
|
+
if line.count("=") != 1:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"{path}:{lineno}: expected exactly one 'token = caller_id' pair, got {raw_line!r}"
|
|
60
|
+
)
|
|
61
|
+
raw_token, raw_caller_id = line.split("=", 1)
|
|
62
|
+
token = _normalize(raw_token)
|
|
63
|
+
caller_id = _normalize(raw_caller_id)
|
|
64
|
+
if not token or not caller_id:
|
|
65
|
+
raise ValueError(f"{path}:{lineno}: token and caller_id must both be non-empty")
|
|
66
|
+
if caller_id == _LOCAL_CALLER_ID:
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"{path}:{lineno}: caller_id {_LOCAL_CALLER_ID!r} is reserved "
|
|
69
|
+
"for the stdio/unauthenticated identity, not usable in a token file"
|
|
70
|
+
)
|
|
71
|
+
existing = token_map.get(token)
|
|
72
|
+
if existing is not None and existing != caller_id:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"{path}:{lineno}: token already maps to caller_id {existing!r}, "
|
|
75
|
+
f"got a second, different caller_id {caller_id!r} for the same token value"
|
|
76
|
+
)
|
|
77
|
+
token_map[token] = caller_id
|
|
78
|
+
return token_map
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _StaticTokenVerifier(TokenVerifier):
|
|
82
|
+
"""``TokenVerifier`` (SDK protocol, ``mcp.server.auth.provider``) over a
|
|
83
|
+
fixed ``{token: caller_id}`` map loaded once at server startup — no
|
|
84
|
+
live-reload (phase-3 spec, out of scope; architecture doc §6 phrases
|
|
85
|
+
it conditionally, "если появится").
|
|
86
|
+
|
|
87
|
+
Verification is a linear scan with ``hmac.compare_digest`` per
|
|
88
|
+
candidate, NOT a dict lookup by token value — architecture doc §6:
|
|
89
|
+
"сравнение — constant-time". A dict lookup is faster (O(1) vs. O(n))
|
|
90
|
+
but its timing depends on the incoming token's hash/equality path in
|
|
91
|
+
a way ``compare_digest`` is specifically designed to avoid; the token
|
|
92
|
+
list in a self-hosted deployment is small (single/low-double digits),
|
|
93
|
+
so the O(n) scan costs nothing that matters here."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, token_map: dict[str, str]) -> None:
|
|
96
|
+
self._entries: tuple[tuple[str, str], ...] = tuple(token_map.items())
|
|
97
|
+
|
|
98
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
99
|
+
for configured_token, caller_id in self._entries:
|
|
100
|
+
if hmac.compare_digest(token, configured_token):
|
|
101
|
+
return AccessToken(token=token, client_id=caller_id, scopes=[])
|
|
102
|
+
return None
|