p4cmd 2.9.2__tar.gz → 2.10.1__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 (31) hide show
  1. {p4cmd-2.9.2 → p4cmd-2.10.1}/PKG-INFO +1 -1
  2. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd/__init__.py +1 -1
  3. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd/p4cmd.py +16 -2
  4. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd/p4errors.py +14 -0
  5. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd.egg-info/PKG-INFO +1 -1
  6. {p4cmd-2.9.2 → p4cmd-2.10.1}/tests/test_p4client.py +90 -0
  7. {p4cmd-2.9.2 → p4cmd-2.10.1}/LICENSE +0 -0
  8. {p4cmd-2.9.2 → p4cmd-2.10.1}/README.md +0 -0
  9. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd/p4file.py +0 -0
  10. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd/resources/__init__.py +0 -0
  11. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd/resources/perforce_DEPOT_ONLY.png +0 -0
  12. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd/resources/perforce_MOVED.png +0 -0
  13. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd/resources/perforce_NEED_SYNC.png +0 -0
  14. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd/resources/perforce_OPEN_FOR_ADD.png +0 -0
  15. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd/resources/perforce_OPEN_FOR_DELETE.png +0 -0
  16. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd/resources/perforce_OPEN_FOR_EDIT.png +0 -0
  17. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd/resources/perforce_UNKNOWN.png +0 -0
  18. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd/resources/perforce_UNTRACKED.png +0 -0
  19. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd/resources/perforce_UP_TO_DATE.png +0 -0
  20. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd/utils.py +0 -0
  21. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd.egg-info/SOURCES.txt +0 -0
  22. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd.egg-info/dependency_links.txt +0 -0
  23. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd.egg-info/requires.txt +0 -0
  24. {p4cmd-2.9.2 → p4cmd-2.10.1}/p4cmd.egg-info/top_level.txt +0 -0
  25. {p4cmd-2.9.2 → p4cmd-2.10.1}/setup.cfg +0 -0
  26. {p4cmd-2.9.2 → p4cmd-2.10.1}/setup.py +0 -0
  27. {p4cmd-2.9.2 → p4cmd-2.10.1}/tests/__init__.py +0 -0
  28. {p4cmd-2.9.2 → p4cmd-2.10.1}/tests/conftest.py +0 -0
  29. {p4cmd-2.9.2 → p4cmd-2.10.1}/tests/test_integration.py +0 -0
  30. {p4cmd-2.9.2 → p4cmd-2.10.1}/tests/test_p4file.py +0 -0
  31. {p4cmd-2.9.2 → p4cmd-2.10.1}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: p4cmd
3
- Version: 2.9.2
3
+ Version: 2.10.1
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.9.2"
1
+ VERSION = "2.10.1"
2
2
 
3
3
  from .p4cmd import P4Client
4
4
  from .p4file import P4File
@@ -84,7 +84,7 @@ class P4Client(object):
84
84
  """
85
85
  self.max_parallel_connections = value
86
86
 
87
- def run_cmd(self, cmd, args=None, file_list=None, use_global_options=True, online_check=True):
87
+ def run_cmd(self, cmd, args=None, file_list=None, use_global_options=True, online_check=True, raise_on_errors=False):
88
88
  """
89
89
  Reads the output stream of the command and returns it as a marshaled dict.
90
90
 
@@ -145,6 +145,20 @@ class P4Client(object):
145
145
  dict_list.append(output_dict)
146
146
  pipe.kill()
147
147
 
148
+ # Check for error dicts in the results
149
+ errors = [d for d in dict_list if d.get(b"code") == b"error" or d.get("code") == "error"]
150
+ if errors:
151
+ messages = []
152
+ for e in errors:
153
+ msg = e.get(b"data") or e.get("data") or e.get(b"error") or e.get("error") or "unknown error"
154
+ if isinstance(msg, bytes):
155
+ msg = msg.decode("utf-8", errors="replace")
156
+ messages.append(msg.strip())
157
+ for msg in messages:
158
+ logging.warning(f"p4 {cmd}: {msg}")
159
+ if raise_on_errors:
160
+ raise p4errors.P4CommandError(cmd, messages)
161
+
148
162
  return dict_list
149
163
 
150
164
  def __replace_description_in_template(self, template, new_description):
@@ -1288,7 +1302,7 @@ class P4Client(object):
1288
1302
  if p4file.local_file_path is None:
1289
1303
  data = self.__get_dict_value(file_dict, "data") or ""
1290
1304
  if " - " in data:
1291
- p4file.local_file_path = data.split(" - ")[0].strip()
1305
+ p4file.local_file_path = data.rsplit(" - ", 1)[0].strip()
1292
1306
  p4file.have_revision = self.__get_dict_value(file_dict, "haveRev")
1293
1307
  p4file.head_revision = self.__get_dict_value(file_dict, "headRev")
1294
1308
  p4file.last_submit_time = self.__get_dict_value(file_dict, "headTime")
@@ -15,6 +15,20 @@ class WorkSpaceError(P4cmdError):
15
15
  pass
16
16
 
17
17
 
18
+ class P4CommandError(P4cmdError):
19
+ """Raised when a P4 command returns one or more error dicts.
20
+
21
+ Attributes:
22
+ cmd: The P4 command that failed (e.g. "sync", "edit").
23
+ messages: List of error message strings from the error dicts.
24
+ """
25
+ def __init__(self, cmd, messages):
26
+ self.cmd = cmd
27
+ self.messages = messages
28
+ joined = "; ".join(str(m).strip() for m in messages)
29
+ super().__init__(f"p4 {cmd}: {joined}")
30
+
31
+
18
32
  class ServerOffline(P4cmdError):
19
33
  """Raised when the Perforce server is unreachable.
20
34
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: p4cmd
3
- Version: 2.9.2
3
+ Version: 2.10.1
4
4
  Summary: Simple P4 python module
5
5
  Home-page: https://github.com/nielsvaes/p4cmd
6
6
  Author: Niels Vaes
@@ -1,10 +1,12 @@
1
1
  """Tests for p4cmd/p4cmd.py — P4Client with run_cmd mocked."""
2
+ import logging
2
3
  import socket
3
4
  from unittest.mock import MagicMock, patch
4
5
 
5
6
  import pytest
6
7
 
7
8
  from p4cmd.p4cmd import P4Client
9
+ from p4cmd.p4errors import P4CommandError
8
10
  from p4cmd.p4file import P4File, Status
9
11
  from tests.conftest import make_fstat_dict
10
12
 
@@ -210,6 +212,20 @@ def test_fstat_actionowner_field(p4client):
210
212
  assert any("charlie@testclient" in entry for entry in files[0].checked_out_by)
211
213
 
212
214
 
215
+ def test_fstat_untracked_file_with_dash_in_name(p4client):
216
+ """fstat_to_p4_files correctly parses untracked file paths containing ' - '."""
217
+ fstat = [
218
+ {
219
+ b"code": b"error",
220
+ b"data": b"D:/p4/games/Animations/my_clip - Copy.pose - no such file(s).",
221
+ }
222
+ ]
223
+ with patch.object(p4client, "find_p4_client", return_value="testclient"):
224
+ files = p4client.fstat_to_p4_files(fstat)
225
+ assert len(files) == 1
226
+ assert files[0].local_file_path == "D:/p4/games/Animations/my_clip - Copy.pose"
227
+
228
+
213
229
  def test_fstat_invalid_revision_becomes_none(p4client):
214
230
  fstat = [
215
231
  make_fstat_dict(
@@ -577,6 +593,80 @@ def test_get_depot_paths_missing_depot_file(p4client):
577
593
  assert result == ["//depot/project/file.txt"]
578
594
 
579
595
 
596
+ # ---------------------------------------------------------------------------
597
+ # run_cmd error handling
598
+ # ---------------------------------------------------------------------------
599
+
600
+ def test_run_cmd_logs_warning_on_error_dicts(p4client, caplog):
601
+ """run_cmd logs a warning for each error dict returned by p4."""
602
+ error_dict = {b"code": b"error", b"data": b"//depot/file.txt - file(s) not on client.\n"}
603
+ ok_dict = {b"code": b"stat", b"depotFile": b"//depot/other.txt"}
604
+ with patch("subprocess.Popen") as mock_popen:
605
+ import marshal as _m, io as _io
606
+ buf = _io.BytesIO()
607
+ _m.dump(error_dict, buf)
608
+ _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()
614
+ with caplog.at_level(logging.WARNING):
615
+ result = p4client.run_cmd("sync", file_list=["//depot/file.txt", "//depot/other.txt"])
616
+ assert len(result) == 2 # both dicts still returned
617
+ assert "file(s) not on client" in caplog.text
618
+
619
+
620
+ def test_run_cmd_raise_on_errors(p4client):
621
+ """run_cmd raises P4CommandError when raise_on_errors=True and errors are present."""
622
+ error_dict = {b"code": b"error", b"data": b"//depot/file.txt - file(s) not on client.\n"}
623
+ with patch("subprocess.Popen") as mock_popen:
624
+ import marshal as _m, io as _io
625
+ buf = _io.BytesIO()
626
+ _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()
632
+ with pytest.raises(P4CommandError, match="file\\(s\\) not on client"):
633
+ p4client.run_cmd("sync", file_list=["//depot/file.txt"], raise_on_errors=True)
634
+
635
+
636
+ def test_run_cmd_no_raise_without_errors(p4client):
637
+ """run_cmd doesn't raise when raise_on_errors=True but no errors are present."""
638
+ ok_dict = {b"code": b"stat", b"depotFile": b"//depot/file.txt"}
639
+ with patch("subprocess.Popen") as mock_popen:
640
+ import marshal as _m, io as _io
641
+ buf = _io.BytesIO()
642
+ _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()
648
+ result = p4client.run_cmd("sync", file_list=["//depot/file.txt"], raise_on_errors=True)
649
+ assert result == [ok_dict]
650
+
651
+
652
+ def test_run_cmd_handles_string_key_error_dicts(p4client, caplog):
653
+ """run_cmd handles error dicts with string keys (from ValueError fallback)."""
654
+ # String-keyed error dicts come from the ValueError handler in run_cmd
655
+ error_dict = {b"code": b"error", b"data": b"some error\n"}
656
+ with patch("subprocess.Popen") as mock_popen:
657
+ import marshal as _m, io as _io
658
+ buf = _io.BytesIO()
659
+ _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()
665
+ with caplog.at_level(logging.WARNING):
666
+ p4client.run_cmd("sync", file_list=["//depot/file.txt"])
667
+ assert "some error" in caplog.text
668
+
669
+
580
670
  def test_get_local_paths_missing_path(p4client):
581
671
  """get_local_paths skips entries where path is missing (e.g. unmapped paths from p4 where)."""
582
672
  info_dicts = [
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