ver 1.0.0 initial commit
This commit is contained in:
@@ -0,0 +1,775 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
op — command-line interface to an OpenProject instance.
|
||||
|
||||
A thin, friendly CLI over the OpenProject API v3, designed for Claude (and you)
|
||||
to read and manage projects, work packages, time entries, and more.
|
||||
|
||||
Run `op.py --help` or `op.py <command> --help` for usage.
|
||||
|
||||
Output: human-readable tables by default; add `--json` to any command for the
|
||||
raw HAL+JSON (useful for chaining / precise field access).
|
||||
|
||||
Examples:
|
||||
op.py ping
|
||||
op.py projects --search infra
|
||||
op.py wp list --project 3 --assignee me --status open
|
||||
op.py wp show 1234
|
||||
op.py wp create --project 3 --type Task --subject "Fix the thing" \
|
||||
--description "details..." --assignee me --priority High
|
||||
op.py wp update 1234 --status "In progress" --done-ratio 50
|
||||
op.py wp comment 1234 --text "Working on it"
|
||||
op.py wp close 1234
|
||||
op.py wp delete 1234 --yes
|
||||
op.py time log --wp 1234 --hours 1.5 --comment "investigation"
|
||||
op.py raw GET /api/v3/statuses
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import sys
|
||||
|
||||
from opclient import OpenProjectClient, OpenProjectError
|
||||
|
||||
|
||||
def _today():
|
||||
return datetime.date.today().isoformat()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Output helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def emit_json(obj):
|
||||
print(json.dumps(obj, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def table(rows, headers):
|
||||
"""Render a simple aligned text table."""
|
||||
if not rows:
|
||||
print("(no results)")
|
||||
return
|
||||
cols = list(zip(*([headers] + rows)))
|
||||
widths = [max(len(str(c)) for c in col) for col in cols]
|
||||
line = " ".join(str(h).ljust(w) for h, w in zip(headers, widths))
|
||||
print(line)
|
||||
print(" ".join("-" * w for w in widths))
|
||||
for row in rows:
|
||||
print(" ".join(str(c).ljust(w) for c, w in zip(row, widths)))
|
||||
|
||||
|
||||
def wp_row(wp, client):
|
||||
return [
|
||||
wp.get("id"),
|
||||
(wp.get("subject") or "")[:50],
|
||||
client.link_title(wp, "type") or "",
|
||||
client.link_title(wp, "status") or "",
|
||||
client.link_title(wp, "assignee") or "—",
|
||||
client.link_title(wp, "project") or "",
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Filter building
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def build_wp_filters(client, args):
|
||||
filters = []
|
||||
if getattr(args, "project", None):
|
||||
proj = client.find_project(args.project)
|
||||
filters.append({"project": {"operator": "=", "values": [str(proj["id"])]}})
|
||||
if getattr(args, "assignee", None):
|
||||
if args.assignee.lower() == "me":
|
||||
filters.append({"assignee": {"operator": "=", "values": ["me"]}})
|
||||
else:
|
||||
user = client.find_user(args.assignee)
|
||||
filters.append({"assignee": {"operator": "=", "values": [str(user["id"])]}})
|
||||
if getattr(args, "status", None):
|
||||
s = args.status.lower()
|
||||
if s == "open":
|
||||
filters.append({"status": {"operator": "o", "values": None}})
|
||||
elif s in ("closed", "close"):
|
||||
filters.append({"status": {"operator": "c", "values": None}})
|
||||
else:
|
||||
st = client.find_status(args.status)
|
||||
filters.append({"status": {"operator": "=", "values": [str(st["id"])]}})
|
||||
if getattr(args, "type", None):
|
||||
t = client.find_type(args.type)
|
||||
filters.append({"type": {"operator": "=", "values": [str(t["id"])]}})
|
||||
if getattr(args, "query", None):
|
||||
filters.append({"search": {"operator": "**", "values": [args.query]}})
|
||||
return filters
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Commands
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def cmd_ping(client, args):
|
||||
me = client.me()
|
||||
name = me.get("name") or me.get("login")
|
||||
print(f"OK — authenticated as {name} (id {me.get('id')}) at {client.base_url}")
|
||||
|
||||
|
||||
def cmd_me(client, args):
|
||||
me = client.me()
|
||||
if args.json:
|
||||
emit_json(me)
|
||||
return
|
||||
for k in ("id", "name", "login", "email", "admin", "status"):
|
||||
if k in me:
|
||||
print(f"{k:>8}: {me[k]}")
|
||||
|
||||
|
||||
def cmd_projects(client, args):
|
||||
filters = None
|
||||
if args.search:
|
||||
filters = [{"name_and_identifier": {"operator": "~", "values": [args.search]}}]
|
||||
items = client.get_all("/api/v3/projects", params={"filters": filters} if filters else None,
|
||||
max_items=args.limit)
|
||||
if args.json:
|
||||
emit_json(items)
|
||||
return
|
||||
rows = [[p.get("id"), p.get("identifier"), (p.get("name") or "")[:50],
|
||||
"archived" if not p.get("active", True) else "active"] for p in items]
|
||||
table(rows, ["ID", "IDENTIFIER", "NAME", "STATE"])
|
||||
print(f"\n{len(items)} project(s)")
|
||||
|
||||
|
||||
def cmd_wp_list(client, args):
|
||||
filters = build_wp_filters(client, args)
|
||||
params = {"pageSize": args.limit}
|
||||
if filters:
|
||||
params["filters"] = filters
|
||||
if args.sort:
|
||||
params["sortBy"] = [args.sort.split(":")] if ":" in args.sort else [[args.sort, "asc"]]
|
||||
items = client.get_all("/api/v3/work_packages", params=params, max_items=args.limit)
|
||||
if args.json:
|
||||
emit_json(items)
|
||||
return
|
||||
rows = [wp_row(wp, client) for wp in items]
|
||||
table(rows, ["ID", "SUBJECT", "TYPE", "STATUS", "ASSIGNEE", "PROJECT"])
|
||||
print(f"\n{len(items)} work package(s)")
|
||||
|
||||
|
||||
def cmd_wp_show(client, args):
|
||||
wp = client.get(f"/api/v3/work_packages/{args.id}")
|
||||
if args.json:
|
||||
emit_json(wp)
|
||||
return
|
||||
L = client.link_title
|
||||
print(f"#{wp.get('id')} {wp.get('subject')}")
|
||||
print(f" project : {L(wp,'project')}")
|
||||
print(f" type : {L(wp,'type')}")
|
||||
print(f" status : {L(wp,'status')} ({wp.get('percentageDone', 0)}% done)")
|
||||
print(f" priority: {L(wp,'priority')}")
|
||||
print(f" assignee: {L(wp,'assignee') or '—'}")
|
||||
print(f" author : {L(wp,'author')}")
|
||||
if wp.get("startDate") or wp.get("dueDate"):
|
||||
print(f" dates : {wp.get('startDate') or '?'} → {wp.get('dueDate') or '?'}")
|
||||
print(f" version : lockVersion={wp.get('lockVersion')}")
|
||||
desc = (wp.get("description") or {}).get("raw") or ""
|
||||
if desc.strip():
|
||||
print(" description:")
|
||||
for line in desc.splitlines():
|
||||
print(f" {line}")
|
||||
|
||||
|
||||
def _wp_link_body(client, args):
|
||||
"""Build the _links + scalar body for create/update from common args."""
|
||||
body = {}
|
||||
links = {}
|
||||
if getattr(args, "subject", None) is not None:
|
||||
body["subject"] = args.subject
|
||||
if getattr(args, "description", None) is not None:
|
||||
body["description"] = {"format": "markdown", "raw": args.description}
|
||||
if getattr(args, "start", None) is not None:
|
||||
body["startDate"] = args.start
|
||||
if getattr(args, "due", None) is not None:
|
||||
body["dueDate"] = args.due
|
||||
if getattr(args, "done_ratio", None) is not None:
|
||||
body["percentageDone"] = int(args.done_ratio)
|
||||
if getattr(args, "project", None):
|
||||
links["project"] = {"href": f"/api/v3/projects/{client.find_project(args.project)['id']}"}
|
||||
if getattr(args, "type", None):
|
||||
links["type"] = {"href": f"/api/v3/types/{client.find_type(args.type)['id']}"}
|
||||
if getattr(args, "status", None):
|
||||
links["status"] = {"href": f"/api/v3/statuses/{client.find_status(args.status)['id']}"}
|
||||
if getattr(args, "priority", None):
|
||||
links["priority"] = {"href": f"/api/v3/priorities/{client.find_priority(args.priority)['id']}"}
|
||||
if getattr(args, "assignee", None):
|
||||
if args.assignee.lower() in ("none", "unassign"):
|
||||
links["assignee"] = {"href": None}
|
||||
else:
|
||||
links["assignee"] = {"href": f"/api/v3/users/{client.find_user(args.assignee)['id']}"}
|
||||
if getattr(args, "parent", None):
|
||||
links["parent"] = {"href": f"/api/v3/work_packages/{args.parent}"}
|
||||
if links:
|
||||
body["_links"] = links
|
||||
return body
|
||||
|
||||
|
||||
def cmd_wp_create(client, args):
|
||||
body = _wp_link_body(client, args)
|
||||
if "subject" not in body:
|
||||
raise OpenProjectError("--subject is required to create a work package.")
|
||||
if "_links" not in body or "project" not in body["_links"]:
|
||||
raise OpenProjectError("--project is required to create a work package.")
|
||||
if "type" not in body.get("_links", {}):
|
||||
# Default to the project's first available type.
|
||||
proj_id = client.id_from_href(body["_links"]["project"]["href"])
|
||||
types = client.get_all(f"/api/v3/projects/{proj_id}/types", max_items=1)
|
||||
if types:
|
||||
body["_links"]["type"] = {"href": types[0]["_links"]["self"]["href"]}
|
||||
created = client.create_work_package(body)
|
||||
if args.json:
|
||||
emit_json(created)
|
||||
return
|
||||
print(f"Created #{created.get('id')}: {created.get('subject')}")
|
||||
print(f" {client.base_url}/work_packages/{created.get('id')}")
|
||||
|
||||
|
||||
def cmd_wp_update(client, args):
|
||||
current = client.get(f"/api/v3/work_packages/{args.id}")
|
||||
body = _wp_link_body(client, args)
|
||||
if not body:
|
||||
raise OpenProjectError("Nothing to update — pass at least one field.")
|
||||
body["lockVersion"] = current.get("lockVersion")
|
||||
updated = client.patch(f"/api/v3/work_packages/{args.id}", body=body)
|
||||
if args.json:
|
||||
emit_json(updated)
|
||||
return
|
||||
print(f"Updated #{updated.get('id')}: {updated.get('subject')} "
|
||||
f"[{client.link_title(updated,'status')}]")
|
||||
|
||||
|
||||
def cmd_wp_comment(client, args):
|
||||
body = {"comment": {"format": "markdown", "raw": args.text}}
|
||||
res = client.post(f"/api/v3/work_packages/{args.id}/activities", body=body)
|
||||
if args.json:
|
||||
emit_json(res)
|
||||
return
|
||||
print(f"Comment added to #{args.id}.")
|
||||
|
||||
|
||||
def cmd_wp_close(client, args):
|
||||
current = client.get(f"/api/v3/work_packages/{args.id}")
|
||||
status = client.find_status(args.status_name)
|
||||
body = {
|
||||
"lockVersion": current.get("lockVersion"),
|
||||
"_links": {"status": {"href": f"/api/v3/statuses/{status['id']}"}},
|
||||
}
|
||||
updated = client.patch(f"/api/v3/work_packages/{args.id}", body=body)
|
||||
print(f"#{args.id} → {client.link_title(updated,'status')}")
|
||||
|
||||
|
||||
def cmd_wp_delete(client, args):
|
||||
if not args.yes:
|
||||
raise OpenProjectError(
|
||||
f"Refusing to delete #{args.id} without --yes. "
|
||||
f"This permanently removes the work package."
|
||||
)
|
||||
client.delete(f"/api/v3/work_packages/{args.id}")
|
||||
print(f"Deleted work package #{args.id}.")
|
||||
|
||||
|
||||
def cmd_time_log(client, args):
|
||||
wp = client.get(f"/api/v3/work_packages/{args.wp}")
|
||||
proj_href = wp["_links"]["project"]["href"]
|
||||
body = {
|
||||
"hours": f"PT{args.hours}H" if not str(args.hours).startswith("PT") else args.hours,
|
||||
"_links": {
|
||||
"workPackage": {"href": f"/api/v3/work_packages/{args.wp}"},
|
||||
"project": {"href": proj_href},
|
||||
},
|
||||
}
|
||||
if args.comment:
|
||||
body["comment"] = {"format": "markdown", "raw": args.comment}
|
||||
# spentOn is required by the API; default to today when not given.
|
||||
body["spentOn"] = args.date or _today()
|
||||
if args.activity:
|
||||
act = client._resolve_by_name("/api/v3/time_entries/activities", args.activity)
|
||||
body["_links"]["activity"] = {"href": act["_links"]["self"]["href"]}
|
||||
res = client.post("/api/v3/time_entries", body=body)
|
||||
if args.json:
|
||||
emit_json(res)
|
||||
return
|
||||
print(f"Logged {args.hours}h on #{args.wp}.")
|
||||
|
||||
|
||||
def cmd_users(client, args):
|
||||
filters = None
|
||||
if args.search:
|
||||
filters = [{"name": {"operator": "~", "values": [args.search]}}]
|
||||
items = client.get_all("/api/v3/users", params={"filters": filters} if filters else None,
|
||||
max_items=args.limit)
|
||||
if args.json:
|
||||
emit_json(items)
|
||||
return
|
||||
rows = [[u.get("id"), u.get("login"), u.get("name"), u.get("email", "")] for u in items]
|
||||
table(rows, ["ID", "LOGIN", "NAME", "EMAIL"])
|
||||
|
||||
|
||||
def _simple_list(client, path, args, name_field="name"):
|
||||
items = client.get_all(path, max_items=200)
|
||||
if args.json:
|
||||
emit_json(items)
|
||||
return
|
||||
rows = [[i.get("id"), i.get(name_field)] for i in items]
|
||||
table(rows, ["ID", "NAME"])
|
||||
|
||||
|
||||
def cmd_types(client, args):
|
||||
if args.project:
|
||||
proj = client.find_project(args.project)
|
||||
_simple_list(client, f"/api/v3/projects/{proj['id']}/types", args)
|
||||
else:
|
||||
_simple_list(client, "/api/v3/types", args)
|
||||
|
||||
|
||||
def cmd_statuses(client, args):
|
||||
_simple_list(client, "/api/v3/statuses", args)
|
||||
|
||||
|
||||
def cmd_priorities(client, args):
|
||||
_simple_list(client, "/api/v3/priorities", args)
|
||||
|
||||
|
||||
def cmd_roles(client, args):
|
||||
_simple_list(client, "/api/v3/roles", args)
|
||||
|
||||
|
||||
def cmd_project_create(client, args):
|
||||
created = client.create_project(
|
||||
args.name, identifier=args.identifier,
|
||||
description=args.description, parent=args.parent)
|
||||
if args.json:
|
||||
emit_json(created)
|
||||
return
|
||||
print(f"Created project '{created.get('name')}' "
|
||||
f"(id {created.get('id')}, identifier {created.get('identifier')})")
|
||||
print(f" {client.base_url}/projects/{created.get('identifier')}")
|
||||
|
||||
|
||||
def cmd_members_add(client, args):
|
||||
proj = client.find_project(args.project)
|
||||
user = client.find_user(args.user)
|
||||
client.add_membership(proj["id"], user["id"], args.role)
|
||||
print(f"Added {user.get('name')} to '{proj.get('name')}' as {args.role}.")
|
||||
|
||||
|
||||
def cmd_wp_relate(client, args):
|
||||
res = client.create_relation(args.id, args.to, rel_type=args.type,
|
||||
description=args.description)
|
||||
print(f"#{args.id} {args.type} #{args.to} (relation {res.get('id')})")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Import: spreadsheet -> project + work packages
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Default value mappings (tuned for the APTA planning register; override-able
|
||||
# would be a future flag, but these are sensible defaults for H/M/L + statuses).
|
||||
PRIORITY_MAP = {"H": "High", "M": "Normal", "L": "Low",
|
||||
"HIGH": "High", "MEDIUM": "Normal", "LOW": "Low"}
|
||||
STATUS_MAP = {
|
||||
"OPEN": "New", "NEW": "New",
|
||||
"IN PROGRESS": "In progress", "INPROGRESS": "In progress",
|
||||
"COMPLETE": "Closed", "COMPLETED": "Closed", "DONE": "Closed",
|
||||
"CLOSED": "Closed",
|
||||
}
|
||||
DEFAULT_PRIORITY = "Normal"
|
||||
DEFAULT_STATUS = "New"
|
||||
|
||||
# Column header -> internal field. Header lookup is case-insensitive and matches
|
||||
# on a prefix so "Priority (H/M/L)" maps to priority.
|
||||
COLUMN_ALIASES = {
|
||||
"task": "subject", "title": "subject", "name": "subject",
|
||||
"category": "category", "group": "category",
|
||||
"owner": "owner", "assignee": "owner", "assigned": "owner",
|
||||
"priority": "priority",
|
||||
"status": "status",
|
||||
"due": "due", "due date": "due",
|
||||
"notes": "notes", "notes / dependencies": "notes", "dependencies": "notes",
|
||||
"#": "ref", "id": "ref",
|
||||
}
|
||||
|
||||
|
||||
def _map_columns(headers):
|
||||
"""Build {internal_field: header_string} from the sheet headers."""
|
||||
mapping = {}
|
||||
for h in headers:
|
||||
if not h:
|
||||
continue
|
||||
key = h.strip().lower()
|
||||
field = COLUMN_ALIASES.get(key)
|
||||
if not field:
|
||||
for alias, fld in COLUMN_ALIASES.items():
|
||||
if key.startswith(alias):
|
||||
field = fld
|
||||
break
|
||||
if field and field not in mapping:
|
||||
mapping[field] = h
|
||||
return mapping
|
||||
|
||||
|
||||
def _build_description(rec, cols, owner_noted):
|
||||
notes = (rec.get(cols.get("notes", "")) or "").strip()
|
||||
meta = []
|
||||
if cols.get("ref") and rec.get(cols["ref"]):
|
||||
ref = str(rec[cols["ref"]]).rstrip("0").rstrip(".")
|
||||
meta.append(f"- Source row #: {ref}")
|
||||
if cols.get("category") and rec.get(cols["category"]):
|
||||
meta.append(f"- Category: {rec[cols['category']].strip()}")
|
||||
if owner_noted:
|
||||
meta.append(f"- Owner: {owner_noted}")
|
||||
if cols.get("due") and (rec.get(cols["due"]) or "").strip():
|
||||
meta.append(f"- Due (from sheet): {rec[cols['due']].strip()}")
|
||||
parts = []
|
||||
if notes:
|
||||
parts.append(notes)
|
||||
if meta:
|
||||
parts.append("---\n" + "\n".join(meta))
|
||||
return "\n\n".join(parts) if parts else None
|
||||
|
||||
|
||||
def cmd_import_xlsx(client, args):
|
||||
import xlsx_read # local import so the CLI still loads without the file present
|
||||
|
||||
headers, records = xlsx_read.read_table(args.path, args.sheet)
|
||||
cols = _map_columns(headers)
|
||||
if "subject" not in cols:
|
||||
raise OpenProjectError(
|
||||
f"Could not find a task/title column in sheet '{args.sheet}'. "
|
||||
f"Headers seen: {[h for h in headers if h]}")
|
||||
|
||||
# Keep only rows with a real subject.
|
||||
rows = [r for r in records if (r.get(cols["subject"]) or "").strip()]
|
||||
if args.limit:
|
||||
rows = rows[: args.limit]
|
||||
|
||||
# Category normalization (merge near-duplicates), e.g.
|
||||
# --merge-category "Display & Technology=Display & Tech"
|
||||
merge_map = {}
|
||||
for pair in getattr(args, "merge_category", None) or []:
|
||||
if "=" in pair:
|
||||
old, new = pair.split("=", 1)
|
||||
merge_map[old.strip().lower()] = new.strip()
|
||||
cat_col = cols.get("category")
|
||||
|
||||
def norm_cat(r):
|
||||
c = (r.get(cat_col) or "Uncategorized").strip() if cat_col else "Tasks"
|
||||
return merge_map.get(c.lower(), c)
|
||||
|
||||
# Preserve first-seen category order.
|
||||
categories = []
|
||||
for r in rows:
|
||||
c = norm_cat(r)
|
||||
if c not in categories:
|
||||
categories.append(c)
|
||||
|
||||
# Resolve owners once (match existing users, else note in description).
|
||||
owner_col = cols.get("owner")
|
||||
owner_cache = {}
|
||||
if owner_col:
|
||||
for r in rows:
|
||||
o = (r.get(owner_col) or "").strip()
|
||||
if not o or o.upper() == "TBD" or o in owner_cache:
|
||||
continue
|
||||
try:
|
||||
owner_cache[o] = client.find_user(o)
|
||||
except OpenProjectError:
|
||||
owner_cache[o] = None
|
||||
|
||||
def map_priority(r):
|
||||
raw = (r.get(cols["priority"]) or "").strip().upper() if "priority" in cols else ""
|
||||
return PRIORITY_MAP.get(raw, DEFAULT_PRIORITY)
|
||||
|
||||
def map_status(r):
|
||||
raw = (r.get(cols["status"]) or "").strip().upper() if "status" in cols else ""
|
||||
return STATUS_MAP.get(raw, DEFAULT_STATUS)
|
||||
|
||||
# ----- Dry run: report the plan, write nothing ----- #
|
||||
if not args.execute:
|
||||
print(f"DRY RUN — would import {len(rows)} task(s) from sheet "
|
||||
f"'{args.sheet}' into a new project '{args.project_name}'.\n")
|
||||
print(f"Detected columns: {cols}\n")
|
||||
print(f"Hierarchy: {len(categories)} parent Summary task(s), each with its rows as children:")
|
||||
for c in categories:
|
||||
n = sum(1 for r in rows if norm_cat(r) == c)
|
||||
print(f" • {c} ({n})")
|
||||
if owner_col:
|
||||
matched = {o: u for o, u in owner_cache.items() if u}
|
||||
unmatched = sorted(o for o, u in owner_cache.items() if not u)
|
||||
print(f"\nOwners: {len(matched)} matched to users "
|
||||
f"{[u.get('name') for u in matched.values()]}; "
|
||||
f"{len(unmatched)} recorded in description: {unmatched}")
|
||||
print("\nSample of first 5 mapped tasks:")
|
||||
for r in rows[:5]:
|
||||
print(f" [{map_priority(r)}/{map_status(r)}] "
|
||||
f"{(r.get(cols['subject']) or '').strip()[:60]}")
|
||||
print("\nNothing was written. Re-run with --execute to create everything.")
|
||||
return
|
||||
|
||||
# ----- Execute: create project -> parents -> children ----- #
|
||||
print(f"Creating project '{args.project_name}'...")
|
||||
project = client.create_project(args.project_name, identifier=args.identifier,
|
||||
description=args.project_description)
|
||||
pid = project["id"]
|
||||
proj_href = f"/api/v3/projects/{pid}"
|
||||
summary_type = client.find_type("Summary task")
|
||||
task_type = client.find_type(args.task_type)
|
||||
print(f" project id {pid} — {client.base_url}/projects/{project['identifier']}")
|
||||
|
||||
# Parent per category.
|
||||
parent_id = {}
|
||||
for c in categories:
|
||||
body = {"subject": c, "_links": {
|
||||
"project": {"href": proj_href},
|
||||
"type": {"href": f"/api/v3/types/{summary_type['id']}"}}}
|
||||
wp = client.create_work_package(body)
|
||||
parent_id[c] = wp["id"]
|
||||
print(f" parent: {c} -> #{wp['id']}")
|
||||
|
||||
created, errors = 0, []
|
||||
for r in rows:
|
||||
subject = (r.get(cols["subject"]) or "").strip()
|
||||
cat = norm_cat(r)
|
||||
owner_raw = (r.get(owner_col) or "").strip() if owner_col else ""
|
||||
owner_user = owner_cache.get(owner_raw)
|
||||
owner_noted = owner_raw if (owner_raw and owner_raw.upper() != "TBD" and not owner_user) else None
|
||||
links = {
|
||||
"project": {"href": proj_href},
|
||||
"type": {"href": f"/api/v3/types/{task_type['id']}"},
|
||||
"parent": {"href": f"/api/v3/work_packages/{parent_id[cat]}"},
|
||||
"priority": {"href": f"/api/v3/priorities/{client.find_priority(map_priority(r))['id']}"},
|
||||
"status": {"href": f"/api/v3/statuses/{client.find_status(map_status(r))['id']}"},
|
||||
}
|
||||
if owner_user:
|
||||
links["assignee"] = {"href": f"/api/v3/users/{owner_user['id']}"}
|
||||
body = {"subject": subject[:255], "_links": links}
|
||||
desc = _build_description(r, cols, owner_noted)
|
||||
if desc:
|
||||
body["description"] = {"format": "markdown", "raw": desc}
|
||||
try:
|
||||
wp = client.create_work_package(body)
|
||||
created += 1
|
||||
if created % 20 == 0:
|
||||
print(f" ... {created}/{len(rows)} tasks created")
|
||||
except OpenProjectError as exc:
|
||||
errors.append((subject[:50], str(exc)))
|
||||
|
||||
print(f"\nDone. Project '{project['name']}': "
|
||||
f"{len(categories)} parents + {created} tasks created.")
|
||||
print(f" {client.base_url}/projects/{project['identifier']}/work_packages")
|
||||
if errors:
|
||||
print(f"\n{len(errors)} row(s) failed:")
|
||||
for subj, err in errors[:15]:
|
||||
print(f" ✗ {subj}: {err}")
|
||||
|
||||
|
||||
def cmd_raw(client, args):
|
||||
body = json.loads(args.data) if args.data else None
|
||||
method = args.method.upper()
|
||||
if method == "GET":
|
||||
res = client.get(args.path)
|
||||
elif method == "POST":
|
||||
res = client.post(args.path, body=body)
|
||||
elif method == "PATCH":
|
||||
res = client.patch(args.path, body=body)
|
||||
elif method == "DELETE":
|
||||
res = client.delete(args.path)
|
||||
else:
|
||||
raise OpenProjectError(f"Unsupported method {method}")
|
||||
emit_json(res)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Argument parser
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(prog="op", description="OpenProject CLI")
|
||||
p.add_argument("--json", action="store_true", help="raw JSON output")
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("ping", help="verify connectivity + auth")
|
||||
sub.add_parser("me", help="show the authenticated user")
|
||||
|
||||
sp = sub.add_parser("projects", help="list/search projects")
|
||||
sp.add_argument("--search", help="name/identifier fragment")
|
||||
sp.add_argument("--limit", type=int, default=100)
|
||||
|
||||
us = sub.add_parser("users", help="list/search users")
|
||||
us.add_argument("--search")
|
||||
us.add_argument("--limit", type=int, default=100)
|
||||
|
||||
ty = sub.add_parser("types", help="list work package types")
|
||||
ty.add_argument("--project", help="restrict to types enabled in a project")
|
||||
sub.add_parser("statuses", help="list statuses")
|
||||
sub.add_parser("priorities", help="list priorities")
|
||||
sub.add_parser("roles", help="list membership roles")
|
||||
|
||||
pj = sub.add_parser("project", help="project admin")
|
||||
pjsub = pj.add_subparsers(dest="project_command", required=True)
|
||||
pc = pjsub.add_parser("create", help="create a project")
|
||||
pc.add_argument("--name", required=True)
|
||||
pc.add_argument("--identifier", help="url slug (default: derived from name)")
|
||||
pc.add_argument("--description")
|
||||
pc.add_argument("--parent", help="parent project ref (for a subproject)")
|
||||
|
||||
mb = sub.add_parser("members", help="project memberships")
|
||||
mbsub = mb.add_subparsers(dest="members_command", required=True)
|
||||
ma = mbsub.add_parser("add", help="add a user to a project with a role")
|
||||
ma.add_argument("--project", required=True)
|
||||
ma.add_argument("--user", required=True)
|
||||
ma.add_argument("--role", required=True, help="role name or id (e.g. Member)")
|
||||
|
||||
wp = sub.add_parser("wp", help="work packages")
|
||||
wpsub = wp.add_subparsers(dest="wp_command", required=True)
|
||||
|
||||
wl = wpsub.add_parser("list", help="list/search work packages")
|
||||
wl.add_argument("--project")
|
||||
wl.add_argument("--assignee", help="user ref or 'me'")
|
||||
wl.add_argument("--status", help="'open', 'closed', or a status name/id")
|
||||
wl.add_argument("--type")
|
||||
wl.add_argument("--query", help="free-text search")
|
||||
wl.add_argument("--sort", help="field or field:asc|desc")
|
||||
wl.add_argument("--limit", type=int, default=50)
|
||||
|
||||
ws = wpsub.add_parser("show", help="show one work package")
|
||||
ws.add_argument("id")
|
||||
|
||||
wc = wpsub.add_parser("create", help="create a work package")
|
||||
wc.add_argument("--project", required=True)
|
||||
wc.add_argument("--subject", required=True)
|
||||
wc.add_argument("--type")
|
||||
wc.add_argument("--description")
|
||||
wc.add_argument("--assignee")
|
||||
wc.add_argument("--status")
|
||||
wc.add_argument("--priority")
|
||||
wc.add_argument("--parent")
|
||||
wc.add_argument("--start")
|
||||
wc.add_argument("--due")
|
||||
wc.add_argument("--done-ratio", dest="done_ratio")
|
||||
|
||||
wu = wpsub.add_parser("update", help="update a work package (auto lockVersion)")
|
||||
wu.add_argument("id")
|
||||
wu.add_argument("--subject")
|
||||
wu.add_argument("--description")
|
||||
wu.add_argument("--assignee", help="user ref, 'me', or 'none' to unassign")
|
||||
wu.add_argument("--status")
|
||||
wu.add_argument("--priority")
|
||||
wu.add_argument("--type")
|
||||
wu.add_argument("--parent")
|
||||
wu.add_argument("--start")
|
||||
wu.add_argument("--due")
|
||||
wu.add_argument("--done-ratio", dest="done_ratio")
|
||||
|
||||
wcm = wpsub.add_parser("comment", help="add a comment")
|
||||
wcm.add_argument("id")
|
||||
wcm.add_argument("--text", required=True)
|
||||
|
||||
wcl = wpsub.add_parser("close", help="set status (default 'Closed')")
|
||||
wcl.add_argument("id")
|
||||
wcl.add_argument("--status-name", dest="status_name", default="Closed")
|
||||
|
||||
wd = wpsub.add_parser("delete", help="delete a work package (needs --yes)")
|
||||
wd.add_argument("id")
|
||||
wd.add_argument("--yes", action="store_true")
|
||||
|
||||
wr = wpsub.add_parser("relate", help="link two work packages")
|
||||
wr.add_argument("id")
|
||||
wr.add_argument("--to", required=True, help="target work package id")
|
||||
wr.add_argument("--type", default="relates",
|
||||
help="relates|blocks|blocked|precedes|follows|requires|...")
|
||||
wr.add_argument("--description")
|
||||
|
||||
tm = sub.add_parser("time", help="time entries")
|
||||
tmsub = tm.add_subparsers(dest="time_command", required=True)
|
||||
tl = tmsub.add_parser("log", help="log time on a work package")
|
||||
tl.add_argument("--wp", required=True)
|
||||
tl.add_argument("--hours", required=True)
|
||||
tl.add_argument("--comment")
|
||||
tl.add_argument("--date", help="YYYY-MM-DD (default today)")
|
||||
tl.add_argument("--activity")
|
||||
|
||||
im = sub.add_parser("import", help="import a spreadsheet into a project")
|
||||
imsub = im.add_subparsers(dest="import_command", required=True)
|
||||
ix = imsub.add_parser("xlsx", help="import an .xlsx sheet as a project of tasks")
|
||||
ix.add_argument("path", help="path to the .xlsx file")
|
||||
ix.add_argument("--sheet", required=True, help="worksheet name to import")
|
||||
ix.add_argument("--project-name", dest="project_name", required=True)
|
||||
ix.add_argument("--identifier", help="project url slug")
|
||||
ix.add_argument("--project-description", dest="project_description")
|
||||
ix.add_argument("--task-type", dest="task_type", default="Task")
|
||||
ix.add_argument("--merge-category", dest="merge_category", action="append",
|
||||
default=[], help="merge a category: 'OLD=NEW' (repeatable)")
|
||||
ix.add_argument("--limit", type=int, help="only import the first N rows")
|
||||
ix.add_argument("--execute", action="store_true",
|
||||
help="actually write (default is a dry run)")
|
||||
|
||||
rw = sub.add_parser("raw", help="raw API call (escape hatch)")
|
||||
rw.add_argument("method")
|
||||
rw.add_argument("path")
|
||||
rw.add_argument("--data", help="JSON request body")
|
||||
|
||||
return p
|
||||
|
||||
|
||||
DISPATCH = {
|
||||
("ping",): cmd_ping,
|
||||
("me",): cmd_me,
|
||||
("projects",): cmd_projects,
|
||||
("users",): cmd_users,
|
||||
("types",): cmd_types,
|
||||
("statuses",): cmd_statuses,
|
||||
("priorities",): cmd_priorities,
|
||||
("roles",): cmd_roles,
|
||||
("project", "create"): cmd_project_create,
|
||||
("members", "add"): cmd_members_add,
|
||||
("wp", "list"): cmd_wp_list,
|
||||
("wp", "show"): cmd_wp_show,
|
||||
("wp", "create"): cmd_wp_create,
|
||||
("wp", "update"): cmd_wp_update,
|
||||
("wp", "comment"): cmd_wp_comment,
|
||||
("wp", "close"): cmd_wp_close,
|
||||
("wp", "delete"): cmd_wp_delete,
|
||||
("wp", "relate"): cmd_wp_relate,
|
||||
("time", "log"): cmd_time_log,
|
||||
("import", "xlsx"): cmd_import_xlsx,
|
||||
("raw",): cmd_raw,
|
||||
}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = build_parser().parse_args(argv)
|
||||
key = (args.command,)
|
||||
if args.command == "wp":
|
||||
key = ("wp", args.wp_command)
|
||||
elif args.command == "time":
|
||||
key = ("time", args.time_command)
|
||||
elif args.command == "project":
|
||||
key = ("project", args.project_command)
|
||||
elif args.command == "members":
|
||||
key = ("members", args.members_command)
|
||||
elif args.command == "import":
|
||||
key = ("import", args.import_command)
|
||||
handler = DISPATCH.get(key)
|
||||
if not handler:
|
||||
print(f"Unknown command: {key}", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
client = OpenProjectClient.from_config()
|
||||
handler(client, args)
|
||||
return 0
|
||||
except OpenProjectError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,355 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
OpenProject API v3 client — standard library only (no pip installs).
|
||||
|
||||
Talks to an OpenProject instance over its HAL+JSON REST API.
|
||||
Auth: API token via HTTP Basic auth (username "apikey", password = token).
|
||||
|
||||
Config resolution order (first hit wins for each key):
|
||||
1. Environment variables OPENPROJECT_URL / OPENPROJECT_API_KEY
|
||||
2. config.json next to the plugin root (../config.json relative to this file)
|
||||
3. ~/.config/openproject/config.json
|
||||
|
||||
This module is imported by op.py (the CLI) and can also be used directly:
|
||||
|
||||
from opclient import OpenProjectClient
|
||||
op = OpenProjectClient.from_config()
|
||||
me = op.get("/api/v3/users/me")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
__all__ = ["OpenProjectClient", "OpenProjectError", "load_config"]
|
||||
|
||||
DEFAULT_TIMEOUT = 30
|
||||
|
||||
|
||||
class OpenProjectError(Exception):
|
||||
"""Raised for API and configuration errors. Carries status + parsed body."""
|
||||
|
||||
def __init__(self, message, status=None, body=None):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.body = body
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Configuration
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _config_search_paths():
|
||||
"""Look for config.json at the plugin root and common locations.
|
||||
|
||||
Scripts live at <root>/skills/openproject/scripts/, so we walk up a few
|
||||
parent directories to find the plugin root regardless of install depth.
|
||||
"""
|
||||
paths = []
|
||||
if os.environ.get("CLAUDE_PLUGIN_ROOT"):
|
||||
paths.append(os.path.join(os.environ["CLAUDE_PLUGIN_ROOT"], "config.json"))
|
||||
cur = os.path.dirname(os.path.abspath(__file__))
|
||||
for _ in range(5): # scripts -> openproject -> skills -> <root> -> ...
|
||||
paths.append(os.path.join(cur, "config.json"))
|
||||
cur = os.path.dirname(cur)
|
||||
paths.append(os.path.expanduser("~/.config/openproject/config.json"))
|
||||
seen, ordered = set(), []
|
||||
for p in paths:
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
ordered.append(p)
|
||||
return ordered
|
||||
|
||||
|
||||
def load_config():
|
||||
"""Merge config sources. Env vars override file values."""
|
||||
cfg = {"url": None, "api_key": None, "default_project": None}
|
||||
|
||||
for path in _config_search_paths():
|
||||
if os.path.isfile(path):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
for key in cfg:
|
||||
if data.get(key) not in (None, "", "REPLACE_WITH_YOUR_API_TOKEN"):
|
||||
cfg[key] = data[key]
|
||||
except (OSError, ValueError) as exc:
|
||||
print(f"warning: could not read {path}: {exc}", file=sys.stderr)
|
||||
break # first existing config file wins
|
||||
|
||||
# Environment overrides
|
||||
if os.environ.get("OPENPROJECT_URL"):
|
||||
cfg["url"] = os.environ["OPENPROJECT_URL"]
|
||||
if os.environ.get("OPENPROJECT_API_KEY"):
|
||||
cfg["api_key"] = os.environ["OPENPROJECT_API_KEY"]
|
||||
if os.environ.get("OPENPROJECT_DEFAULT_PROJECT"):
|
||||
cfg["default_project"] = os.environ["OPENPROJECT_DEFAULT_PROJECT"]
|
||||
|
||||
return cfg
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Client
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class OpenProjectClient:
|
||||
def __init__(self, url, api_key, default_project=None, timeout=DEFAULT_TIMEOUT):
|
||||
if not url:
|
||||
raise OpenProjectError(
|
||||
"No OpenProject URL configured. Set OPENPROJECT_URL or config.json 'url'."
|
||||
)
|
||||
if not api_key:
|
||||
raise OpenProjectError(
|
||||
"No API key configured. Set OPENPROJECT_API_KEY or config.json 'api_key'."
|
||||
)
|
||||
self.base_url = url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.default_project = default_project
|
||||
self.timeout = timeout
|
||||
token = base64.b64encode(f"apikey:{api_key}".encode()).decode()
|
||||
self._auth_header = f"Basic {token}"
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, timeout=DEFAULT_TIMEOUT):
|
||||
cfg = load_config()
|
||||
return cls(cfg["url"], cfg["api_key"], cfg.get("default_project"), timeout)
|
||||
|
||||
# -- low-level HTTP ----------------------------------------------------- #
|
||||
|
||||
def _request(self, method, path, params=None, body=None):
|
||||
# Allow callers to pass either "/api/v3/..." or a full URL.
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
url = path
|
||||
else:
|
||||
url = self.base_url + path
|
||||
if params:
|
||||
# Drop None values; JSON-encode any non-string (filters/sortBy).
|
||||
clean = {}
|
||||
for k, v in params.items():
|
||||
if v is None:
|
||||
continue
|
||||
clean[k] = v if isinstance(v, str) else json.dumps(v)
|
||||
url += "?" + urllib.parse.urlencode(clean)
|
||||
|
||||
data = None
|
||||
headers = {
|
||||
"Authorization": self._auth_header,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
raw = resp.read()
|
||||
if not raw:
|
||||
return {}
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8", "replace")
|
||||
parsed = None
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except ValueError:
|
||||
pass
|
||||
msg = self._format_api_error(exc.code, parsed, raw)
|
||||
raise OpenProjectError(msg, status=exc.code, body=parsed or raw)
|
||||
except urllib.error.URLError as exc:
|
||||
raise OpenProjectError(
|
||||
f"Could not reach {self.base_url} ({exc.reason}). "
|
||||
f"Check the URL and network."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_api_error(code, parsed, raw):
|
||||
if isinstance(parsed, dict):
|
||||
message = parsed.get("message", "")
|
||||
details = []
|
||||
embedded = parsed.get("_embedded", {})
|
||||
for err in embedded.get("errors", []) or []:
|
||||
if isinstance(err, dict) and err.get("message"):
|
||||
details.append(err["message"])
|
||||
if details:
|
||||
message = message + " | " + "; ".join(details)
|
||||
if message:
|
||||
return f"HTTP {code}: {message}"
|
||||
return f"HTTP {code}: {raw[:400]}"
|
||||
|
||||
def get(self, path, params=None):
|
||||
return self._request("GET", path, params=params)
|
||||
|
||||
def post(self, path, body=None, params=None):
|
||||
return self._request("POST", path, params=params, body=body or {})
|
||||
|
||||
def patch(self, path, body=None, params=None):
|
||||
return self._request("PATCH", path, params=params, body=body or {})
|
||||
|
||||
def delete(self, path, params=None):
|
||||
return self._request("DELETE", path, params=params)
|
||||
|
||||
# -- pagination --------------------------------------------------------- #
|
||||
|
||||
def get_all(self, path, params=None, max_items=1000):
|
||||
"""Follow nextByOffset links, accumulating collection elements."""
|
||||
params = dict(params or {})
|
||||
params.setdefault("pageSize", 100)
|
||||
params.setdefault("offset", 1)
|
||||
items = []
|
||||
while True:
|
||||
page = self.get(path, params=params)
|
||||
embedded = page.get("_embedded", {})
|
||||
elements = embedded.get("elements", [])
|
||||
items.extend(elements)
|
||||
if len(items) >= max_items:
|
||||
return items[:max_items]
|
||||
total = page.get("total", 0)
|
||||
if len(items) >= total or not elements:
|
||||
return items
|
||||
params["offset"] = int(params["offset"]) + 1
|
||||
|
||||
# -- helpers / resolvers ------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def id_from_href(href):
|
||||
if not href:
|
||||
return None
|
||||
return href.rstrip("/").split("/")[-1]
|
||||
|
||||
@staticmethod
|
||||
def link_title(resource, link_name):
|
||||
link = (resource.get("_links") or {}).get(link_name) or {}
|
||||
return link.get("title")
|
||||
|
||||
def me(self):
|
||||
return self.get("/api/v3/users/me")
|
||||
|
||||
def find_project(self, ref):
|
||||
"""Resolve a project by numeric id, identifier, or (partial) name."""
|
||||
if ref is None:
|
||||
return None
|
||||
ref = str(ref)
|
||||
if ref.isdigit():
|
||||
return self.get(f"/api/v3/projects/{ref}")
|
||||
# Try identifier lookup directly.
|
||||
try:
|
||||
return self.get(f"/api/v3/projects/{urllib.parse.quote(ref)}")
|
||||
except OpenProjectError:
|
||||
pass
|
||||
# Fall back to name search.
|
||||
flt = [{"name_and_identifier": {"operator": "~", "values": [ref]}}]
|
||||
results = self.get_all("/api/v3/projects", params={"filters": flt}, max_items=50)
|
||||
if not results:
|
||||
raise OpenProjectError(f"No project matching '{ref}'.")
|
||||
return results[0]
|
||||
|
||||
def find_user(self, ref):
|
||||
"""Resolve a user by 'me', numeric id, login, or name fragment."""
|
||||
if ref is None:
|
||||
return None
|
||||
ref = str(ref)
|
||||
if ref.lower() == "me":
|
||||
return self.me()
|
||||
if ref.isdigit():
|
||||
return self.get(f"/api/v3/users/{ref}")
|
||||
flt = [{"name": {"operator": "~", "values": [ref]}}]
|
||||
results = self.get_all("/api/v3/users", params={"filters": flt}, max_items=25)
|
||||
if not results:
|
||||
raise OpenProjectError(f"No user matching '{ref}'.")
|
||||
return results[0]
|
||||
|
||||
def _resolve_by_name(self, collection_path, ref, fields=("name",)):
|
||||
"""Generic resolver for small collections (types, statuses, priorities)."""
|
||||
ref = str(ref)
|
||||
if ref.isdigit():
|
||||
return self.get(f"{collection_path}/{ref}")
|
||||
for item in self.get_all(collection_path, max_items=200):
|
||||
for field in fields:
|
||||
val = item.get(field)
|
||||
if val and val.lower() == ref.lower():
|
||||
return item
|
||||
# second pass: substring match
|
||||
for item in self.get_all(collection_path, max_items=200):
|
||||
for field in fields:
|
||||
val = item.get(field)
|
||||
if val and ref.lower() in val.lower():
|
||||
return item
|
||||
raise OpenProjectError(f"No item in {collection_path} matching '{ref}'.")
|
||||
|
||||
def find_type(self, ref):
|
||||
return self._resolve_by_name("/api/v3/types", ref)
|
||||
|
||||
def find_status(self, ref):
|
||||
return self._resolve_by_name("/api/v3/statuses", ref)
|
||||
|
||||
def find_priority(self, ref):
|
||||
return self._resolve_by_name("/api/v3/priorities", ref)
|
||||
|
||||
def find_role(self, ref):
|
||||
return self._resolve_by_name("/api/v3/roles", ref)
|
||||
|
||||
# -- create / relate helpers ------------------------------------------- #
|
||||
|
||||
@staticmethod
|
||||
def slugify(name):
|
||||
"""Make a valid OpenProject project identifier from a name."""
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", str(name).lower()).strip("-")
|
||||
return slug[:100] or "project"
|
||||
|
||||
def create_project(self, name, identifier=None, description=None, parent=None):
|
||||
body = {"name": name, "identifier": identifier or self.slugify(name)}
|
||||
if description:
|
||||
body["description"] = {"format": "markdown", "raw": description}
|
||||
if parent:
|
||||
pid = self.find_project(parent)["id"]
|
||||
body["_links"] = {"parent": {"href": f"/api/v3/projects/{pid}"}}
|
||||
return self.post("/api/v3/projects", body=body)
|
||||
|
||||
def add_membership(self, project_id, user_id, role_ref):
|
||||
role = self.find_role(role_ref)
|
||||
body = {
|
||||
"_links": {
|
||||
"project": {"href": f"/api/v3/projects/{project_id}"},
|
||||
"principal": {"href": f"/api/v3/users/{user_id}"},
|
||||
"roles": [{"href": f"/api/v3/roles/{role['id']}"}],
|
||||
}
|
||||
}
|
||||
return self.post("/api/v3/memberships", body=body)
|
||||
|
||||
# OpenProject relation types: relates, duplicates, blocks, blocked, precedes,
|
||||
# follows, includes, partof, requires, required.
|
||||
def create_relation(self, from_id, to_id, rel_type="relates", description=None):
|
||||
body = {
|
||||
"type": rel_type,
|
||||
"_links": {"to": {"href": f"/api/v3/work_packages/{to_id}"}},
|
||||
}
|
||||
if description:
|
||||
body["description"] = description
|
||||
return self.post(f"/api/v3/work_packages/{from_id}/relations", body=body)
|
||||
|
||||
def create_work_package(self, body, notify=False):
|
||||
"""Create a WP; falls back to create-without-status + PATCH when the
|
||||
workflow rejects setting a non-default status on creation."""
|
||||
params = {"notify": "false"} if not notify else None
|
||||
try:
|
||||
return self.post("/api/v3/work_packages", body=body, params=params)
|
||||
except OpenProjectError as exc:
|
||||
links = body.get("_links", {})
|
||||
if exc.status in (422,) and "status" in links:
|
||||
status_link = links.pop("status")
|
||||
created = self.post("/api/v3/work_packages", body=body, params=params)
|
||||
patch = {"lockVersion": created.get("lockVersion"),
|
||||
"_links": {"status": status_link}}
|
||||
try:
|
||||
return self.patch(
|
||||
f"/api/v3/work_packages/{created['id']}", body=patch)
|
||||
except OpenProjectError:
|
||||
return created # created at default status; status change rejected
|
||||
raise
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Minimal .xlsx reader — standard library only (no openpyxl/pandas).
|
||||
|
||||
An .xlsx file is a zip of XML parts. This reads worksheet cell values, resolving
|
||||
shared strings and inline strings, and returns plain Python rows. Enough for
|
||||
tabular imports; it does NOT evaluate formulas (it returns the cached value if
|
||||
present) and ignores styling/number formats.
|
||||
|
||||
API:
|
||||
from xlsx_read import read_sheet, sheet_names
|
||||
names = sheet_names("file.xlsx")
|
||||
rows = read_sheet("file.xlsx", "To-Do List") # -> list[list[str|None]]
|
||||
table = read_table("file.xlsx", "To-Do List") # -> (headers, list[dict])
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import zipfile
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
_NS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
|
||||
_RID = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id"
|
||||
_RNS = "{http://schemas.openxmlformats.org/package/2006/relationships}"
|
||||
|
||||
|
||||
def _col_to_num(ref):
|
||||
"""'B7' -> 2 (1-based column index)."""
|
||||
letters = re.match(r"[A-Z]+", ref).group()
|
||||
n = 0
|
||||
for ch in letters:
|
||||
n = n * 26 + (ord(ch) - 64)
|
||||
return n
|
||||
|
||||
|
||||
def _shared_strings(zf):
|
||||
if "xl/sharedStrings.xml" not in zf.namelist():
|
||||
return []
|
||||
root = ET.fromstring(zf.read("xl/sharedStrings.xml"))
|
||||
out = []
|
||||
for si in root.findall(f"{_NS}si"):
|
||||
out.append("".join(t.text or "" for t in si.iter(f"{_NS}t")))
|
||||
return out
|
||||
|
||||
|
||||
def _sheet_map(zf):
|
||||
"""Return ordered list of (sheet_name, worksheet_part_path)."""
|
||||
wb = ET.fromstring(zf.read("xl/workbook.xml"))
|
||||
rels = ET.fromstring(zf.read("xl/_rels/workbook.xml.rels"))
|
||||
rid2target = {r.get("Id"): r.get("Target")
|
||||
for r in rels.iter(f"{_RNS}Relationship")}
|
||||
sheets = []
|
||||
for s in wb.iter(f"{_NS}sheet"):
|
||||
target = rid2target.get(s.get(_RID))
|
||||
if not target:
|
||||
continue
|
||||
if not target.startswith("xl/"):
|
||||
target = "xl/" + target.lstrip("/")
|
||||
sheets.append((s.get("name"), target))
|
||||
return sheets
|
||||
|
||||
|
||||
def sheet_names(path):
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
return [name for name, _ in _sheet_map(zf)]
|
||||
|
||||
|
||||
def read_sheet(path, sheet_name):
|
||||
"""Return rows as lists of cell values (str or None), padded to max width."""
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
shared = _shared_strings(zf)
|
||||
target = None
|
||||
for name, part in _sheet_map(zf):
|
||||
if name == sheet_name:
|
||||
target = part
|
||||
break
|
||||
if target is None:
|
||||
raise KeyError(f"Sheet '{sheet_name}' not found. "
|
||||
f"Available: {[n for n, _ in _sheet_map(zf)]}")
|
||||
root = ET.fromstring(zf.read(target))
|
||||
rows = []
|
||||
max_col = 0
|
||||
for row in root.iter(f"{_NS}row"):
|
||||
cells = {}
|
||||
for c in row.findall(f"{_NS}c"):
|
||||
ref = c.get("r")
|
||||
if not ref:
|
||||
continue
|
||||
ctype = c.get("t")
|
||||
v = c.find(f"{_NS}v")
|
||||
inline = c.find(f"{_NS}is")
|
||||
val = None
|
||||
if ctype == "s" and v is not None:
|
||||
val = shared[int(v.text)]
|
||||
elif ctype == "inlineStr" and inline is not None:
|
||||
val = "".join(t.text or "" for t in inline.iter(f"{_NS}t"))
|
||||
elif v is not None:
|
||||
val = v.text
|
||||
idx = _col_to_num(ref)
|
||||
cells[idx] = val
|
||||
max_col = max(max_col, idx)
|
||||
rows.append(cells)
|
||||
return [[r.get(i) for i in range(1, max_col + 1)] for r in rows]
|
||||
|
||||
|
||||
def read_table(path, sheet_name, header_row=0):
|
||||
"""Return (headers, list[dict]) using the given 0-based header row index.
|
||||
|
||||
Rows above the header are skipped; the first row at/after header_row whose
|
||||
first cell is non-empty is treated as the header.
|
||||
"""
|
||||
rows = read_sheet(path, sheet_name)
|
||||
if not rows:
|
||||
return [], []
|
||||
headers = [(h or "").strip() for h in rows[header_row]]
|
||||
records = []
|
||||
for r in rows[header_row + 1:]:
|
||||
rec = {}
|
||||
for i, h in enumerate(headers):
|
||||
if not h:
|
||||
continue
|
||||
rec[h] = r[i] if i < len(r) else None
|
||||
records.append(rec)
|
||||
return headers, records
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
p = sys.argv[1]
|
||||
print("Sheets:", sheet_names(p))
|
||||
if len(sys.argv) > 2:
|
||||
hdrs, recs = read_table(p, sys.argv[2])
|
||||
print("Headers:", hdrs)
|
||||
print(f"{len(recs)} data rows")
|
||||
Reference in New Issue
Block a user