agent-folder-workspace 0.1.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.
- agent_folder_workspace/__init__.py +42 -0
- agent_folder_workspace/__main__.py +6 -0
- agent_folder_workspace/adapters/__init__.py +5 -0
- agent_folder_workspace/adapters/base.py +93 -0
- agent_folder_workspace/adapters/legacy_office.py +552 -0
- agent_folder_workspace/adapters/manager.py +178 -0
- agent_folder_workspace/adapters/odf.py +636 -0
- agent_folder_workspace/adapters/ooxml.py +645 -0
- agent_folder_workspace/adapters/pdf.py +680 -0
- agent_folder_workspace/adapters/sqlite.py +326 -0
- agent_folder_workspace/adapters/structured.py +280 -0
- agent_folder_workspace/adapters/text.py +145 -0
- agent_folder_workspace/alternative_backends.py +329 -0
- agent_folder_workspace/backend_conversion_worker.py +232 -0
- agent_folder_workspace/backend_probe_worker.py +347 -0
- agent_folder_workspace/backends.py +321 -0
- agent_folder_workspace/cache.py +395 -0
- agent_folder_workspace/cfb.py +425 -0
- agent_folder_workspace/cli.py +139 -0
- agent_folder_workspace/config.py +37 -0
- agent_folder_workspace/cursors.py +46 -0
- agent_folder_workspace/errors.py +27 -0
- agent_folder_workspace/ids.py +42 -0
- agent_folder_workspace/installer.py +180 -0
- agent_folder_workspace/legacy.py +697 -0
- agent_folder_workspace/mcp_server.py +179 -0
- agent_folder_workspace/models.py +158 -0
- agent_folder_workspace/opencode/plugin/agent-folder-workspace.ts.tmpl +41 -0
- agent_folder_workspace/opencode/skill/SKILL.md +24 -0
- agent_folder_workspace/parser_workers.py +690 -0
- agent_folder_workspace/path_safety.py +28 -0
- agent_folder_workspace/probe_fixtures/probe.doc +0 -0
- agent_folder_workspace/probe_fixtures/probe.ppt +0 -0
- agent_folder_workspace/probe_fixtures/probe.xls +0 -0
- agent_folder_workspace/py.typed +1 -0
- agent_folder_workspace/registry.py +394 -0
- agent_folder_workspace/schemas/CapabilityReportV1.schema.json +121 -0
- agent_folder_workspace/schemas/ContentPageV1.schema.json +138 -0
- agent_folder_workspace/schemas/DiagnosticV1.schema.json +49 -0
- agent_folder_workspace/schemas/NodePageV1.schema.json +340 -0
- agent_folder_workspace/schemas/NodeV1.schema.json +289 -0
- agent_folder_workspace/schemas/SearchPageV1.schema.json +95 -0
- agent_folder_workspace/workspace.py +1141 -0
- agent_folder_workspace-0.1.0.dist-info/METADATA +407 -0
- agent_folder_workspace-0.1.0.dist-info/RECORD +48 -0
- agent_folder_workspace-0.1.0.dist-info/WHEEL +4 -0
- agent_folder_workspace-0.1.0.dist-info/entry_points.txt +2 -0
- agent_folder_workspace-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Read-only SQLite hierarchy adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import sqlite3
|
|
7
|
+
from contextlib import closing
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from agent_folder_workspace.adapters.base import BaseAdapter, ReadResult, VirtualNodeSpec
|
|
12
|
+
from agent_folder_workspace.config import WorkspaceConfig
|
|
13
|
+
from agent_folder_workspace.errors import InputValidationError
|
|
14
|
+
from agent_folder_workspace.models import NodeKind, NodeV1
|
|
15
|
+
from agent_folder_workspace.path_safety import first_unsafe_component
|
|
16
|
+
|
|
17
|
+
_BLOCK_ROWS = 1_000
|
|
18
|
+
_INLINE_BLOB_BYTES = 64 * 1024
|
|
19
|
+
_KINDS = {
|
|
20
|
+
"tables": ("table", NodeKind.TABLE),
|
|
21
|
+
"views": ("view", NodeKind.VIEW),
|
|
22
|
+
"indexes": ("index", NodeKind.INDEX),
|
|
23
|
+
"triggers": ("trigger", NodeKind.TRIGGER),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _quote_identifier(value: str) -> str:
|
|
28
|
+
return '"' + value.replace('"', '""') + '"'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _wire_value(value: object) -> object:
|
|
32
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
33
|
+
payload = bytes(value)
|
|
34
|
+
return {
|
|
35
|
+
"type": "blob",
|
|
36
|
+
"encoding": "base64",
|
|
37
|
+
"size": len(payload),
|
|
38
|
+
"data_base64": base64.b64encode(payload).decode("ascii"),
|
|
39
|
+
}
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SqliteAdapter(BaseAdapter):
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _authorize(
|
|
46
|
+
action: int,
|
|
47
|
+
argument1: str | None,
|
|
48
|
+
_argument2: str | None,
|
|
49
|
+
_database: str | None,
|
|
50
|
+
_trigger: str | None,
|
|
51
|
+
) -> int:
|
|
52
|
+
allowed = {
|
|
53
|
+
sqlite3.SQLITE_SELECT,
|
|
54
|
+
sqlite3.SQLITE_READ,
|
|
55
|
+
sqlite3.SQLITE_FUNCTION,
|
|
56
|
+
getattr(sqlite3, "SQLITE_RECURSIVE", -1),
|
|
57
|
+
}
|
|
58
|
+
if action == sqlite3.SQLITE_PRAGMA and (argument1 or "").casefold() in {
|
|
59
|
+
"table_info",
|
|
60
|
+
"table_xinfo",
|
|
61
|
+
"index_info",
|
|
62
|
+
"index_list",
|
|
63
|
+
"foreign_key_list",
|
|
64
|
+
}:
|
|
65
|
+
return sqlite3.SQLITE_OK
|
|
66
|
+
return sqlite3.SQLITE_OK if action in allowed else sqlite3.SQLITE_DENY
|
|
67
|
+
|
|
68
|
+
def _connect(self, source: Path) -> sqlite3.Connection:
|
|
69
|
+
for candidate in (
|
|
70
|
+
source,
|
|
71
|
+
Path(f"{source}-journal"),
|
|
72
|
+
Path(f"{source}-wal"),
|
|
73
|
+
Path(f"{source}-shm"),
|
|
74
|
+
):
|
|
75
|
+
unsafe = first_unsafe_component(candidate)
|
|
76
|
+
if unsafe is not None:
|
|
77
|
+
raise InputValidationError(
|
|
78
|
+
f"SQLite database or sidecar contains a link or reparse point: {unsafe}"
|
|
79
|
+
)
|
|
80
|
+
uri = f"file:{quote(source.as_posix(), safe='/')}?mode=ro"
|
|
81
|
+
connection = sqlite3.connect(uri, uri=True)
|
|
82
|
+
connection.row_factory = sqlite3.Row
|
|
83
|
+
connection.execute("PRAGMA query_only=ON")
|
|
84
|
+
connection.set_authorizer(self._authorize)
|
|
85
|
+
return connection
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _column_names(connection: sqlite3.Connection, table_name: str) -> list[str]:
|
|
89
|
+
quoted = _quote_identifier(table_name)
|
|
90
|
+
return [str(row["name"]) for row in connection.execute(f"PRAGMA table_xinfo({quoted})")]
|
|
91
|
+
|
|
92
|
+
def children(
|
|
93
|
+
self, node: NodeV1, source: Path, config: WorkspaceConfig
|
|
94
|
+
) -> list[VirtualNodeSpec]:
|
|
95
|
+
if node.logical_path == "/":
|
|
96
|
+
with closing(self._connect(source)) as connection:
|
|
97
|
+
result = []
|
|
98
|
+
for label, (sqlite_type, _) in _KINDS.items():
|
|
99
|
+
count = connection.execute(
|
|
100
|
+
"SELECT COUNT(*) FROM sqlite_master WHERE type = ? "
|
|
101
|
+
"AND name NOT LIKE 'sqlite_%'",
|
|
102
|
+
(sqlite_type,),
|
|
103
|
+
).fetchone()[0]
|
|
104
|
+
result.append(
|
|
105
|
+
VirtualNodeSpec(
|
|
106
|
+
name=label,
|
|
107
|
+
kind=NodeKind.CONTAINER,
|
|
108
|
+
logical_path=f"/sqlite/{label}",
|
|
109
|
+
child_count=int(count),
|
|
110
|
+
metadata={"sqlite_category": label},
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
return result
|
|
114
|
+
category = node.metadata.get("sqlite_category")
|
|
115
|
+
if node.kind == NodeKind.CONTAINER and isinstance(category, str) and category in _KINDS:
|
|
116
|
+
sqlite_type, node_kind = _KINDS[category]
|
|
117
|
+
with closing(self._connect(source)) as connection:
|
|
118
|
+
rows = connection.execute(
|
|
119
|
+
"SELECT name, sql FROM sqlite_master WHERE type = ? "
|
|
120
|
+
"AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
|
121
|
+
(sqlite_type,),
|
|
122
|
+
).fetchall()
|
|
123
|
+
return [
|
|
124
|
+
VirtualNodeSpec(
|
|
125
|
+
name=str(row["name"]),
|
|
126
|
+
kind=node_kind,
|
|
127
|
+
logical_path=f"/sqlite/{category}/{row['name']}",
|
|
128
|
+
metadata={
|
|
129
|
+
"sqlite_category": category,
|
|
130
|
+
"sqlite_name": str(row["name"]),
|
|
131
|
+
"sqlite_sql": row["sql"],
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
for row in rows
|
|
135
|
+
]
|
|
136
|
+
table_name = node.metadata.get("sqlite_name")
|
|
137
|
+
if node.kind == NodeKind.ROW_BLOCK and isinstance(table_name, str):
|
|
138
|
+
start = int(node.metadata.get("start", 0))
|
|
139
|
+
end = int(node.metadata.get("end", start))
|
|
140
|
+
quoted_table = _quote_identifier(table_name)
|
|
141
|
+
with closing(self._connect(source)) as connection:
|
|
142
|
+
columns = self._column_names(connection, table_name)
|
|
143
|
+
if not columns:
|
|
144
|
+
return []
|
|
145
|
+
projection = ", ".join(
|
|
146
|
+
f"typeof({_quote_identifier(column)}) AS {_quote_identifier(f'type_{index}')}, "
|
|
147
|
+
f"length({_quote_identifier(column)}) AS {_quote_identifier(f'length_{index}')}"
|
|
148
|
+
for index, column in enumerate(columns)
|
|
149
|
+
)
|
|
150
|
+
rows = connection.execute(
|
|
151
|
+
f"SELECT {projection} FROM {quoted_table} LIMIT ? OFFSET ?", # noqa: S608
|
|
152
|
+
(max(end - start, 0), start),
|
|
153
|
+
).fetchall()
|
|
154
|
+
specs: list[VirtualNodeSpec] = []
|
|
155
|
+
for relative_row, row in enumerate(rows):
|
|
156
|
+
absolute_row = start + relative_row
|
|
157
|
+
for column_index, column in enumerate(columns):
|
|
158
|
+
if row[f"type_{column_index}"] != "blob":
|
|
159
|
+
continue
|
|
160
|
+
size = int(row[f"length_{column_index}"] or 0)
|
|
161
|
+
if size <= _INLINE_BLOB_BYTES:
|
|
162
|
+
continue
|
|
163
|
+
specs.append(
|
|
164
|
+
VirtualNodeSpec(
|
|
165
|
+
name=f"Row {absolute_row + 1} / {column}",
|
|
166
|
+
kind=NodeKind.STREAM,
|
|
167
|
+
logical_path=f"{node.logical_path}/blob/{absolute_row}/{column_index}",
|
|
168
|
+
size=size,
|
|
169
|
+
metadata={
|
|
170
|
+
"sqlite_blob": True,
|
|
171
|
+
"sqlite_name": table_name,
|
|
172
|
+
"sqlite_column": column,
|
|
173
|
+
"sqlite_row_offset": absolute_row,
|
|
174
|
+
},
|
|
175
|
+
source_part=f"sqlite:blob:{table_name}:{absolute_row}:{column}",
|
|
176
|
+
source_offset=0,
|
|
177
|
+
source_length=size,
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
return specs
|
|
181
|
+
if node.metadata.get("sqlite_columns") is True and isinstance(table_name, str):
|
|
182
|
+
quoted = _quote_identifier(table_name)
|
|
183
|
+
with closing(self._connect(source)) as connection:
|
|
184
|
+
rows = connection.execute(f"PRAGMA table_xinfo({quoted})").fetchall()
|
|
185
|
+
return [
|
|
186
|
+
VirtualNodeSpec(
|
|
187
|
+
name=str(row["name"]),
|
|
188
|
+
kind=NodeKind.COLUMN,
|
|
189
|
+
logical_path=f"{node.logical_path}/{row['cid']}",
|
|
190
|
+
child_count=0,
|
|
191
|
+
metadata={
|
|
192
|
+
"sqlite_name": table_name,
|
|
193
|
+
"column_id": int(row["cid"]),
|
|
194
|
+
"declared_type": str(row["type"] or ""),
|
|
195
|
+
"not_null": bool(row["notnull"]),
|
|
196
|
+
"default": row["dflt_value"],
|
|
197
|
+
"primary_key": int(row["pk"]),
|
|
198
|
+
"hidden": int(row["hidden"]),
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
for row in rows
|
|
202
|
+
]
|
|
203
|
+
if node.kind in {NodeKind.TABLE, NodeKind.VIEW} and isinstance(table_name, str):
|
|
204
|
+
quoted = _quote_identifier(table_name)
|
|
205
|
+
with closing(self._connect(source)) as connection:
|
|
206
|
+
# SQLite cannot bind identifiers; ``quoted`` escapes every quote.
|
|
207
|
+
count = int(
|
|
208
|
+
connection.execute(
|
|
209
|
+
f"SELECT COUNT(*) FROM {quoted}" # noqa: S608
|
|
210
|
+
).fetchone()[0]
|
|
211
|
+
)
|
|
212
|
+
return [
|
|
213
|
+
VirtualNodeSpec(
|
|
214
|
+
name="columns",
|
|
215
|
+
kind=NodeKind.CONTAINER,
|
|
216
|
+
logical_path=f"{node.logical_path}/columns",
|
|
217
|
+
metadata={"sqlite_name": table_name, "sqlite_columns": True},
|
|
218
|
+
),
|
|
219
|
+
*[
|
|
220
|
+
VirtualNodeSpec(
|
|
221
|
+
name=f"Rows {start + 1}-{min(start + _BLOCK_ROWS, count)}",
|
|
222
|
+
kind=NodeKind.ROW_BLOCK,
|
|
223
|
+
logical_path=(
|
|
224
|
+
f"{node.logical_path}/rows/{start}:{min(start + _BLOCK_ROWS, count)}"
|
|
225
|
+
),
|
|
226
|
+
metadata={
|
|
227
|
+
"sqlite_name": table_name,
|
|
228
|
+
"start": start,
|
|
229
|
+
"end": min(start + _BLOCK_ROWS, count),
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
for start in range(0, count, _BLOCK_ROWS)
|
|
233
|
+
],
|
|
234
|
+
]
|
|
235
|
+
return []
|
|
236
|
+
|
|
237
|
+
def read(
|
|
238
|
+
self,
|
|
239
|
+
node: NodeV1,
|
|
240
|
+
source: Path,
|
|
241
|
+
offset: int,
|
|
242
|
+
limit: int,
|
|
243
|
+
config: WorkspaceConfig,
|
|
244
|
+
) -> ReadResult:
|
|
245
|
+
table_name = node.metadata.get("sqlite_name")
|
|
246
|
+
if node.kind == NodeKind.ROW_BLOCK and isinstance(table_name, str):
|
|
247
|
+
start = int(node.metadata.get("start", 0))
|
|
248
|
+
end = int(node.metadata.get("end", start))
|
|
249
|
+
row_limit = min(limit, max(end - start - offset, 0))
|
|
250
|
+
quoted = _quote_identifier(table_name)
|
|
251
|
+
with closing(self._connect(source)) as connection:
|
|
252
|
+
columns = self._column_names(connection, table_name)
|
|
253
|
+
projections = []
|
|
254
|
+
for index, column in enumerate(columns):
|
|
255
|
+
quoted_column = _quote_identifier(column)
|
|
256
|
+
projections.append(
|
|
257
|
+
"CASE WHEN "
|
|
258
|
+
f"typeof({quoted_column}) = 'blob' AND "
|
|
259
|
+
f"length({quoted_column}) > {_INLINE_BLOB_BYTES} "
|
|
260
|
+
f"THEN NULL ELSE {quoted_column} END AS {quoted_column}"
|
|
261
|
+
)
|
|
262
|
+
projections.append(
|
|
263
|
+
"CASE WHEN "
|
|
264
|
+
f"typeof({quoted_column}) = 'blob' AND "
|
|
265
|
+
f"length({quoted_column}) > {_INLINE_BLOB_BYTES} "
|
|
266
|
+
f"THEN length({quoted_column}) ELSE NULL END AS "
|
|
267
|
+
f"{_quote_identifier(f'__folderws_blob_size_{index}')}"
|
|
268
|
+
)
|
|
269
|
+
# SQLite cannot bind identifiers; ``quoted`` escapes every quote.
|
|
270
|
+
rows = connection.execute(
|
|
271
|
+
f"SELECT {', '.join(projections)} FROM {quoted} LIMIT ? OFFSET ?", # noqa: S608
|
|
272
|
+
(row_limit, start + offset),
|
|
273
|
+
).fetchall()
|
|
274
|
+
items = []
|
|
275
|
+
for relative_row, row in enumerate(rows):
|
|
276
|
+
item: dict[str, object] = {}
|
|
277
|
+
absolute_row = start + offset + relative_row
|
|
278
|
+
for index, column in enumerate(columns):
|
|
279
|
+
blob_size = row[f"__folderws_blob_size_{index}"]
|
|
280
|
+
if blob_size is not None:
|
|
281
|
+
item[column] = {
|
|
282
|
+
"type": "blob",
|
|
283
|
+
"size": int(blob_size),
|
|
284
|
+
"content_access": "list_children",
|
|
285
|
+
"child_name": f"Row {absolute_row + 1} / {column}",
|
|
286
|
+
}
|
|
287
|
+
else:
|
|
288
|
+
item[column] = _wire_value(row[column])
|
|
289
|
+
items.append(item)
|
|
290
|
+
return ReadResult(
|
|
291
|
+
items=items,
|
|
292
|
+
total=end - start,
|
|
293
|
+
)
|
|
294
|
+
sql = node.metadata.get("sqlite_sql")
|
|
295
|
+
if node.kind == NodeKind.COLUMN:
|
|
296
|
+
return ReadResult(items=[dict(node.metadata)] if offset == 0 else [], total=1)
|
|
297
|
+
return ReadResult(items=[{"sql": sql}] if sql else [], total=1 if sql else 0)
|
|
298
|
+
|
|
299
|
+
def read_binary_part(
|
|
300
|
+
self,
|
|
301
|
+
node: NodeV1,
|
|
302
|
+
source: Path,
|
|
303
|
+
offset: int,
|
|
304
|
+
length: int,
|
|
305
|
+
config: WorkspaceConfig,
|
|
306
|
+
) -> tuple[bytes, int]:
|
|
307
|
+
if node.metadata.get("sqlite_blob") is not True:
|
|
308
|
+
return super().read_binary_part(node, source, offset, length, config)
|
|
309
|
+
table_name = str(node.metadata["sqlite_name"])
|
|
310
|
+
column = str(node.metadata["sqlite_column"])
|
|
311
|
+
row_offset = int(node.metadata["sqlite_row_offset"])
|
|
312
|
+
quoted_table = _quote_identifier(table_name)
|
|
313
|
+
quoted_column = _quote_identifier(column)
|
|
314
|
+
with closing(self._connect(source)) as connection:
|
|
315
|
+
row = connection.execute(
|
|
316
|
+
f"SELECT length({quoted_column}) AS size, " # noqa: S608
|
|
317
|
+
f"substr({quoted_column}, ?, ?) AS chunk FROM {quoted_table} "
|
|
318
|
+
"LIMIT 1 OFFSET ?",
|
|
319
|
+
(offset + 1, length, row_offset),
|
|
320
|
+
).fetchone()
|
|
321
|
+
if row is None or row["size"] is None:
|
|
322
|
+
return b"", 0
|
|
323
|
+
payload = row["chunk"]
|
|
324
|
+
if not isinstance(payload, bytes):
|
|
325
|
+
payload = bytes(payload or b"")
|
|
326
|
+
return payload, int(row["size"])
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Safe hierarchical adapters for JSON, YAML, XML, and HTML."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections import Counter, defaultdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml # type: ignore[import-untyped]
|
|
11
|
+
from defusedxml import ElementTree as SafeElementTree # type: ignore[import-untyped]
|
|
12
|
+
from defusedxml.common import DefusedXmlException # type: ignore[import-untyped]
|
|
13
|
+
from lxml import etree, html # type: ignore[import-untyped]
|
|
14
|
+
|
|
15
|
+
from agent_folder_workspace.adapters.base import BaseAdapter, ReadResult, VirtualNodeSpec
|
|
16
|
+
from agent_folder_workspace.config import WorkspaceConfig
|
|
17
|
+
from agent_folder_workspace.errors import InputValidationError, ResourceLimitError
|
|
18
|
+
from agent_folder_workspace.models import NodeKind, NodeV1
|
|
19
|
+
|
|
20
|
+
_BLOCK_ROWS = 1_000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _bounded_bytes(source: Path, config: WorkspaceConfig) -> bytes:
|
|
24
|
+
size = source.stat().st_size
|
|
25
|
+
if size > config.max_xml_bytes:
|
|
26
|
+
raise ResourceLimitError(f"structured text exceeds {config.max_xml_bytes} bytes")
|
|
27
|
+
with source.open("rb") as handle:
|
|
28
|
+
return handle.read(config.max_xml_bytes + 1)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _node_kind(value: Any) -> NodeKind:
|
|
32
|
+
return NodeKind.CONTAINER if isinstance(value, (dict, list)) else NodeKind.RECORD
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _path_token(value: object) -> str:
|
|
36
|
+
return str(value).replace("~", "~0").replace("/", "~1")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StructuredTextAdapter(BaseAdapter):
|
|
40
|
+
def _format(self, source: Path) -> str:
|
|
41
|
+
suffix = source.suffix.casefold()
|
|
42
|
+
if suffix in {".yaml", ".yml"}:
|
|
43
|
+
return "yaml"
|
|
44
|
+
if suffix == ".xml":
|
|
45
|
+
return "xml"
|
|
46
|
+
if suffix in {".html", ".htm"}:
|
|
47
|
+
return "html"
|
|
48
|
+
if suffix == ".jsonl":
|
|
49
|
+
return "jsonl"
|
|
50
|
+
return "json"
|
|
51
|
+
|
|
52
|
+
def _structured_data(self, source: Path, config: WorkspaceConfig) -> Any:
|
|
53
|
+
payload = _bounded_bytes(source, config).decode("utf-8-sig", errors="strict")
|
|
54
|
+
try:
|
|
55
|
+
if self._format(source) == "yaml":
|
|
56
|
+
return yaml.safe_load(payload)
|
|
57
|
+
if self._format(source) == "jsonl":
|
|
58
|
+
return [json.loads(line) for line in payload.splitlines() if line.strip()]
|
|
59
|
+
return json.loads(payload)
|
|
60
|
+
except (UnicodeError, json.JSONDecodeError, yaml.YAMLError) as error:
|
|
61
|
+
raise InputValidationError(f"invalid structured text: {source.name}") from error
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _resolve(value: Any, path: list[object]) -> Any:
|
|
65
|
+
current = value
|
|
66
|
+
for component in path:
|
|
67
|
+
current = current[component]
|
|
68
|
+
return current
|
|
69
|
+
|
|
70
|
+
def _data_children(
|
|
71
|
+
self, node: NodeV1, source: Path, config: WorkspaceConfig
|
|
72
|
+
) -> list[VirtualNodeSpec]:
|
|
73
|
+
data = self._structured_data(source, config)
|
|
74
|
+
path = list(node.metadata.get("structured_path", []))
|
|
75
|
+
value = self._resolve(data, path)
|
|
76
|
+
if isinstance(value, dict):
|
|
77
|
+
return [
|
|
78
|
+
VirtualNodeSpec(
|
|
79
|
+
name=str(key),
|
|
80
|
+
kind=_node_kind(child),
|
|
81
|
+
logical_path="/data/" + "/".join(_path_token(part) for part in [*path, key]),
|
|
82
|
+
child_count=len(child) if isinstance(child, (dict, list)) else 0,
|
|
83
|
+
metadata={"structured_path": [*path, key]},
|
|
84
|
+
)
|
|
85
|
+
for key, child in value.items()
|
|
86
|
+
]
|
|
87
|
+
if isinstance(value, list):
|
|
88
|
+
if len(value) > _BLOCK_ROWS:
|
|
89
|
+
return [
|
|
90
|
+
VirtualNodeSpec(
|
|
91
|
+
name=f"Items {start + 1}-{min(start + _BLOCK_ROWS, len(value))}",
|
|
92
|
+
kind=NodeKind.ROW_BLOCK,
|
|
93
|
+
logical_path=(
|
|
94
|
+
"/data/"
|
|
95
|
+
+ "/".join(_path_token(part) for part in path)
|
|
96
|
+
+ f"/~block/{start}:{min(start + _BLOCK_ROWS, len(value))}"
|
|
97
|
+
),
|
|
98
|
+
child_count=0,
|
|
99
|
+
metadata={
|
|
100
|
+
"structured_path": path,
|
|
101
|
+
"structured_block": True,
|
|
102
|
+
"start": start,
|
|
103
|
+
"end": min(start + _BLOCK_ROWS, len(value)),
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
for start in range(0, len(value), _BLOCK_ROWS)
|
|
107
|
+
]
|
|
108
|
+
return [
|
|
109
|
+
VirtualNodeSpec(
|
|
110
|
+
name=f"[{index}]",
|
|
111
|
+
kind=_node_kind(child),
|
|
112
|
+
logical_path="/data/" + "/".join(_path_token(part) for part in [*path, index]),
|
|
113
|
+
child_count=len(child) if isinstance(child, (dict, list)) else 0,
|
|
114
|
+
metadata={"structured_path": [*path, index]},
|
|
115
|
+
)
|
|
116
|
+
for index, child in enumerate(value)
|
|
117
|
+
]
|
|
118
|
+
return []
|
|
119
|
+
|
|
120
|
+
def _xml_root(self, source: Path, config: WorkspaceConfig) -> Any:
|
|
121
|
+
payload = _bounded_bytes(source, config)
|
|
122
|
+
try:
|
|
123
|
+
return SafeElementTree.fromstring(payload)
|
|
124
|
+
except DefusedXmlException as error:
|
|
125
|
+
raise InputValidationError("unsafe XML entity or declaration blocked") from error
|
|
126
|
+
except SafeElementTree.ParseError as error:
|
|
127
|
+
raise InputValidationError(f"invalid XML: {source.name}") from error
|
|
128
|
+
|
|
129
|
+
def _html_root(self, source: Path, config: WorkspaceConfig) -> etree._Element:
|
|
130
|
+
payload = _bounded_bytes(source, config)
|
|
131
|
+
parser = html.HTMLParser(no_network=True, recover=True)
|
|
132
|
+
try:
|
|
133
|
+
return html.fromstring(payload, parser=parser)
|
|
134
|
+
except (etree.ParserError, ValueError) as error:
|
|
135
|
+
raise InputValidationError(f"invalid HTML: {source.name}") from error
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _element(root: Any, path: list[int]) -> Any:
|
|
139
|
+
current = root
|
|
140
|
+
for index in path:
|
|
141
|
+
current = list(current)[index]
|
|
142
|
+
return current
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _tag(element: Any) -> str:
|
|
146
|
+
tag = element.tag
|
|
147
|
+
if isinstance(tag, str) and tag.startswith("{"):
|
|
148
|
+
return tag.split("}", 1)[1]
|
|
149
|
+
return str(tag)
|
|
150
|
+
|
|
151
|
+
def _element_children(
|
|
152
|
+
self,
|
|
153
|
+
node: NodeV1,
|
|
154
|
+
source: Path,
|
|
155
|
+
config: WorkspaceConfig,
|
|
156
|
+
format_name: str,
|
|
157
|
+
) -> list[VirtualNodeSpec]:
|
|
158
|
+
root = (
|
|
159
|
+
self._xml_root(source, config)
|
|
160
|
+
if format_name == "xml"
|
|
161
|
+
else self._html_root(source, config)
|
|
162
|
+
)
|
|
163
|
+
if node.logical_path == "/":
|
|
164
|
+
return [
|
|
165
|
+
VirtualNodeSpec(
|
|
166
|
+
name=self._tag(root),
|
|
167
|
+
kind=NodeKind.CONTAINER,
|
|
168
|
+
logical_path=f"/{format_name}/root",
|
|
169
|
+
child_count=len(list(root)),
|
|
170
|
+
metadata={"element_path": [], "element_format": format_name},
|
|
171
|
+
)
|
|
172
|
+
]
|
|
173
|
+
path = [int(item) for item in node.metadata.get("element_path", [])]
|
|
174
|
+
element = self._element(root, path)
|
|
175
|
+
children = list(element)
|
|
176
|
+
if len(children) > _BLOCK_ROWS:
|
|
177
|
+
return [
|
|
178
|
+
VirtualNodeSpec(
|
|
179
|
+
name=f"Elements {start + 1}-{min(start + _BLOCK_ROWS, len(children))}",
|
|
180
|
+
kind=NodeKind.ROW_BLOCK,
|
|
181
|
+
logical_path=(
|
|
182
|
+
f"/{format_name}/"
|
|
183
|
+
+ "/".join(str(item) for item in path)
|
|
184
|
+
+ f"/~block/{start}:{min(start + _BLOCK_ROWS, len(children))}"
|
|
185
|
+
),
|
|
186
|
+
child_count=0,
|
|
187
|
+
metadata={
|
|
188
|
+
"element_path": path,
|
|
189
|
+
"element_format": format_name,
|
|
190
|
+
"element_block": True,
|
|
191
|
+
"start": start,
|
|
192
|
+
"end": min(start + _BLOCK_ROWS, len(children)),
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
for start in range(0, len(children), _BLOCK_ROWS)
|
|
196
|
+
]
|
|
197
|
+
totals = Counter(self._tag(child) for child in children)
|
|
198
|
+
positions: dict[str, int] = defaultdict(int)
|
|
199
|
+
specs = []
|
|
200
|
+
for index, child in enumerate(children):
|
|
201
|
+
tag = self._tag(child)
|
|
202
|
+
positions[tag] += 1
|
|
203
|
+
name = f"{tag}[{positions[tag]}]" if totals[tag] > 1 else tag
|
|
204
|
+
child_path = [*path, index]
|
|
205
|
+
specs.append(
|
|
206
|
+
VirtualNodeSpec(
|
|
207
|
+
name=name,
|
|
208
|
+
kind=NodeKind.CONTAINER if len(list(child)) else NodeKind.RECORD,
|
|
209
|
+
logical_path=f"/{format_name}/" + "/".join(str(item) for item in child_path),
|
|
210
|
+
child_count=len(list(child)),
|
|
211
|
+
metadata={"element_path": child_path, "element_format": format_name},
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
return specs
|
|
215
|
+
|
|
216
|
+
def children(
|
|
217
|
+
self, node: NodeV1, source: Path, config: WorkspaceConfig
|
|
218
|
+
) -> list[VirtualNodeSpec]:
|
|
219
|
+
format_name = self._format(source)
|
|
220
|
+
if format_name in {"json", "jsonl", "yaml"}:
|
|
221
|
+
return self._data_children(node, source, config)
|
|
222
|
+
return self._element_children(node, source, config, format_name)
|
|
223
|
+
|
|
224
|
+
def read(
|
|
225
|
+
self,
|
|
226
|
+
node: NodeV1,
|
|
227
|
+
source: Path,
|
|
228
|
+
offset: int,
|
|
229
|
+
limit: int,
|
|
230
|
+
config: WorkspaceConfig,
|
|
231
|
+
) -> ReadResult:
|
|
232
|
+
format_name = self._format(source)
|
|
233
|
+
if format_name in {"json", "jsonl", "yaml"}:
|
|
234
|
+
data = self._structured_data(source, config)
|
|
235
|
+
path = list(node.metadata.get("structured_path", []))
|
|
236
|
+
value = self._resolve(data, path)
|
|
237
|
+
if node.metadata.get("structured_block") is True:
|
|
238
|
+
if not isinstance(value, list):
|
|
239
|
+
raise InputValidationError("structured block source is not a list")
|
|
240
|
+
start = int(node.metadata.get("start", 0))
|
|
241
|
+
end = int(node.metadata.get("end", start))
|
|
242
|
+
selected_start = start + offset
|
|
243
|
+
selected = value[selected_start : min(selected_start + limit, end)]
|
|
244
|
+
return ReadResult(items=selected, total=end - start)
|
|
245
|
+
if isinstance(value, list):
|
|
246
|
+
return ReadResult(items=value[offset : offset + limit], total=len(value))
|
|
247
|
+
if isinstance(value, dict):
|
|
248
|
+
items = [{"key": key, "value": child} for key, child in value.items()]
|
|
249
|
+
return ReadResult(items=items[offset : offset + limit], total=len(items))
|
|
250
|
+
return ReadResult(items=[value] if offset == 0 else [], total=1)
|
|
251
|
+
root = (
|
|
252
|
+
self._xml_root(source, config)
|
|
253
|
+
if format_name == "xml"
|
|
254
|
+
else self._html_root(source, config)
|
|
255
|
+
)
|
|
256
|
+
path = [int(item) for item in node.metadata.get("element_path", [])]
|
|
257
|
+
element = self._element(root, path)
|
|
258
|
+
if node.metadata.get("element_block") is True:
|
|
259
|
+
children = list(element)
|
|
260
|
+
start = int(node.metadata.get("start", 0))
|
|
261
|
+
end = int(node.metadata.get("end", start))
|
|
262
|
+
selected_start = start + offset
|
|
263
|
+
items = []
|
|
264
|
+
for child in children[selected_start : min(selected_start + limit, end)]:
|
|
265
|
+
items.append(
|
|
266
|
+
{
|
|
267
|
+
"tag": self._tag(child),
|
|
268
|
+
"attributes": dict(child.attrib),
|
|
269
|
+
"text": child.text.strip() if child.text and child.text.strip() else None,
|
|
270
|
+
"tail": child.tail.strip() if child.tail and child.tail.strip() else None,
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
return ReadResult(items=items, total=end - start)
|
|
274
|
+
item = {
|
|
275
|
+
"tag": self._tag(element),
|
|
276
|
+
"attributes": dict(element.attrib),
|
|
277
|
+
"text": element.text.strip() if element.text and element.text.strip() else None,
|
|
278
|
+
"tail": element.tail.strip() if element.tail and element.tail.strip() else None,
|
|
279
|
+
}
|
|
280
|
+
return ReadResult(items=[item] if offset == 0 else [], total=1)
|