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.
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