kd-cli 2.0.4__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.
- kd/__init__.py +45 -0
- kd/api/__init__.py +15 -0
- kd/api/articles.py +232 -0
- kd/api/http.py +234 -0
- kd/api/hub.py +69 -0
- kd/api/saved_queries.py +76 -0
- kd/api/tags.py +107 -0
- kd/api/youtrack.py +414 -0
- kd/article_marker.py +348 -0
- kd/auth.py +96 -0
- kd/cli/__init__.py +5 -0
- kd/cli/core/__init__.py +25 -0
- kd/cli/core/command_base.py +133 -0
- kd/cli/core/command_parser.py +246 -0
- kd/cli/core/namespaces.py +175 -0
- kd/cli/core/option_registry.py +265 -0
- kd/cli/input.py +42 -0
- kd/cli/main.py +612 -0
- kd/cli/namespaces/__init__.py +38 -0
- kd/cli/namespaces/alias/__init__.py +87 -0
- kd/cli/namespaces/article/__init__.py +843 -0
- kd/cli/namespaces/init.py +42 -0
- kd/cli/namespaces/issue/__init__.py +959 -0
- kd/cli/namespaces/primer/__init__.py +337 -0
- kd/cli/namespaces/project/__init__.py +243 -0
- kd/cli/namespaces/query/__init__.py +465 -0
- kd/cli/namespaces/search/__init__.py +82 -0
- kd/cli/namespaces/system/__init__.py +528 -0
- kd/cli/namespaces/tag/__init__.py +337 -0
- kd/cli/namespaces/time/__init__.py +307 -0
- kd/cli/namespaces/user/__init__.py +99 -0
- kd/cli/output.py +287 -0
- kd/cli/tree.py +65 -0
- kd/client.py +1953 -0
- kd/config.py +853 -0
- kd/connection.py +156 -0
- kd/debug.py +107 -0
- kd/exceptions.py +189 -0
- kd/history.py +169 -0
- kd/models/__init__.py +75 -0
- kd/models/article.py +64 -0
- kd/models/assist.py +47 -0
- kd/models/attachment.py +20 -0
- kd/models/comment.py +25 -0
- kd/models/common.py +39 -0
- kd/models/issue.py +67 -0
- kd/models/project.py +60 -0
- kd/models/saved_query.py +54 -0
- kd/models/search.py +20 -0
- kd/models/tag.py +30 -0
- kd/models/user.py +42 -0
- kd/models/work_item.py +59 -0
- kd/primer.py +231 -0
- kd/query_parser.py +181 -0
- kd/resources/__init__.py +1 -0
- kd/resources/command-synonyms.yaml +19 -0
- kd/resources/config-template.yaml +71 -0
- kd/resources/error-messages.yaml +101 -0
- kd_cli-2.0.4.dist-info/METADATA +149 -0
- kd_cli-2.0.4.dist-info/RECORD +64 -0
- kd_cli-2.0.4.dist-info/WHEEL +5 -0
- kd_cli-2.0.4.dist-info/entry_points.txt +2 -0
- kd_cli-2.0.4.dist-info/licenses/LICENSE +21 -0
- kd_cli-2.0.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""User namespace: /user
|
|
2
|
+
|
|
3
|
+
Commands: list, create, delete
|
|
4
|
+
API layer: Hub REST API (/hub/api/rest/users)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from kd.cli.core.command_base import Command, CommandResult
|
|
12
|
+
from kd.cli.core.namespaces import Namespace
|
|
13
|
+
from kd.cli.core.option_registry import ResolvedOptions
|
|
14
|
+
from kd.exceptions import ValidationError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class UserListCommand(Command):
|
|
18
|
+
"""List users."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
super().__init__(
|
|
22
|
+
"list",
|
|
23
|
+
"List users",
|
|
24
|
+
requires_client=True,
|
|
25
|
+
usage="kd /user list",
|
|
26
|
+
examples=["kd /user list"],
|
|
27
|
+
see_also=["/user create"],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def execute(
|
|
31
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
32
|
+
) -> CommandResult:
|
|
33
|
+
users = client.users.list()
|
|
34
|
+
return [
|
|
35
|
+
{
|
|
36
|
+
"login": u.login,
|
|
37
|
+
"name": u.name,
|
|
38
|
+
"email": u.email or "",
|
|
39
|
+
"banned": u.banned,
|
|
40
|
+
}
|
|
41
|
+
for u in users
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class UserCreateCommand(Command):
|
|
46
|
+
"""Create a user."""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
super().__init__(
|
|
50
|
+
"create",
|
|
51
|
+
"Create a user",
|
|
52
|
+
requires_client=True,
|
|
53
|
+
usage="kd /user create LOGIN NAME EMAIL",
|
|
54
|
+
examples=['kd /user create testbot "Test Bot" testbot@example.com'],
|
|
55
|
+
see_also=["/user list", "/user delete"],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def execute(
|
|
59
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
60
|
+
) -> CommandResult:
|
|
61
|
+
if len(args) < 3:
|
|
62
|
+
raise ValidationError("Usage: kd /user create LOGIN NAME EMAIL")
|
|
63
|
+
user = client.users.create(login=args[0], name=args[1], email=args[2])
|
|
64
|
+
return {
|
|
65
|
+
"login": user.login,
|
|
66
|
+
"name": user.name,
|
|
67
|
+
"email": user.email or "",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class UserDeleteCommand(Command):
|
|
72
|
+
"""Delete a user."""
|
|
73
|
+
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
super().__init__(
|
|
76
|
+
"delete",
|
|
77
|
+
"Delete a user",
|
|
78
|
+
requires_client=True,
|
|
79
|
+
usage="kd /user delete HUB_USER_ID",
|
|
80
|
+
examples=["kd /user delete abc-123"],
|
|
81
|
+
see_also=["/user list"],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def execute(
|
|
85
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
86
|
+
) -> CommandResult:
|
|
87
|
+
if not args:
|
|
88
|
+
raise ValidationError("Usage: kd /user delete HUB_USER_ID")
|
|
89
|
+
client.users.delete(args[0])
|
|
90
|
+
return {"ok": True, "action": "delete", "user": args[0]}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def create_user_namespace() -> Namespace:
|
|
94
|
+
"""Build the /user namespace tree."""
|
|
95
|
+
ns = Namespace("user", "User management", path="user")
|
|
96
|
+
ns.register_command("list", UserListCommand())
|
|
97
|
+
ns.register_command("create", UserCreateCommand())
|
|
98
|
+
ns.register_command("delete", UserDeleteCommand())
|
|
99
|
+
return ns
|
kd/cli/output.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Output formatting.
|
|
2
|
+
|
|
3
|
+
Renders command results in multiple formats:
|
|
4
|
+
- table: human-readable tabular output (default for TTY)
|
|
5
|
+
- json: JSON output for piping to jq
|
|
6
|
+
- yaml: YAML output
|
|
7
|
+
- md: Markdown table
|
|
8
|
+
|
|
9
|
+
Auto-detects TTY vs pipe: uses table format for interactive terminals,
|
|
10
|
+
falls back to json when piped.
|
|
11
|
+
|
|
12
|
+
Value normalization runs before serialization: epoch-ms timestamps become
|
|
13
|
+
ISO 8601, enums become their ``.value``, and nested collections render
|
|
14
|
+
cleanly in every format.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
from tabulate import tabulate
|
|
26
|
+
|
|
27
|
+
from kd.cli.core.command_base import CommandResult
|
|
28
|
+
from kd.cli.tree import TreeNode, render_tree
|
|
29
|
+
|
|
30
|
+
# Fields whose int values are Unix-epoch milliseconds.
|
|
31
|
+
TIMESTAMP_FIELDS = frozenset({"created", "updated", "resolved"})
|
|
32
|
+
|
|
33
|
+
# Fields whose int values are epoch-ms but semantically a date (no time component).
|
|
34
|
+
DATE_FIELDS = frozenset({"date"})
|
|
35
|
+
|
|
36
|
+
# Threshold: timestamps below this are normal ints, not epoch-ms.
|
|
37
|
+
# 1_000_000_000_000 ms ≈ 2001-09-09 — all YouTrack timestamps exceed this.
|
|
38
|
+
_EPOCH_MS_FLOOR = 1_000_000_000_000
|
|
39
|
+
|
|
40
|
+
# Detail-table scalars longer than this (or containing newlines) render as
|
|
41
|
+
# separate unindented blocks rather than being padded into the key column by
|
|
42
|
+
# tabulate. 80 matches the common terminal width; shorter values fit inline
|
|
43
|
+
# without padding damage.
|
|
44
|
+
_LONG_TEXT_THRESHOLD = 80
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_long_text(value: Any) -> bool:
|
|
48
|
+
"""Detect scalar strings that should render as their own block."""
|
|
49
|
+
return isinstance(value, str) and ("\n" in value or len(value) > _LONG_TEXT_THRESHOLD)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _format_timestamp(epoch_ms: int) -> str:
|
|
53
|
+
"""Convert Unix-epoch milliseconds to ``YYYY-MM-DD HH:MM UTC``."""
|
|
54
|
+
dt = datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc)
|
|
55
|
+
return dt.strftime("%Y-%m-%d %H:%M UTC")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _format_date(epoch_ms: int) -> str:
|
|
59
|
+
"""Convert Unix-epoch milliseconds to ``YYYY-MM-DD`` (UTC)."""
|
|
60
|
+
dt = datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc)
|
|
61
|
+
return dt.strftime("%Y-%m-%d")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class OutputFormatter:
|
|
65
|
+
"""Formats CommandResult for display."""
|
|
66
|
+
|
|
67
|
+
def format(
|
|
68
|
+
self,
|
|
69
|
+
data: CommandResult,
|
|
70
|
+
fmt: str | None = None,
|
|
71
|
+
fields: str | None = None,
|
|
72
|
+
display_hint: str | None = None,
|
|
73
|
+
) -> str:
|
|
74
|
+
"""Format data for output.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
data: Structured data from a command.
|
|
78
|
+
fmt: Output format. Auto-detected if None (TTY->table, pipe->json).
|
|
79
|
+
fields: Comma-separated field names to include.
|
|
80
|
+
display_hint: Rendering hint from the command (e.g. ``"tree"``).
|
|
81
|
+
"""
|
|
82
|
+
if data is None:
|
|
83
|
+
return ""
|
|
84
|
+
if isinstance(data, str):
|
|
85
|
+
return data
|
|
86
|
+
|
|
87
|
+
normalized: list[dict[str, Any]] | dict[str, Any] = self._normalize(data)
|
|
88
|
+
|
|
89
|
+
if fmt is None:
|
|
90
|
+
fmt = "table" if sys.stdout.isatty() else "json"
|
|
91
|
+
|
|
92
|
+
# Tree display: table/md render as pretty tree, json/yaml serialize structure.
|
|
93
|
+
if display_hint == "tree" and isinstance(normalized, dict) and fmt in ("table", "md"):
|
|
94
|
+
return self._render_tree_display(normalized)
|
|
95
|
+
|
|
96
|
+
field_list = [f.strip() for f in fields.split(",")] if fields else None
|
|
97
|
+
|
|
98
|
+
match fmt:
|
|
99
|
+
case "table":
|
|
100
|
+
return self._format_table(normalized, field_list)
|
|
101
|
+
case "json":
|
|
102
|
+
return self._format_json(normalized, field_list)
|
|
103
|
+
case "yaml":
|
|
104
|
+
return self._format_yaml(normalized, field_list)
|
|
105
|
+
case "md":
|
|
106
|
+
return self._format_markdown(normalized, field_list)
|
|
107
|
+
case _:
|
|
108
|
+
return self._format_table(normalized, field_list)
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# Normalization
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def _normalize(self, data: Any) -> Any:
|
|
115
|
+
"""Walk *data* and normalize values for safe serialization."""
|
|
116
|
+
if isinstance(data, dict):
|
|
117
|
+
return {k: self._normalize_value(k, v) for k, v in data.items()}
|
|
118
|
+
if isinstance(data, list):
|
|
119
|
+
return [self._normalize(item) for item in data]
|
|
120
|
+
return data
|
|
121
|
+
|
|
122
|
+
def _normalize_value(self, key: str, value: Any) -> Any:
|
|
123
|
+
"""Normalize a single value based on its key and type."""
|
|
124
|
+
if isinstance(value, int) and key in DATE_FIELDS and value > _EPOCH_MS_FLOOR:
|
|
125
|
+
return _format_date(value)
|
|
126
|
+
if isinstance(value, int) and key in TIMESTAMP_FIELDS and value > _EPOCH_MS_FLOOR:
|
|
127
|
+
return _format_timestamp(value)
|
|
128
|
+
if hasattr(value, "value") and hasattr(value, "name"): # enum
|
|
129
|
+
return value.value
|
|
130
|
+
if isinstance(value, dict):
|
|
131
|
+
return self._normalize(value)
|
|
132
|
+
if isinstance(value, list):
|
|
133
|
+
return [self._normalize(item) for item in value]
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
# Tree rendering
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _render_tree_display(data: dict[str, Any]) -> str:
|
|
142
|
+
"""Render nested tree dict as pretty box-drawing output."""
|
|
143
|
+
|
|
144
|
+
def _to_node(d: dict[str, Any]) -> TreeNode:
|
|
145
|
+
return TreeNode(
|
|
146
|
+
id=d.get("id", ""),
|
|
147
|
+
label=d.get("summary", d.get("id", "")),
|
|
148
|
+
has_children=bool(d.get("children")),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def _fetch_children(node_id: str) -> list[TreeNode]:
|
|
152
|
+
# Walk the tree dict to find the node and return its children
|
|
153
|
+
children = _find_children(data, node_id)
|
|
154
|
+
return [_to_node(c) for c in children]
|
|
155
|
+
|
|
156
|
+
def _find_children(node: dict[str, Any], target_id: str) -> list[dict[str, Any]]:
|
|
157
|
+
kids: list[dict[str, Any]] = node.get("children", [])
|
|
158
|
+
if node.get("id") == target_id:
|
|
159
|
+
return kids
|
|
160
|
+
for child in kids:
|
|
161
|
+
result = _find_children(child, target_id)
|
|
162
|
+
if result is not None:
|
|
163
|
+
return result
|
|
164
|
+
return []
|
|
165
|
+
|
|
166
|
+
root = _to_node(data)
|
|
167
|
+
return render_tree(root, _fetch_children)
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
# Format dispatchers
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def _format_table(
|
|
174
|
+
self, data: list[dict[str, Any]] | dict[str, Any], fields: list[str] | None
|
|
175
|
+
) -> str:
|
|
176
|
+
"""Format as plain text table."""
|
|
177
|
+
if isinstance(data, dict):
|
|
178
|
+
filtered_dict = self._filter_dict(data, fields)
|
|
179
|
+
return self._render_detail_table(filtered_dict)
|
|
180
|
+
|
|
181
|
+
filtered_rows = self._filter_list(data, fields)
|
|
182
|
+
if not filtered_rows:
|
|
183
|
+
return "(no entries)"
|
|
184
|
+
headers = list(filtered_rows[0].keys())
|
|
185
|
+
rows = [list(row.values()) for row in filtered_rows]
|
|
186
|
+
result: str = tabulate(rows, headers=headers, tablefmt="simple")
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
def _render_detail_table(self, data: dict[str, Any]) -> str:
|
|
190
|
+
"""Render a single-entity detail view.
|
|
191
|
+
|
|
192
|
+
Scalar fields are aligned in a two-column key/value block.
|
|
193
|
+
Nested lists of dicts become indented sub-tables with a header.
|
|
194
|
+
Long-text scalars (newlines or > ``_LONG_TEXT_THRESHOLD`` chars)
|
|
195
|
+
are lifted out of the scalar block and rendered unindented so
|
|
196
|
+
multi-line markdown stays copy-paste clean.
|
|
197
|
+
"""
|
|
198
|
+
parts: list[str] = []
|
|
199
|
+
rows: list[list[Any]] = []
|
|
200
|
+
|
|
201
|
+
def _flush_rows() -> None:
|
|
202
|
+
if rows:
|
|
203
|
+
parts.append(tabulate(rows, tablefmt="plain"))
|
|
204
|
+
rows.clear()
|
|
205
|
+
|
|
206
|
+
for key, value in data.items():
|
|
207
|
+
if isinstance(value, list) and value and isinstance(value[0], dict):
|
|
208
|
+
_flush_rows()
|
|
209
|
+
parts.append(f"\n{key}")
|
|
210
|
+
sub_headers = list(value[0].keys())
|
|
211
|
+
sub_rows = [list(item.values()) for item in value]
|
|
212
|
+
parts.append(
|
|
213
|
+
" "
|
|
214
|
+
+ tabulate(sub_rows, headers=sub_headers, tablefmt="simple").replace(
|
|
215
|
+
"\n", "\n "
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
elif _is_long_text(value):
|
|
219
|
+
_flush_rows()
|
|
220
|
+
parts.append(f"\n{key}:")
|
|
221
|
+
parts.append(str(value))
|
|
222
|
+
else:
|
|
223
|
+
rows.append([key, value])
|
|
224
|
+
_flush_rows()
|
|
225
|
+
return "\n".join(parts)
|
|
226
|
+
|
|
227
|
+
def _format_json(
|
|
228
|
+
self, data: list[dict[str, Any]] | dict[str, Any], fields: list[str] | None
|
|
229
|
+
) -> str:
|
|
230
|
+
"""Format as JSON."""
|
|
231
|
+
filtered = self._filter(data, fields)
|
|
232
|
+
return json.dumps(filtered, indent=2)
|
|
233
|
+
|
|
234
|
+
def _format_yaml(
|
|
235
|
+
self, data: list[dict[str, Any]] | dict[str, Any], fields: list[str] | None
|
|
236
|
+
) -> str:
|
|
237
|
+
"""Format as YAML."""
|
|
238
|
+
filtered = self._filter(data, fields)
|
|
239
|
+
result: str = yaml.safe_dump(
|
|
240
|
+
filtered,
|
|
241
|
+
default_flow_style=False,
|
|
242
|
+
sort_keys=False,
|
|
243
|
+
allow_unicode=True,
|
|
244
|
+
)
|
|
245
|
+
return result.rstrip()
|
|
246
|
+
|
|
247
|
+
def _format_markdown(
|
|
248
|
+
self, data: list[dict[str, Any]] | dict[str, Any], fields: list[str] | None
|
|
249
|
+
) -> str:
|
|
250
|
+
"""Format as Markdown table."""
|
|
251
|
+
if isinstance(data, dict):
|
|
252
|
+
filtered_dict = self._filter_dict(data, fields)
|
|
253
|
+
rows = [[k, v] for k, v in filtered_dict.items()]
|
|
254
|
+
result: str = tabulate(rows, headers=["Key", "Value"], tablefmt="github")
|
|
255
|
+
return result
|
|
256
|
+
|
|
257
|
+
filtered_rows = self._filter_list(data, fields)
|
|
258
|
+
if not filtered_rows:
|
|
259
|
+
return ""
|
|
260
|
+
headers = list(filtered_rows[0].keys())
|
|
261
|
+
rows = [list(row.values()) for row in filtered_rows]
|
|
262
|
+
result = tabulate(rows, headers=headers, tablefmt="github")
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
def _filter(
|
|
266
|
+
self,
|
|
267
|
+
data: list[dict[str, Any]] | dict[str, Any],
|
|
268
|
+
fields: list[str] | None,
|
|
269
|
+
) -> list[dict[str, Any]] | dict[str, Any]:
|
|
270
|
+
"""Apply field filtering to data."""
|
|
271
|
+
if isinstance(data, dict):
|
|
272
|
+
return self._filter_dict(data, fields)
|
|
273
|
+
return self._filter_list(data, fields)
|
|
274
|
+
|
|
275
|
+
def _filter_dict(self, data: dict[str, Any], fields: list[str] | None) -> dict[str, Any]:
|
|
276
|
+
"""Filter dict keys to only requested fields."""
|
|
277
|
+
if not fields:
|
|
278
|
+
return data
|
|
279
|
+
return {k: v for k, v in data.items() if k in fields}
|
|
280
|
+
|
|
281
|
+
def _filter_list(
|
|
282
|
+
self, data: list[dict[str, Any]], fields: list[str] | None
|
|
283
|
+
) -> list[dict[str, Any]]:
|
|
284
|
+
"""Filter list of dicts to only requested fields."""
|
|
285
|
+
if not fields:
|
|
286
|
+
return data
|
|
287
|
+
return [{k: v for k, v in row.items() if k in fields} for row in data]
|
kd/cli/tree.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Tree rendering utility.
|
|
2
|
+
|
|
3
|
+
Renders hierarchical data as an indented tree with box-drawing characters::
|
|
4
|
+
|
|
5
|
+
Root Node
|
|
6
|
+
├── Child 1
|
|
7
|
+
│ └── Grandchild
|
|
8
|
+
└── Child 2
|
|
9
|
+
|
|
10
|
+
Generic interface: provide a root node and a callback that fetches children.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Callable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TreeNode:
|
|
21
|
+
"""A node in a tree display."""
|
|
22
|
+
|
|
23
|
+
id: str
|
|
24
|
+
label: str
|
|
25
|
+
has_children: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render_tree(
|
|
29
|
+
root: TreeNode,
|
|
30
|
+
fetch_children: Callable[[str], list[TreeNode]],
|
|
31
|
+
max_depth: int = 5,
|
|
32
|
+
) -> str:
|
|
33
|
+
"""Render a tree as a string with box-drawing characters.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
root: The root node to display.
|
|
37
|
+
fetch_children: Callback that takes a node ID and returns child nodes.
|
|
38
|
+
max_depth: Maximum depth to recurse (0 = root only).
|
|
39
|
+
"""
|
|
40
|
+
lines: list[str] = [f"{root.label}"]
|
|
41
|
+
if max_depth > 0 and root.has_children:
|
|
42
|
+
children = fetch_children(root.id)
|
|
43
|
+
_render_children(lines, children, fetch_children, "", max_depth - 1)
|
|
44
|
+
return "\n".join(lines)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _render_children(
|
|
48
|
+
lines: list[str],
|
|
49
|
+
children: list[TreeNode],
|
|
50
|
+
fetch_children: Callable[[str], list[TreeNode]],
|
|
51
|
+
prefix: str,
|
|
52
|
+
remaining_depth: int,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Recursively render child nodes with correct box-drawing prefixes."""
|
|
55
|
+
for i, child in enumerate(children):
|
|
56
|
+
is_last = i == len(children) - 1
|
|
57
|
+
connector = "└── " if is_last else "├── "
|
|
58
|
+
lines.append(f"{prefix}{connector}{child.label}")
|
|
59
|
+
|
|
60
|
+
if remaining_depth > 0 and child.has_children:
|
|
61
|
+
extension = " " if is_last else "│ "
|
|
62
|
+
grandchildren = fetch_children(child.id)
|
|
63
|
+
_render_children(
|
|
64
|
+
lines, grandchildren, fetch_children, prefix + extension, remaining_depth - 1
|
|
65
|
+
)
|