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
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:
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user