pyclaudecli 1.0.3__tar.gz → 1.0.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyclaudecli
3
- Version: 1.0.3
3
+ Version: 1.0.4
4
4
  Summary: Python library wrapping the Claude Code CLI: prompts, background agents, auth, MCP, plugins, and more
5
5
  Author-email: Suriya Ravichandran <suriyaravichandran@itrendsolution.com>
6
6
  License: MIT
@@ -9,7 +9,10 @@ Keywords: anthropic,automation,claude,claude-code,cli,sdk,wrapper
9
9
  Classifier: Development Status :: 3 - Alpha
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Operating System :: Microsoft :: Windows
12
14
  Classifier: Operating System :: OS Independent
15
+ Classifier: Operating System :: POSIX :: Linux
13
16
  Classifier: Programming Language :: Python :: 3
14
17
  Classifier: Programming Language :: Python :: 3 :: Only
15
18
  Classifier: Programming Language :: Python :: 3.8
@@ -17,6 +20,7 @@ Classifier: Programming Language :: Python :: 3.9
17
20
  Classifier: Programming Language :: Python :: 3.10
18
21
  Classifier: Programming Language :: Python :: 3.11
19
22
  Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
20
24
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
25
  Requires-Python: >=3.8
22
26
  Description-Content-Type: text/markdown
@@ -54,6 +58,10 @@ pip install pyclaudecli
54
58
 
55
59
  Requires the `claude` CLI itself to be installed and on `PATH` (see the [Claude Code docs](https://claude.com/claude-code)).
56
60
 
61
+ Runs on Linux, macOS and Windows, Python 3.8+. On Windows the CLI is installed as
62
+ `claude.cmd`, which `CreateProcess` won't find from a bare `"claude"` — the wrapper
63
+ resolves it through `PATHEXT` for you, so the default `ClaudeCLI()` works there too.
64
+
57
65
  ## Quickstart
58
66
 
59
67
  ```python
@@ -456,6 +464,9 @@ auth lives with the `claude` CLI and your environment. Remember the argv of a ru
456
464
  process is visible to other users on the same machine via `ps`, so prefer env vars over
457
465
  flags for anything sensitive.
458
466
 
467
+ Every call is an argv list on every platform — no shell, and no `shell=True` fallback on
468
+ Windows, so quoting rules can't be turned into an injection.
469
+
459
470
  ## Development
460
471
 
461
472
  ```bash
@@ -31,6 +31,10 @@ pip install pyclaudecli
31
31
 
32
32
  Requires the `claude` CLI itself to be installed and on `PATH` (see the [Claude Code docs](https://claude.com/claude-code)).
33
33
 
34
+ Runs on Linux, macOS and Windows, Python 3.8+. On Windows the CLI is installed as
35
+ `claude.cmd`, which `CreateProcess` won't find from a bare `"claude"` — the wrapper
36
+ resolves it through `PATHEXT` for you, so the default `ClaudeCLI()` works there too.
37
+
34
38
  ## Quickstart
35
39
 
36
40
  ```python
@@ -433,6 +437,9 @@ auth lives with the `claude` CLI and your environment. Remember the argv of a ru
433
437
  process is visible to other users on the same machine via `ps`, so prefer env vars over
434
438
  flags for anything sensitive.
435
439
 
440
+ Every call is an argv list on every platform — no shell, and no `shell=True` fallback on
441
+ Windows, so quoting rules can't be turned into an injection.
442
+
436
443
  ## Development
437
444
 
438
445
  ```bash
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyclaudecli"
7
- version = "1.0.3"
7
+ version = "1.0.4"
8
8
  description = "Python library wrapping the Claude Code CLI: prompts, background agents, auth, MCP, plugins, and more"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -25,6 +25,10 @@ classifiers = [
25
25
  "Programming Language :: Python :: 3.10",
26
26
  "Programming Language :: Python :: 3.11",
27
27
  "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Operating System :: POSIX :: Linux",
30
+ "Operating System :: MacOS",
31
+ "Operating System :: Microsoft :: Windows",
28
32
  "Topic :: Software Development :: Libraries :: Python Modules",
29
33
  ]
30
34
 
@@ -3,7 +3,7 @@
3
3
  from .client import ClaudeCLI, build_flags
4
4
  from .exceptions import ClaudeCLIError, ClaudeNotFoundError, ClaudeTimeoutError
5
5
 
6
- __version__ = "1.0.3"
6
+ __version__ = "1.0.4"
7
7
 
8
8
  __all__ = [
9
9
  "ClaudeCLI",
@@ -3,9 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import queue
6
7
  import re
7
- import select
8
+ import shutil
8
9
  import subprocess
10
+ import threading
9
11
  import time
10
12
  from dataclasses import dataclass
11
13
  from typing import Callable, Iterator, Optional, Sequence
@@ -69,6 +71,63 @@ def format_command(command: Sequence[str]) -> str:
69
71
  return " ".join(redact_command(command))
70
72
 
71
73
 
74
+ def resolve_binary(binary: str, env: Optional[dict] = None) -> str:
75
+ """Finds the executable to run, the same way a shell would.
76
+
77
+ On Windows the `claude` CLI is an npm shim (`claude.cmd`), and
78
+ CreateProcess only ever appends `.exe` — so a bare "claude" is not found
79
+ unless it is resolved through PATHEXT first. `shutil.which` handles that,
80
+ and on POSIX it is a no-op beyond returning the absolute path.
81
+
82
+ Falls back to the name as given (an explicit path, or something the OS can
83
+ still resolve), so the usual ClaudeNotFoundError is what callers see.
84
+ """
85
+ if os.path.dirname(binary):
86
+ return binary
87
+ path = (env or os.environ).get("PATH")
88
+ return shutil.which(binary, path=path) or binary
89
+
90
+
91
+ class _PipeReader:
92
+ """Reads a child's stdout from a thread, so waiting works everywhere.
93
+
94
+ `select` only accepts sockets on Windows, so it cannot be used to poll a
95
+ subprocess pipe. A daemon thread doing blocking reads is portable, and
96
+ still hands back partial output the moment it arrives — which matters for
97
+ a login prompt that never ends in a newline.
98
+ """
99
+
100
+ _EOF = object()
101
+
102
+ def __init__(self, fd: int) -> None:
103
+ self._fd = fd
104
+ self._chunks: "queue.Queue" = queue.Queue()
105
+ self._thread = threading.Thread(target=self._pump, daemon=True)
106
+ self._thread.start()
107
+
108
+ def _pump(self) -> None:
109
+ try:
110
+ while True:
111
+ data = os.read(self._fd, 4096)
112
+ if not data:
113
+ break
114
+ self._chunks.put(data)
115
+ except (OSError, ValueError):
116
+ pass # pipe closed underneath us; treated as EOF
117
+ finally:
118
+ self._chunks.put(self._EOF)
119
+
120
+ def read(self, timeout: float) -> Optional[str]:
121
+ """Returns decoded output, "" if nothing arrived in time, None at EOF."""
122
+ try:
123
+ item = self._chunks.get(timeout=timeout)
124
+ except queue.Empty:
125
+ return ""
126
+ if item is self._EOF:
127
+ return None
128
+ return item.decode("utf-8", errors="replace")
129
+
130
+
72
131
  @dataclass
73
132
  class CommandResult:
74
133
  args: list
@@ -91,7 +150,7 @@ def run(
91
150
  timeout: Optional[float] = None,
92
151
  check: bool = True,
93
152
  ) -> CommandResult:
94
- command = [binary, *args]
153
+ command = [resolve_binary(binary, env), *args]
95
154
  try:
96
155
  completed = subprocess.run(
97
156
  command,
@@ -138,7 +197,7 @@ def stream_lines(
138
197
  env: Optional[dict] = None,
139
198
  ) -> Iterator[str]:
140
199
  """Runs a command and yields decoded stdout lines as they arrive."""
141
- command = [binary, *args]
200
+ command = [resolve_binary(binary, env), *args]
142
201
  try:
143
202
  proc = subprocess.Popen(
144
203
  command,
@@ -182,7 +241,7 @@ def run_interactive(
182
241
  For subcommands that need a real terminal (attach, setup-token, an
183
242
  interactive mcp/import picker) rather than captured output.
184
243
  """
185
- command = [binary, *args]
244
+ command = [resolve_binary(binary, env), *args]
186
245
  try:
187
246
  return subprocess.call(command, cwd=cwd, env=env)
188
247
  except FileNotFoundError as exc:
@@ -212,7 +271,7 @@ def oauth_login(
212
271
  found in the output so far. Falls back to `input()` if neither is given.
213
272
  Returns the child's exit code.
214
273
  """
215
- command = [binary, *args]
274
+ command = [resolve_binary(binary, env), *args]
216
275
  try:
217
276
  proc = subprocess.Popen(
218
277
  command,
@@ -231,20 +290,22 @@ def oauth_login(
231
290
  ) from exc
232
291
 
233
292
  assert proc.stdout is not None and proc.stdin is not None
234
- fd = proc.stdout.fileno()
293
+ # Send exactly "\n", not the platform line ending: on Windows text mode
294
+ # would translate it to "\r\n" and the CLI would read a stray CR.
295
+ if hasattr(proc.stdin, "reconfigure"):
296
+ proc.stdin.reconfigure(newline="\n")
297
+ reader = _PipeReader(proc.stdout.fileno())
235
298
  buffer = ""
236
299
  url: Optional[str] = None
237
300
  deadline = time.time() + timeout
238
301
 
239
302
  try:
240
303
  while time.time() < deadline and proc.poll() is None:
241
- ready, _, _ = select.select([fd], [], [], 0.5)
242
- if not ready:
243
- continue
244
-
245
- chunk = os.read(fd, 4096).decode(errors="replace")
246
- if not chunk:
304
+ chunk = reader.read(0.5)
305
+ if chunk is None: # the child closed its output
247
306
  break
307
+ if not chunk:
308
+ continue
248
309
 
249
310
  buffer += chunk
250
311
  # A login that never reaches its prompt must not grow the buffer
@@ -289,21 +350,21 @@ def oauth_login(
289
350
 
290
351
  drain_deadline = time.time() + 30
291
352
  while proc.poll() is None and time.time() < drain_deadline:
292
- ready, _, _ = select.select([fd], [], [], 0.5)
293
- if ready:
294
- chunk = os.read(fd, 4096).decode(errors="replace")
295
- if not chunk:
296
- break
297
- if on_output:
298
- on_output(chunk)
353
+ chunk = reader.read(0.5)
354
+ if chunk is None:
355
+ break
356
+ if chunk and on_output:
357
+ on_output(chunk)
299
358
 
300
- if proc.poll() is None:
359
+ # Reaching EOF on stdout does not mean the child has exited yet, so
360
+ # give it a moment to actually finish rather than calling it a timeout.
361
+ try:
362
+ return proc.wait(timeout=max(drain_deadline - time.time(), 5))
363
+ except subprocess.TimeoutExpired:
301
364
  raise ClaudeTimeoutError(
302
365
  f"'{format_command(command)}' did not finish after the code was submitted",
303
366
  cmd=redact_command(command),
304
- )
305
-
306
- return proc.returncode
367
+ ) from None
307
368
  finally:
308
369
  # Never leave a half-driven login (or its pipes) behind, whatever
309
370
  # went wrong — including an exception raised by `code_provider`.
@@ -601,5 +601,6 @@ class ClaudeCLI:
601
601
  """
602
602
  import subprocess
603
603
 
604
- args = [self.binary, "gateway", *build_flags({"config": config})]
605
- return subprocess.Popen(args, cwd=self.cwd, env=self._env())
604
+ env = self._env()
605
+ args = [_process.resolve_binary(self.binary, env), "gateway", *build_flags({"config": config})]
606
+ return subprocess.Popen(args, cwd=self.cwd, env=env)
@@ -0,0 +1,206 @@
1
+ """Cross-platform behaviour: nothing here is POSIX-only or needs the claude CLI."""
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ import textwrap
7
+
8
+ import pytest
9
+
10
+ from pyclaudecli import _process
11
+ from pyclaudecli._process import _PipeReader, oauth_login, resolve_binary
12
+ from pyclaudecli.exceptions import ClaudeTimeoutError
13
+
14
+
15
+ # -- no POSIX-only polling ------------------------------------------------
16
+
17
+ def test_module_does_not_use_select():
18
+ """`select` accepts only sockets on Windows, so pipes must not rely on it."""
19
+ assert not hasattr(_process, "select")
20
+
21
+
22
+ def test_source_has_no_select_call():
23
+ import inspect
24
+
25
+ assert "select.select(" not in inspect.getsource(_process)
26
+
27
+
28
+ # -- binary resolution ----------------------------------------------------
29
+
30
+ def test_resolve_binary_finds_an_executable_on_path():
31
+ name = "cmd" if os.name == "nt" else "sh"
32
+ resolved = resolve_binary(name)
33
+ assert os.path.isabs(resolved)
34
+ assert os.path.exists(resolved)
35
+
36
+
37
+ def test_resolve_binary_passes_through_unknown_names():
38
+ # Left as-is so the usual ClaudeNotFoundError is what the caller sees.
39
+ assert resolve_binary("definitely-not-installed-xyz") == "definitely-not-installed-xyz"
40
+
41
+
42
+ def test_resolve_binary_leaves_explicit_paths_alone():
43
+ assert resolve_binary(sys.executable) == sys.executable
44
+ assert resolve_binary(os.path.join("some", "dir", "claude")) == os.path.join("some", "dir", "claude")
45
+
46
+
47
+ def test_resolve_binary_honours_a_custom_path(tmp_path):
48
+ name = "fake-claude.bat" if os.name == "nt" else "fake-claude"
49
+ fake = tmp_path / name
50
+ fake.write_text("")
51
+ fake.chmod(0o755)
52
+ assert resolve_binary(name, {"PATH": str(tmp_path)}) == str(fake)
53
+
54
+
55
+ # -- portable pipe reader -------------------------------------------------
56
+
57
+ def test_pipe_reader_returns_partial_output():
58
+ read_fd, write_fd = os.pipe()
59
+ try:
60
+ os.write(write_fd, b"no trailing newline here")
61
+ reader = _PipeReader(read_fd)
62
+ assert reader.read(2.0) == "no trailing newline here"
63
+ finally:
64
+ os.close(write_fd)
65
+
66
+
67
+ def test_pipe_reader_signals_eof_with_none():
68
+ read_fd, write_fd = os.pipe()
69
+ os.close(write_fd)
70
+ assert _PipeReader(read_fd).read(2.0) is None
71
+
72
+
73
+ def test_pipe_reader_returns_empty_string_when_idle():
74
+ read_fd, write_fd = os.pipe()
75
+ try:
76
+ assert _PipeReader(read_fd).read(0.2) == ""
77
+ finally:
78
+ os.close(write_fd)
79
+ os.close(read_fd)
80
+
81
+
82
+ # -- oauth_login driven against a stand-in CLI ----------------------------
83
+
84
+ def write_fake_cli(tmp_path, body):
85
+ script = tmp_path / "fake_cli.py"
86
+ script.write_text(textwrap.dedent(body))
87
+ return script
88
+
89
+
90
+ PROMPTING_CLI = '''
91
+ import sys
92
+ record = sys.argv[1]
93
+ sys.stdout.write("Open https://claude.ai/oauth/authorize?x=1 in your browser\\n")
94
+ sys.stdout.write("Paste code here: ") # no newline, like the real prompt
95
+ sys.stdout.flush()
96
+ line = sys.stdin.readline()
97
+ with open(record, "w") as fh:
98
+ fh.write(repr(line))
99
+ sys.stdout.write("\\nLogged in\\n")
100
+ sys.exit(0)
101
+ '''
102
+
103
+
104
+ def test_oauth_login_completes_with_a_supplied_code(tmp_path):
105
+ record = tmp_path / "received.txt"
106
+ script = write_fake_cli(tmp_path, PROMPTING_CLI)
107
+
108
+ seen = []
109
+ rc = oauth_login(
110
+ sys.executable,
111
+ [str(script), str(record)],
112
+ code="123456",
113
+ on_output=seen.append,
114
+ timeout=30,
115
+ )
116
+
117
+ assert rc == 0
118
+ assert record.read_text() == repr("123456\n") # exactly one line, no stray CR
119
+ assert "claude.ai/oauth" in "".join(seen)
120
+
121
+
122
+ def test_oauth_login_passes_the_url_to_code_provider(tmp_path):
123
+ record = tmp_path / "received.txt"
124
+ script = write_fake_cli(tmp_path, PROMPTING_CLI)
125
+ urls = []
126
+
127
+ def provider(url):
128
+ urls.append(url)
129
+ return "abc999"
130
+
131
+ assert oauth_login(sys.executable, [str(script), str(record)],
132
+ code_provider=provider, on_output=lambda _: None, timeout=30) == 0
133
+ assert urls and urls[0].startswith("https://claude.ai/oauth/authorize")
134
+ assert record.read_text() == repr("abc999\n")
135
+
136
+
137
+ def test_oauth_login_sends_only_the_first_line(tmp_path):
138
+ record = tmp_path / "received.txt"
139
+ script = write_fake_cli(tmp_path, PROMPTING_CLI)
140
+
141
+ oauth_login(sys.executable, [str(script), str(record)],
142
+ code="code1\nrm -rf /\n", on_output=lambda _: None, timeout=30)
143
+
144
+ assert record.read_text() == repr("code1\n")
145
+
146
+
147
+ def test_oauth_login_times_out_and_kills_the_child(tmp_path):
148
+ script = write_fake_cli(tmp_path, '''
149
+ import time
150
+ time.sleep(30)
151
+ ''')
152
+
153
+ with pytest.raises(ClaudeTimeoutError):
154
+ oauth_login(sys.executable, [str(script)], code="x",
155
+ on_output=lambda _: None, timeout=1.5)
156
+
157
+
158
+ def test_oauth_login_cleans_up_when_code_provider_raises(tmp_path):
159
+ record = tmp_path / "received.txt"
160
+ script = write_fake_cli(tmp_path, PROMPTING_CLI)
161
+
162
+ def boom(url):
163
+ raise RuntimeError("no code available")
164
+
165
+ with pytest.raises(RuntimeError):
166
+ oauth_login(sys.executable, [str(script), str(record)],
167
+ code_provider=boom, on_output=lambda _: None, timeout=30)
168
+
169
+
170
+ # -- the plain runners still work through the same resolution -------------
171
+
172
+ def test_run_executes_a_resolved_binary(tmp_path):
173
+ script = write_fake_cli(tmp_path, '''
174
+ print("hello from the stand-in CLI")
175
+ ''')
176
+ result = _process.run(sys.executable, [str(script)])
177
+ assert result.ok
178
+ assert "hello from the stand-in CLI" in result.stdout
179
+
180
+
181
+ def test_stream_lines_yields_output(tmp_path):
182
+ script = write_fake_cli(tmp_path, '''
183
+ for i in range(3):
184
+ print("line", i, flush=True)
185
+ ''')
186
+ lines = list(_process.stream_lines(sys.executable, [str(script)]))
187
+ assert lines == ["line 0", "line 1", "line 2"]
188
+
189
+
190
+ def test_start_gateway_resolves_its_binary(monkeypatch):
191
+ """The gateway builds its own Popen, so it needs the same resolution."""
192
+ import subprocess as sp
193
+
194
+ from pyclaudecli import ClaudeCLI
195
+
196
+ seen = {}
197
+
198
+ def fake_popen(args, **kwargs):
199
+ seen["args"] = args
200
+ return object()
201
+
202
+ monkeypatch.setattr(sp, "Popen", fake_popen)
203
+ ClaudeCLI("sh" if os.name != "nt" else "cmd").start_gateway()
204
+
205
+ assert os.path.isabs(seen["args"][0])
206
+ assert seen["args"][1] == "gateway"
@@ -4,5 +4,5 @@ requires-python = ">=3.8"
4
4
 
5
5
  [[package]]
6
6
  name = "pyclaudecli"
7
- version = "1.0.3"
7
+ version = "1.0.4"
8
8
  source = { editable = "." }
File without changes
File without changes