jira-cli-toolkit 2.5.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.
@@ -0,0 +1,25 @@
1
+ CLI Toolkit for Jira
2
+ Copyright (C) 2026 Abhishek Aggarwal
3
+ SPDX-License-Identifier: AGPL-3.0-only
4
+
5
+ Original project code is licensed under version 3 only of the GNU Affero
6
+ General Public License, except for separately identified third-party material.
7
+
8
+ This program is free software: you can redistribute it and/or modify it
9
+ under the terms of the GNU Affero General Public License as published by
10
+ the Free Software Foundation, version 3 of the License.
11
+
12
+ This program is distributed in the hope that it will be useful,
13
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ GNU Affero General Public License for more details.
16
+
17
+ You should have received a copy of the GNU Affero General Public License
18
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
19
+
20
+ Third-party components and assets retain their copyright notices and
21
+ applicable license terms. Preserve those notices when redistributing them.
22
+ Website font/component/icon notices are in web/public/third-party-notices.txt.
23
+ Dependencies and separately identified third-party material are not relicensed
24
+ by this notice. Applicable AGPL obligations for covered combined works remain.
25
+ The project license does not grant rights to third-party trademarks.
jsup/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Jira CLI Toolkit — Jira Cloud CLI with a compatible jsup entry point."""
2
+ from .config import CONFIG_PATH, get_config, init_config, show_config
3
+ from .client import Jira, JiraError, adf, adf_to_text
4
+
5
+ __all__ = ["CONFIG_PATH", "get_config", "init_config", "show_config",
6
+ "Jira", "JiraError", "adf", "adf_to_text"]
7
+ __version__ = "2.5.1"
jsup/api.py ADDED
@@ -0,0 +1,352 @@
1
+ """`jira api`: authenticated requests to any Jira Cloud REST path, without a token in the command.
2
+
3
+ The selected identity, trusted site and scoped-token gateway come from the existing
4
+ resolver. Callers choose the method, path, query, headers and body; the CLI keeps control
5
+ of the destination, authentication, content framing and transport.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import json
11
+ import os
12
+ import re
13
+ import sys
14
+ import tempfile
15
+ from contextlib import ExitStack
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from urllib.parse import quote, urlsplit
19
+
20
+ from .client import Jira
21
+
22
+ METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
23
+ # Methods retried after transient failures. A POST is never retried, even for searches.
24
+ SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
25
+ BODYLESS_METHODS = {"GET", "HEAD", "OPTIONS"}
26
+ MAX_DATA = 10 * 1024 * 1024
27
+ MAX_UPLOAD = 50 * 1024 * 1024
28
+ MAX_RESPONSE = 250 * 1024 * 1024
29
+ MAX_ERROR_BODY = 64 * 1024
30
+ # The CLI owns authentication, destination, framing and transport.
31
+ RESERVED_HEADERS = {
32
+ "authorization", "proxy-authorization", "cookie", "host", "content-length", "content-type",
33
+ "transfer-encoding", "connection", "keep-alive", "te", "trailer", "upgrade", "expect",
34
+ "forwarded", "via",
35
+ }
36
+ RESERVED_PREFIXES = ("proxy-", "x-forwarded-", "sec-")
37
+ HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
38
+ TEXT_TYPES = re.compile(r"^(text/|application/([\w.+-]*\+)?(json|xml|javascript|x-www-form-urlencoded)\b)", re.I)
39
+
40
+
41
+ class ApiError(Exception):
42
+ """A failed request, reported as a redacted JSON object on stderr."""
43
+
44
+ def __init__(self, exit_code: int, code: str, message: str, **details):
45
+ super().__init__(message)
46
+ self.exit_code, self.code, self.details = exit_code, code, details
47
+
48
+
49
+ def invalid(message: str) -> ApiError:
50
+ return ApiError(2, "invalid_input", message)
51
+
52
+
53
+ @dataclass
54
+ class Request:
55
+ method: str
56
+ path: str
57
+ params: list[tuple[str, str]] = field(default_factory=list)
58
+ headers: dict[str, str] = field(default_factory=dict)
59
+ body: bytes | None = None
60
+ content_type: str | None = None
61
+ form: list[tuple[str, str | None, Path | None]] = field(default_factory=list)
62
+
63
+
64
+ def validate_path(path: str) -> str:
65
+ if path == "spec" or not path.startswith("/"):
66
+ raise invalid("PATH must be a Jira REST path such as /rest/api/3/myself. "
67
+ "Use jira api spec refresh or jira api spec status for discovery.")
68
+ if any(ord(ch) < 0x21 or ord(ch) > 0x7E for ch in path):
69
+ raise invalid("PATH must be printable ASCII without spaces; percent-encode other characters.")
70
+ if "?" in path or "#" in path:
71
+ raise invalid("PATH must not contain a query or fragment; pass query values with --query KEY=VALUE.")
72
+ if "\\" in path or "//" in path:
73
+ raise invalid("PATH must not contain backslashes or empty segments.")
74
+ if not path.startswith("/rest/"):
75
+ raise invalid("PATH must start with /rest/, for example /rest/api/3/myself or /rest/agile/1.0/board.")
76
+ if re.search(r"%(?![0-9A-Fa-f]{2})", path):
77
+ raise invalid("PATH contains an invalid percent-encoding.")
78
+ # Encoded separators and dot segments could address a different resource than the one shown.
79
+ if re.search(r"%(2[fF]|5[cC])", path):
80
+ raise invalid("PATH must not contain encoded slashes or backslashes.")
81
+ segments = re.sub(r"%2[eE]", ".", path).split("/")
82
+ if any(segment in (".", "..") for segment in segments):
83
+ raise invalid("PATH must not contain . or .. segments.")
84
+ return path
85
+
86
+
87
+ def parse_query(values: list[str]) -> list[tuple[str, str]]:
88
+ params = []
89
+ for item in values:
90
+ key, sep, value = item.partition("=")
91
+ if not sep or not key:
92
+ raise invalid(f"--query expects KEY=VALUE, got {item!r}.")
93
+ params.append((key, value))
94
+ return params
95
+
96
+
97
+ def parse_headers(values: list[str]) -> dict[str, str]:
98
+ headers: dict[str, str] = {}
99
+ for item in values:
100
+ name, sep, value = item.partition(":")
101
+ name, value = name.strip(), value.strip()
102
+ if not sep or not HEADER_NAME.match(name):
103
+ raise invalid(f"--header expects 'Name: value', got {item!r}.")
104
+ if any(ch in value for ch in "\r\n\0"):
105
+ raise invalid(f"Header {name} contains a line break or NUL character.")
106
+ lower = name.lower()
107
+ if lower in RESERVED_HEADERS or lower.startswith(RESERVED_PREFIXES):
108
+ hint = " Use --content-type." if lower == "content-type" else ""
109
+ raise invalid(f"Header {name} is managed by the CLI and cannot be set.{hint}")
110
+ headers[name] = value
111
+ return headers
112
+
113
+
114
+ class _Inputs:
115
+ """Reads @file and @- sources with size limits; stdin may be consumed only once."""
116
+
117
+ def __init__(self, stdin=None):
118
+ self.stdin = stdin if stdin is not None else sys.stdin.buffer
119
+ self.used_stdin = False
120
+
121
+ def read(self, value: str, option: str, limit: int) -> bytes:
122
+ if value == "@-":
123
+ if self.used_stdin:
124
+ raise invalid("Only one option can read from stdin (@-).")
125
+ self.used_stdin = True
126
+ data = self.stdin.read(limit + 1)
127
+ elif value.startswith("@"):
128
+ path = Path(value[1:])
129
+ if not path.is_file():
130
+ raise invalid(f"{option}: file not found: {path}")
131
+ if path.stat().st_size > limit:
132
+ raise invalid(f"{option}: {path} is larger than {limit} bytes.")
133
+ data = path.read_bytes()
134
+ else:
135
+ data = value.encode("utf-8")
136
+ if len(data) > limit:
137
+ raise invalid(f"{option} is larger than {limit} bytes.")
138
+ return data
139
+
140
+
141
+ def build_request(args, stdin=None) -> Request:
142
+ method = args.method.upper()
143
+ request = Request(method=method, path=validate_path(args.path),
144
+ params=parse_query(args.query), headers=parse_headers(args.header))
145
+ bodies = [name for name, value in (("--data", args.data), ("--raw-data", args.raw_data), ("--form", args.form)) if value]
146
+ if len(bodies) > 1:
147
+ raise invalid(f"Choose one body option; got {' and '.join(bodies)}.")
148
+ # A body never silently changes the method.
149
+ if bodies and method in BODYLESS_METHODS:
150
+ raise invalid(f"{bodies[0]} needs an explicit method that accepts a body, such as -X POST.")
151
+ if args.content_type and not (args.data or args.raw_data):
152
+ raise invalid("--content-type applies only to --data or --raw-data.")
153
+ if args.content_type and any(ch in args.content_type for ch in "\r\n\0"):
154
+ raise invalid("--content-type contains a line break or NUL character.")
155
+ inputs = _Inputs(stdin)
156
+ if args.data:
157
+ body = inputs.read(args.data, "--data", MAX_DATA)
158
+ try:
159
+ json.loads(body)
160
+ except (ValueError, UnicodeDecodeError):
161
+ raise invalid("--data must be valid JSON; use --raw-data with --content-type for other formats.") from None
162
+ # Send the caller's bytes unchanged so no endpoint field is added, reordered or dropped.
163
+ request.body, request.content_type = body, args.content_type or "application/json"
164
+ elif args.raw_data:
165
+ if not args.content_type:
166
+ raise invalid("--raw-data needs --content-type.")
167
+ request.body = inputs.read(args.raw_data, "--raw-data", MAX_DATA)
168
+ request.content_type = args.content_type
169
+ total = 0
170
+ for item in args.form:
171
+ name, sep, value = item.partition("=")
172
+ if not sep or not name:
173
+ raise invalid(f"--form expects NAME=VALUE or NAME=@FILE, got {item!r}.")
174
+ if value.startswith("@"):
175
+ path = Path(value[1:])
176
+ if value == "@-" or not path.is_file():
177
+ raise invalid(f"--form {name}: expected an existing file, got {value!r}.")
178
+ total += path.stat().st_size
179
+ if total > MAX_UPLOAD:
180
+ raise invalid(f"Uploads are limited to {MAX_UPLOAD} bytes per request.")
181
+ request.form.append((name, None, path))
182
+ else:
183
+ request.form.append((name, value, None))
184
+ return request
185
+
186
+
187
+ def secret_values(email, token) -> list[str]:
188
+ """The token and every form derived from it that could appear in output."""
189
+ if not token:
190
+ return []
191
+ values = [token, quote(token, safe="")]
192
+ if email:
193
+ basic = base64.b64encode(f"{email}:{token}".encode()).decode()
194
+ values += [basic, basic.rstrip("=")]
195
+ return sorted({v for v in values if v}, key=len, reverse=True)
196
+
197
+
198
+ def _redactor(cfg: dict):
199
+ secrets = secret_values(cfg.get("email"), cfg.get("token"))
200
+
201
+ def redact(text: str) -> str:
202
+ for secret in secrets:
203
+ text = text.replace(secret, "[redacted]")
204
+ return text
205
+ return redact
206
+
207
+
208
+ def _read(response, limit: int) -> tuple[bytes, bool]:
209
+ """Read at most limit bytes; report whether more remained."""
210
+ data = bytearray()
211
+ for chunk in response.iter_content(64 * 1024):
212
+ data += chunk
213
+ if len(data) > limit:
214
+ return bytes(data[:limit]), True
215
+ return bytes(data), False
216
+
217
+
218
+ def _error_body(response, redact):
219
+ raw, _ = _read(response, MAX_ERROR_BODY)
220
+ text = redact(raw.decode(response.encoding or "utf-8", errors="replace"))
221
+ try:
222
+ return json.loads(text)
223
+ except ValueError:
224
+ return text
225
+
226
+
227
+ def check_output(destination: str) -> Path:
228
+ output = Path(destination)
229
+ if output.exists():
230
+ raise invalid(f"Destination already exists: {output}")
231
+ if not output.parent.is_dir():
232
+ raise invalid(f"Destination folder does not exist: {output.parent}")
233
+ return output
234
+
235
+
236
+ def _write_file(response, destination: str) -> int:
237
+ # Checked again here in case the destination appeared during the request.
238
+ output = check_output(destination)
239
+ fd, temporary = tempfile.mkstemp(prefix=".jira-api-", dir=output.parent)
240
+ received = 0
241
+ try:
242
+ with os.fdopen(fd, "wb") as stream:
243
+ for chunk in response.iter_content(1024 * 1024):
244
+ received += len(chunk)
245
+ if received > MAX_RESPONSE:
246
+ raise ApiError(1, "response_too_large", f"Response exceeds {MAX_RESPONSE} bytes; nothing was saved.")
247
+ stream.write(chunk)
248
+ # Hard-link creation fails atomically if the destination appeared meanwhile.
249
+ os.link(temporary, output)
250
+ finally:
251
+ os.unlink(temporary)
252
+ return received
253
+
254
+
255
+ def _write_stdout(response, stdout) -> None:
256
+ content_type = response.headers.get("Content-Type", "")
257
+ terminal = getattr(stdout, "isatty", lambda: False)()
258
+ if terminal and content_type and not TEXT_TYPES.match(content_type):
259
+ length = response.headers.get("Content-Length", "unknown")
260
+ print(f"Binary response ({content_type}, {length} bytes) not printed to the terminal; rerun with --output FILE.",
261
+ file=sys.stderr)
262
+ return
263
+ buffer = stdout.buffer if hasattr(stdout, "buffer") else stdout
264
+ if terminal and "json" in content_type.lower():
265
+ # Humans get indented JSON; pipes always get the upstream bytes.
266
+ body, truncated = _read(response, MAX_RESPONSE)
267
+ if truncated:
268
+ raise ApiError(1, "response_too_large", f"Response exceeds {MAX_RESPONSE} bytes.")
269
+ try:
270
+ body = (json.dumps(json.loads(body), indent=2, ensure_ascii=False) + "\n").encode("utf-8")
271
+ except ValueError:
272
+ pass
273
+ buffer.write(body)
274
+ buffer.flush()
275
+ return
276
+ received = 0
277
+ for chunk in response.iter_content(64 * 1024):
278
+ received += len(chunk)
279
+ if received > MAX_RESPONSE:
280
+ raise ApiError(1, "response_too_large", f"Response exceeds {MAX_RESPONSE} bytes; output is incomplete.")
281
+ buffer.write(chunk)
282
+ buffer.flush()
283
+
284
+
285
+ def _print_metadata(response, redact) -> None:
286
+ print(f"HTTP {response.status_code} {response.reason or ''}".rstrip(), file=sys.stderr)
287
+ for name, value in response.headers.items():
288
+ # Session cookies from the gateway are credentials too.
289
+ shown = "[redacted]" if name.lower() == "set-cookie" else redact(value)
290
+ print(f"{name}: {shown}", file=sys.stderr)
291
+ print(file=sys.stderr)
292
+
293
+
294
+ def execute(jira: Jira, request: Request, args, cfg: dict, stdout=None) -> None:
295
+ stdout = stdout or sys.stdout
296
+ redact = _redactor(cfg)
297
+ headers = dict(request.headers)
298
+ headers.setdefault("Accept", "application/json")
299
+ kw = dict(params=request.params, headers=headers, stream=True)
300
+ with ExitStack() as stack:
301
+ if request.form:
302
+ # requests writes the multipart boundary itself.
303
+ headers["Content-Type"] = None
304
+ kw["files"] = [(name, (path.name, stack.enter_context(path.open("rb")), "application/octet-stream")) if path
305
+ else (name, (None, value)) for name, value, path in request.form]
306
+ elif request.body is not None:
307
+ headers["Content-Type"] = request.content_type
308
+ kw["data"] = request.body
309
+ else:
310
+ headers["Content-Type"] = None
311
+ response = jira._send(request.method, request.path, safe=request.method in SAFE_METHODS, **kw)
312
+ with response:
313
+ # The prepared URL must still point at the trusted base after encoding.
314
+ sent, base = urlsplit(response.request.url if response.request else jira.site + request.path), urlsplit(jira.site)
315
+ if (sent.scheme, sent.netloc) != (base.scheme, base.netloc):
316
+ raise ApiError(2, "invalid_input", "The request did not resolve to the selected Jira site.")
317
+ if args.include:
318
+ _print_metadata(response, redact)
319
+ status = response.status_code
320
+ if 300 <= status < 400:
321
+ # Signed download URLs carry access tokens in the query, so only the address is shown.
322
+ target = urlsplit(response.headers.get("Location", ""))
323
+ raise ApiError(1, "jira_error", "Jira answered with a redirect, which is not followed so credentials stay "
324
+ "on the selected site. For endpoints that support it, such as attachment content, "
325
+ "pass --query redirect=false.", status=status, method=request.method, path=request.path,
326
+ location=redact(f"{target.scheme}://{target.netloc}{target.path}" if target.netloc else target.path))
327
+ if status >= 400:
328
+ raise ApiError(1, "jira_error", f"{request.method} {request.path} -> {status}", status=status,
329
+ method=request.method, path=request.path, body=_error_body(response, redact))
330
+ if request.method == "HEAD" or status == 204:
331
+ return
332
+ if args.output:
333
+ received = _write_file(response, args.output)
334
+ print(f"Saved {received} bytes to {Path(args.output).absolute()}", file=sys.stderr)
335
+ else:
336
+ _write_stdout(response, stdout)
337
+
338
+
339
+ def run(args, cfg: dict) -> None:
340
+ if args.csv or args.columns:
341
+ raise invalid("--csv and --columns do not apply to api; the response is written as Jira returns it.")
342
+ request = build_request(args)
343
+ if args.output:
344
+ # Fail before sending, so nothing is downloaded or changed for an unusable destination.
345
+ check_output(args.output)
346
+ if not (cfg["site"] and cfg["email"] and cfg["token"]):
347
+ raise invalid("No Jira identity is configured. Run: jira auth login")
348
+ jira = Jira(cfg.get("api_site", cfg["site"]), cfg["email"], cfg["token"])
349
+ try:
350
+ execute(jira, request, args, cfg)
351
+ finally:
352
+ jira.close()