chore: import upstream snapshot with attribution
Publish SDK (PyPI) / publish (push) Waiting to run
Publish SDK (npm) / publish (@aionui/officecli-sdk) (push) Waiting to run
Publish SDK (npm) / publish (@officecli/officecli-sdk) (push) Waiting to run
Publish SDK (npm) / publish (@officecli/sdk) (push) Waiting to run
Publish SDK (npm) / publish (officecli-sdk) (push) Waiting to run
SDK smoke / smoke (windows-latest) (push) Waiting to run
SDK smoke / smoke (macos-latest) (push) Waiting to run
SDK smoke / smoke (ubuntu-latest) (push) Waiting to run
Skill parity / diff (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 13:09:29 +08:00
commit 8cb1f9f479
1189 changed files with 386007 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
# officecli — Python SDK
A **thin** Python SDK for the [officecli](https://officecli.ai) **resident pipe**. It does one
thing: forward an officecli command to a running resident over its named pipe and
hand back the response — no per-command process spawn, so a loop of edits is
~hundreds of times faster than shelling out to the CLI per command.
"Thin" is the point: there is **no second vocabulary** to learn. A command is the
same dict you'd put in an officecli `batch` list; the SDK just carries it over the
pipe. Anything a `doc.set_cell(...)` / `doc.add_paragraph(...)` method would do is
**fully supported** — you just spell it `doc.send({"command": "set", ...})`, with
the exact same effect. One uniform verb instead of dozens of per-element named
methods: same power, nothing extra to memorize, and new officecli features work
the day they ship without an SDK update.
## The officecli CLI (auto-installed if missing)
`pip install officecli-sdk` installs **only this SDK** (the Python library); the
real work is done by the `officecli` binary. You don't have to install it
yourself — if `officecli` isn't found on your `PATH` (or in the default install
location), the SDK **provisions it on first use**: it runs officecli's official
installer (`install.sh` on macOS/Linux, `install.ps1` on Windows), fetching from
the `d.officecli.ai` mirror with GitHub as a fallback. A one-line notice is
printed before it installs — it never does so silently. Pass `auto_install=False`
to `open()`/`create()` to disable this and require a pre-installed CLI instead.
To install the CLI ahead of time (or to control where it lands):
```bash
python -m officecli install # runs officecli's official installer
# …or directly:
curl -fsSL https://d.officecli.ai/install.sh | bash
# Windows (PowerShell):
irm https://d.officecli.ai/install.ps1 | iex
```
`officecli.install()` does the same from Python. If the CLI can't be found or
installed, the SDK raises a clear error pointing here (never a cryptic
`FileNotFoundError`).
## Install
```bash
pip install officecli-sdk # once published — note: import name is `officecli`
# or, from a checkout of this repo:
pip install ./sdk/python
```
The pip/distribution name is `officecli-sdk`, but you `import officecli`
(distribution name ≠ import name, like `pip install pillow``import PIL`).
Zero third-party dependencies (standard library only).
## Quickstart
```python
import officecli
# create() makes a new file and returns a live session handle;
# open() does the same for an existing file. Both return a Document.
with officecli.create("report.xlsx", "--force") as doc:
doc.send({"command": "set", "path": "/Sheet1/A1",
"props": {"text": "Region", "bold": "true"}})
doc.send({"command": "set", "path": "/Sheet1/B1", "props": {"formula": "=SUM(B2:B9)"}})
# read one back (returns the parsed JSON envelope)
node = doc.send({"command": "get", "path": "/Sheet1/A1"})
print(node["data"]["results"][0]["text"]) # -> Region
# many edits in ONE pipe round-trip
doc.batch([
{"command": "set", "path": "/Sheet1/A2", "props": {"text": "North"}},
{"command": "set", "path": "/Sheet1/A3", "props": {"text": "South"}},
])
doc.send({"command": "save"})
# leaving `with` closes the resident (which flushes to disk)
# borrow an already-running resident without owning it: skip `with`/close()
d = officecli.open("report.xlsx")
print(d.send({"command": "view", "mode": "stats"}, as_json=False))
```
See `demo.py` for a fuller example.
## The command dict
`send(item)` and `batch([item, ...])` take the officecli **batch-item** shape:
```jsonc
{ "command": "set", // or "op"; picks the officecli command
"path": "/Sheet1/A1", // every key except command/op/props is forwarded
"props": { "text": "hi" } } // verbatim as a command argument
```
Keys are officecli's own batch fields (`command`/`op`, `path`, `parent`, `type`,
`index`, `after`, `before`, `to`, `selector`, `mode`, `depth`, `part`, `xpath`,
`action`, `xml`) plus a nested `props`. The client maintains no field list of its
own — run `officecli help` (or see the batch docs) for the full reference.
`send(..., as_json=False)` requests plain-text output (e.g. `view` / `raw` /
`dump`), mirroring the CLI's `--json` toggle.
## Errors & resilience
- Transport/process failures raise `officecli.OfficeCliError` (`.code` carries the
exit code). Business outcomes (e.g. `validate` failing, a bad path) are **not**
exceptions — they live in the returned envelope's `success` field, same as the
CLI's exit code.
- If the resident has gone (crash, idle-timeout, missing pipe), `send`/`batch`
transparently restart it and retry once. If it's alive but the pipe is
unresponsive (busy), they raise rather than risk racing the live resident.
## Versioning
This client derives the resident's pipe address from the document path the same
way officecli does. That derivation is the one piece coupled to officecli
internals, so keep the client version compatible with your installed officecli.
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
Demo: build a small sales report .xlsx using the officecli Python client.
Run: python3 demo.py [path-to-officecli-binary]
Shows the whole loop over a single resident:
create -> writes applied as one batch -> read back -> save -> close -> reopen.
"""
import os
import sys
import officecli # the client (officecli.py next to this file)
# Locate the binary: 1st arg, else "officecli" on PATH.
BIN = sys.argv[1] if len(sys.argv) > 1 else "officecli"
OUT = os.path.abspath("sales_report.xlsx")
# Sample data: (region, units, price)
ROWS = [
("North", 120, 9.5),
("South", 95, 11.0),
("East", 140, 8.75),
("West", 60, 12.5),
("Central", 110, 10.0),
]
COL = "ABCDE" # A..E
def cell(c, r):
return f"/Sheet1/{c}{r}"
def main():
# create returns a live handle bound to the resident it auto-starts.
# --force overwrites a leftover from a previous run.
with officecli.create(OUT, "--force", binary=BIN) as doc: # make file + get handle
# Build every write as a batch-shaped item, then apply them all in ONE
# round-trip. Same dict shape officecli's `batch` command documents.
items = []
# Header row
for j, title in enumerate(["Region", "Units", "Price", "Revenue"]):
items.append({"command": "set", "path": cell(COL[j], 1),
"props": {"text": title, "bold": "true"}})
# Data rows + a live formula for Revenue (=Units*Price)
for i, (region, units, price) in enumerate(ROWS, start=2):
items.append({"command": "set", "path": cell("A", i), "props": {"text": region}})
items.append({"command": "set", "path": cell("B", i), "props": {"text": str(units)}})
items.append({"command": "set", "path": cell("C", i), "props": {"text": str(price)}})
items.append({"command": "set", "path": cell("D", i), "props": {"formula": f"=B{i}*C{i}"}})
# Totals row
last = len(ROWS) + 1
items.append({"command": "set", "path": cell("A", last + 1),
"props": {"text": "TOTAL", "bold": "true"}})
items.append({"command": "set", "path": cell("B", last + 1),
"props": {"formula": f"=SUM(B2:B{last})"}})
items.append({"command": "set", "path": cell("D", last + 1),
"props": {"formula": f"=SUM(D2:D{last})"}})
doc.batch(items) # all writes, one pipe round-trip
# Read one cell back over the pipe (single command, same dict shape).
node = doc.send({"command": "get", "path": cell("A", 1)})
results = node.get("data", {}).get("results", [{}])
print("A1 reads back as:", results[0].get("text") if results else None)
# In-session validate over the pipe (no extra process spawn). This is
# the path that used to corrupt styles.xml; safe now that ValidateDocument
# validates a clone instead of the live package.
v = doc.send({"command": "validate"})
print("validate (in-session):", "OK" if v.get("success") else v)
doc.send({"command": "save"}) # flush in-memory doc to disk
# context exit -> close the resident (which flushes to disk too)
# Round-trip proof: reopen the CLOSED file fresh and confirm it both
# validates and kept its content. open() does the one-shot bootstrap spawn
# for us, so the demo stays entirely on the SDK — no hand-rolled subprocess.
with officecli.open(OUT, binary=BIN) as doc:
v = doc.send({"command": "validate"})
print("validate (reopened):", "OK" if v.get("success") else v)
a1 = doc.send({"command": "get", "path": cell("A", 1)})
print("A1 after reopen:", a1.get("data", {}).get("results", [{}])[0].get("text"))
print(f"wrote {OUT} ({os.path.getsize(OUT)} bytes)")
if __name__ == "__main__":
main()
+620
View File
@@ -0,0 +1,620 @@
r"""
officecli — a thin Python shell over officecli's resident pipe.
It does ONE thing: forward a command to the running resident over its named
pipe and hand back the response. There is NO second vocabulary to learn: a
command is the same dict you'd put in an officecli `batch` list — e.g.
{"command":"set","path":"/Sheet1/A1","props":{"text":"Hello"}}. `send` forwards
one; `batch` forwards many in a single round-trip.
Two surfaces, by design:
- bootstrap (infrequent): `create` / `open` spawn ONE CLI process — a file that
isn't open yet (or doesn't exist yet) has no resident to talk to.
- everything else (the hot path): `send` / `batch` are pure pipe round-trips,
no per-command process spawn.
import officecli
with officecli.create("report.xlsx", "--force") as doc: # make file + get handle
doc.send({"command": "set", "path": "/Sheet1/A1",
"props": {"text": "Hello"}})
print(doc.send({"command": "get", "path": "/Sheet1/A1"}))
doc.send({"command": "save"})
# ...or officecli.open("existing.xlsx") for a file that already exists.
The item keys are officecli's batch fields (command/op, path, parent, type,
index, after, before, to, selector, text, mode, depth, part, xpath, action,
xml) plus a nested `props` dict. Everything except command/op/props is
forwarded verbatim as a command argument; the resident dispatches it exactly
like the matching CLI command. See `officecli help` / the batch docs for the
field-and-prop reference — this shell adds none of its own.
Protocol (matches ResidentServer.cs / ResidentClient.cs):
- pipe name : officecli-<SHA256(fullpath)[:16] uppercase>;
fullpath upper-cased on macOS/Windows, left as-is on Linux.
- unix path : $TMPDIR/CoreFxPipe_<name> (+ "-ping"); $TMPDIR else /tmp
- win path : \\.\pipe\<name> (+ "-ping")
- framing : one request line + one response line, UTF-8, '\n' terminated;
one connection == one command.
- request : PascalCase {"Command","Args","Props","Json"}
- response : {"ExitCode","Stdout","Stderr"}
"""
import os
import sys
import json
import time
import socket
import hashlib
import shutil
import threading
import subprocess
# Mirror officecli's TryResident busy-delivery policy (CommandBuilder.cs): a
# generous connect timeout + a few retries with backoff, applied identically to
# every command. The reply read itself blocks (no timeout) — like officecli's
# PipeReadLine — trusting the resident to answer once our turn comes up in its
# serialized queue. Because retries only re-attempt the CONNECT (before the
# command executes), re-sending is safe even for mutations; there is no
# "read timed out, resend" path that could double-apply.
_BUSY_CONNECT_TIMEOUT = 30.0 # = ResidentBusyConnectTimeoutMs (30000)
_BUSY_MAX_RETRIES = 3 # = ResidentBusyMaxRetries
# = CommandBuilder's DefaultOpenIdleSeconds. `open` upgrades a reused resident
# (which `create` may have auto-started with a short 60s timeout) to the 12min
# interactive window, so a long editing session over an SDK handle isn't cut
# short by the create-time timeout.
_OPEN_IDLE_SECONDS = 12 * 60
_IS_WIN = sys.platform.startswith("win")
_IS_MAC = sys.platform == "darwin"
_builtin_open = open # preserved; this module defines its own open() below
# officecli's official installer (README one-liner). install() shells out to it;
# the missing-CLI error points users at it / at install().
# Installer scripts: the d.officecli.ai mirror is primary; GitHub raw is only a
# fallback (same order as install.sh / install.ps1 themselves). The mirror is
# Cloudflare-fronted and reachable where raw.githubusercontent.com may be
# rate-limited or blocked.
_INSTALL_SH_MIRROR = "https://d.officecli.ai/install.sh"
_INSTALL_SH_GITHUB = "https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh"
_INSTALL_PS1_MIRROR = "https://d.officecli.ai/install.ps1"
_INSTALL_PS1_GITHUB = "https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1"
_MISSING_CLI = (
"officecli CLI not found: {bin!r} is not on PATH nor in the default install "
"location (~/.local/bin, or %LOCALAPPDATA%\\OfficeCLI on Windows). This SDK only forwards "
"commands to the officecli binary, which must be installed separately. Install it:\n"
" python -m officecli install # runs the official installer\n"
" # or: curl -fsSL " + _INSTALL_SH_MIRROR + " | bash\n"
"Already installed elsewhere? pass binary=\"/path/to/officecli\"."
)
class OfficeCliError(Exception):
"""Raised on transport/process failure (could not reach the resident).
Business outcomes are NOT exceptions — they live in the returned envelope's
'success' field, same as the CLI's exit code."""
def __init__(self, code, msg):
super().__init__(f"[exit {code}] {msg}")
self.code = code
# ---------------------------------------------------------------- pipe address
def _dotnet_tempdir():
# Mirror .NET Path.GetTempPath() on Unix exactly: $TMPDIR else /tmp.
return os.environ.get("TMPDIR") or "/tmp"
def _canonical_path(file_path):
"""Match the path officecli's resident hashes into the pipe name. On Windows
it derives the name from GetFullPath, which expands 8.3 short components
(RUNNER~1, or any user name > 8 chars under %TEMP%) to their long form.
os.path.abspath does NOT expand 8.3, so a short path hashes to a different
pipe and every connect fails with ENOENT — hence realpath, which does expand
it. realpath needs the file to exist; fall back to the abspath when it
doesn't. realpath ALSO resolves symlinks/junctions, which GetFullPath does
not; harmless here because we hand this resolved path to the resident, so the
server's GetFullPath sees the already-resolved string and both sides hash the
same thing. Windows only — on unix officecli uses GetFullPath (no symlink
resolution), so realpath would diverge there (e.g. /tmp -> /private/tmp on
macOS)."""
resolved = os.path.abspath(file_path)
if _IS_WIN:
try:
return os.path.realpath(resolved)
except OSError:
pass
return resolved
def pipe_paths(file_path):
"""(main, ping) pipe addresses for a document path. Exposed for debugging."""
full = _canonical_path(file_path)
if _IS_MAC or _IS_WIN:
full = full.upper() # Linux: case-sensitive, no upper
h = hashlib.sha256(full.encode("utf-8")).hexdigest().upper()[:16]
name = f"officecli-{h}"
if _IS_WIN:
return rf"\\.\pipe\{name}", rf"\\.\pipe\{name}-ping"
base = os.path.join(_dotnet_tempdir(), f"CoreFxPipe_{name}")
return base, base + "-ping"
# ---------------------------------------------------------------- transport
# One attempt: bound the CONNECT, then block on the reply (no read timeout) —
# exactly like officecli's TrySend (Connect(timeout) + blocking PipeReadLine).
def _send_unix(sock_path, line, connect_timeout):
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
s.settimeout(connect_timeout)
s.connect(sock_path)
s.settimeout(None) # block on the reply; resident answers in turn
s.sendall(line)
buf = b""
while not buf.endswith(b"\n"):
chunk = s.recv(65536)
if not chunk:
break
buf += chunk
return buf
finally:
s.close()
def _send_win(pipe_path, line, connect_timeout):
deadline = time.time() + connect_timeout
while True: # bound the "open" (connect) phase
try:
f = _builtin_open(pipe_path, "r+b", buffering=0) # not the module open()
break
except FileNotFoundError:
# No pipe == no resident. Fail FAST, like _send_unix's connect()
# raising ENOENT immediately — do NOT spin to the deadline. This is
# what makes a max_retries=0 probe (_serves/alive) fail fast instead
# of sitting through the whole connect_timeout when nothing is there.
raise
except OSError:
# The pipe exists but the open lost the race (e.g. ERROR_PIPE_BUSY:
# every server instance is mid-handoff). The resident IS alive, so
# retry until the connect deadline.
if time.time() > deadline:
raise
time.sleep(0.02)
try:
# FileIO.write (raw, buffering=0) issues a single WriteFile and may
# return a short count, so loop until the whole request is out — a
# truncated request leaves the resident blocking for a newline that
# never comes, deadlocking the (untimed) reply read. Mirrors _send_unix's
# sendall() and the C# client's Stream.Write.
view = memoryview(line)
sent = 0
while sent < len(view):
n = f.write(view[sent:])
if n is None: # non-blocking handle not ready (shouldn't happen)
continue
sent += n
buf = b""
while not buf.endswith(b"\n"): # blocking read, like PipeReadLine
chunk = f.read(65536)
if not chunk:
break
buf += chunk
return buf
finally:
f.close()
def _rpc(sock_path, req, connect_timeout=_BUSY_CONNECT_TIMEOUT, max_retries=_BUSY_MAX_RETRIES):
"""Forward one request, mirroring officecli's TrySend: bounded connect + a few
retries with backoff, then a blocking read. A retry only re-attempts the
connect (before the command runs), so it never double-applies a mutation. If
the command still can't be delivered, raise a busy/unresponsive error — never
fall back to touching the file directly (that would race the resident).
`max_retries` overrides the busy-retry count. Liveness probes (_serves) pass 0
so a missing/stale pipe fails FAST instead of sleeping through ~0.3s of backoff
— retrying a probe the resident isn't answering can't make it answer; the
busy-retry policy is for delivering a real command to a slow-but-live pipe."""
line = (json.dumps(req, ensure_ascii=False) + "\n").encode("utf-8")
send = _send_win if _IS_WIN else _send_unix
for attempt in range(max_retries + 1):
try:
raw = send(sock_path, line, connect_timeout)
break
except OSError as e:
if attempt >= max_retries:
raise OfficeCliError(-1,
f"resident is running but the command could not be delivered "
f"(pipe busy or unresponsive); retry, or close and reopen [{e}]")
time.sleep(0.05 * (attempt + 1)) # = TrySend's 50*(n+1)ms backoff
# utf-8-sig: the resident's StreamWriter (Encoding.UTF8) prepends a BOM the
# C# StreamReader strips; we must too, or json.loads chokes on the leading .
text = raw.decode("utf-8-sig")
if not text.strip():
# Empty/closed reply: the resident accepted the connection but closed
# without a complete response (e.g. crashed mid-serve). We refuse to
# re-send — the command may already have been APPLIED before the resident
# died, so re-sending would double-apply a non-idempotent op — and raise
# instead. officecli's TrySend now matches: its retry covers only the
# connect phase (before the command is written); on an empty reply after a
# successful write it returns null without re-sending, the C# equivalent of
# this raise. _cmd's recovery then restarts a dead resident and retries once
# (a fresh connect, before re-send), and _serves()/alive() (which swallow
# OfficeCliError) read an empty reply as "not alive".
raise OfficeCliError(-1,
"resident closed the connection without a response "
"(it may have crashed mid-command); retry, or close and reopen")
return json.loads(text)
def _parse(resp):
"""Return the useful payload: the parsed JSON envelope (dict/list) if Stdout is
a JSON object/array, otherwise the raw Stdout text ("" when empty). We accept
ONLY dict/list from json.loads — a text-mode reply that happens to BE a bare
JSON scalar ("42", "true", "null", a quoted string) must stay text, or the
caller can't tell literal text "42" from the number 42 (and None from a missing
key). Faithful to the response — no synthesizing a dict for view/raw text."""
out = resp.get("Stdout", "")
try:
v = json.loads(out)
except ValueError:
return out
return v if isinstance(v, (dict, list)) else out
def _strv(d):
# Drop None-valued props (omit), matching how _cmd() drops None args — a prop
# set to None means "don't send it", not "send empty string". Pass "" for
# an explicit empty value.
return {k: str(v) for k, v in d.items() if v is not None}
def _serves(ping_path, full_path, timeout=1.0):
"""Is a resident alive on `ping_path` AND serving `full_path`? Probes the
always-responsive `-ping` pipe (officecli's TryConnect equivalent): it answers
even while the MAIN pipe is busy. The path-match guards against a stale socket
serving a different/renamed file. `full_path` must already be absolute.
Single-shot (max_retries=0): a probe should fail fast, not sit through the
busy-retry backoff that a real command delivery uses."""
try:
resp = _rpc(ping_path, {"Command": "__ping__"}, timeout, max_retries=0)
except OfficeCliError:
return False
served = resp.get("Stdout", "").strip() # ping echoes the served file path
if not served:
return False
a = os.path.abspath(served)
return a == full_path or ((_IS_MAC or _IS_WIN) and a.lower() == full_path.lower())
def _install_dir_candidate(name):
"""Where the official installer (install.sh / install.ps1) drops the binary:
~/.local/bin on macOS/Linux, %LOCALAPPDATA%\\OfficeCLI on Windows. Used only
as a PATH-miss fallback (see _resolve_binary)."""
if _IS_WIN:
base = os.environ.get("LOCALAPPDATA")
if not base:
return None
exe = name if name.lower().endswith(".exe") else name + ".exe"
return os.path.join(base, "OfficeCLI", exe)
return os.path.join(os.path.expanduser("~"), ".local", "bin", name)
def _resolve_binary(binary):
"""Resolve the officecli binary to invoke. Order: explicit path (a value with
a path separator) is trusted as-is; otherwise a bare name is looked up on
PATH; if PATH misses, fall back to the official installer's known location.
Why the fallback: the installer adds its dir to PATH via the shell rc file, so
a bare 'officecli' resolves in an interactive terminal — but NOT in processes
that never sourced that rc (IDE-spawned Python, cron, systemd, CI). The binary
is still sitting at the known install path; find it there instead of failing.
Idempotent: an already-resolved absolute path passes straight through, so it's
safe to call at every entry point (create + Document)."""
if os.sep in binary or (os.altsep and os.altsep in binary):
return binary # explicit path: trust the caller
found = shutil.which(binary)
if found:
return found # on PATH: normal case
cand = _install_dir_candidate(binary) # PATH miss: try the known install dir
if cand and os.path.isfile(cand) and os.access(cand, os.X_OK):
return cand
return binary # give up; _run_cli raises the helpful error
def _runs_ok(binary):
"""True iff `<binary> --version` actually runs and exits 0. Accept only a
WORKING officecli — skip a present-but-broken file, and don't trigger a
needless install when a usable officecli is already there."""
try:
return subprocess.run([binary, "--version"], capture_output=True).returncode == 0
except OSError:
return False
def _ensure_binary(binary, auto_install=True):
"""Resolve to a WORKING officecli, provisioning one if none is found and
auto_install is set. An explicit path (with a separator) is trusted as-is;
otherwise each candidate (PATH, then the installer's known location) is
accepted only when `officecli --version` actually runs — so a present-but-
broken binary is skipped and a usable one never triggers a needless install.
install() picks install.sh (unix) or install.ps1 (Windows), so auto-install
works on both."""
if os.sep in binary or (os.altsep and os.altsep in binary):
return binary # explicit path: trust the caller
for cand in filter(None, (shutil.which(binary), _install_dir_candidate(binary))):
if _runs_ok(cand):
return cand # a working officecli is already here
if auto_install:
print("officecli CLI not found — installing from d.officecli.ai ...", file=sys.stderr)
install() # CLI absent/unusable → official installer
for cand in filter(None, (shutil.which(binary), _install_dir_candidate(binary))):
if _runs_ok(cand):
return cand
return binary # give up; _run_cli raises the helpful error
def _run_cli(binary, argv):
"""Run `binary <argv...>` (capturing output). A missing binary surfaces as a
clear OfficeCliError with install guidance, not a raw FileNotFoundError."""
try:
return subprocess.run([binary, *argv], capture_output=True, text=True)
except FileNotFoundError:
raise OfficeCliError(127, _MISSING_CLI.format(bin=binary)) from None
# ---------------------------------------------------------------- the shell
class Document:
def __init__(self, path, binary="officecli", timeout=30.0):
# Canonical (Windows 8.3-expanded) so the pipe name AND the _serves()
# path comparison both match what the resident reports.
self.path = _canonical_path(path)
self.bin = _resolve_binary(binary)
self.timeout = timeout # connect timeout (s); the reply read blocks
self._main, self._ping = pipe_paths(self.path)
self._restart_lock = threading.Lock() # serialize dead-resident restarts
self._start()
def _start(self):
# If a resident is ALREADY serving this file, reuse it — no process spawn.
# Mirrors officecli, where a command after `create` reuses the resident
# `create` auto-started instead of re-running `open`. _serves() is a real
# liveness probe (ping the -ping pipe + verify the served path), not a
# socket-file-exists check, so a stale/dead socket fails the probe and
# falls through to `officecli open`, which replaces it via TryConnect.
# (A plain os.path.exists() here would wrongly skip on a stale socket.)
if _serves(self._ping, self.path):
return
# Otherwise spawn `officecli open` (one process). It's idempotent and uses
# the same TryConnect to start a fresh resident or replace a stale socket.
r = _run_cli(self.bin, ["open", self.path])
if r.returncode != 0:
raise OfficeCliError(r.returncode, r.stderr or r.stdout)
# -- transport primitive: build {Command,Args,Props,Json}, forward, parse --
def _cmd(self, command, args=None, props=None, as_json=True, timeout=None):
# `as_json`, not `json`, so we don't shadow the imported json module.
# timeout=None uses this Document's default (self.timeout). It bounds the
# CONNECT/delivery (with retries); the reply read blocks, so a legitimately
# slow command isn't cut off — it waits for the resident, like officecli.
req = {"Command": command, "Json": as_json}
if args:
req["Args"] = {k: str(v) for k, v in args.items() if v is not None}
if props is not None:
req["Props"] = _strv(props)
t = self.timeout if timeout is None else timeout
try:
return _rpc(self._main, req, t)
except OfficeCliError:
# Delivery failed after _rpc's own connect retries. Use the -ping pipe
# to tell DEAD from BUSY — officecli's own distinction (alive()):
# • ALIVE but main pipe unresponsive → do NOT bypass it. officecli
# deliberately dropped the direct-file fallback: a second writer
# racing the live resident loses data on its eventual save. Re-raise
# the busy error so the caller can retry or close+reopen.
# • DEAD (crashed / stale socket) → restart with one `officecli open`
# and retry ONCE. Safe across reads and mutations: mutations live in
# memory until save/close, so a crash loses them and disk holds the
# last save — replaying against the restarted (disk-state) resident
# reproduces the lost op once, with nothing live to double-apply.
if self.alive():
raise
# Serialize the restart across threads sharing this Document. Without
# the lock, N concurrent callers each see alive()==False and each spawn
# `officecli open`, leaving N-1 orphaned residents on the same file
# (which can then race each other's save). Re-check alive() inside the
# lock so only the first thread restarts; the rest find it back up.
with self._restart_lock:
if not self.alive():
self._start()
return _rpc(self._main, req, t)
# -- the surface: send ONE batch-shaped command, or a LIST of them ---------
def send(self, item, as_json=True, timeout=None):
"""Forward ONE command in officecli's batch-item shape and return its
parsed result (the JSON envelope, or raw text for content commands).
`item` is exactly a dict you'd put in a `batch` list, e.g.
{"command": "set", "path": "/Sheet1/A1", "props": {"text": "hi"}}
{"command": "get", "path": "/Sheet1/A1"}
Keys are officecli's batch fields; `command` (or `op`) picks the command,
`props` becomes the property map, and every other key is forwarded
verbatim as a command argument — no field list maintained here, so new
officecli fields work without touching this shell.
`as_json=False` requests plain-text output (view/raw/dump), mirroring the
CLI's --json toggle."""
command = item.get("command") or item.get("op")
if not command:
raise OfficeCliError(-1, "send(item): item needs a 'command' (or 'op') key")
args = {k: v for k, v in item.items() if k not in ("command", "op", "props")}
return _parse(self._cmd(command, args, item.get("props"),
as_json=as_json, timeout=timeout))
def batch(self, items, force=True, stop_on_error=False, timeout=None):
"""Forward officecli's `batch` command: apply a LIST of the same item
dicts as `send` in ONE round-trip — the fast path for many writes. Same
contract as `send`, just plural."""
args = {"batchJson": json.dumps(items, ensure_ascii=False),
"force": force, "stopOnError": stop_on_error}
return _parse(self._cmd("batch", args, timeout=timeout))
def _set_idle_timeout(self, seconds):
# Best-effort idle-timeout upgrade, served on the always-responsive ping
# pipe (bypasses _commandLock, answers even while the main pipe is busy).
# Mirrors ResidentClient.SendSetIdleTimeout: a failure is non-fatal — the
# resident is still usable, it just keeps its original idle schedule.
# Single-shot (max_retries=0): don't sit through the busy backoff for a
# best-effort nicety.
try:
_rpc(self._ping, {"Command": "__set-idle-timeout__",
"Args": {"seconds": str(seconds)}},
self.timeout, max_retries=0)
except OfficeCliError:
pass
def alive(self, timeout=1.0):
"""Return True iff a resident is alive AND serving this file. Probes the
always-responsive `-ping` pipe (officecli's TryConnect), which answers even
while the MAIN pipe is busy — so it distinguishes "alive but busy" from
"gone". This is the discriminator `_cmd` uses on a delivery failure (busy →
raise, gone → restart+retry); send/batch already auto-recover from a gone
resident, so call this only when you want to check liveness yourself."""
return _serves(self._ping, self.path, timeout)
# -- lifecycle ------------------------------------------------------------
def close(self):
# = `officecli close`: stop the resident. It flushes the in-memory doc to
# disk as it shuts down (handler.Dispose), so no separate save is needed —
# verified: a set followed by __close__ alone lands on disk.
#
# The resident acks AFTER shutting down, so a missing/empty ack (lost to a
# crash or the 5s write-timeout) still means "closed". A real shutdown
# data-loss is a NON-empty error response, so it surfaces through _parse.
try:
return _parse(_rpc(self._ping, {"Command": "__close__"}, self.timeout))
except OfficeCliError:
# Only swallow if the resident is actually gone. If it's still alive
# (ping pipe was momentarily unreachable/busy), the close did NOT take
# effect — re-raise, or the caller wrongly believes the file is released
# and may race a re-open/overwrite.
if self.alive():
raise
return "" # resident gone / ack lost — end state is "closed"
def __enter__(self):
return self
def __exit__(self, *a):
# `with` means "I manage this session" → close on exit. To only borrow a
# resident another program owns, DON'T use `with` and DON'T call close():
# d = officecli.open(f); d.send(...) # left running
self.close()
def create(path, *args, binary="officecli", timeout=30.0, auto_install=True):
"""Create a blank Office document and return a live `Document` handle for it.
Parallel to `open`: both return the session handle you actually work with —
they differ only in the file's expected state. `open` requires an existing
file; `create` makes a new one (like file mode "x" vs "r"). Extra CLI flags
pass through verbatim, so there's no option list maintained here:
with officecli.create("report.xlsx", "--force") as doc:
doc.send({"command": "set", "path": "/Sheet1/A1", "props": {"text": "hi"}})
officecli.create("doc", "--type", "docx")
One CLI spawn (`officecli create`), which also auto-starts a resident for the
new file; the returned Document binds to THAT resident (no second spawn).
Raises OfficeCliError on failure, inheriting officecli's exact semantics:
• file held by a LIVE resident → file_locked (close it first). We do NOT
silently close+overwrite it — in a shared workspace that resident may be
another owner's active session.
• file exists without --force → file_exists (pass "--force" to overwrite)."""
full = os.path.abspath(path)
binary = _ensure_binary(binary, auto_install)
r = _run_cli(binary, ["create", full, *args])
if r.returncode != 0:
raise OfficeCliError(r.returncode, r.stderr or r.stdout)
# create auto-started a resident for the new file; bind a handle to it
# (Document.__init__ -> _start -> _serves finds it alive, so no extra spawn).
return Document(full, binary=binary, timeout=timeout)
def open(path, binary="officecli", timeout=30.0, auto_install=True):
"""Open an EXISTING document and return a live `Document` handle (parallel to
`create`, which makes a new file). `officecli open` is idempotent: it reuses a
resident already serving this file or starts one — and if a live resident is
already up, no process is spawned at all.
Lifecycle:
Owner — `with officecli.open(f) as d: ...` (exit closes the resident)
Borrow — `d = officecli.open(f); d.send(...)` (no `with`/close → left running)
Failure model (applies to every send/batch on the handle):
• resident DEAD/gone (crash, idle-timeout, missing pipe) → transparently
restarted and the command retried once; the caller sees no error.
• resident ALIVE but the pipe is unresponsive (busy) → raises OfficeCliError
— never a deadlock, and never bypassing the live resident (that would race
its save and lose data). Retry, or close() and reopen.
`timeout` bounds command DELIVERY (connect + retries) in seconds, mirroring
officecli's TrySend; the reply read itself blocks (a busy resident answers in
turn). Override per call via send(..., timeout=...) / batch(..., timeout=...);
use alive() to probe liveness."""
doc = Document(path, binary=_ensure_binary(binary, auto_install), timeout=timeout)
# Mirror CLI `open`: when reusing a resident `create` auto-started with a
# short 60s timeout, upgrade it to the 12min interactive window. (If _start
# spawned `officecli open` instead, that path already set 12min; re-sending
# is idempotent.)
doc._set_idle_timeout(_OPEN_IDLE_SECONDS)
return doc
def install():
"""Install the officecli CLI binary via its OFFICIAL installer — install.sh on
unix, install.ps1 on Windows. Reuses officecli's own installers (platform
detection + checksum + ~/.local/bin or %LOCALAPPDATA%\\OfficeCLI), rather than
reimplementing download logic that would drift from upstream.
Called automatically by open()/create() when the CLI is missing (pass
auto_install=False to disable), and exposed directly as `python -m officecli
install`. Returns None on success; raises OfficeCliError on failure. Output is
NOT captured, so the installer's progress and checksum lines stream to the
user."""
if _IS_WIN:
print(f"Installing officecli via {_INSTALL_PS1_MIRROR} (github fallback) ...", file=sys.stderr)
# Windows PowerShell (powershell.exe) ships with the OS; -ExecutionPolicy
# Bypass lets the remote script run without changing machine policy. Fetch
# the script mirror-first, github fallback, then run it.
ps = (f"$s = try {{ irm '{_INSTALL_PS1_MIRROR}' }} "
f"catch {{ irm '{_INSTALL_PS1_GITHUB}' }}; $s | iex")
r = subprocess.run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps])
if r.returncode != 0:
raise OfficeCliError(r.returncode,
f"officecli install failed (exit {r.returncode}). Run manually:\n"
f" irm {_INSTALL_PS1_MIRROR} | iex")
return None
print(f"Installing officecli via {_INSTALL_SH_MIRROR} (github fallback) ...", file=sys.stderr)
# (curl mirror || curl github) | bash — the subshell emits whichever fetch
# succeeds; the group keeps the pipe bound to the whole fallback. Output is
# NOT captured, so progress and checksum lines stream to the user.
sh = f"(curl -fsSL {_INSTALL_SH_MIRROR} 2>/dev/null || curl -fsSL {_INSTALL_SH_GITHUB}) | bash"
r = subprocess.run(["bash", "-c", sh])
if r.returncode != 0:
raise OfficeCliError(r.returncode,
f"officecli install failed (exit {r.returncode}). Run manually:\n"
f" curl -fsSL {_INSTALL_SH_MIRROR} | bash")
return None
# Advertised surface = the command shell + its error. pipe_paths stays importable
# (officecli.pipe_paths) as a debug helper but isn't part of the command API.
__all__ = ["open", "create", "install", "Document", "OfficeCliError"]
if __name__ == "__main__":
# `python -m officecli install` — bootstrap the CLI binary.
if len(sys.argv) >= 2 and sys.argv[1] == "install":
install()
else:
print("usage: python -m officecli install", file=sys.stderr)
sys.exit(2)
+34
View File
@@ -0,0 +1,34 @@
[build-system]
requires = ["setuptools>=77"] # >=77 for the PEP 639 SPDX `license` string
build-backend = "setuptools.build_meta"
[project]
# Distribution (pip) name. NOT the import name — the module stays `officecli`
# (`import officecli`), like `pip install pillow` → `import PIL`. PyPI rejects
# the bare name "officecli" as too similar to the unrelated "office-cli" project.
name = "officecli-sdk"
version = "0.1.7"
description = "Thin Python SDK for the officecli resident pipe — forwards officecli commands to a running resident, no per-command process spawn."
readme = "README.md"
requires-python = ">=3.8"
license = "Apache-2.0" # PEP 639 SPDX expression (do NOT also add a License:: classifier — PyPI rejects the combo)
keywords = ["officecli", "office", "docx", "xlsx", "pptx", "ooxml"]
dependencies = [] # standard library only
classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: MacOS",
"Operating System :: POSIX :: Linux",
"Operating System :: Microsoft :: Windows",
]
# TODO(maintainer): optionally add authors = [{ name = "...", email = "..." }] above.
[project.urls]
Homepage = "https://officecli.ai"
Repository = "https://github.com/iOfficeAI/OfficeCLI"
# IMPORTANT: this package is only the SDK. It shells out to the `officecli`
# CLI binary, which must be installed separately and on PATH (Homebrew, etc.).
# pip cannot install that binary for you — see README.
[tool.setuptools]
py-modules = ["officecli"] # single-file module: officecli.py
+25
View File
@@ -0,0 +1,25 @@
"""CI smoke test (not shipped — pyproject ships only officecli.py). On a runner
without officecli on PATH, create() triggers auto_install (install.sh on unix,
install.ps1 on Windows), proving the cross-platform provisioning + the pipe
round-trip end to end. Exits non-zero on any failure."""
import os
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import officecli # noqa: E402
f = os.path.join(tempfile.gettempdir(), f"officecli-smoke-{os.getpid()}.xlsx")
d = officecli.create(f, "--force")
d.send({"command": "set", "path": "/Sheet1/A1", "props": {"text": "smoke-ok"}})
g = d.send({"command": "get", "path": "/Sheet1/A1"})
d.close()
try:
os.unlink(f)
except OSError:
pass
if "smoke-ok" not in str(g):
print("python SDK smoke FAIL: A1 mismatch", g)
sys.exit(1)
print("python SDK smoke PASS")