Files
iofficeai--officecli/sdk/python/officecli.py
T
wehub-resource-sync 8cb1f9f479
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
chore: import upstream snapshot with attribution
2026-07-13 13:09:29 +08:00

621 lines
32 KiB
Python

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)