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.
Files changed (64) hide show
  1. kd/__init__.py +45 -0
  2. kd/api/__init__.py +15 -0
  3. kd/api/articles.py +232 -0
  4. kd/api/http.py +234 -0
  5. kd/api/hub.py +69 -0
  6. kd/api/saved_queries.py +76 -0
  7. kd/api/tags.py +107 -0
  8. kd/api/youtrack.py +414 -0
  9. kd/article_marker.py +348 -0
  10. kd/auth.py +96 -0
  11. kd/cli/__init__.py +5 -0
  12. kd/cli/core/__init__.py +25 -0
  13. kd/cli/core/command_base.py +133 -0
  14. kd/cli/core/command_parser.py +246 -0
  15. kd/cli/core/namespaces.py +175 -0
  16. kd/cli/core/option_registry.py +265 -0
  17. kd/cli/input.py +42 -0
  18. kd/cli/main.py +612 -0
  19. kd/cli/namespaces/__init__.py +38 -0
  20. kd/cli/namespaces/alias/__init__.py +87 -0
  21. kd/cli/namespaces/article/__init__.py +843 -0
  22. kd/cli/namespaces/init.py +42 -0
  23. kd/cli/namespaces/issue/__init__.py +959 -0
  24. kd/cli/namespaces/primer/__init__.py +337 -0
  25. kd/cli/namespaces/project/__init__.py +243 -0
  26. kd/cli/namespaces/query/__init__.py +465 -0
  27. kd/cli/namespaces/search/__init__.py +82 -0
  28. kd/cli/namespaces/system/__init__.py +528 -0
  29. kd/cli/namespaces/tag/__init__.py +337 -0
  30. kd/cli/namespaces/time/__init__.py +307 -0
  31. kd/cli/namespaces/user/__init__.py +99 -0
  32. kd/cli/output.py +287 -0
  33. kd/cli/tree.py +65 -0
  34. kd/client.py +1953 -0
  35. kd/config.py +853 -0
  36. kd/connection.py +156 -0
  37. kd/debug.py +107 -0
  38. kd/exceptions.py +189 -0
  39. kd/history.py +169 -0
  40. kd/models/__init__.py +75 -0
  41. kd/models/article.py +64 -0
  42. kd/models/assist.py +47 -0
  43. kd/models/attachment.py +20 -0
  44. kd/models/comment.py +25 -0
  45. kd/models/common.py +39 -0
  46. kd/models/issue.py +67 -0
  47. kd/models/project.py +60 -0
  48. kd/models/saved_query.py +54 -0
  49. kd/models/search.py +20 -0
  50. kd/models/tag.py +30 -0
  51. kd/models/user.py +42 -0
  52. kd/models/work_item.py +59 -0
  53. kd/primer.py +231 -0
  54. kd/query_parser.py +181 -0
  55. kd/resources/__init__.py +1 -0
  56. kd/resources/command-synonyms.yaml +19 -0
  57. kd/resources/config-template.yaml +71 -0
  58. kd/resources/error-messages.yaml +101 -0
  59. kd_cli-2.0.4.dist-info/METADATA +149 -0
  60. kd_cli-2.0.4.dist-info/RECORD +64 -0
  61. kd_cli-2.0.4.dist-info/WHEEL +5 -0
  62. kd_cli-2.0.4.dist-info/entry_points.txt +2 -0
  63. kd_cli-2.0.4.dist-info/licenses/LICENSE +21 -0
  64. 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
+ )