agent-tool-openproject-cli 0.2.1__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_tool_openproject_cli-0.2.1.dist-info/METADATA +457 -0
- agent_tool_openproject_cli-0.2.1.dist-info/RECORD +40 -0
- agent_tool_openproject_cli-0.2.1.dist-info/WHEEL +5 -0
- agent_tool_openproject_cli-0.2.1.dist-info/entry_points.txt +2 -0
- agent_tool_openproject_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
- agent_tool_openproject_cli-0.2.1.dist-info/top_level.txt +1 -0
- opcli/__init__.py +3 -0
- opcli/__main__.py +6 -0
- opcli/cli.py +166 -0
- opcli/client.py +252 -0
- opcli/commands/__init__.py +1 -0
- opcli/commands/_shared.py +50 -0
- opcli/commands/attachments.py +104 -0
- opcli/commands/auth.py +113 -0
- opcli/commands/comments.py +90 -0
- opcli/commands/costs.py +205 -0
- opcli/commands/custom_fields.py +82 -0
- opcli/commands/filelinks.py +100 -0
- opcli/commands/guide.py +198 -0
- opcli/commands/memberships.py +110 -0
- opcli/commands/notifications.py +130 -0
- opcli/commands/projects.py +149 -0
- opcli/commands/raw.py +83 -0
- opcli/commands/search.py +256 -0
- opcli/commands/settings.py +59 -0
- opcli/commands/time_entries.py +214 -0
- opcli/commands/users.py +109 -0
- opcli/commands/wiki.py +49 -0
- opcli/commands/workpackages.py +338 -0
- opcli/config.py +134 -0
- opcli/context.py +117 -0
- opcli/credentials.py +139 -0
- opcli/duration.py +56 -0
- opcli/errors.py +81 -0
- opcli/hal.py +75 -0
- opcli/output.py +225 -0
- opcli/resolve.py +183 -0
- opcli/searchspec.py +269 -0
- opcli/serialize.py +225 -0
- opcli/wpfilters.py +159 -0
opcli/credentials.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Secure storage of OpenProject API tokens.
|
|
2
|
+
|
|
3
|
+
Order of preference for reading a token:
|
|
4
|
+
|
|
5
|
+
1. The ``OPCLI_TOKEN`` environment variable (used by CI / the test suite and
|
|
6
|
+
handy for one-off scripting).
|
|
7
|
+
2. The operating-system keyring (Secret Service / macOS Keychain / Windows
|
|
8
|
+
Credential Locker) via the :mod:`keyring` library — the safe default.
|
|
9
|
+
3. A ``0600`` fallback file in the config directory, used only when no real
|
|
10
|
+
keyring backend is available (headless boxes without a Secret Service).
|
|
11
|
+
We warn loudly in that case because the token is stored in clear text.
|
|
12
|
+
|
|
13
|
+
The token is the only secret we persist. Everything else (base URL, the API
|
|
14
|
+
username, TLS options) lives in the plain-text config file.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import stat
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
KEYRING_SERVICE = "op-cli"
|
|
26
|
+
ENV_TOKEN = "OPCLI_TOKEN"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _config_dir() -> Path:
|
|
30
|
+
base = os.environ.get("OPCLI_CONFIG_DIR")
|
|
31
|
+
if base:
|
|
32
|
+
return Path(base)
|
|
33
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
34
|
+
root = Path(xdg) if xdg else Path.home() / ".config"
|
|
35
|
+
return root / "op-cli"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fallback_file() -> Path:
|
|
39
|
+
return _config_dir() / "credentials.json"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _keyring_available() -> bool:
|
|
43
|
+
try:
|
|
44
|
+
import keyring
|
|
45
|
+
from keyring.backends import fail
|
|
46
|
+
|
|
47
|
+
return not isinstance(keyring.get_keyring(), fail.Keyring)
|
|
48
|
+
except Exception:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _read_fallback() -> dict[str, str]:
|
|
53
|
+
path = _fallback_file()
|
|
54
|
+
if not path.exists():
|
|
55
|
+
return {}
|
|
56
|
+
try:
|
|
57
|
+
return json.loads(path.read_text())
|
|
58
|
+
except Exception:
|
|
59
|
+
return {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _write_fallback(data: dict[str, str]) -> None:
|
|
63
|
+
path = _fallback_file()
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
# Create with 0600 from the start so the plaintext token is never briefly
|
|
66
|
+
# world-readable (mode 0o600 has no group/other bits, so umask can't widen it).
|
|
67
|
+
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
68
|
+
with os.fdopen(fd, "w") as fh:
|
|
69
|
+
json.dump(data, fh, indent=2)
|
|
70
|
+
try:
|
|
71
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600 (also fixes a pre-existing file)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def backend_name() -> str:
|
|
77
|
+
"""Human-readable name of the active secret backend (for `auth status`)."""
|
|
78
|
+
if os.environ.get(ENV_TOKEN):
|
|
79
|
+
return f"environment variable ${ENV_TOKEN}"
|
|
80
|
+
if _keyring_available():
|
|
81
|
+
try:
|
|
82
|
+
import keyring
|
|
83
|
+
|
|
84
|
+
return f"OS keyring ({keyring.get_keyring().__class__.__name__})"
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
return f"plaintext fallback file ({_fallback_file()})"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def store_token(profile: str, token: str) -> str:
|
|
91
|
+
"""Persist *token* for *profile*. Returns the backend used."""
|
|
92
|
+
if _keyring_available():
|
|
93
|
+
try:
|
|
94
|
+
import keyring
|
|
95
|
+
|
|
96
|
+
keyring.set_password(KEYRING_SERVICE, profile, token)
|
|
97
|
+
return "keyring"
|
|
98
|
+
except Exception as exc: # pragma: no cover - depends on host
|
|
99
|
+
print(f"warning: keyring store failed ({exc}); using fallback file", file=sys.stderr)
|
|
100
|
+
data = _read_fallback()
|
|
101
|
+
data[profile] = token
|
|
102
|
+
_write_fallback(data)
|
|
103
|
+
print(
|
|
104
|
+
f"warning: no OS keyring available — token stored in clear text at {_fallback_file()} (0600)",
|
|
105
|
+
file=sys.stderr,
|
|
106
|
+
)
|
|
107
|
+
return "file"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_token(profile: str) -> str | None:
|
|
111
|
+
"""Resolve the token for *profile*, honouring the env override first."""
|
|
112
|
+
env = os.environ.get(ENV_TOKEN)
|
|
113
|
+
if env:
|
|
114
|
+
return env
|
|
115
|
+
if _keyring_available():
|
|
116
|
+
try:
|
|
117
|
+
import keyring
|
|
118
|
+
|
|
119
|
+
tok = keyring.get_password(KEYRING_SERVICE, profile)
|
|
120
|
+
if tok:
|
|
121
|
+
return tok
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
return _read_fallback().get(profile)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def delete_token(profile: str) -> None:
|
|
128
|
+
"""Remove any stored token for *profile* from every backend."""
|
|
129
|
+
if _keyring_available():
|
|
130
|
+
try:
|
|
131
|
+
import keyring
|
|
132
|
+
|
|
133
|
+
keyring.delete_password(KEYRING_SERVICE, profile)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
data = _read_fallback()
|
|
137
|
+
if profile in data:
|
|
138
|
+
del data[profile]
|
|
139
|
+
_write_fallback(data)
|
opcli/duration.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""ISO-8601 duration <-> decimal hours.
|
|
2
|
+
|
|
3
|
+
OpenProject stores time-ish values (``estimatedTime``, time-entry ``hours``)
|
|
4
|
+
as ISO-8601 durations like ``PT2H30M`` and *rejects* plain decimals. Humans and
|
|
5
|
+
agents want to type ``2.5``. These helpers bridge the two, both directions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
_ISO_RE = re.compile(
|
|
13
|
+
r"^P(?:(?P<w>\d+(?:\.\d+)?)W)?(?:(?P<d>\d+(?:\.\d+)?)D)?"
|
|
14
|
+
r"(?:T(?:(?P<h>\d+(?:\.\d+)?)H)?(?:(?P<m>\d+(?:\.\d+)?)M)?(?:(?P<s>\d+(?:\.\d+)?)S)?)?$"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def hours_to_iso(hours: float) -> str:
|
|
19
|
+
"""``2.5`` -> ``"PT2H30M"``. Uses hours/minutes/seconds only."""
|
|
20
|
+
total = round(float(hours) * 3600)
|
|
21
|
+
if total == 0:
|
|
22
|
+
return "PT0S"
|
|
23
|
+
h, rem = divmod(total, 3600)
|
|
24
|
+
m, s = divmod(rem, 60)
|
|
25
|
+
out = "PT"
|
|
26
|
+
if h:
|
|
27
|
+
out += f"{h}H"
|
|
28
|
+
if m:
|
|
29
|
+
out += f"{m}M"
|
|
30
|
+
if s:
|
|
31
|
+
out += f"{s}S"
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def iso_to_hours(value: str | None) -> float | None:
|
|
36
|
+
"""``"PT2H30M"`` -> ``2.5``. Returns ``None`` for falsy input.
|
|
37
|
+
|
|
38
|
+
Days count as 24h and weeks as 7 days — adequate for OpenProject's usage
|
|
39
|
+
(it never emits year/month components for durations).
|
|
40
|
+
"""
|
|
41
|
+
if not value:
|
|
42
|
+
return None
|
|
43
|
+
m = _ISO_RE.match(value.strip())
|
|
44
|
+
if not m:
|
|
45
|
+
return None
|
|
46
|
+
parts = {k: float(v) if v else 0.0 for k, v in m.groupdict().items()}
|
|
47
|
+
return parts["w"] * 168 + parts["d"] * 24 + parts["h"] + parts["m"] / 60 + parts["s"] / 3600
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_hours_input(value: str) -> str:
|
|
51
|
+
"""Accept either a decimal (``"2.5"``) or an ISO duration (``"PT2H30M"``)
|
|
52
|
+
from the user and normalise to the ISO form the API wants."""
|
|
53
|
+
v = value.strip()
|
|
54
|
+
if v.upper().startswith("P"):
|
|
55
|
+
return v # already ISO
|
|
56
|
+
return hours_to_iso(float(v))
|
opcli/errors.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Exception types for the OpenProject CLI.
|
|
2
|
+
|
|
3
|
+
Every user-facing failure funnels through :class:`OpError` so the CLI entry
|
|
4
|
+
point can render a single, clean error line (and a JSON error object when
|
|
5
|
+
``--output json`` is active) instead of a Python traceback.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OpError(Exception):
|
|
14
|
+
"""Base class for all expected, user-facing errors."""
|
|
15
|
+
|
|
16
|
+
exit_code = 1
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, *, detail: Any = None):
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.message = message
|
|
21
|
+
self.detail = detail
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict[str, Any]:
|
|
24
|
+
out: dict[str, Any] = {"error": self.message}
|
|
25
|
+
if self.detail is not None:
|
|
26
|
+
out["detail"] = self.detail
|
|
27
|
+
return out
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConfigError(OpError):
|
|
31
|
+
"""Something is wrong with configuration or stored credentials."""
|
|
32
|
+
|
|
33
|
+
exit_code = 3
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AuthError(OpError):
|
|
37
|
+
"""Authentication/authorization failed (401/403)."""
|
|
38
|
+
|
|
39
|
+
exit_code = 4
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NotFoundError(OpError):
|
|
43
|
+
"""A requested resource does not exist (404)."""
|
|
44
|
+
|
|
45
|
+
exit_code = 5
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ConflictError(OpError):
|
|
49
|
+
"""Optimistic-locking or uniqueness conflict (409/422 stale lockVersion)."""
|
|
50
|
+
|
|
51
|
+
exit_code = 6
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ValidationError(OpError):
|
|
55
|
+
"""The server rejected the request payload (422)."""
|
|
56
|
+
|
|
57
|
+
exit_code = 7
|
|
58
|
+
|
|
59
|
+
def __init__(self, message: str, *, detail: Any = None, field_errors: Any = None):
|
|
60
|
+
super().__init__(message, detail=detail)
|
|
61
|
+
self.field_errors = field_errors
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict[str, Any]:
|
|
64
|
+
out = super().to_dict()
|
|
65
|
+
if self.field_errors:
|
|
66
|
+
out["fieldErrors"] = self.field_errors
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ApiError(OpError):
|
|
71
|
+
"""A non-specific API error carrying the HTTP status and server payload."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, message: str, *, status: int | None = None, detail: Any = None):
|
|
74
|
+
super().__init__(message, detail=detail)
|
|
75
|
+
self.status = status
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict[str, Any]:
|
|
78
|
+
out = super().to_dict()
|
|
79
|
+
if self.status is not None:
|
|
80
|
+
out["status"] = self.status
|
|
81
|
+
return out
|
opcli/hal.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Helpers for OpenProject's HAL+JSON representation.
|
|
2
|
+
|
|
3
|
+
OpenProject resources are HAL documents: attributes live at the top level,
|
|
4
|
+
relationships live under ``_links`` (each a ``{"href": ...}`` or a list of
|
|
5
|
+
them), and embedded resources live under ``_embedded``. Collections put their
|
|
6
|
+
items in ``_embedded.elements`` with ``total``/``count``/``pageSize`` alongside.
|
|
7
|
+
|
|
8
|
+
These helpers keep that structure out of the command code.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any, Iterable
|
|
15
|
+
|
|
16
|
+
Json = dict[str, Any]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def link(href: str | None) -> Json:
|
|
20
|
+
"""Build a HAL link object. ``None`` clears a relationship (e.g. unassign)."""
|
|
21
|
+
return {"href": href}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ref(resource: str, id: Any) -> Json:
|
|
25
|
+
"""Build a link to ``/api/v3/<resource>/<id>`` (the accepted path form)."""
|
|
26
|
+
return {"href": f"/api/v3/{resource}/{id}"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_ID_RE = re.compile(r"/api/v3/[^/]+/(\d+)(?:/.*)?$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def id_from_href(href: str | None) -> int | None:
|
|
33
|
+
if not href:
|
|
34
|
+
return None
|
|
35
|
+
m = _ID_RE.search(href)
|
|
36
|
+
return int(m.group(1)) if m else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def link_href(doc: Json, name: str) -> str | None:
|
|
40
|
+
node = (doc.get("_links") or {}).get(name)
|
|
41
|
+
if isinstance(node, dict):
|
|
42
|
+
return node.get("href")
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def link_title(doc: Json, name: str) -> str | None:
|
|
47
|
+
node = (doc.get("_links") or {}).get(name)
|
|
48
|
+
if isinstance(node, dict):
|
|
49
|
+
return node.get("title")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def link_id(doc: Json, name: str) -> int | None:
|
|
54
|
+
return id_from_href(link_href(doc, name))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def elements(collection: Json) -> list[Json]:
|
|
58
|
+
return (collection.get("_embedded") or {}).get("elements") or []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def total(collection: Json) -> int:
|
|
62
|
+
return int(collection.get("total") or 0)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def formattable(raw: str | None) -> Json:
|
|
66
|
+
"""Wrap text as a Formattable (markdown) value used by description/comment."""
|
|
67
|
+
return {"raw": raw or ""}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def iter_link_list(doc: Json, name: str) -> Iterable[Json]:
|
|
71
|
+
node = (doc.get("_links") or {}).get(name)
|
|
72
|
+
if isinstance(node, list):
|
|
73
|
+
yield from node
|
|
74
|
+
elif isinstance(node, dict):
|
|
75
|
+
yield node
|
opcli/output.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Rendering of command results.
|
|
2
|
+
|
|
3
|
+
The CLI is *agent-first*, so JSON is the default output: stable, complete, and
|
|
4
|
+
trivial to parse. A ``table`` mode (Rich) exists for humans. Commands hand the
|
|
5
|
+
formatter the raw data plus an optional column spec; the formatter decides how
|
|
6
|
+
to present it based on the active ``--output`` mode.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
import enum
|
|
13
|
+
import json as jsonlib
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any, Callable, Sequence
|
|
16
|
+
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
# A column is (header, accessor). accessor is a dict key or a callable(row)->value.
|
|
21
|
+
Column = tuple[str, "str | Callable[[dict], Any]"]
|
|
22
|
+
|
|
23
|
+
_err_console = Console(stderr=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OutputFormat(str, enum.Enum):
|
|
27
|
+
json = "json"
|
|
28
|
+
table = "table"
|
|
29
|
+
markdown = "markdown"
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def coerce(cls, value: "str | OutputFormat | None") -> "OutputFormat | None":
|
|
33
|
+
"""Parse a loose string (accepts 'md' for markdown). None passes through."""
|
|
34
|
+
if value is None or isinstance(value, cls):
|
|
35
|
+
return value
|
|
36
|
+
v = str(value).strip().lower()
|
|
37
|
+
if v in ("md", "markdown"):
|
|
38
|
+
return cls.markdown
|
|
39
|
+
if v in ("json", "j"):
|
|
40
|
+
return cls.json
|
|
41
|
+
if v in ("table", "tbl", "t"):
|
|
42
|
+
return cls.table
|
|
43
|
+
raise ValueError(f"unknown output format '{value}' (choose json, table, or markdown)")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _accessor_value(row: dict, accessor: "str | Callable[[dict], Any]") -> Any:
|
|
47
|
+
if callable(accessor):
|
|
48
|
+
try:
|
|
49
|
+
return accessor(row)
|
|
50
|
+
except Exception:
|
|
51
|
+
return None
|
|
52
|
+
return row.get(accessor)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fmt_cell(value: Any) -> str:
|
|
56
|
+
if value is None:
|
|
57
|
+
return ""
|
|
58
|
+
if isinstance(value, bool):
|
|
59
|
+
return "yes" if value else "no"
|
|
60
|
+
if isinstance(value, (list, tuple)):
|
|
61
|
+
return ", ".join(str(v) for v in value)
|
|
62
|
+
return str(value)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Emitter:
|
|
66
|
+
def __init__(self, fmt: OutputFormat = OutputFormat.json, *, color: bool = True, fields: Sequence[str] | None = None):
|
|
67
|
+
self.fmt = fmt
|
|
68
|
+
self.console = Console(no_color=not color, highlight=False)
|
|
69
|
+
# user-selected fields (dotted paths ok, e.g. "assignee.name"); None = command defaults
|
|
70
|
+
self.fields = [f.strip() for f in fields if f.strip()] if fields else None
|
|
71
|
+
|
|
72
|
+
# ---- main entry --------------------------------------------------
|
|
73
|
+
def emit(
|
|
74
|
+
self,
|
|
75
|
+
data: Any,
|
|
76
|
+
*,
|
|
77
|
+
columns: Sequence[Column] | None = None,
|
|
78
|
+
title: str | None = None,
|
|
79
|
+
empty: str = "(no results)",
|
|
80
|
+
) -> None:
|
|
81
|
+
if self.fields:
|
|
82
|
+
# --fields overrides both the JSON shape and the table/markdown columns
|
|
83
|
+
if self.fmt == OutputFormat.json:
|
|
84
|
+
self._emit_json(_project(data, self.fields))
|
|
85
|
+
return
|
|
86
|
+
columns = [(f, (lambda r, _f=f: _dotted_get(r, _f))) for f in self.fields]
|
|
87
|
+
|
|
88
|
+
if self.fmt == OutputFormat.json:
|
|
89
|
+
self._emit_json(data)
|
|
90
|
+
return
|
|
91
|
+
if self.fmt == OutputFormat.markdown:
|
|
92
|
+
self._emit_markdown(data, columns=columns, title=title, empty=empty)
|
|
93
|
+
return
|
|
94
|
+
self._emit_table(data, columns=columns, title=title, empty=empty)
|
|
95
|
+
|
|
96
|
+
def message(self, text: str) -> None:
|
|
97
|
+
"""A human status line (table mode only; suppressed in json mode)."""
|
|
98
|
+
if self.fmt != OutputFormat.json:
|
|
99
|
+
self.console.print(text)
|
|
100
|
+
|
|
101
|
+
# ---- json --------------------------------------------------------
|
|
102
|
+
def _emit_json(self, data: Any) -> None:
|
|
103
|
+
sys.stdout.write(jsonlib.dumps(_jsonable(data), indent=2, ensure_ascii=False, default=str))
|
|
104
|
+
sys.stdout.write("\n")
|
|
105
|
+
|
|
106
|
+
# ---- table -------------------------------------------------------
|
|
107
|
+
def _emit_table(
|
|
108
|
+
self,
|
|
109
|
+
data: Any,
|
|
110
|
+
*,
|
|
111
|
+
columns: Sequence[Column] | None,
|
|
112
|
+
title: str | None,
|
|
113
|
+
empty: str,
|
|
114
|
+
) -> None:
|
|
115
|
+
rows = data if isinstance(data, list) else [data] if isinstance(data, dict) else None
|
|
116
|
+
if rows is None:
|
|
117
|
+
self.console.print(str(data))
|
|
118
|
+
return
|
|
119
|
+
if not rows:
|
|
120
|
+
self.console.print(f"[dim]{empty}[/dim]")
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
if columns is None:
|
|
124
|
+
# key/value table for a single object, else fall back to JSON.
|
|
125
|
+
if len(rows) == 1 and isinstance(rows[0], dict):
|
|
126
|
+
self._kv_table(rows[0], title=title)
|
|
127
|
+
return
|
|
128
|
+
self._emit_json(data)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
table = Table(title=title, show_lines=False, header_style="bold")
|
|
132
|
+
for header, _ in columns:
|
|
133
|
+
table.add_column(header)
|
|
134
|
+
for row in rows:
|
|
135
|
+
table.add_row(*[_fmt_cell(_accessor_value(row, acc)) for _, acc in columns])
|
|
136
|
+
self.console.print(table)
|
|
137
|
+
|
|
138
|
+
# ---- markdown ----------------------------------------------------
|
|
139
|
+
def _emit_markdown(self, data: Any, *, columns, title, empty) -> None:
|
|
140
|
+
out = sys.stdout
|
|
141
|
+
if title:
|
|
142
|
+
out.write(f"### {title}\n\n")
|
|
143
|
+
rows = data if isinstance(data, list) else [data] if isinstance(data, dict) else None
|
|
144
|
+
if rows is None:
|
|
145
|
+
out.write(f"{data}\n")
|
|
146
|
+
return
|
|
147
|
+
if not rows:
|
|
148
|
+
out.write(f"_{empty}_\n")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
if columns is not None:
|
|
152
|
+
headers = [h for h, _ in columns]
|
|
153
|
+
cells = [[_md_cell(_accessor_value(r, acc)) for _, acc in columns] for r in rows]
|
|
154
|
+
out.write(_md_table(headers, cells))
|
|
155
|
+
return
|
|
156
|
+
if len(rows) == 1 and isinstance(rows[0], dict):
|
|
157
|
+
# single object -> Field/Value table
|
|
158
|
+
cells = [[_md_cell(k), _md_cell(v)] for k, v in rows[0].items()]
|
|
159
|
+
out.write(_md_table(["Field", "Value"], cells))
|
|
160
|
+
return
|
|
161
|
+
# list without a column spec -> fenced JSON (still valid markdown)
|
|
162
|
+
out.write("```json\n")
|
|
163
|
+
out.write(jsonlib.dumps(_jsonable(data), indent=2, ensure_ascii=False, default=str))
|
|
164
|
+
out.write("\n```\n")
|
|
165
|
+
|
|
166
|
+
def _kv_table(self, obj: dict, *, title: str | None) -> None:
|
|
167
|
+
table = Table(title=title, show_header=False, box=None)
|
|
168
|
+
table.add_column("field", style="bold cyan")
|
|
169
|
+
table.add_column("value")
|
|
170
|
+
for key, value in obj.items():
|
|
171
|
+
table.add_row(key, _fmt_cell(value))
|
|
172
|
+
self.console.print(table)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def print_error(err: Any, fmt: OutputFormat) -> None:
|
|
176
|
+
"""Render an error to stderr in the active format."""
|
|
177
|
+
if fmt == OutputFormat.json:
|
|
178
|
+
from .errors import OpError
|
|
179
|
+
|
|
180
|
+
payload = err.to_dict() if isinstance(err, OpError) else {"error": str(err)}
|
|
181
|
+
_err_console.file.write(jsonlib.dumps(payload, indent=2, ensure_ascii=False, default=str) + "\n")
|
|
182
|
+
else:
|
|
183
|
+
_err_console.print(f"[red]error:[/red] {err}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _dotted_get(row: Any, path: str) -> Any:
|
|
187
|
+
"""Fetch a possibly-nested value by dotted path, e.g. ``assignee.name``."""
|
|
188
|
+
cur = row
|
|
189
|
+
for part in path.split("."):
|
|
190
|
+
if isinstance(cur, dict):
|
|
191
|
+
cur = cur.get(part)
|
|
192
|
+
else:
|
|
193
|
+
return None
|
|
194
|
+
return cur
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _project(data: Any, fields: Sequence[str]) -> Any:
|
|
198
|
+
"""Keep only the selected fields (dotted paths allowed). The dotted string is
|
|
199
|
+
used as the output key so the projection is flat and predictable."""
|
|
200
|
+
if isinstance(data, list):
|
|
201
|
+
return [{f: _dotted_get(r, f) for f in fields} if isinstance(r, dict) else r for r in data]
|
|
202
|
+
if isinstance(data, dict):
|
|
203
|
+
return {f: _dotted_get(data, f) for f in fields}
|
|
204
|
+
return data
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _md_cell(value: Any) -> str:
|
|
208
|
+
text = _fmt_cell(value)
|
|
209
|
+
# keep the table one row per record: escape pipes and flatten newlines
|
|
210
|
+
return text.replace("\\", "\\\\").replace("|", "\\|").replace("\n", "<br>")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _md_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str:
|
|
214
|
+
lines = ["| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |"]
|
|
215
|
+
for row in rows:
|
|
216
|
+
lines.append("| " + " | ".join(row) + " |")
|
|
217
|
+
return "\n".join(lines) + "\n"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _jsonable(obj: Any) -> Any:
|
|
221
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
222
|
+
return dataclasses.asdict(obj)
|
|
223
|
+
if isinstance(obj, enum.Enum):
|
|
224
|
+
return obj.value
|
|
225
|
+
return obj
|