officecli-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
officecli.py ADDED
@@ -0,0 +1,407 @@
1
+ r"""
2
+ officecli — a thin Python shell over officecli's resident pipe.
3
+
4
+ It does ONE thing: forward a command to the running resident over its named
5
+ pipe and hand back the response. There is NO second vocabulary to learn: a
6
+ command is the same dict you'd put in an officecli `batch` list — e.g.
7
+ {"command":"set","path":"/Sheet1/A1","props":{"text":"Hello"}}. `send` forwards
8
+ one; `batch` forwards many in a single round-trip.
9
+
10
+ Two surfaces, by design:
11
+ - bootstrap (infrequent): `create` / `open` spawn ONE CLI process — a file that
12
+ isn't open yet (or doesn't exist yet) has no resident to talk to.
13
+ - everything else (the hot path): `send` / `batch` are pure pipe round-trips,
14
+ no per-command process spawn.
15
+
16
+ import officecli
17
+ with officecli.create("report.xlsx", "--force") as doc: # make file + get handle
18
+ doc.send({"command": "set", "path": "/Sheet1/A1",
19
+ "props": {"text": "Hello"}})
20
+ print(doc.send({"command": "get", "path": "/Sheet1/A1"}))
21
+ doc.send({"command": "save"})
22
+ # ...or officecli.open("existing.xlsx") for a file that already exists.
23
+
24
+ The item keys are officecli's batch fields (command/op, path, parent, type,
25
+ index, after, before, to, selector, text, mode, depth, part, xpath, action,
26
+ xml) plus a nested `props` dict. Everything except command/op/props is
27
+ forwarded verbatim as a command argument; the resident dispatches it exactly
28
+ like the matching CLI command. See `officecli help` / the batch docs for the
29
+ field-and-prop reference — this shell adds none of its own.
30
+
31
+ Protocol (matches ResidentServer.cs / ResidentClient.cs):
32
+ - pipe name : officecli-<SHA256(fullpath)[:16] uppercase>;
33
+ fullpath upper-cased on macOS/Windows, left as-is on Linux.
34
+ - unix path : $TMPDIR/CoreFxPipe_<name> (+ "-ping"); $TMPDIR else /tmp
35
+ - win path : \\.\pipe\<name> (+ "-ping")
36
+ - framing : one request line + one response line, UTF-8, '\n' terminated;
37
+ one connection == one command.
38
+ - request : PascalCase {"Command","Args","Props","Json"}
39
+ - response : {"ExitCode","Stdout","Stderr"}
40
+ """
41
+
42
+ import os
43
+ import sys
44
+ import json
45
+ import time
46
+ import socket
47
+ import hashlib
48
+ import threading
49
+ import subprocess
50
+
51
+ # Mirror officecli's TryResident busy-delivery policy (CommandBuilder.cs): a
52
+ # generous connect timeout + a few retries with backoff, applied identically to
53
+ # every command. The reply read itself blocks (no timeout) — like officecli's
54
+ # PipeReadLine — trusting the resident to answer once our turn comes up in its
55
+ # serialized queue. Because retries only re-attempt the CONNECT (before the
56
+ # command executes), re-sending is safe even for mutations; there is no
57
+ # "read timed out, resend" path that could double-apply.
58
+ _BUSY_CONNECT_TIMEOUT = 30.0 # = ResidentBusyConnectTimeoutMs (30000)
59
+ _BUSY_MAX_RETRIES = 3 # = ResidentBusyMaxRetries
60
+
61
+ _IS_WIN = sys.platform.startswith("win")
62
+ _IS_MAC = sys.platform == "darwin"
63
+ _builtin_open = open # preserved; this module defines its own open() below
64
+
65
+
66
+ class OfficeCliError(Exception):
67
+ """Raised on transport/process failure (could not reach the resident).
68
+ Business outcomes are NOT exceptions — they live in the returned envelope's
69
+ 'success' field, same as the CLI's exit code."""
70
+ def __init__(self, code, msg):
71
+ super().__init__(f"[exit {code}] {msg}")
72
+ self.code = code
73
+
74
+
75
+ # ---------------------------------------------------------------- pipe address
76
+ def _dotnet_tempdir():
77
+ # Mirror .NET Path.GetTempPath() on Unix exactly: $TMPDIR else /tmp.
78
+ return os.environ.get("TMPDIR") or "/tmp"
79
+
80
+
81
+ def pipe_paths(file_path):
82
+ """(main, ping) pipe addresses for a document path. Exposed for debugging."""
83
+ full = os.path.abspath(file_path)
84
+ if _IS_MAC or _IS_WIN:
85
+ full = full.upper() # Linux: case-sensitive, no upper
86
+ h = hashlib.sha256(full.encode("utf-8")).hexdigest().upper()[:16]
87
+ name = f"officecli-{h}"
88
+ if _IS_WIN:
89
+ return rf"\\.\pipe\{name}", rf"\\.\pipe\{name}-ping"
90
+ base = os.path.join(_dotnet_tempdir(), f"CoreFxPipe_{name}")
91
+ return base, base + "-ping"
92
+
93
+
94
+ # ---------------------------------------------------------------- transport
95
+ # One attempt: bound the CONNECT, then block on the reply (no read timeout) —
96
+ # exactly like officecli's TrySend (Connect(timeout) + blocking PipeReadLine).
97
+ def _send_unix(sock_path, line, connect_timeout):
98
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
99
+ try:
100
+ s.settimeout(connect_timeout)
101
+ s.connect(sock_path)
102
+ s.settimeout(None) # block on the reply; resident answers in turn
103
+ s.sendall(line)
104
+ buf = b""
105
+ while not buf.endswith(b"\n"):
106
+ chunk = s.recv(65536)
107
+ if not chunk:
108
+ break
109
+ buf += chunk
110
+ return buf
111
+ finally:
112
+ s.close()
113
+
114
+
115
+ def _send_win(pipe_path, line, connect_timeout):
116
+ deadline = time.time() + connect_timeout
117
+ while True: # bound the "open" (connect) phase
118
+ try:
119
+ f = _builtin_open(pipe_path, "r+b", buffering=0) # not the module open()
120
+ break
121
+ except OSError:
122
+ if time.time() > deadline:
123
+ raise
124
+ time.sleep(0.02)
125
+ try:
126
+ f.write(line)
127
+ buf = b""
128
+ while not buf.endswith(b"\n"): # blocking read, like PipeReadLine
129
+ chunk = f.read(65536)
130
+ if not chunk:
131
+ break
132
+ buf += chunk
133
+ return buf
134
+ finally:
135
+ f.close()
136
+
137
+
138
+ def _rpc(sock_path, req, connect_timeout=_BUSY_CONNECT_TIMEOUT, max_retries=_BUSY_MAX_RETRIES):
139
+ """Forward one request, mirroring officecli's TrySend: bounded connect + a few
140
+ retries with backoff, then a blocking read. A retry only re-attempts the
141
+ connect (before the command runs), so it never double-applies a mutation. If
142
+ the command still can't be delivered, raise a busy/unresponsive error — never
143
+ fall back to touching the file directly (that would race the resident).
144
+
145
+ `max_retries` overrides the busy-retry count. Liveness probes (_serves) pass 0
146
+ so a missing/stale pipe fails FAST instead of sleeping through ~0.3s of backoff
147
+ — retrying a probe the resident isn't answering can't make it answer; the
148
+ busy-retry policy is for delivering a real command to a slow-but-live pipe."""
149
+ line = (json.dumps(req, ensure_ascii=False) + "\n").encode("utf-8")
150
+ send = _send_win if _IS_WIN else _send_unix
151
+ for attempt in range(max_retries + 1):
152
+ try:
153
+ raw = send(sock_path, line, connect_timeout)
154
+ break
155
+ except OSError as e:
156
+ if attempt >= max_retries:
157
+ raise OfficeCliError(-1,
158
+ f"resident is running but the command could not be delivered "
159
+ f"(pipe busy or unresponsive); retry, or close and reopen [{e}]")
160
+ time.sleep(0.05 * (attempt + 1)) # = TrySend's 50*(n+1)ms backoff
161
+ # utf-8-sig: the resident's StreamWriter (Encoding.UTF8) prepends a BOM the
162
+ # C# StreamReader strips; we must too, or json.loads chokes on the leading .
163
+ text = raw.decode("utf-8-sig")
164
+ if not text.strip():
165
+ # Empty/closed reply: the resident accepted the connection but closed
166
+ # without a complete response (e.g. crashed mid-serve). We DELIBERATELY
167
+ # DIVERGE from officecli's TrySend here: TrySend re-sends on an empty reply
168
+ # (ResidentClient.cs `if (responseLine == null) continue;`) and returns null
169
+ # only after exhausting its retries — i.e. it would re-execute a command the
170
+ # resident may have already applied before dying. We refuse that
171
+ # double-apply risk and raise instead. _cmd's recovery then restarts a dead
172
+ # resident and retries once (a fresh connect, before re-send), and
173
+ # _serves()/alive() (which swallow OfficeCliError) read an empty reply as
174
+ # "not alive".
175
+ raise OfficeCliError(-1,
176
+ "resident closed the connection without a response "
177
+ "(it may have crashed mid-command); retry, or close and reopen")
178
+ return json.loads(text)
179
+
180
+
181
+ def _parse(resp):
182
+ """Return the useful payload: the parsed JSON envelope (dict/list) if Stdout is
183
+ a JSON object/array, otherwise the raw Stdout text ("" when empty). We accept
184
+ ONLY dict/list from json.loads — a text-mode reply that happens to BE a bare
185
+ JSON scalar ("42", "true", "null", a quoted string) must stay text, or the
186
+ caller can't tell literal text "42" from the number 42 (and None from a missing
187
+ key). Faithful to the response — no synthesizing a dict for view/raw text."""
188
+ out = resp.get("Stdout", "")
189
+ try:
190
+ v = json.loads(out)
191
+ except ValueError:
192
+ return out
193
+ return v if isinstance(v, (dict, list)) else out
194
+
195
+
196
+ def _strv(d):
197
+ # Drop None-valued props (omit), matching how _cmd() drops None args — a prop
198
+ # set to None means "don't send it", not "send empty string". Pass "" for
199
+ # an explicit empty value.
200
+ return {k: str(v) for k, v in d.items() if v is not None}
201
+
202
+
203
+ def _serves(ping_path, full_path, timeout=1.0):
204
+ """Is a resident alive on `ping_path` AND serving `full_path`? Probes the
205
+ always-responsive `-ping` pipe (officecli's TryConnect equivalent): it answers
206
+ even while the MAIN pipe is busy. The path-match guards against a stale socket
207
+ serving a different/renamed file. `full_path` must already be absolute.
208
+ Single-shot (max_retries=0): a probe should fail fast, not sit through the
209
+ busy-retry backoff that a real command delivery uses."""
210
+ try:
211
+ resp = _rpc(ping_path, {"Command": "__ping__"}, timeout, max_retries=0)
212
+ except OfficeCliError:
213
+ return False
214
+ served = resp.get("Stdout", "").strip() # ping echoes the served file path
215
+ if not served:
216
+ return False
217
+ a = os.path.abspath(served)
218
+ return a == full_path or ((_IS_MAC or _IS_WIN) and a.lower() == full_path.lower())
219
+
220
+
221
+ # ---------------------------------------------------------------- the shell
222
+ class Document:
223
+ def __init__(self, path, binary="officecli", timeout=30.0):
224
+ self.path = os.path.abspath(path)
225
+ self.bin = binary
226
+ self.timeout = timeout # connect timeout (s); the reply read blocks
227
+ self._main, self._ping = pipe_paths(self.path)
228
+ self._restart_lock = threading.Lock() # serialize dead-resident restarts
229
+ self._start()
230
+
231
+ def _start(self):
232
+ # If a resident is ALREADY serving this file, reuse it — no process spawn.
233
+ # Mirrors officecli, where a command after `create` reuses the resident
234
+ # `create` auto-started instead of re-running `open`. _serves() is a real
235
+ # liveness probe (ping the -ping pipe + verify the served path), not a
236
+ # socket-file-exists check, so a stale/dead socket fails the probe and
237
+ # falls through to `officecli open`, which replaces it via TryConnect.
238
+ # (A plain os.path.exists() here would wrongly skip on a stale socket.)
239
+ if _serves(self._ping, self.path):
240
+ return
241
+ # Otherwise spawn `officecli open` (one process). It's idempotent and uses
242
+ # the same TryConnect to start a fresh resident or replace a stale socket.
243
+ r = subprocess.run([self.bin, "open", self.path], capture_output=True, text=True)
244
+ if r.returncode != 0:
245
+ raise OfficeCliError(r.returncode, r.stderr or r.stdout)
246
+
247
+ # -- transport primitive: build {Command,Args,Props,Json}, forward, parse --
248
+ def _cmd(self, command, args=None, props=None, as_json=True, timeout=None):
249
+ # `as_json`, not `json`, so we don't shadow the imported json module.
250
+ # timeout=None uses this Document's default (self.timeout). It bounds the
251
+ # CONNECT/delivery (with retries); the reply read blocks, so a legitimately
252
+ # slow command isn't cut off — it waits for the resident, like officecli.
253
+ req = {"Command": command, "Json": as_json}
254
+ if args:
255
+ req["Args"] = {k: str(v) for k, v in args.items() if v is not None}
256
+ if props is not None:
257
+ req["Props"] = _strv(props)
258
+ t = self.timeout if timeout is None else timeout
259
+ try:
260
+ return _rpc(self._main, req, t)
261
+ except OfficeCliError:
262
+ # Delivery failed after _rpc's own connect retries. Use the -ping pipe
263
+ # to tell DEAD from BUSY — officecli's own distinction (alive()):
264
+ # • ALIVE but main pipe unresponsive → do NOT bypass it. officecli
265
+ # deliberately dropped the direct-file fallback: a second writer
266
+ # racing the live resident loses data on its eventual save. Re-raise
267
+ # the busy error so the caller can retry or close+reopen.
268
+ # • DEAD (crashed / stale socket) → restart with one `officecli open`
269
+ # and retry ONCE. Safe across reads and mutations: mutations live in
270
+ # memory until save/close, so a crash loses them and disk holds the
271
+ # last save — replaying against the restarted (disk-state) resident
272
+ # reproduces the lost op once, with nothing live to double-apply.
273
+ if self.alive():
274
+ raise
275
+ # Serialize the restart across threads sharing this Document. Without
276
+ # the lock, N concurrent callers each see alive()==False and each spawn
277
+ # `officecli open`, leaving N-1 orphaned residents on the same file
278
+ # (which can then race each other's save). Re-check alive() inside the
279
+ # lock so only the first thread restarts; the rest find it back up.
280
+ with self._restart_lock:
281
+ if not self.alive():
282
+ self._start()
283
+ return _rpc(self._main, req, t)
284
+
285
+ # -- the surface: send ONE batch-shaped command, or a LIST of them ---------
286
+ def send(self, item, as_json=True, timeout=None):
287
+ """Forward ONE command in officecli's batch-item shape and return its
288
+ parsed result (the JSON envelope, or raw text for content commands).
289
+
290
+ `item` is exactly a dict you'd put in a `batch` list, e.g.
291
+ {"command": "set", "path": "/Sheet1/A1", "props": {"text": "hi"}}
292
+ {"command": "get", "path": "/Sheet1/A1"}
293
+ Keys are officecli's batch fields; `command` (or `op`) picks the command,
294
+ `props` becomes the property map, and every other key is forwarded
295
+ verbatim as a command argument — no field list maintained here, so new
296
+ officecli fields work without touching this shell.
297
+
298
+ `as_json=False` requests plain-text output (view/raw/dump), mirroring the
299
+ CLI's --json toggle."""
300
+ command = item.get("command") or item.get("op")
301
+ if not command:
302
+ raise OfficeCliError(-1, "send(item): item needs a 'command' (or 'op') key")
303
+ args = {k: v for k, v in item.items() if k not in ("command", "op", "props")}
304
+ return _parse(self._cmd(command, args, item.get("props"),
305
+ as_json=as_json, timeout=timeout))
306
+
307
+ def batch(self, items, force=True, stop_on_error=False, timeout=None):
308
+ """Forward officecli's `batch` command: apply a LIST of the same item
309
+ dicts as `send` in ONE round-trip — the fast path for many writes. Same
310
+ contract as `send`, just plural."""
311
+ args = {"batchJson": json.dumps(items, ensure_ascii=False),
312
+ "force": force, "stopOnError": stop_on_error}
313
+ return _parse(self._cmd("batch", args, timeout=timeout))
314
+
315
+ def alive(self, timeout=1.0):
316
+ """Return True iff a resident is alive AND serving this file. Probes the
317
+ always-responsive `-ping` pipe (officecli's TryConnect), which answers even
318
+ while the MAIN pipe is busy — so it distinguishes "alive but busy" from
319
+ "gone". This is the discriminator `_cmd` uses on a delivery failure (busy →
320
+ raise, gone → restart+retry); send/batch already auto-recover from a gone
321
+ resident, so call this only when you want to check liveness yourself."""
322
+ return _serves(self._ping, self.path, timeout)
323
+
324
+ # -- lifecycle ------------------------------------------------------------
325
+ def close(self):
326
+ # = `officecli close`: stop the resident. It flushes the in-memory doc to
327
+ # disk as it shuts down (handler.Dispose), so no separate save is needed —
328
+ # verified: a set followed by __close__ alone lands on disk.
329
+ #
330
+ # The resident acks AFTER shutting down, so a missing/empty ack (lost to a
331
+ # crash or the 5s write-timeout) still means "closed". A real shutdown
332
+ # data-loss is a NON-empty error response, so it surfaces through _parse.
333
+ try:
334
+ return _parse(_rpc(self._ping, {"Command": "__close__"}, self.timeout))
335
+ except OfficeCliError:
336
+ # Only swallow if the resident is actually gone. If it's still alive
337
+ # (ping pipe was momentarily unreachable/busy), the close did NOT take
338
+ # effect — re-raise, or the caller wrongly believes the file is released
339
+ # and may race a re-open/overwrite.
340
+ if self.alive():
341
+ raise
342
+ return "" # resident gone / ack lost — end state is "closed"
343
+
344
+ def __enter__(self):
345
+ return self
346
+
347
+ def __exit__(self, *a):
348
+ # `with` means "I manage this session" → close on exit. To only borrow a
349
+ # resident another program owns, DON'T use `with` and DON'T call close():
350
+ # d = officecli.open(f); d.send(...) # left running
351
+ self.close()
352
+
353
+
354
+ def create(path, *args, binary="officecli", timeout=30.0):
355
+ """Create a blank Office document and return a live `Document` handle for it.
356
+
357
+ Parallel to `open`: both return the session handle you actually work with —
358
+ they differ only in the file's expected state. `open` requires an existing
359
+ file; `create` makes a new one (like file mode "x" vs "r"). Extra CLI flags
360
+ pass through verbatim, so there's no option list maintained here:
361
+ with officecli.create("report.xlsx", "--force") as doc:
362
+ doc.send({"command": "set", "path": "/Sheet1/A1", "props": {"text": "hi"}})
363
+ officecli.create("doc", "--type", "docx")
364
+
365
+ One CLI spawn (`officecli create`), which also auto-starts a resident for the
366
+ new file; the returned Document binds to THAT resident (no second spawn).
367
+ Raises OfficeCliError on failure, inheriting officecli's exact semantics:
368
+ • file held by a LIVE resident → file_locked (close it first). We do NOT
369
+ silently close+overwrite it — in a shared workspace that resident may be
370
+ another owner's active session.
371
+ • file exists without --force → file_exists (pass "--force" to overwrite)."""
372
+ full = os.path.abspath(path)
373
+ r = subprocess.run([binary, "create", full, *args], capture_output=True, text=True)
374
+ if r.returncode != 0:
375
+ raise OfficeCliError(r.returncode, r.stderr or r.stdout)
376
+ # create auto-started a resident for the new file; bind a handle to it
377
+ # (Document.__init__ -> _start -> _serves finds it alive, so no extra spawn).
378
+ return Document(full, binary=binary, timeout=timeout)
379
+
380
+
381
+ def open(path, binary="officecli", timeout=30.0):
382
+ """Open an EXISTING document and return a live `Document` handle (parallel to
383
+ `create`, which makes a new file). `officecli open` is idempotent: it reuses a
384
+ resident already serving this file or starts one — and if a live resident is
385
+ already up, no process is spawned at all.
386
+
387
+ Lifecycle:
388
+ Owner — `with officecli.open(f) as d: ...` (exit closes the resident)
389
+ Borrow — `d = officecli.open(f); d.send(...)` (no `with`/close → left running)
390
+
391
+ Failure model (applies to every send/batch on the handle):
392
+ • resident DEAD/gone (crash, idle-timeout, missing pipe) → transparently
393
+ restarted and the command retried once; the caller sees no error.
394
+ • resident ALIVE but the pipe is unresponsive (busy) → raises OfficeCliError
395
+ — never a deadlock, and never bypassing the live resident (that would race
396
+ its save and lose data). Retry, or close() and reopen.
397
+
398
+ `timeout` bounds command DELIVERY (connect + retries) in seconds, mirroring
399
+ officecli's TrySend; the reply read itself blocks (a busy resident answers in
400
+ turn). Override per call via send(..., timeout=...) / batch(..., timeout=...);
401
+ use alive() to probe liveness."""
402
+ return Document(path, binary=binary, timeout=timeout)
403
+
404
+
405
+ # Advertised surface = the command shell + its error. pipe_paths stays importable
406
+ # (officecli.pipe_paths) as a debug helper but isn't part of the command API.
407
+ __all__ = ["open", "create", "Document", "OfficeCliError"]
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: officecli-sdk
3
+ Version: 0.1.0
4
+ Summary: Thin Python SDK for the officecli resident pipe — forwards officecli commands to a running resident, no per-command process spawn.
5
+ License-Expression: Apache-2.0
6
+ Keywords: officecli,office,docx,xlsx,pptx,ooxml
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: MacOS
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Operating System :: Microsoft :: Windows
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+
14
+ # officecli — Python SDK
15
+
16
+ A **thin** Python SDK for the [officecli](../../) **resident pipe**. It does one
17
+ thing: forward an officecli command to a running resident over its named pipe and
18
+ hand back the response — no per-command process spawn, so a loop of edits is
19
+ ~hundreds of times faster than shelling out to the CLI per command.
20
+
21
+ "Thin" is the point: there is **no second vocabulary** to learn. A command is the
22
+ same dict you'd put in an officecli `batch` list; the SDK just carries it over the
23
+ pipe. Anything a `doc.set_cell(...)` / `doc.add_paragraph(...)` method would do is
24
+ **fully supported** — you just spell it `doc.send({"command": "set", ...})`, with
25
+ the exact same effect. One uniform verb instead of dozens of per-element named
26
+ methods: same power, nothing extra to memorize, and new officecli features work
27
+ the day they ship without an SDK update.
28
+
29
+ ## Requirement: the officecli CLI must be installed
30
+
31
+ `pip install officecli-sdk` installs **only this SDK** (the Python library). It
32
+ shells out to the `officecli` binary, which must be installed separately and on
33
+ your `PATH` (Homebrew, etc.). If `officecli --version` works in your shell, you're
34
+ set.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install officecli-sdk # once published — note: import name is `officecli`
40
+ # or, from a checkout of this repo:
41
+ pip install ./sdk/python
42
+ ```
43
+
44
+ The pip/distribution name is `officecli-sdk`, but you `import officecli`
45
+ (distribution name ≠ import name, like `pip install pillow` → `import PIL`).
46
+
47
+ Zero third-party dependencies (standard library only).
48
+
49
+ ## Quickstart
50
+
51
+ ```python
52
+ import officecli
53
+
54
+ # create() makes a new file and returns a live session handle;
55
+ # open() does the same for an existing file. Both return a Document.
56
+ with officecli.create("report.xlsx", "--force") as doc:
57
+ doc.send({"command": "set", "path": "/Sheet1/A1",
58
+ "props": {"text": "Region", "bold": "true"}})
59
+ doc.send({"command": "set", "path": "/Sheet1/B1", "props": {"formula": "=SUM(B2:B9)"}})
60
+
61
+ # read one back (returns the parsed JSON envelope)
62
+ node = doc.send({"command": "get", "path": "/Sheet1/A1"})
63
+ print(node["data"]["results"][0]["text"]) # -> Region
64
+
65
+ # many edits in ONE pipe round-trip
66
+ doc.batch([
67
+ {"command": "set", "path": "/Sheet1/A2", "props": {"text": "North"}},
68
+ {"command": "set", "path": "/Sheet1/A3", "props": {"text": "South"}},
69
+ ])
70
+
71
+ doc.send({"command": "save"})
72
+ # leaving `with` closes the resident (which flushes to disk)
73
+
74
+ # borrow an already-running resident without owning it: skip `with`/close()
75
+ d = officecli.open("report.xlsx")
76
+ print(d.send({"command": "view", "mode": "stats"}, as_json=False))
77
+ ```
78
+
79
+ See `demo.py` for a fuller example.
80
+
81
+ ## The command dict
82
+
83
+ `send(item)` and `batch([item, ...])` take the officecli **batch-item** shape:
84
+
85
+ ```jsonc
86
+ { "command": "set", // or "op"; picks the officecli command
87
+ "path": "/Sheet1/A1", // every key except command/op/props is forwarded
88
+ "props": { "text": "hi" } } // verbatim as a command argument
89
+ ```
90
+
91
+ Keys are officecli's own batch fields (`command`/`op`, `path`, `parent`, `type`,
92
+ `index`, `after`, `before`, `to`, `selector`, `mode`, `depth`, `part`, `xpath`,
93
+ `action`, `xml`) plus a nested `props`. The client maintains no field list of its
94
+ own — run `officecli help` (or see the batch docs) for the full reference.
95
+
96
+ `send(..., as_json=False)` requests plain-text output (e.g. `view` / `raw` /
97
+ `dump`), mirroring the CLI's `--json` toggle.
98
+
99
+ ## Errors & resilience
100
+
101
+ - Transport/process failures raise `officecli.OfficeCliError` (`.code` carries the
102
+ exit code). Business outcomes (e.g. `validate` failing, a bad path) are **not**
103
+ exceptions — they live in the returned envelope's `success` field, same as the
104
+ CLI's exit code.
105
+ - If the resident has gone (crash, idle-timeout, missing pipe), `send`/`batch`
106
+ transparently restart it and retry once. If it's alive but the pipe is
107
+ unresponsive (busy), they raise rather than risk racing the live resident.
108
+
109
+ ## Versioning
110
+
111
+ This client derives the resident's pipe address from the document path the same
112
+ way officecli does. That derivation is the one piece coupled to officecli
113
+ internals, so keep the client version compatible with your installed officecli.
@@ -0,0 +1,5 @@
1
+ officecli.py,sha256=kxYJuvYtwk8DQL7x5_EUwg77Bpbly496guWAdom8Rzc,20901
2
+ officecli_sdk-0.1.0.dist-info/METADATA,sha256=iX1BsZZdAUD-jY6BOcfjBgGhJIp3dgjQq1jXZRtAxyc,4790
3
+ officecli_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
4
+ officecli_sdk-0.1.0.dist-info/top_level.txt,sha256=Duyfr3awLo95iqdTh_rPtxVNTtCG0exmiHf0lzZe3F0,10
5
+ officecli_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ officecli