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.
Files changed (48) hide show
  1. agent_folder_workspace/__init__.py +42 -0
  2. agent_folder_workspace/__main__.py +6 -0
  3. agent_folder_workspace/adapters/__init__.py +5 -0
  4. agent_folder_workspace/adapters/base.py +93 -0
  5. agent_folder_workspace/adapters/legacy_office.py +552 -0
  6. agent_folder_workspace/adapters/manager.py +178 -0
  7. agent_folder_workspace/adapters/odf.py +636 -0
  8. agent_folder_workspace/adapters/ooxml.py +645 -0
  9. agent_folder_workspace/adapters/pdf.py +680 -0
  10. agent_folder_workspace/adapters/sqlite.py +326 -0
  11. agent_folder_workspace/adapters/structured.py +280 -0
  12. agent_folder_workspace/adapters/text.py +145 -0
  13. agent_folder_workspace/alternative_backends.py +329 -0
  14. agent_folder_workspace/backend_conversion_worker.py +232 -0
  15. agent_folder_workspace/backend_probe_worker.py +347 -0
  16. agent_folder_workspace/backends.py +321 -0
  17. agent_folder_workspace/cache.py +395 -0
  18. agent_folder_workspace/cfb.py +425 -0
  19. agent_folder_workspace/cli.py +139 -0
  20. agent_folder_workspace/config.py +37 -0
  21. agent_folder_workspace/cursors.py +46 -0
  22. agent_folder_workspace/errors.py +27 -0
  23. agent_folder_workspace/ids.py +42 -0
  24. agent_folder_workspace/installer.py +180 -0
  25. agent_folder_workspace/legacy.py +697 -0
  26. agent_folder_workspace/mcp_server.py +179 -0
  27. agent_folder_workspace/models.py +158 -0
  28. agent_folder_workspace/opencode/plugin/agent-folder-workspace.ts.tmpl +41 -0
  29. agent_folder_workspace/opencode/skill/SKILL.md +24 -0
  30. agent_folder_workspace/parser_workers.py +690 -0
  31. agent_folder_workspace/path_safety.py +28 -0
  32. agent_folder_workspace/probe_fixtures/probe.doc +0 -0
  33. agent_folder_workspace/probe_fixtures/probe.ppt +0 -0
  34. agent_folder_workspace/probe_fixtures/probe.xls +0 -0
  35. agent_folder_workspace/py.typed +1 -0
  36. agent_folder_workspace/registry.py +394 -0
  37. agent_folder_workspace/schemas/CapabilityReportV1.schema.json +121 -0
  38. agent_folder_workspace/schemas/ContentPageV1.schema.json +138 -0
  39. agent_folder_workspace/schemas/DiagnosticV1.schema.json +49 -0
  40. agent_folder_workspace/schemas/NodePageV1.schema.json +340 -0
  41. agent_folder_workspace/schemas/NodeV1.schema.json +289 -0
  42. agent_folder_workspace/schemas/SearchPageV1.schema.json +95 -0
  43. agent_folder_workspace/workspace.py +1141 -0
  44. agent_folder_workspace-0.1.0.dist-info/METADATA +407 -0
  45. agent_folder_workspace-0.1.0.dist-info/RECORD +48 -0
  46. agent_folder_workspace-0.1.0.dist-info/WHEEL +4 -0
  47. agent_folder_workspace-0.1.0.dist-info/entry_points.txt +2 -0
  48. 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)