183 lines
6.2 KiB
Python
183 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Attachment helpers for the power-user Redmine ticket client.
|
|
|
|
List (cache-first), download to local dir, upload via Redmine REST:
|
|
POST /uploads.json?filename=… (raw body)
|
|
PUT /issues/:id.json with issue.uploads token
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import mimetypes
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib import error as urllib_error
|
|
from urllib import parse as urllib_parse
|
|
from urllib import request as urllib_request
|
|
|
|
|
|
def safe_filename(name: str) -> str:
|
|
name = (name or "attachment.bin").strip().replace("\\", "_").replace("/", "_")
|
|
name = re.sub(r"[^\w.\- ()\[\]+@]+", "_", name)
|
|
return name or "attachment.bin"
|
|
|
|
|
|
def attachments_from_issue(issue: dict[str, Any]) -> list[dict[str, Any]]:
|
|
atts = issue.get("attachments")
|
|
if isinstance(atts, list):
|
|
return [a for a in atts if isinstance(a, dict)]
|
|
return []
|
|
|
|
|
|
def attachment_dir(cache_dir: Path, project: str, issue_id: int) -> Path:
|
|
path = Path(cache_dir) / project / "attachments" / str(int(issue_id))
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def pick_attachment(attachments: list[dict[str, Any]], attachment_id: int | None) -> dict[str, Any]:
|
|
if not attachments:
|
|
raise LookupError("Issue has no attachments.")
|
|
if attachment_id is None:
|
|
if len(attachments) == 1:
|
|
return attachments[0]
|
|
raise LookupError(
|
|
"Multiple attachments; pass --attachment-id. ids="
|
|
+ ",".join(str(a.get("id")) for a in attachments)
|
|
)
|
|
for att in attachments:
|
|
if int(att.get("id") or 0) == int(attachment_id):
|
|
return att
|
|
raise LookupError(f"Attachment id {attachment_id} not found on issue.")
|
|
|
|
|
|
def download_url_for_attachment(att: dict[str, Any], base_url: str) -> str:
|
|
urls = download_url_candidates(att, base_url)
|
|
return urls[0]
|
|
|
|
|
|
def download_url_candidates(att: dict[str, Any], base_url: str) -> list[str]:
|
|
"""Ordered download URL candidates (host rewrite + path variants)."""
|
|
base = base_url.rstrip("/")
|
|
aid = att.get("id")
|
|
filename = str(att.get("filename") or "").strip()
|
|
candidates: list[str] = []
|
|
|
|
def add(url: str) -> None:
|
|
if url and url not in candidates:
|
|
candidates.append(url)
|
|
|
|
for key in ("content_url", "contentUrl"):
|
|
raw = att.get(key)
|
|
if not raw:
|
|
continue
|
|
raw = str(raw)
|
|
add(raw)
|
|
# rewrite host to active base_url (prod content_url on LAN client, etc.)
|
|
try:
|
|
from urllib.parse import urlparse, urlunparse
|
|
|
|
parsed = urlparse(raw)
|
|
base_p = urlparse(base if "://" in base else f"http://{base}")
|
|
rewritten = urlunparse(
|
|
(
|
|
base_p.scheme or parsed.scheme,
|
|
base_p.netloc or parsed.netloc,
|
|
parsed.path,
|
|
"",
|
|
parsed.query,
|
|
"",
|
|
)
|
|
)
|
|
add(rewritten)
|
|
except Exception:
|
|
pass
|
|
|
|
if aid is not None:
|
|
add(f"{base}/attachments/download/{int(aid)}")
|
|
if filename:
|
|
from urllib.parse import quote
|
|
|
|
add(f"{base}/attachments/download/{int(aid)}/{quote(filename)}")
|
|
if not candidates:
|
|
raise LookupError("Attachment missing id and content_url.")
|
|
return candidates
|
|
|
|
|
|
def download_attachment_bytes(
|
|
*,
|
|
url: str,
|
|
api_key: str,
|
|
impersonate_user: str | None = None,
|
|
timeout: int = 120,
|
|
fallback_urls: list[str] | None = None,
|
|
) -> bytes:
|
|
headers = {
|
|
"X-Redmine-API-Key": api_key,
|
|
"Accept": "*/*",
|
|
}
|
|
if impersonate_user:
|
|
headers["X-Redmine-Switch-User"] = impersonate_user
|
|
urls = [url] + [u for u in (fallback_urls or []) if u != url]
|
|
last_err: Exception | None = None
|
|
for u in urls:
|
|
req = urllib_request.Request(u, headers=headers, method="GET")
|
|
try:
|
|
with urllib_request.urlopen(req, timeout=timeout) as resp:
|
|
return resp.read()
|
|
except urllib_error.HTTPError as exc:
|
|
detail = exc.read().decode("utf-8", errors="replace")[:200]
|
|
last_err = RuntimeError(f"HTTP {exc.code} downloading attachment ({u}): {detail}")
|
|
continue
|
|
except urllib_error.URLError as exc:
|
|
last_err = RuntimeError(f"Could not download attachment ({u}): {exc.reason}")
|
|
continue
|
|
raise last_err or RuntimeError("Attachment download failed")
|
|
|
|
|
|
def upload_file_token(
|
|
*,
|
|
base_url: str,
|
|
api_key: str,
|
|
path: Path,
|
|
filename: str | None = None,
|
|
impersonate_user: str | None = None,
|
|
timeout: int = 120,
|
|
) -> dict[str, Any]:
|
|
"""POST raw file to /uploads.json?filename=… Returns upload object with token."""
|
|
path = Path(path)
|
|
if not path.is_file():
|
|
raise FileNotFoundError(f"File not found: {path}")
|
|
name = filename or path.name
|
|
data = path.read_bytes()
|
|
q = urllib_parse.urlencode({"filename": name})
|
|
url = f"{base_url.rstrip('/')}/uploads.json?{q}"
|
|
headers = {
|
|
"X-Redmine-API-Key": api_key,
|
|
"Content-Type": "application/octet-stream",
|
|
"Accept": "application/json",
|
|
}
|
|
if impersonate_user:
|
|
headers["X-Redmine-Switch-User"] = impersonate_user
|
|
req = urllib_request.Request(url, data=data, headers=headers, method="POST")
|
|
try:
|
|
with urllib_request.urlopen(req, timeout=timeout) as resp:
|
|
raw = resp.read()
|
|
except urllib_error.HTTPError as exc:
|
|
detail = exc.read().decode("utf-8", errors="replace")[:500]
|
|
raise RuntimeError(f"HTTP {exc.code} uploading file: {detail}") from exc
|
|
except urllib_error.URLError as exc:
|
|
raise RuntimeError(f"Could not upload file: {exc.reason}") from exc
|
|
if not raw:
|
|
raise RuntimeError("Empty response from /uploads.json")
|
|
payload = json.loads(raw.decode("utf-8"))
|
|
upload = payload.get("upload") if isinstance(payload, dict) else None
|
|
if not isinstance(upload, dict) or not upload.get("token"):
|
|
raise RuntimeError(f"Unexpected upload response: {payload!r}")
|
|
content_type = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
|
upload.setdefault("filename", name)
|
|
upload.setdefault("content_type", content_type)
|
|
return upload
|