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.
- {pyclaudecli-1.0.3 → pyclaudecli-1.0.4}/PKG-INFO +12 -1
- {pyclaudecli-1.0.3 → pyclaudecli-1.0.4}/README.md +7 -0
- {pyclaudecli-1.0.3 → pyclaudecli-1.0.4}/pyproject.toml +5 -1
- {pyclaudecli-1.0.3 → pyclaudecli-1.0.4}/src/pyclaudecli/__init__.py +1 -1
- {pyclaudecli-1.0.3 → pyclaudecli-1.0.4}/src/pyclaudecli/_process.py +84 -23
- {pyclaudecli-1.0.3 → pyclaudecli-1.0.4}/src/pyclaudecli/client.py +3 -2
- pyclaudecli-1.0.4/tests/test_portability.py +206 -0
- {pyclaudecli-1.0.3 → pyclaudecli-1.0.4}/uv.lock +1 -1
- {pyclaudecli-1.0.3 → pyclaudecli-1.0.4}/.gitignore +0 -0
- {pyclaudecli-1.0.3 → pyclaudecli-1.0.4}/LICENSE +0 -0
- {pyclaudecli-1.0.3 → pyclaudecli-1.0.4}/src/pyclaudecli/__main__.py +0 -0
- {pyclaudecli-1.0.3 → pyclaudecli-1.0.4}/src/pyclaudecli/exceptions.py +0 -0
- {pyclaudecli-1.0.3 → pyclaudecli-1.0.4}/tests/test_security.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pyclaudecli
|
|
3
|
-
Version: 1.0.
|
|
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.
|
|
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,9 +3,11 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
+
import queue
|
|
6
7
|
import re
|
|
7
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
if
|
|
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
|
-
|
|
293
|
-
if
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
605
|
-
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|