p4cmd 2.11.2__tar.gz → 2.11.3__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.
- {p4cmd-2.11.2 → p4cmd-2.11.3}/PKG-INFO +1 -1
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/__init__.py +1 -1
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/p4cmd.py +93 -35
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd.egg-info/PKG-INFO +1 -1
- p4cmd-2.11.3/tests/conftest.py +57 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/tests/test_p4client.py +146 -51
- p4cmd-2.11.2/tests/conftest.py +0 -32
- {p4cmd-2.11.2 → p4cmd-2.11.3}/LICENSE +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/README.md +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/p4errors.py +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/p4file.py +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/__init__.py +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_DEPOT_ONLY.png +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_MOVED.png +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_NEED_SYNC.png +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_OPEN_FOR_ADD.png +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_OPEN_FOR_DELETE.png +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_OPEN_FOR_EDIT.png +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_UNKNOWN.png +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_UNTRACKED.png +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_UP_TO_DATE.png +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/utils.py +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd.egg-info/SOURCES.txt +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd.egg-info/dependency_links.txt +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd.egg-info/requires.txt +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd.egg-info/top_level.txt +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/setup.cfg +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/setup.py +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/tests/__init__.py +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/tests/test_integration.py +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/tests/test_p4file.py +0 -0
- {p4cmd-2.11.2 → p4cmd-2.11.3}/tests/test_utils.py +0 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import io
|
|
1
3
|
import os
|
|
2
4
|
import marshal
|
|
3
5
|
import subprocess
|
|
4
6
|
import socket
|
|
7
|
+
import tempfile
|
|
5
8
|
import warnings
|
|
6
9
|
from pprint import pformat
|
|
7
10
|
|
|
@@ -17,6 +20,41 @@ MAX_CMD_LEN = 8190
|
|
|
17
20
|
MAX_ARG_LEN = 8000 # max length of args string when combined, close to max, but leaving some extra margin
|
|
18
21
|
|
|
19
22
|
|
|
23
|
+
def _run_command(command, cwd, input=None, stderr=None, check=False):
|
|
24
|
+
"""
|
|
25
|
+
Run a shell command and return what it wrote to stdout, once it has exited.
|
|
26
|
+
|
|
27
|
+
The output goes to a temporary file rather than a pipe. Reading a pipe until it closes waits for every
|
|
28
|
+
process holding its write end, not just the command: on Windows, a process that something else in this
|
|
29
|
+
process starts at the same moment with handle inheritance (e.g. a host application starting a server on
|
|
30
|
+
another thread) inherits the write end and keeps it open, so the read never ends. The file is read once
|
|
31
|
+
the command has exited, whoever else holds it.
|
|
32
|
+
|
|
33
|
+
stdin is NUL, or a temporary file holding ``input``: the parent's stdin isn't duplicated (OSError
|
|
34
|
+
WinError 50 in non-interactive sessions, see #13), and a command asking for input it wasn't given gets
|
|
35
|
+
none instead of waiting for it.
|
|
36
|
+
|
|
37
|
+
:param input: *bytes* for the command's stdin, e.g. the form for ``p4 change -i``
|
|
38
|
+
:param stderr: as for subprocess.Popen: None leaves it as it is, subprocess.STDOUT adds it to the output
|
|
39
|
+
:param check: *bool* if True, a non-zero exit status raises subprocess.CalledProcessError with the
|
|
40
|
+
output, like subprocess.check_output
|
|
41
|
+
"""
|
|
42
|
+
with contextlib.ExitStack() as files:
|
|
43
|
+
stdin = subprocess.DEVNULL
|
|
44
|
+
if input is not None:
|
|
45
|
+
stdin = files.enter_context(tempfile.TemporaryFile())
|
|
46
|
+
stdin.write(input)
|
|
47
|
+
stdin.seek(0)
|
|
48
|
+
output = files.enter_context(tempfile.TemporaryFile())
|
|
49
|
+
with subprocess.Popen(command, stdin=stdin, stdout=output, stderr=stderr, shell=True, cwd=cwd) as process:
|
|
50
|
+
returncode = process.wait()
|
|
51
|
+
output.seek(0)
|
|
52
|
+
data = output.read()
|
|
53
|
+
if check and returncode:
|
|
54
|
+
raise subprocess.CalledProcessError(returncode, command, output=data)
|
|
55
|
+
return data
|
|
56
|
+
|
|
57
|
+
|
|
20
58
|
class P4Client(object):
|
|
21
59
|
def __init__(self, perforce_root, user=None, client=None, server=None, silent=None, max_parallel_connections=4):
|
|
22
60
|
"""
|
|
@@ -61,7 +99,20 @@ class P4Client(object):
|
|
|
61
99
|
if self.server is None:
|
|
62
100
|
raise p4errors.WorkSpaceError("Could not find P4PORT")
|
|
63
101
|
|
|
64
|
-
|
|
102
|
+
# `p4 where` comes back empty when the client cannot talk to the
|
|
103
|
+
# server -- most commonly an expired ticket. Indexing [0] blindly
|
|
104
|
+
# turned that into a bare `IndexError: list index out of range`,
|
|
105
|
+
# which tells the caller nothing. Callers that reconstruct a
|
|
106
|
+
# P4Client to recover a dropped connection need to be able to tell
|
|
107
|
+
# "log in again" apart from a genuine bug.
|
|
108
|
+
depot_paths = self.get_depot_paths([self.perforce_root])
|
|
109
|
+
if not depot_paths:
|
|
110
|
+
raise p4errors.WorkSpaceError(
|
|
111
|
+
"Could not resolve the depot root for %s. The server may be "
|
|
112
|
+
"unreachable, or your Perforce ticket may have expired -- try "
|
|
113
|
+
"`p4 login`." % self.perforce_root
|
|
114
|
+
)
|
|
115
|
+
self.depot_root = depot_paths[0]
|
|
65
116
|
|
|
66
117
|
@classmethod
|
|
67
118
|
def from_env(cls, *args, **kwargs):
|
|
@@ -152,36 +203,38 @@ class P4Client(object):
|
|
|
152
203
|
if len(command) > MAX_CMD_LEN:
|
|
153
204
|
# This shouldn't happen, but just in case the command prefix end up really long
|
|
154
205
|
_log.warning(
|
|
155
|
-
f"Command length: {format(len(command))} exceeds MAX_CMD_LEN {MAX_CMD_LEN} on command: {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
206
|
+
f"Command length: {format(len(command))} exceeds MAX_CMD_LEN {MAX_CMD_LEN} on command: {command}")
|
|
207
|
+
|
|
208
|
+
output = _run_command(command, self.perforce_root)
|
|
209
|
+
if not marshal_output:
|
|
210
|
+
# Without -G, p4 emits plain text (and `p4 set` ignores -G even when passed),
|
|
211
|
+
# so marshal.load is guaranteed to fail. Use the output as it is — no second
|
|
212
|
+
# subprocess, which in non-interactive sessions hit OSError(WinError 50) on
|
|
213
|
+
# DuplicateHandle in the old check_output fallback.
|
|
214
|
+
dict_list.append({
|
|
215
|
+
"command": command,
|
|
216
|
+
"code": "raw",
|
|
217
|
+
"raw_output": output,
|
|
218
|
+
})
|
|
219
|
+
else:
|
|
220
|
+
stream = io.BytesIO(output)
|
|
221
|
+
parsed = []
|
|
222
|
+
try:
|
|
223
|
+
while True:
|
|
224
|
+
parsed.append(marshal.load(stream))
|
|
225
|
+
except EOFError:
|
|
226
|
+
dict_list.extend(parsed)
|
|
227
|
+
except ValueError as error:
|
|
228
|
+
# Output that isn't all marshaled dicts is returned whole, as one raw entry,
|
|
229
|
+
# without the dicts read before the error (they're in it). It's the output
|
|
230
|
+
# already read: running p4 again would repeat a sync or submit. With -G, p4
|
|
231
|
+
# reports its errors on stdout, so this is all it said.
|
|
164
232
|
dict_list.append({
|
|
165
233
|
"command": command,
|
|
166
234
|
"code": "raw",
|
|
167
|
-
"
|
|
235
|
+
"error": str(error),
|
|
236
|
+
"raw_output": output,
|
|
168
237
|
})
|
|
169
|
-
else:
|
|
170
|
-
try:
|
|
171
|
-
while True:
|
|
172
|
-
value_dict = marshal.load(output)
|
|
173
|
-
dict_list.append(value_dict)
|
|
174
|
-
except EOFError:
|
|
175
|
-
pass
|
|
176
|
-
except ValueError as error:
|
|
177
|
-
output_dict = {
|
|
178
|
-
"command": command,
|
|
179
|
-
"code": "raw",
|
|
180
|
-
"error": str(error),
|
|
181
|
-
"raw_output": subprocess.check_output(command, stderr=subprocess.STDOUT, shell=True, cwd=self.perforce_root)
|
|
182
|
-
}
|
|
183
|
-
dict_list.append(output_dict)
|
|
184
|
-
pipe.kill()
|
|
185
238
|
|
|
186
239
|
# Check for error dicts in the results
|
|
187
240
|
errors = [d for d in dict_list if d.get(b"code") == b"error" or d.get("code") == "error"]
|
|
@@ -390,18 +443,20 @@ class P4Client(object):
|
|
|
390
443
|
if not self.host_online():
|
|
391
444
|
raise p4errors.ServerOffline("Can't connect to %s on port %s" % (self.__server_address(), self.__port_number()))
|
|
392
445
|
|
|
393
|
-
template =
|
|
446
|
+
template = _run_command(
|
|
394
447
|
f'p4 -u {self.user} -c {self.client} --field "Files=" change -o',
|
|
448
|
+
self.perforce_root,
|
|
395
449
|
stderr=subprocess.STDOUT,
|
|
396
|
-
|
|
450
|
+
check=True).decode()
|
|
397
451
|
|
|
398
452
|
modified = self.__replace_description_in_template(template, description)
|
|
399
453
|
|
|
400
|
-
output =
|
|
454
|
+
output = _run_command(
|
|
401
455
|
f"p4 -u {self.user} -c {self.client} change -i",
|
|
456
|
+
self.perforce_root,
|
|
402
457
|
input=modified.encode(),
|
|
403
458
|
stderr=subprocess.STDOUT,
|
|
404
|
-
|
|
459
|
+
check=True).decode()
|
|
405
460
|
|
|
406
461
|
parts = output.split(" ")
|
|
407
462
|
if len(parts) < 2:
|
|
@@ -446,15 +501,18 @@ class P4Client(object):
|
|
|
446
501
|
try:
|
|
447
502
|
changelist = self.__ensure_changelist(changelist)
|
|
448
503
|
|
|
449
|
-
template =
|
|
504
|
+
template = _run_command(
|
|
450
505
|
f'p4 -u {self.user} -c {self.client} change -o {changelist}',
|
|
451
|
-
|
|
506
|
+
self.perforce_root,
|
|
507
|
+
check=True).decode()
|
|
452
508
|
|
|
453
509
|
modified = self.__replace_description_in_template(template, new_description)
|
|
454
510
|
|
|
455
|
-
|
|
511
|
+
_run_command(
|
|
456
512
|
f'p4 -u {self.user} -c {self.client} change -i',
|
|
457
|
-
|
|
513
|
+
self.perforce_root,
|
|
514
|
+
input=modified.encode(),
|
|
515
|
+
check=True)
|
|
458
516
|
|
|
459
517
|
return True
|
|
460
518
|
except subprocess.CalledProcessError as e:
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
from p4cmd.p4cmd import P4Client
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def make_fstat_dict(**kwargs):
|
|
10
|
+
"""Build a bytes-keyed dict like P4's marshaled fstat output."""
|
|
11
|
+
return {k.encode(): v.encode() for k, v in kwargs.items()}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def fake_p4_popen(mock_popen, output=b"", returncode=0):
|
|
15
|
+
"""Make a patched subprocess.Popen act like p4 writing ``output`` (bytes) to its stdout, which
|
|
16
|
+
_run_command gives it as a file, and exiting with ``returncode``. A list of outputs gives one per
|
|
17
|
+
call. What each call got as stdin (subprocess.DEVNULL, or the bytes in its stdin file) is collected
|
|
18
|
+
in ``mock_popen.stdin_received``."""
|
|
19
|
+
outputs = list(output) if isinstance(output, list) else None
|
|
20
|
+
mock_popen.stdin_received = []
|
|
21
|
+
|
|
22
|
+
def popen(*args, **kwargs):
|
|
23
|
+
stdin = kwargs.get("stdin")
|
|
24
|
+
mock_popen.stdin_received.append(stdin if stdin is subprocess.DEVNULL else stdin.read())
|
|
25
|
+
kwargs["stdout"].write(outputs.pop(0) if outputs is not None else output)
|
|
26
|
+
process = MagicMock()
|
|
27
|
+
process.__enter__.return_value = process
|
|
28
|
+
process.__exit__.return_value = False
|
|
29
|
+
process.wait.return_value = returncode
|
|
30
|
+
process.returncode = returncode
|
|
31
|
+
return process
|
|
32
|
+
|
|
33
|
+
mock_popen.side_effect = popen
|
|
34
|
+
return mock_popen
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def p4client(tmp_path):
|
|
39
|
+
"""
|
|
40
|
+
A P4Client with all server calls mocked out.
|
|
41
|
+
Passing explicit user/client/server skips the `p4 set` auto-detection calls.
|
|
42
|
+
Only get_depot_paths (called for depot_root) still hits run_cmd — mocked here.
|
|
43
|
+
"""
|
|
44
|
+
(tmp_path / ".p4config").write_text("P4PORT=ssl:perforce.example.com:1666\n")
|
|
45
|
+
where_result = [
|
|
46
|
+
{b"depotFile": b"//depot/project/...", b"path": b"/fake/project/...", b"unmap": b""}
|
|
47
|
+
]
|
|
48
|
+
with patch.object(P4Client, "host_online", return_value=True):
|
|
49
|
+
with patch.object(P4Client, "run_cmd", return_value=where_result):
|
|
50
|
+
client = P4Client(
|
|
51
|
+
str(tmp_path),
|
|
52
|
+
user="testuser",
|
|
53
|
+
client="testclient",
|
|
54
|
+
server="ssl:perforce.example.com:1666",
|
|
55
|
+
)
|
|
56
|
+
client.depot_root = "//depot/project"
|
|
57
|
+
return client
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
"""Tests for p4cmd/p4cmd.py — P4Client with run_cmd mocked."""
|
|
2
2
|
import logging
|
|
3
3
|
import socket
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
4
8
|
from unittest.mock import MagicMock, patch
|
|
5
9
|
|
|
6
10
|
import pytest
|
|
7
11
|
|
|
8
|
-
from p4cmd.p4cmd import P4Client
|
|
9
|
-
from p4cmd.p4errors import P4CommandError
|
|
12
|
+
from p4cmd.p4cmd import P4Client, _run_command
|
|
13
|
+
from p4cmd.p4errors import P4CommandError, WorkSpaceError
|
|
10
14
|
from p4cmd.p4file import P4File, Status
|
|
11
|
-
from tests.conftest import make_fstat_dict
|
|
15
|
+
from tests.conftest import fake_p4_popen, make_fstat_dict
|
|
12
16
|
|
|
13
17
|
|
|
14
18
|
# ---------------------------------------------------------------------------
|
|
@@ -526,12 +530,7 @@ def test_run_cmd_no_chdir(p4client):
|
|
|
526
530
|
patch("p4cmd.p4cmd.subprocess.Popen") as mock_popen, \
|
|
527
531
|
patch.object(p4client, "host_online", return_value=True):
|
|
528
532
|
# Simulate an empty marshal stream (EOF immediately)
|
|
529
|
-
|
|
530
|
-
mock_pipe = MagicMock()
|
|
531
|
-
mock_pipe.stdout = io.BytesIO(b"")
|
|
532
|
-
mock_pipe.__enter__ = lambda s: s
|
|
533
|
-
mock_pipe.__exit__ = MagicMock(return_value=False)
|
|
534
|
-
mock_popen.return_value = mock_pipe
|
|
533
|
+
fake_p4_popen(mock_popen)
|
|
535
534
|
|
|
536
535
|
p4client.run_cmd("changes", args=[], online_check=False)
|
|
537
536
|
|
|
@@ -539,6 +538,29 @@ def test_run_cmd_no_chdir(p4client):
|
|
|
539
538
|
# cwd should have been passed to Popen
|
|
540
539
|
_, kwargs = mock_popen.call_args
|
|
541
540
|
assert kwargs.get("cwd") == p4client.perforce_root
|
|
541
|
+
assert kwargs["stdin"] is subprocess.DEVNULL
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def test_run_command_does_not_wait_for_processes_holding_its_output():
|
|
545
|
+
"""
|
|
546
|
+
A process that outlives the command and holds its output (here one the command leaves running;
|
|
547
|
+
in practice one that inherited it when something else started it at the same moment) doesn't
|
|
548
|
+
keep _run_command waiting. Read from a pipe until it closes, this took the 3 s of that process.
|
|
549
|
+
"""
|
|
550
|
+
if sys.platform == "win32":
|
|
551
|
+
command = "echo hi& start /b ping -n 4 127.0.0.1 > NUL"
|
|
552
|
+
else:
|
|
553
|
+
command = "echo hi; sleep 3 &"
|
|
554
|
+
start = time.perf_counter()
|
|
555
|
+
output = _run_command(command, tempfile.gettempdir())
|
|
556
|
+
assert output.strip() == b"hi"
|
|
557
|
+
assert time.perf_counter() - start < 2.0
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def test_run_command_gives_the_command_its_input():
|
|
561
|
+
"""input reaches the command as its stdin (the form for `p4 change -i`), and its output comes back."""
|
|
562
|
+
command = f'"{sys.executable}" -c "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read().upper())"'
|
|
563
|
+
assert _run_command(command, tempfile.gettempdir(), input=b"change form\n") == b"CHANGE FORM\n"
|
|
542
564
|
|
|
543
565
|
|
|
544
566
|
def test_get_p4_setting_malformed_output(p4client):
|
|
@@ -552,8 +574,9 @@ def test_get_p4_setting_malformed_output(p4client):
|
|
|
552
574
|
def test_make_new_changelist_malformed_output(p4client):
|
|
553
575
|
"""make_new_changelist returns None instead of crashing on unexpected p4 output."""
|
|
554
576
|
template = b"Change: new\n\nDescription:\n\t<enter description here>\n\nFiles:\n"
|
|
555
|
-
with patch("p4cmd.p4cmd.subprocess.
|
|
577
|
+
with patch("p4cmd.p4cmd.subprocess.Popen") as mock_popen, \
|
|
556
578
|
patch.object(p4client, "host_online", return_value=True):
|
|
579
|
+
fake_p4_popen(mock_popen, [template, b"Unexpected output\n"])
|
|
557
580
|
result = p4client.make_new_changelist("test description")
|
|
558
581
|
assert result is None
|
|
559
582
|
|
|
@@ -561,16 +584,56 @@ def test_make_new_changelist_malformed_output(p4client):
|
|
|
561
584
|
def test_make_new_changelist_preserves_newlines(p4client):
|
|
562
585
|
"""make_new_changelist correctly handles descriptions containing newlines."""
|
|
563
586
|
template = b"Change: new\n\nDescription:\n\t<enter description here>\n\nFiles:\n"
|
|
564
|
-
with patch("p4cmd.p4cmd.subprocess.
|
|
587
|
+
with patch("p4cmd.p4cmd.subprocess.Popen") as mock_popen, \
|
|
565
588
|
patch.object(p4client, "host_online", return_value=True):
|
|
589
|
+
fake_p4_popen(mock_popen, [template, b"Change 12345 created.\n"])
|
|
566
590
|
result = p4client.make_new_changelist("Line one.\nLine two.")
|
|
567
591
|
|
|
568
592
|
assert result == 12345
|
|
569
593
|
# Verify the description sent to p4 change -i has tab-indented lines
|
|
570
|
-
|
|
571
|
-
|
|
594
|
+
change_o_stdin, change_i_stdin = mock_popen.stdin_received
|
|
595
|
+
assert change_o_stdin is subprocess.DEVNULL
|
|
596
|
+
input_data = change_i_stdin.decode()
|
|
572
597
|
assert "\tLine one." in input_data
|
|
573
598
|
assert "\tLine two." in input_data
|
|
599
|
+
# In the workspace root, like run_cmd, so both use its .p4config
|
|
600
|
+
assert all(call.kwargs["cwd"] == p4client.perforce_root for call in mock_popen.call_args_list)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def test_make_new_changelist_raises_when_p4_fails(p4client):
|
|
604
|
+
"""A failing p4 raises CalledProcessError with what p4 said, and no form is sent to `change -i`."""
|
|
605
|
+
with patch("p4cmd.p4cmd.subprocess.Popen") as mock_popen, \
|
|
606
|
+
patch.object(p4client, "host_online", return_value=True):
|
|
607
|
+
fake_p4_popen(mock_popen, b"Perforce password (P4PASSWD) invalid or unset.\n", returncode=1)
|
|
608
|
+
with pytest.raises(subprocess.CalledProcessError) as error:
|
|
609
|
+
p4client.make_new_changelist("test description")
|
|
610
|
+
assert b"P4PASSWD" in error.value.output
|
|
611
|
+
assert mock_popen.call_count == 1
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def test_update_changelist_description_sends_the_new_description(p4client):
|
|
615
|
+
"""The changelist's form goes back to `change -i` with the new description in place of the old."""
|
|
616
|
+
template = (b"Change: 12345\n\nClient: testclient\n\nUser: testuser\n\nStatus: pending\n\n"
|
|
617
|
+
b"Description:\n\tOld description\n\nFiles:\n")
|
|
618
|
+
with patch("p4cmd.p4cmd.subprocess.Popen") as mock_popen:
|
|
619
|
+
fake_p4_popen(mock_popen, [template, b"Change 12345 updated.\n"])
|
|
620
|
+
result = p4client.update_changelist_description(12345, "New description")
|
|
621
|
+
|
|
622
|
+
assert result is True
|
|
623
|
+
change_o_stdin, change_i_stdin = mock_popen.stdin_received
|
|
624
|
+
assert change_o_stdin is subprocess.DEVNULL
|
|
625
|
+
assert "\tNew description" in change_i_stdin.decode()
|
|
626
|
+
assert "Old description" not in change_i_stdin.decode()
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def test_update_changelist_description_returns_false_when_p4_fails(p4client, caplog):
|
|
630
|
+
"""A failing p4 is logged and gives False."""
|
|
631
|
+
with patch("p4cmd.p4cmd.subprocess.Popen") as mock_popen:
|
|
632
|
+
fake_p4_popen(mock_popen, b"", returncode=1)
|
|
633
|
+
with caplog.at_level(logging.ERROR):
|
|
634
|
+
result = p4client.update_changelist_description(12345, "New description")
|
|
635
|
+
assert result is False
|
|
636
|
+
assert "Failed to update changelist description" in caplog.text
|
|
574
637
|
|
|
575
638
|
|
|
576
639
|
def test_get_pending_changelists_none_desc(p4client):
|
|
@@ -606,11 +669,7 @@ def test_run_cmd_logs_warning_on_error_dicts(p4client, caplog):
|
|
|
606
669
|
buf = _io.BytesIO()
|
|
607
670
|
_m.dump(error_dict, buf)
|
|
608
671
|
_m.dump(ok_dict, buf)
|
|
609
|
-
buf.
|
|
610
|
-
mock_popen.return_value.__enter__ = lambda s: s
|
|
611
|
-
mock_popen.return_value.__exit__ = MagicMock(return_value=False)
|
|
612
|
-
mock_popen.return_value.stdout = buf
|
|
613
|
-
mock_popen.return_value.kill = MagicMock()
|
|
672
|
+
fake_p4_popen(mock_popen, buf.getvalue())
|
|
614
673
|
with caplog.at_level(logging.WARNING):
|
|
615
674
|
result = p4client.run_cmd("sync", file_list=["//depot/file.txt", "//depot/other.txt"])
|
|
616
675
|
assert len(result) == 2 # both dicts still returned
|
|
@@ -624,11 +683,7 @@ def test_run_cmd_raise_on_errors(p4client):
|
|
|
624
683
|
import marshal as _m, io as _io
|
|
625
684
|
buf = _io.BytesIO()
|
|
626
685
|
_m.dump(error_dict, buf)
|
|
627
|
-
buf.
|
|
628
|
-
mock_popen.return_value.__enter__ = lambda s: s
|
|
629
|
-
mock_popen.return_value.__exit__ = MagicMock(return_value=False)
|
|
630
|
-
mock_popen.return_value.stdout = buf
|
|
631
|
-
mock_popen.return_value.kill = MagicMock()
|
|
686
|
+
fake_p4_popen(mock_popen, buf.getvalue())
|
|
632
687
|
with pytest.raises(P4CommandError, match="file\\(s\\) not on client"):
|
|
633
688
|
p4client.run_cmd("sync", file_list=["//depot/file.txt"], raise_on_errors=True)
|
|
634
689
|
|
|
@@ -640,11 +695,7 @@ def test_run_cmd_no_raise_without_errors(p4client):
|
|
|
640
695
|
import marshal as _m, io as _io
|
|
641
696
|
buf = _io.BytesIO()
|
|
642
697
|
_m.dump(ok_dict, buf)
|
|
643
|
-
buf.
|
|
644
|
-
mock_popen.return_value.__enter__ = lambda s: s
|
|
645
|
-
mock_popen.return_value.__exit__ = MagicMock(return_value=False)
|
|
646
|
-
mock_popen.return_value.stdout = buf
|
|
647
|
-
mock_popen.return_value.kill = MagicMock()
|
|
698
|
+
fake_p4_popen(mock_popen, buf.getvalue())
|
|
648
699
|
result = p4client.run_cmd("sync", file_list=["//depot/file.txt"], raise_on_errors=True)
|
|
649
700
|
assert result == [ok_dict]
|
|
650
701
|
|
|
@@ -657,25 +708,35 @@ def test_run_cmd_handles_string_key_error_dicts(p4client, caplog):
|
|
|
657
708
|
import marshal as _m, io as _io
|
|
658
709
|
buf = _io.BytesIO()
|
|
659
710
|
_m.dump(error_dict, buf)
|
|
660
|
-
buf.
|
|
661
|
-
mock_popen.return_value.__enter__ = lambda s: s
|
|
662
|
-
mock_popen.return_value.__exit__ = MagicMock(return_value=False)
|
|
663
|
-
mock_popen.return_value.stdout = buf
|
|
664
|
-
mock_popen.return_value.kill = MagicMock()
|
|
711
|
+
fake_p4_popen(mock_popen, buf.getvalue())
|
|
665
712
|
with caplog.at_level(logging.WARNING):
|
|
666
713
|
p4client.run_cmd("sync", file_list=["//depot/file.txt"])
|
|
667
714
|
assert "some error" in caplog.text
|
|
668
715
|
|
|
669
716
|
|
|
717
|
+
def test_run_cmd_value_error_returns_the_output_once(p4client):
|
|
718
|
+
"""
|
|
719
|
+
Output that isn't all marshaled dicts gives one raw entry with all of it, without the dicts
|
|
720
|
+
read before the error, and p4 isn't run again (that would repeat a sync or submit).
|
|
721
|
+
"""
|
|
722
|
+
import marshal as _m
|
|
723
|
+
output = _m.dumps({b"code": b"stat", b"depotFile": b"//depot/file.txt"}) + b"not marshal data\n"
|
|
724
|
+
with patch("subprocess.Popen") as mock_popen, \
|
|
725
|
+
patch("p4cmd.p4cmd.subprocess.check_output") as mock_check_output:
|
|
726
|
+
fake_p4_popen(mock_popen, output)
|
|
727
|
+
result = p4client.run_cmd("sync", file_list=["//depot/file.txt"], online_check=False)
|
|
728
|
+
|
|
729
|
+
assert len(result) == 1
|
|
730
|
+
assert result[0]["code"] == "raw"
|
|
731
|
+
assert result[0]["raw_output"] == output
|
|
732
|
+
assert mock_popen.call_count == 1
|
|
733
|
+
mock_check_output.assert_not_called()
|
|
734
|
+
|
|
735
|
+
|
|
670
736
|
def test_run_cmd_value_error_fallback_no_warning(p4client, caplog):
|
|
671
737
|
"""The marshal_output=False path (e.g. `p4 set`) must not log spurious error warnings."""
|
|
672
738
|
with patch("subprocess.Popen") as mock_popen:
|
|
673
|
-
|
|
674
|
-
buf = _io.BytesIO(b"P4USER=niels.vaes (set)\n")
|
|
675
|
-
mock_popen.return_value.__enter__ = lambda s: s
|
|
676
|
-
mock_popen.return_value.__exit__ = MagicMock(return_value=False)
|
|
677
|
-
mock_popen.return_value.stdout = buf
|
|
678
|
-
mock_popen.return_value.kill = MagicMock()
|
|
739
|
+
fake_p4_popen(mock_popen, b"P4USER=niels.vaes (set)\n")
|
|
679
740
|
with caplog.at_level(logging.WARNING):
|
|
680
741
|
result = p4client.get_p4_setting("P4USER")
|
|
681
742
|
assert result == "niels.vaes"
|
|
@@ -688,17 +749,13 @@ def test_run_cmd_no_global_options_does_not_spawn_second_subprocess(p4client):
|
|
|
688
749
|
p4 outputs plain text — marshal.load is guaranteed to fail. The old fallback
|
|
689
750
|
spawned a second subprocess via check_output without pinning stdin, which
|
|
690
751
|
triggered OSError(WinError 50) on DuplicateHandle in non-interactive sessions
|
|
691
|
-
(UE commandlets, scheduled tasks, CI). The fix
|
|
692
|
-
|
|
752
|
+
(UE commandlets, scheduled tasks, CI). The fix uses the output of the one
|
|
753
|
+
subprocess, whose stdin is NUL.
|
|
693
754
|
"""
|
|
694
|
-
import io as _io
|
|
695
755
|
p4_set_output = b"P4USER=niels.vaes (set)\r\n"
|
|
696
756
|
with patch("subprocess.Popen") as mock_popen, \
|
|
697
757
|
patch("p4cmd.p4cmd.subprocess.check_output") as mock_check_output:
|
|
698
|
-
mock_popen
|
|
699
|
-
mock_popen.return_value.__exit__ = MagicMock(return_value=False)
|
|
700
|
-
mock_popen.return_value.stdout = _io.BytesIO(p4_set_output)
|
|
701
|
-
mock_popen.return_value.kill = MagicMock()
|
|
758
|
+
fake_p4_popen(mock_popen, p4_set_output)
|
|
702
759
|
|
|
703
760
|
result = p4client.get_p4_setting("P4USER")
|
|
704
761
|
|
|
@@ -708,13 +765,9 @@ def test_run_cmd_no_global_options_does_not_spawn_second_subprocess(p4client):
|
|
|
708
765
|
|
|
709
766
|
def test_run_cmd_use_global_options_deprecation_alias(p4client):
|
|
710
767
|
"""The old `use_global_options` kwarg is accepted but emits DeprecationWarning."""
|
|
711
|
-
import io as _io
|
|
712
768
|
import warnings as _warnings
|
|
713
769
|
with patch("subprocess.Popen") as mock_popen:
|
|
714
|
-
mock_popen
|
|
715
|
-
mock_popen.return_value.__exit__ = MagicMock(return_value=False)
|
|
716
|
-
mock_popen.return_value.stdout = _io.BytesIO(b"P4USER=niels.vaes (set)\n")
|
|
717
|
-
mock_popen.return_value.kill = MagicMock()
|
|
770
|
+
fake_p4_popen(mock_popen, b"P4USER=niels.vaes (set)\n")
|
|
718
771
|
|
|
719
772
|
with _warnings.catch_warnings(record=True) as caught:
|
|
720
773
|
_warnings.simplefilter("always")
|
|
@@ -827,3 +880,45 @@ def test_sync_files_warns_when_file_missing_unexpectedly(p4client, caplog):
|
|
|
827
880
|
with caplog.at_level("WARNING", logger="p4cmd.p4cmd"):
|
|
828
881
|
p4client.sync_files([path])
|
|
829
882
|
assert "didn't exist after syncing" in caplog.text
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
# ---------------------------------------------------------------------------
|
|
886
|
+
# __init__ depot-root resolution
|
|
887
|
+
# ---------------------------------------------------------------------------
|
|
888
|
+
|
|
889
|
+
def test_init_raises_workspace_error_when_depot_root_unresolvable(tmp_path):
|
|
890
|
+
"""An unresolvable depot root must raise WorkSpaceError, not IndexError.
|
|
891
|
+
|
|
892
|
+
``p4 where`` returns nothing when the client is not authenticated
|
|
893
|
+
(expired ticket), so ``get_depot_paths(...)[0]`` blew up with a bare
|
|
894
|
+
``IndexError: list index out of range``. Callers that retry the
|
|
895
|
+
constructor to recover a dropped connection then log that meaningless
|
|
896
|
+
message instead of "you need to log in", and cannot distinguish an
|
|
897
|
+
auth failure from a real bug.
|
|
898
|
+
"""
|
|
899
|
+
(tmp_path / ".p4config").write_text("P4PORT=ssl:perforce.example.com:1666\n")
|
|
900
|
+
with patch.object(P4Client, "host_online", return_value=True):
|
|
901
|
+
with patch.object(P4Client, "run_cmd", return_value=[]):
|
|
902
|
+
with pytest.raises(WorkSpaceError):
|
|
903
|
+
P4Client(
|
|
904
|
+
str(tmp_path),
|
|
905
|
+
user="testuser",
|
|
906
|
+
client="testclient",
|
|
907
|
+
server="ssl:perforce.example.com:1666",
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def test_init_error_message_mentions_login(tmp_path):
|
|
912
|
+
"""The raised message must point at the likely cause (auth/reachability)."""
|
|
913
|
+
(tmp_path / ".p4config").write_text("P4PORT=ssl:perforce.example.com:1666\n")
|
|
914
|
+
with patch.object(P4Client, "host_online", return_value=True):
|
|
915
|
+
with patch.object(P4Client, "run_cmd", return_value=[]):
|
|
916
|
+
with pytest.raises(WorkSpaceError) as excinfo:
|
|
917
|
+
P4Client(
|
|
918
|
+
str(tmp_path),
|
|
919
|
+
user="testuser",
|
|
920
|
+
client="testclient",
|
|
921
|
+
server="ssl:perforce.example.com:1666",
|
|
922
|
+
)
|
|
923
|
+
message = str(excinfo.value).lower()
|
|
924
|
+
assert "login" in message or "ticket" in message
|
p4cmd-2.11.2/tests/conftest.py
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
from unittest.mock import patch
|
|
3
|
-
|
|
4
|
-
from p4cmd.p4cmd import P4Client
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def make_fstat_dict(**kwargs):
|
|
8
|
-
"""Build a bytes-keyed dict like P4's marshaled fstat output."""
|
|
9
|
-
return {k.encode(): v.encode() for k, v in kwargs.items()}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
@pytest.fixture
|
|
13
|
-
def p4client(tmp_path):
|
|
14
|
-
"""
|
|
15
|
-
A P4Client with all server calls mocked out.
|
|
16
|
-
Passing explicit user/client/server skips the `p4 set` auto-detection calls.
|
|
17
|
-
Only get_depot_paths (called for depot_root) still hits run_cmd — mocked here.
|
|
18
|
-
"""
|
|
19
|
-
(tmp_path / ".p4config").write_text("P4PORT=ssl:perforce.example.com:1666\n")
|
|
20
|
-
where_result = [
|
|
21
|
-
{b"depotFile": b"//depot/project/...", b"path": b"/fake/project/...", b"unmap": b""}
|
|
22
|
-
]
|
|
23
|
-
with patch.object(P4Client, "host_online", return_value=True):
|
|
24
|
-
with patch.object(P4Client, "run_cmd", return_value=where_result):
|
|
25
|
-
client = P4Client(
|
|
26
|
-
str(tmp_path),
|
|
27
|
-
user="testuser",
|
|
28
|
-
client="testclient",
|
|
29
|
-
server="ssl:perforce.example.com:1666",
|
|
30
|
-
)
|
|
31
|
-
client.depot_root = "//depot/project"
|
|
32
|
-
return client
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|