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.
Files changed (32) hide show
  1. {p4cmd-2.11.2 → p4cmd-2.11.3}/PKG-INFO +1 -1
  2. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/__init__.py +1 -1
  3. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/p4cmd.py +93 -35
  4. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd.egg-info/PKG-INFO +1 -1
  5. p4cmd-2.11.3/tests/conftest.py +57 -0
  6. {p4cmd-2.11.2 → p4cmd-2.11.3}/tests/test_p4client.py +146 -51
  7. p4cmd-2.11.2/tests/conftest.py +0 -32
  8. {p4cmd-2.11.2 → p4cmd-2.11.3}/LICENSE +0 -0
  9. {p4cmd-2.11.2 → p4cmd-2.11.3}/README.md +0 -0
  10. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/p4errors.py +0 -0
  11. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/p4file.py +0 -0
  12. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/__init__.py +0 -0
  13. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_DEPOT_ONLY.png +0 -0
  14. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_MOVED.png +0 -0
  15. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_NEED_SYNC.png +0 -0
  16. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_OPEN_FOR_ADD.png +0 -0
  17. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_OPEN_FOR_DELETE.png +0 -0
  18. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_OPEN_FOR_EDIT.png +0 -0
  19. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_UNKNOWN.png +0 -0
  20. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_UNTRACKED.png +0 -0
  21. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/resources/perforce_UP_TO_DATE.png +0 -0
  22. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd/utils.py +0 -0
  23. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd.egg-info/SOURCES.txt +0 -0
  24. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd.egg-info/dependency_links.txt +0 -0
  25. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd.egg-info/requires.txt +0 -0
  26. {p4cmd-2.11.2 → p4cmd-2.11.3}/p4cmd.egg-info/top_level.txt +0 -0
  27. {p4cmd-2.11.2 → p4cmd-2.11.3}/setup.cfg +0 -0
  28. {p4cmd-2.11.2 → p4cmd-2.11.3}/setup.py +0 -0
  29. {p4cmd-2.11.2 → p4cmd-2.11.3}/tests/__init__.py +0 -0
  30. {p4cmd-2.11.2 → p4cmd-2.11.3}/tests/test_integration.py +0 -0
  31. {p4cmd-2.11.2 → p4cmd-2.11.3}/tests/test_p4file.py +0 -0
  32. {p4cmd-2.11.2 → p4cmd-2.11.3}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: p4cmd
3
- Version: 2.11.2
3
+ Version: 2.11.3
4
4
  Summary: Simple P4 python module
5
5
  Home-page: https://github.com/nielsvaes/p4cmd
6
6
  Author: Niels Vaes
@@ -1,4 +1,4 @@
1
- VERSION = "2.11.2"
1
+ VERSION = "2.11.3"
2
2
 
3
3
  from .p4cmd import P4Client
4
4
  from .p4file import P4File
@@ -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
- self.depot_root = self.get_depot_paths([self.perforce_root])[0]
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: {MAX_CMD_LEN}")
156
-
157
- with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, shell=True, cwd=self.perforce_root) as pipe:
158
- output = pipe.stdout
159
- if not marshal_output:
160
- # Without -G, p4 emits plain text (and `p4 set` ignores -G even when passed),
161
- # so marshal.load is guaranteed to fail. Read stdout directly from this Popen
162
- # — it has stdin=PIPE, so it's safe in non-interactive sessions where the old
163
- # check_output fallback hit OSError(WinError 50) on DuplicateHandle.
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
- "raw_output": output.read(),
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 = subprocess.check_output(
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
- shell=True).decode()
450
+ check=True).decode()
397
451
 
398
452
  modified = self.__replace_description_in_template(template, description)
399
453
 
400
- output = subprocess.check_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
- shell=True).decode()
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 = subprocess.check_output(
504
+ template = _run_command(
450
505
  f'p4 -u {self.user} -c {self.client} change -o {changelist}',
451
- shell=True).decode()
506
+ self.perforce_root,
507
+ check=True).decode()
452
508
 
453
509
  modified = self.__replace_description_in_template(template, new_description)
454
510
 
455
- result = subprocess.check_output(
511
+ _run_command(
456
512
  f'p4 -u {self.user} -c {self.client} change -i',
457
- input=modified.encode(), shell=True).decode()
513
+ self.perforce_root,
514
+ input=modified.encode(),
515
+ check=True)
458
516
 
459
517
  return True
460
518
  except subprocess.CalledProcessError as e:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: p4cmd
3
- Version: 2.11.2
3
+ Version: 2.11.3
4
4
  Summary: Simple P4 python module
5
5
  Home-page: https://github.com/nielsvaes/p4cmd
6
6
  Author: Niels Vaes
@@ -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
- import io
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.check_output", side_effect=[template, b"Unexpected output\n"]), \
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.check_output", side_effect=[template, b"Change 12345 created.\n"]) as mock_co, \
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
- submitted = mock_co.call_args_list[1]
571
- input_data = submitted[1]["input"].decode()
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.seek(0)
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.seek(0)
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.seek(0)
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.seek(0)
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
- import io as _io
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 reads stdout directly from the
692
- already-open Popen, which has stdin=PIPE.
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.return_value.__enter__ = lambda s: s
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.return_value.__enter__ = lambda s: s
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
@@ -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