p4cmd 2.10.2__tar.gz → 2.10.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 (31) hide show
  1. {p4cmd-2.10.2 → p4cmd-2.10.3}/PKG-INFO +4 -1
  2. {p4cmd-2.10.2 → p4cmd-2.10.3}/README.md +3 -0
  3. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd/__init__.py +1 -1
  4. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd/p4cmd.py +46 -17
  5. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd.egg-info/PKG-INFO +4 -1
  6. {p4cmd-2.10.2 → p4cmd-2.10.3}/tests/test_p4client.py +52 -6
  7. {p4cmd-2.10.2 → p4cmd-2.10.3}/LICENSE +0 -0
  8. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd/p4errors.py +0 -0
  9. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd/p4file.py +0 -0
  10. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd/resources/__init__.py +0 -0
  11. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd/resources/perforce_DEPOT_ONLY.png +0 -0
  12. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd/resources/perforce_MOVED.png +0 -0
  13. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd/resources/perforce_NEED_SYNC.png +0 -0
  14. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd/resources/perforce_OPEN_FOR_ADD.png +0 -0
  15. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd/resources/perforce_OPEN_FOR_DELETE.png +0 -0
  16. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd/resources/perforce_OPEN_FOR_EDIT.png +0 -0
  17. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd/resources/perforce_UNKNOWN.png +0 -0
  18. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd/resources/perforce_UNTRACKED.png +0 -0
  19. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd/resources/perforce_UP_TO_DATE.png +0 -0
  20. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd/utils.py +0 -0
  21. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd.egg-info/SOURCES.txt +0 -0
  22. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd.egg-info/dependency_links.txt +0 -0
  23. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd.egg-info/requires.txt +0 -0
  24. {p4cmd-2.10.2 → p4cmd-2.10.3}/p4cmd.egg-info/top_level.txt +0 -0
  25. {p4cmd-2.10.2 → p4cmd-2.10.3}/setup.cfg +0 -0
  26. {p4cmd-2.10.2 → p4cmd-2.10.3}/setup.py +0 -0
  27. {p4cmd-2.10.2 → p4cmd-2.10.3}/tests/__init__.py +0 -0
  28. {p4cmd-2.10.2 → p4cmd-2.10.3}/tests/conftest.py +0 -0
  29. {p4cmd-2.10.2 → p4cmd-2.10.3}/tests/test_integration.py +0 -0
  30. {p4cmd-2.10.2 → p4cmd-2.10.3}/tests/test_p4file.py +0 -0
  31. {p4cmd-2.10.2 → p4cmd-2.10.3}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: p4cmd
3
- Version: 2.10.2
3
+ Version: 2.10.3
4
4
  Summary: Simple P4 python module
5
5
  Home-page: https://github.com/nielsvaes/p4cmd
6
6
  Author: Niels Vaes
@@ -24,6 +24,9 @@ Dynamic: summary
24
24
 
25
25
  ### P4CMD 🌴
26
26
 
27
+ [![Tests](https://github.com/nielsvaes/p4cmd/actions/workflows/tests.yml/badge.svg)](https://github.com/nielsvaes/p4cmd/actions/workflows/tests.yml)
28
+ [![Publish](https://github.com/nielsvaes/p4cmd/actions/workflows/python-publish.yml/badge.svg)](https://github.com/nielsvaes/p4cmd/actions/workflows/python-publish.yml)
29
+
27
30
  A Python Perforce package that doesn't bring in any other packages to work.
28
31
  Relies on the `p4` CLI installed on the system.
29
32
 
@@ -1,5 +1,8 @@
1
1
  ### P4CMD 🌴
2
2
 
3
+ [![Tests](https://github.com/nielsvaes/p4cmd/actions/workflows/tests.yml/badge.svg)](https://github.com/nielsvaes/p4cmd/actions/workflows/tests.yml)
4
+ [![Publish](https://github.com/nielsvaes/p4cmd/actions/workflows/python-publish.yml/badge.svg)](https://github.com/nielsvaes/p4cmd/actions/workflows/python-publish.yml)
5
+
3
6
  A Python Perforce package that doesn't bring in any other packages to work.
4
7
  Relies on the `p4` CLI installed on the system.
5
8
 
@@ -1,4 +1,4 @@
1
- VERSION = "2.10.2"
1
+ VERSION = "2.10.3"
2
2
 
3
3
  from .p4cmd import P4Client
4
4
  from .p4file import P4File
@@ -2,6 +2,7 @@ import os
2
2
  import marshal
3
3
  import subprocess
4
4
  import socket
5
+ import warnings
5
6
  from pprint import pformat
6
7
 
7
8
  import logging
@@ -84,18 +85,35 @@ class P4Client(object):
84
85
  """
85
86
  self.max_parallel_connections = value
86
87
 
87
- def run_cmd(self, cmd, args=None, file_list=None, use_global_options=True, online_check=True, raise_on_errors=False):
88
+ def run_cmd(self, cmd, args=None, file_list=None, marshal_output=True, online_check=True, raise_on_errors=False, **kwargs):
88
89
  """
89
- Reads the output stream of the command and returns it as a marshaled dict.
90
+ Reads the output stream of the command and returns it as a list of dicts.
90
91
 
91
92
  :param cmd: *string* p4 command like "change", "reopen", "move"
92
93
  :param args: *list* of string arguments like ["-c", "27277"]
93
94
  :param file_list: *list* of string arguments like ["//depot/folder/file.atom", "D:/Games/Whatever.fbx]
94
- :param use_global_options: *bool*
95
+ :param marshal_output: *bool* when True (default), passes ``-G`` so p4 emits Python marshal-format
96
+ output that we parse into dicts, and also passes ``-u <user> -c <client>`` for identity. Set to
97
+ False for commands that don't honor ``-G`` (notably ``p4 set``, which only reads local settings)
98
+ or when user/client haven't been resolved yet — output is then returned verbatim under the
99
+ ``raw_output`` key.
95
100
  :param online_check: *bool* if set to True, will first check if the remote server is reachable before executing the command.
96
101
  :return: *list* of dictionaries with either the marshaled returns of the command or dictionaries with the
97
102
  raw output of the command
98
103
  """
104
+ # Backwards compat: the old name `use_global_options` was misleading — it sounded like a master
105
+ # switch for p4's global-option family, when it really just toggles `-G` (marshal output) plus the
106
+ # bundled `-u`/`-c` identity flags. Renamed to `marshal_output` to point at the actual consequence
107
+ # (the parsing path); old kwarg kept as a deprecated alias.
108
+ if "use_global_options" in kwargs:
109
+ warnings.warn(
110
+ "`use_global_options` is deprecated; use `marshal_output` instead.",
111
+ DeprecationWarning,
112
+ stacklevel=2,
113
+ )
114
+ marshal_output = kwargs.pop("use_global_options")
115
+ if kwargs:
116
+ raise TypeError(f"run_cmd() got unexpected keyword arguments: {list(kwargs)}")
99
117
 
100
118
  if not self.perforce_root:
101
119
  raise p4errors.WorkSpaceError(f"self.perforce_root (value: {self.perforce_root}) is not set!")
@@ -117,7 +135,7 @@ class P4Client(object):
117
135
  dict_list = []
118
136
  for clamped_arg in clamped_arg_list:
119
137
  for clamped_files in clamped_file_list:
120
- if use_global_options:
138
+ if marshal_output:
121
139
  command = f"p4 -G -u {self.user} -c {self.client} {cmd} {clamped_arg} {clamped_files}"
122
140
  else:
123
141
  command = f"p4 {cmd} {clamped_arg} {clamped_files}"
@@ -129,20 +147,31 @@ class P4Client(object):
129
147
 
130
148
  with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, shell=True, cwd=self.perforce_root) as pipe:
131
149
  output = pipe.stdout
132
- try:
133
- while True:
134
- value_dict = marshal.load(output)
135
- dict_list.append(value_dict)
136
- except EOFError:
137
- pass
138
- except ValueError as error:
139
- output_dict = {
150
+ if not marshal_output:
151
+ # Without -G, p4 emits plain text (and `p4 set` ignores -G even when passed),
152
+ # so marshal.load is guaranteed to fail. Read stdout directly from this Popen
153
+ # — it has stdin=PIPE, so it's safe in non-interactive sessions where the old
154
+ # check_output fallback hit OSError(WinError 50) on DuplicateHandle.
155
+ dict_list.append({
140
156
  "command": command,
141
157
  "code": "raw",
142
- "error": str(error),
143
- "raw_output": subprocess.check_output(command, stderr=subprocess.STDOUT, shell=True, cwd=self.perforce_root)
144
- }
145
- dict_list.append(output_dict)
158
+ "raw_output": output.read(),
159
+ })
160
+ else:
161
+ try:
162
+ while True:
163
+ value_dict = marshal.load(output)
164
+ dict_list.append(value_dict)
165
+ except EOFError:
166
+ pass
167
+ except ValueError as error:
168
+ output_dict = {
169
+ "command": command,
170
+ "code": "raw",
171
+ "error": str(error),
172
+ "raw_output": subprocess.check_output(command, stderr=subprocess.STDOUT, shell=True, cwd=self.perforce_root)
173
+ }
174
+ dict_list.append(output_dict)
146
175
  pipe.kill()
147
176
 
148
177
  # Check for error dicts in the results
@@ -218,7 +247,7 @@ class P4Client(object):
218
247
  """
219
248
  try:
220
249
  # skipping the online check for setting commands
221
- info_dict = self.run_cmd("set", [setting], use_global_options=False, online_check=False)[0]
250
+ info_dict = self.run_cmd("set", [setting], marshal_output=False, online_check=False)[0]
222
251
  except Exception as e:
223
252
  raise p4errors.WorkSpaceError("Unable to find setting %s" % setting) from e
224
253
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: p4cmd
3
- Version: 2.10.2
3
+ Version: 2.10.3
4
4
  Summary: Simple P4 python module
5
5
  Home-page: https://github.com/nielsvaes/p4cmd
6
6
  Author: Niels Vaes
@@ -24,6 +24,9 @@ Dynamic: summary
24
24
 
25
25
  ### P4CMD 🌴
26
26
 
27
+ [![Tests](https://github.com/nielsvaes/p4cmd/actions/workflows/tests.yml/badge.svg)](https://github.com/nielsvaes/p4cmd/actions/workflows/tests.yml)
28
+ [![Publish](https://github.com/nielsvaes/p4cmd/actions/workflows/python-publish.yml/badge.svg)](https://github.com/nielsvaes/p4cmd/actions/workflows/python-publish.yml)
29
+
27
30
  A Python Perforce package that doesn't bring in any other packages to work.
28
31
  Relies on the `p4` CLI installed on the system.
29
32
 
@@ -668,13 +668,10 @@ def test_run_cmd_handles_string_key_error_dicts(p4client, caplog):
668
668
 
669
669
 
670
670
  def test_run_cmd_value_error_fallback_no_warning(p4client, caplog):
671
- """run_cmd's ValueError fallback (e.g. `p4 set`) must not log spurious error warnings."""
672
- with patch("subprocess.Popen") as mock_popen, \
673
- patch("p4cmd.p4cmd.subprocess.check_output", return_value=b"P4USER=niels.vaes (set)\n"):
674
- # Empty stdout triggers ValueError on marshal.load (unknown type code in this case
675
- # would also work; here we use bytes that aren't valid marshal data).
671
+ """The marshal_output=False path (e.g. `p4 set`) must not log spurious error warnings."""
672
+ with patch("subprocess.Popen") as mock_popen:
676
673
  import io as _io
677
- buf = _io.BytesIO(b"\xff\xff\xff\xff") # invalid marshal type code -> ValueError
674
+ buf = _io.BytesIO(b"P4USER=niels.vaes (set)\n")
678
675
  mock_popen.return_value.__enter__ = lambda s: s
679
676
  mock_popen.return_value.__exit__ = MagicMock(return_value=False)
680
677
  mock_popen.return_value.stdout = buf
@@ -685,6 +682,55 @@ def test_run_cmd_value_error_fallback_no_warning(p4client, caplog):
685
682
  assert caplog.records == []
686
683
 
687
684
 
685
+ def test_run_cmd_no_global_options_does_not_spawn_second_subprocess(p4client):
686
+ """
687
+ Regression test for issue #13. When marshal_output=False (e.g. `p4 set`),
688
+ p4 outputs plain text — marshal.load is guaranteed to fail. The old fallback
689
+ spawned a second subprocess via check_output without pinning stdin, which
690
+ 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.
693
+ """
694
+ import io as _io
695
+ p4_set_output = b"P4USER=niels.vaes (set)\r\n"
696
+ with patch("subprocess.Popen") as mock_popen, \
697
+ 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()
702
+
703
+ result = p4client.get_p4_setting("P4USER")
704
+
705
+ assert result == "niels.vaes"
706
+ mock_check_output.assert_not_called()
707
+
708
+
709
+ def test_run_cmd_use_global_options_deprecation_alias(p4client):
710
+ """The old `use_global_options` kwarg is accepted but emits DeprecationWarning."""
711
+ import io as _io
712
+ import warnings as _warnings
713
+ 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()
718
+
719
+ with _warnings.catch_warnings(record=True) as caught:
720
+ _warnings.simplefilter("always")
721
+ result = p4client.run_cmd("set", ["P4USER"], use_global_options=False, online_check=False)
722
+
723
+ assert result[0]["raw_output"] == b"P4USER=niels.vaes (set)\n"
724
+ assert any(issubclass(w.category, DeprecationWarning) and "use_global_options" in str(w.message)
725
+ for w in caught), f"expected DeprecationWarning for use_global_options, got: {[str(w.message) for w in caught]}"
726
+
727
+
728
+ def test_run_cmd_unknown_kwarg_raises(p4client):
729
+ """Unknown kwargs should raise TypeError, not be silently accepted."""
730
+ with pytest.raises(TypeError, match="unexpected keyword arguments"):
731
+ p4client.run_cmd("set", ["P4USER"], totally_made_up=True, online_check=False)
732
+
733
+
688
734
  def test_get_local_paths_missing_path(p4client):
689
735
  """get_local_paths skips entries where path is missing (e.g. unmapped paths from p4 where)."""
690
736
  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