officecli-sdk 0.1.0__tar.gz
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_sdk-0.1.0/PKG-INFO +113 -0
- officecli_sdk-0.1.0/README.md +100 -0
- officecli_sdk-0.1.0/officecli.py +407 -0
- officecli_sdk-0.1.0/officecli_sdk.egg-info/PKG-INFO +113 -0
- officecli_sdk-0.1.0/officecli_sdk.egg-info/SOURCES.txt +7 -0
- officecli_sdk-0.1.0/officecli_sdk.egg-info/dependency_links.txt +1 -0
- officecli_sdk-0.1.0/officecli_sdk.egg-info/top_level.txt +1 -0
- officecli_sdk-0.1.0/pyproject.toml +34 -0
- officecli_sdk-0.1.0/setup.cfg +4 -0
|
@@ -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,100 @@
|
|
|
1
|
+
# officecli — Python SDK
|
|
2
|
+
|
|
3
|
+
A **thin** Python SDK for the [officecli](../../) **resident pipe**. It does one
|
|
4
|
+
thing: forward an officecli command to a running resident over its named pipe and
|
|
5
|
+
hand back the response — no per-command process spawn, so a loop of edits is
|
|
6
|
+
~hundreds of times faster than shelling out to the CLI per command.
|
|
7
|
+
|
|
8
|
+
"Thin" is the point: there is **no second vocabulary** to learn. A command is the
|
|
9
|
+
same dict you'd put in an officecli `batch` list; the SDK just carries it over the
|
|
10
|
+
pipe. Anything a `doc.set_cell(...)` / `doc.add_paragraph(...)` method would do is
|
|
11
|
+
**fully supported** — you just spell it `doc.send({"command": "set", ...})`, with
|
|
12
|
+
the exact same effect. One uniform verb instead of dozens of per-element named
|
|
13
|
+
methods: same power, nothing extra to memorize, and new officecli features work
|
|
14
|
+
the day they ship without an SDK update.
|
|
15
|
+
|
|
16
|
+
## Requirement: the officecli CLI must be installed
|
|
17
|
+
|
|
18
|
+
`pip install officecli-sdk` installs **only this SDK** (the Python library). It
|
|
19
|
+
shells out to the `officecli` binary, which must be installed separately and on
|
|
20
|
+
your `PATH` (Homebrew, etc.). If `officecli --version` works in your shell, you're
|
|
21
|
+
set.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install officecli-sdk # once published — note: import name is `officecli`
|
|
27
|
+
# or, from a checkout of this repo:
|
|
28
|
+
pip install ./sdk/python
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The pip/distribution name is `officecli-sdk`, but you `import officecli`
|
|
32
|
+
(distribution name ≠ import name, like `pip install pillow` → `import PIL`).
|
|
33
|
+
|
|
34
|
+
Zero third-party dependencies (standard library only).
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import officecli
|
|
40
|
+
|
|
41
|
+
# create() makes a new file and returns a live session handle;
|
|
42
|
+
# open() does the same for an existing file. Both return a Document.
|
|
43
|
+
with officecli.create("report.xlsx", "--force") as doc:
|
|
44
|
+
doc.send({"command": "set", "path": "/Sheet1/A1",
|
|
45
|
+
"props": {"text": "Region", "bold": "true"}})
|
|
46
|
+
doc.send({"command": "set", "path": "/Sheet1/B1", "props": {"formula": "=SUM(B2:B9)"}})
|
|
47
|
+
|
|
48
|
+
# read one back (returns the parsed JSON envelope)
|
|
49
|
+
node = doc.send({"command": "get", "path": "/Sheet1/A1"})
|
|
50
|
+
print(node["data"]["results"][0]["text"]) # -> Region
|
|
51
|
+
|
|
52
|
+
# many edits in ONE pipe round-trip
|
|
53
|
+
doc.batch([
|
|
54
|
+
{"command": "set", "path": "/Sheet1/A2", "props": {"text": "North"}},
|
|
55
|
+
{"command": "set", "path": "/Sheet1/A3", "props": {"text": "South"}},
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
doc.send({"command": "save"})
|
|
59
|
+
# leaving `with` closes the resident (which flushes to disk)
|
|
60
|
+
|
|
61
|
+
# borrow an already-running resident without owning it: skip `with`/close()
|
|
62
|
+
d = officecli.open("report.xlsx")
|
|
63
|
+
print(d.send({"command": "view", "mode": "stats"}, as_json=False))
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
See `demo.py` for a fuller example.
|
|
67
|
+
|
|
68
|
+
## The command dict
|
|
69
|
+
|
|
70
|
+
`send(item)` and `batch([item, ...])` take the officecli **batch-item** shape:
|
|
71
|
+
|
|
72
|
+
```jsonc
|
|
73
|
+
{ "command": "set", // or "op"; picks the officecli command
|
|
74
|
+
"path": "/Sheet1/A1", // every key except command/op/props is forwarded
|
|
75
|
+
"props": { "text": "hi" } } // verbatim as a command argument
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Keys are officecli's own batch fields (`command`/`op`, `path`, `parent`, `type`,
|
|
79
|
+
`index`, `after`, `before`, `to`, `selector`, `mode`, `depth`, `part`, `xpath`,
|
|
80
|
+
`action`, `xml`) plus a nested `props`. The client maintains no field list of its
|
|
81
|
+
own — run `officecli help` (or see the batch docs) for the full reference.
|
|
82
|
+
|
|
83
|
+
`send(..., as_json=False)` requests plain-text output (e.g. `view` / `raw` /
|
|
84
|
+
`dump`), mirroring the CLI's `--json` toggle.
|
|
85
|
+
|
|
86
|
+
## Errors & resilience
|
|
87
|
+
|
|
88
|
+
- Transport/process failures raise `officecli.OfficeCliError` (`.code` carries the
|
|
89
|
+
exit code). Business outcomes (e.g. `validate` failing, a bad path) are **not**
|
|
90
|
+
exceptions — they live in the returned envelope's `success` field, same as the
|
|
91
|
+
CLI's exit code.
|
|
92
|
+
- If the resident has gone (crash, idle-timeout, missing pipe), `send`/`batch`
|
|
93
|
+
transparently restart it and retry once. If it's alive but the pipe is
|
|
94
|
+
unresponsive (busy), they raise rather than risk racing the live resident.
|
|
95
|
+
|
|
96
|
+
## Versioning
|
|
97
|
+
|
|
98
|
+
This client derives the resident's pipe address from the document path the same
|
|
99
|
+
way officecli does. That derivation is the one piece coupled to officecli
|
|
100
|
+
internals, so keep the client version compatible with your installed officecli.
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
officecli
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"] # >=77 for the PEP 639 SPDX `license` string
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
# Distribution (pip) name. NOT the import name — the module stays `officecli`
|
|
7
|
+
# (`import officecli`), like `pip install pillow` → `import PIL`. PyPI rejects
|
|
8
|
+
# the bare name "officecli" as too similar to the unrelated "office-cli" project.
|
|
9
|
+
name = "officecli-sdk"
|
|
10
|
+
version = "0.1.0"
|
|
11
|
+
description = "Thin Python SDK for the officecli resident pipe — forwards officecli commands to a running resident, no per-command process spawn."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.8"
|
|
14
|
+
license = "Apache-2.0" # PEP 639 SPDX expression (do NOT also add a License:: classifier — PyPI rejects the combo)
|
|
15
|
+
keywords = ["officecli", "office", "docx", "xlsx", "pptx", "ooxml"]
|
|
16
|
+
dependencies = [] # standard library only
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Operating System :: MacOS",
|
|
20
|
+
"Operating System :: POSIX :: Linux",
|
|
21
|
+
"Operating System :: Microsoft :: Windows",
|
|
22
|
+
]
|
|
23
|
+
# TODO(maintainer): fill in before publishing —
|
|
24
|
+
# authors = [{ name = "...", email = "..." }]
|
|
25
|
+
# [project.urls]
|
|
26
|
+
# Homepage = "..."
|
|
27
|
+
# Repository = "..."
|
|
28
|
+
|
|
29
|
+
# IMPORTANT: this package is only the client. It shells out to the `officecli`
|
|
30
|
+
# CLI binary, which must be installed separately and on PATH (Homebrew, etc.).
|
|
31
|
+
# pip cannot install that binary for you — see README.
|
|
32
|
+
|
|
33
|
+
[tool.setuptools]
|
|
34
|
+
py-modules = ["officecli"] # single-file module: officecli.py
|