p4cmd 2.10.2__tar.gz → 2.11.0__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.11.0}/PKG-INFO +31 -1
  2. {p4cmd-2.10.2 → p4cmd-2.11.0}/README.md +30 -0
  3. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd/__init__.py +1 -1
  4. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd/p4cmd.py +80 -125
  5. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd/utils.py +5 -3
  6. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd.egg-info/PKG-INFO +31 -1
  7. {p4cmd-2.10.2 → p4cmd-2.11.0}/tests/test_p4client.py +101 -6
  8. {p4cmd-2.10.2 → p4cmd-2.11.0}/LICENSE +0 -0
  9. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd/p4errors.py +0 -0
  10. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd/p4file.py +0 -0
  11. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd/resources/__init__.py +0 -0
  12. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd/resources/perforce_DEPOT_ONLY.png +0 -0
  13. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd/resources/perforce_MOVED.png +0 -0
  14. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd/resources/perforce_NEED_SYNC.png +0 -0
  15. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd/resources/perforce_OPEN_FOR_ADD.png +0 -0
  16. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd/resources/perforce_OPEN_FOR_DELETE.png +0 -0
  17. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd/resources/perforce_OPEN_FOR_EDIT.png +0 -0
  18. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd/resources/perforce_UNKNOWN.png +0 -0
  19. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd/resources/perforce_UNTRACKED.png +0 -0
  20. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd/resources/perforce_UP_TO_DATE.png +0 -0
  21. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd.egg-info/SOURCES.txt +0 -0
  22. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd.egg-info/dependency_links.txt +0 -0
  23. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd.egg-info/requires.txt +0 -0
  24. {p4cmd-2.10.2 → p4cmd-2.11.0}/p4cmd.egg-info/top_level.txt +0 -0
  25. {p4cmd-2.10.2 → p4cmd-2.11.0}/setup.cfg +0 -0
  26. {p4cmd-2.10.2 → p4cmd-2.11.0}/setup.py +0 -0
  27. {p4cmd-2.10.2 → p4cmd-2.11.0}/tests/__init__.py +0 -0
  28. {p4cmd-2.10.2 → p4cmd-2.11.0}/tests/conftest.py +0 -0
  29. {p4cmd-2.10.2 → p4cmd-2.11.0}/tests/test_integration.py +0 -0
  30. {p4cmd-2.10.2 → p4cmd-2.11.0}/tests/test_p4file.py +0 -0
  31. {p4cmd-2.10.2 → p4cmd-2.11.0}/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.11.0
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
 
@@ -57,6 +60,33 @@ p4 = p4cmd.P4Client.from_env()
57
60
 
58
61
  If the path you pass doesn't contain a `.p4config` file, P4Client will walk up the directory tree until it finds one and use that as `perforce_root`.
59
62
 
63
+ ### Quieting log output
64
+
65
+ `p4cmd` logs warnings and errors through the standard `logging` module under the `p4cmd` logger. Setting the level on the `p4cmd` parent flows down to all submodules (`p4cmd.p4cmd`, `p4cmd.utils`).
66
+
67
+ **Drop everything except criticals** — matches the old `silent=True` behavior:
68
+
69
+ ```python
70
+ import logging
71
+ logging.getLogger("p4cmd").setLevel(logging.CRITICAL)
72
+ ```
73
+
74
+ **Drop literally everything from p4cmd** (including criticals):
75
+
76
+ ```python
77
+ logging.getLogger("p4cmd").disabled = True
78
+ ```
79
+
80
+ **Keep the messages but route them somewhere other than stderr** — e.g. a log file:
81
+
82
+ ```python
83
+ p4_logger = logging.getLogger("p4cmd")
84
+ p4_logger.propagate = False # don't bubble to root/stderr
85
+ p4_logger.addHandler(logging.FileHandler("p4cmd.log"))
86
+ ```
87
+
88
+ The old `silent=True` constructor kwarg is deprecated and now a no-op — configure the logger instead.
89
+
60
90
  ## P4Client API
61
91
 
62
92
  ### Connection & settings
@@ -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
 
@@ -33,6 +36,33 @@ p4 = p4cmd.P4Client.from_env()
33
36
 
34
37
  If the path you pass doesn't contain a `.p4config` file, P4Client will walk up the directory tree until it finds one and use that as `perforce_root`.
35
38
 
39
+ ### Quieting log output
40
+
41
+ `p4cmd` logs warnings and errors through the standard `logging` module under the `p4cmd` logger. Setting the level on the `p4cmd` parent flows down to all submodules (`p4cmd.p4cmd`, `p4cmd.utils`).
42
+
43
+ **Drop everything except criticals** — matches the old `silent=True` behavior:
44
+
45
+ ```python
46
+ import logging
47
+ logging.getLogger("p4cmd").setLevel(logging.CRITICAL)
48
+ ```
49
+
50
+ **Drop literally everything from p4cmd** (including criticals):
51
+
52
+ ```python
53
+ logging.getLogger("p4cmd").disabled = True
54
+ ```
55
+
56
+ **Keep the messages but route them somewhere other than stderr** — e.g. a log file:
57
+
58
+ ```python
59
+ p4_logger = logging.getLogger("p4cmd")
60
+ p4_logger.propagate = False # don't bubble to root/stderr
61
+ p4_logger.addHandler(logging.FileHandler("p4cmd.log"))
62
+ ```
63
+
64
+ The old `silent=True` constructor kwarg is deprecated and now a no-op — configure the logger instead.
65
+
36
66
  ## P4Client API
37
67
 
38
68
  ### Connection & settings
@@ -1,4 +1,4 @@
1
- VERSION = "2.10.2"
1
+ VERSION = "2.11.0"
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
@@ -10,24 +11,34 @@ from . import p4errors
10
11
  from .p4file import P4File, Status
11
12
  from .utils import split_list_into_strings_of_length, convert_to_list, decode_dictionaries, validate_not_empty
12
13
 
14
+ _log = logging.getLogger(__name__)
15
+
13
16
  MAX_CMD_LEN = 8190
14
17
  MAX_ARG_LEN = 8000 # max length of args string when combined, close to max, but leaving some extra margin
15
18
 
16
19
 
17
20
  class P4Client(object):
18
- def __init__(self, perforce_root, user=None, client=None, server=None, silent=True, max_parallel_connections=4):
21
+ def __init__(self, perforce_root, user=None, client=None, server=None, silent=None, max_parallel_connections=4):
19
22
  """
20
23
  Make a new P4Client
21
24
 
22
25
  :param perforce_root: *string* root of your Perforce workspace. This would also be where your .p4config file is
23
26
  :param user: *string* P4USER, if None will be tried to be found automatically
24
27
  :param client: *string* P4CLIENT, if None will be tried to be found automatically
25
- :param silent: *bool* if True, suppresses error messages to cut down on terminal spam
26
28
  :param max_parallel_connections: *int* max number of connections to use while syncing/submitting. This requires
27
29
  the server to have net.parallel.max and net parallel.threads to be > 1
30
+ :param silent: *deprecated* — no-op. To quiet output, configure the ``p4cmd`` logger, e.g.
31
+ ``logging.getLogger("p4cmd").setLevel(logging.CRITICAL)``.
28
32
  """
33
+ if silent is not None:
34
+ warnings.warn(
35
+ "`silent` is deprecated and has no effect; configure the `p4cmd` logger "
36
+ "instead, e.g. `logging.getLogger('p4cmd').setLevel(logging.CRITICAL)`.",
37
+ DeprecationWarning,
38
+ stacklevel=2,
39
+ )
40
+
29
41
  self.perforce_root = perforce_root
30
- self.silent = silent
31
42
 
32
43
  self.max_parallel_connections = max_parallel_connections
33
44
  self.user = user
@@ -35,8 +46,7 @@ class P4Client(object):
35
46
  self.server = server
36
47
 
37
48
  if not self.__p4config_exists():
38
- if not silent:
39
- logging.warning("No .p4config file found in %s!" % self.perforce_root)
49
+ _log.warning("No .p4config file found in %s!" % self.perforce_root)
40
50
 
41
51
  if user is None:
42
52
  self.user = self.get_p4_setting("P4USER")
@@ -84,18 +94,35 @@ class P4Client(object):
84
94
  """
85
95
  self.max_parallel_connections = value
86
96
 
87
- def run_cmd(self, cmd, args=None, file_list=None, use_global_options=True, online_check=True, raise_on_errors=False):
97
+ def run_cmd(self, cmd, args=None, file_list=None, marshal_output=True, online_check=True, raise_on_errors=False, **kwargs):
88
98
  """
89
- Reads the output stream of the command and returns it as a marshaled dict.
99
+ Reads the output stream of the command and returns it as a list of dicts.
90
100
 
91
101
  :param cmd: *string* p4 command like "change", "reopen", "move"
92
102
  :param args: *list* of string arguments like ["-c", "27277"]
93
103
  :param file_list: *list* of string arguments like ["//depot/folder/file.atom", "D:/Games/Whatever.fbx]
94
- :param use_global_options: *bool*
104
+ :param marshal_output: *bool* when True (default), passes ``-G`` so p4 emits Python marshal-format
105
+ output that we parse into dicts, and also passes ``-u <user> -c <client>`` for identity. Set to
106
+ False for commands that don't honor ``-G`` (notably ``p4 set``, which only reads local settings)
107
+ or when user/client haven't been resolved yet — output is then returned verbatim under the
108
+ ``raw_output`` key.
95
109
  :param online_check: *bool* if set to True, will first check if the remote server is reachable before executing the command.
96
110
  :return: *list* of dictionaries with either the marshaled returns of the command or dictionaries with the
97
111
  raw output of the command
98
112
  """
113
+ # Backwards compat: the old name `use_global_options` was misleading — it sounded like a master
114
+ # switch for p4's global-option family, when it really just toggles `-G` (marshal output) plus the
115
+ # bundled `-u`/`-c` identity flags. Renamed to `marshal_output` to point at the actual consequence
116
+ # (the parsing path); old kwarg kept as a deprecated alias.
117
+ if "use_global_options" in kwargs:
118
+ warnings.warn(
119
+ "`use_global_options` is deprecated; use `marshal_output` instead.",
120
+ DeprecationWarning,
121
+ stacklevel=2,
122
+ )
123
+ marshal_output = kwargs.pop("use_global_options")
124
+ if kwargs:
125
+ raise TypeError(f"run_cmd() got unexpected keyword arguments: {list(kwargs)}")
99
126
 
100
127
  if not self.perforce_root:
101
128
  raise p4errors.WorkSpaceError(f"self.perforce_root (value: {self.perforce_root}) is not set!")
@@ -117,32 +144,43 @@ class P4Client(object):
117
144
  dict_list = []
118
145
  for clamped_arg in clamped_arg_list:
119
146
  for clamped_files in clamped_file_list:
120
- if use_global_options:
147
+ if marshal_output:
121
148
  command = f"p4 -G -u {self.user} -c {self.client} {cmd} {clamped_arg} {clamped_files}"
122
149
  else:
123
150
  command = f"p4 {cmd} {clamped_arg} {clamped_files}"
124
151
 
125
152
  if len(command) > MAX_CMD_LEN:
126
153
  # This shouldn't happen, but just in case the command prefix end up really long
127
- logging.warning(
154
+ _log.warning(
128
155
  f"Command length: {format(len(command))} exceeds MAX_CMD_LEN {MAX_CMD_LEN} on command: {MAX_CMD_LEN}")
129
156
 
130
157
  with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, shell=True, cwd=self.perforce_root) as pipe:
131
158
  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 = {
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.
164
+ dict_list.append({
140
165
  "command": command,
141
166
  "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)
167
+ "raw_output": output.read(),
168
+ })
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)
146
184
  pipe.kill()
147
185
 
148
186
  # Check for error dicts in the results
@@ -155,7 +193,7 @@ class P4Client(object):
155
193
  msg = msg.decode("utf-8", errors="replace")
156
194
  messages.append(msg.strip())
157
195
  for msg in messages:
158
- logging.warning(f"p4 {cmd}: {msg}")
196
+ _log.warning(f"p4 {cmd}: {msg}")
159
197
  if raise_on_errors:
160
198
  raise p4errors.P4CommandError(cmd, messages)
161
199
 
@@ -218,7 +256,7 @@ class P4Client(object):
218
256
  """
219
257
  try:
220
258
  # skipping the online check for setting commands
221
- info_dict = self.run_cmd("set", [setting], use_global_options=False, online_check=False)[0]
259
+ info_dict = self.run_cmd("set", [setting], marshal_output=False, online_check=False)[0]
222
260
  except Exception as e:
223
261
  raise p4errors.WorkSpaceError("Unable to find setting %s" % setting) from e
224
262
 
@@ -367,12 +405,12 @@ class P4Client(object):
367
405
 
368
406
  parts = output.split(" ")
369
407
  if len(parts) < 2:
370
- logging.error(f"Unexpected output from p4 change: {output!r}")
408
+ _log.error(f"Unexpected output from p4 change: {output!r}")
371
409
  return None
372
410
  try:
373
411
  return int(parts[1])
374
412
  except ValueError:
375
- logging.error(f"Could not parse changelist number from: {output!r}")
413
+ _log.error(f"Could not parse changelist number from: {output!r}")
376
414
  return None
377
415
 
378
416
  def changelist_exists(self, changelist):
@@ -420,14 +458,10 @@ class P4Client(object):
420
458
 
421
459
  return True
422
460
  except subprocess.CalledProcessError as e:
423
- logging.debug(f"p4 command failed: {e}")
424
- if not self.silent:
425
- logging.error(f"Failed to update changelist description: {e}")
461
+ _log.error(f"Failed to update changelist description: {e}")
426
462
  return False
427
463
  except OSError as e:
428
- logging.debug(f"OS error: {e}")
429
- if not self.silent:
430
- logging.error(f"Failed to update changelist description: {e}")
464
+ _log.error(f"Failed to update changelist description: {e}")
431
465
  return False
432
466
 
433
467
  @validate_not_empty
@@ -442,11 +476,6 @@ class P4Client(object):
442
476
  file_list = convert_to_list(file_list)
443
477
  changelist = self.__ensure_changelist(changelist)
444
478
  info_dicts = self.run_cmd("reopen", args=["-c", changelist], file_list=file_list)
445
-
446
- for info_dict in info_dicts:
447
- if self.__get_dict_value(info_dict, "code") == "error" and not self.silent:
448
- logging.error(self.__get_dict_value(info_dict, "data"))
449
-
450
479
  return info_dicts
451
480
 
452
481
  def combine_changelists(self, source_changelists, target_changelist):
@@ -510,8 +539,6 @@ class P4Client(object):
510
539
  :return: *list* of info dictionaries
511
540
  """
512
541
  file_list = convert_to_list(file_list)
513
- if not self.silent:
514
- self.__validate_file_list(file_list)
515
542
  if unchanged_only:
516
543
  info_dicts = self.run_cmd("revert", ["-a"], file_list=file_list)
517
544
  else:
@@ -534,9 +561,6 @@ class P4Client(object):
534
561
  :return: *list* of info dicts
535
562
  """
536
563
  folder_list = convert_to_list(folder_list)
537
- if not self.silent:
538
- self.__validate_file_list(folder_list)
539
-
540
564
  cleaned_folder_list = [self._normalize_folder(f) for f in folder_list]
541
565
 
542
566
  if unchanged_only:
@@ -583,9 +607,6 @@ class P4Client(object):
583
607
  :return: *list* of info dicts
584
608
  """
585
609
  folder_list = convert_to_list(folder_list)
586
- if not self.silent:
587
- self.__validate_file_list(folder_list)
588
-
589
610
  cleaned_folder_list = [self._normalize_folder(f) for f in folder_list]
590
611
 
591
612
  info_dicts = self.run_cmd("sync", args=["--parallel", f"threads={self.max_parallel_connections}"],
@@ -611,9 +632,6 @@ class P4Client(object):
611
632
 
612
633
  initial_arg_list = ["-f", "--parallel", f"threads={self.max_parallel_connections}"] if force else ["--parallel",
613
634
  f"threads={self.max_parallel_connections}"]
614
- if not self.silent:
615
- self.__validate_file_list(file_list)
616
-
617
635
  info_dicts = self.run_cmd("sync", args=initial_arg_list, file_list=file_list)
618
636
 
619
637
  if verify:
@@ -622,7 +640,7 @@ class P4Client(object):
622
640
  if local_file_path is None:
623
641
  continue
624
642
  if not os.path.isfile(local_file_path):
625
- logging.warning(f"File didn't exist after syncing, try force syncing it instead: {local_file_path}")
643
+ _log.warning(f"File didn't exist after syncing, try force syncing it instead: {local_file_path}")
626
644
 
627
645
  return info_dicts
628
646
 
@@ -672,8 +690,6 @@ class P4Client(object):
672
690
  :return: *list* of info dicts
673
691
  """
674
692
  folder_list = convert_to_list(folder_list)
675
- if not self.silent:
676
- self.__validate_file_list(folder_list)
677
693
  cleaned_folder_list = [self._normalize_folder(f) for f in folder_list]
678
694
  changelist = self.__ensure_changelist(changelist)
679
695
  args = self._build_reconcile_args(changelist, add, edit, delete)
@@ -691,9 +707,6 @@ class P4Client(object):
691
707
  :return: *list* of info dictionaries
692
708
  """
693
709
  file_list = convert_to_list(file_list)
694
- if not self.silent:
695
- self.__validate_file_list(file_list)
696
-
697
710
  changelist = self.__ensure_changelist(changelist)
698
711
 
699
712
  info_dicts = self.run_cmd("delete", args=["-c", changelist], file_list=file_list)
@@ -761,13 +774,10 @@ class P4Client(object):
761
774
  file_list = self.get_files_in_changelist(changelist)
762
775
  else:
763
776
  file_list = convert_to_list(file_list)
764
- if not self.silent:
765
- self.__validate_file_list(file_list)
766
777
 
767
778
  # if there are no files to shelve, return empty list
768
779
  if not file_list:
769
- if not self.silent:
770
- logging.warning(f"No files to shelve in changelist {changelist}")
780
+ _log.warning(f"No files to shelve in changelist {changelist}")
771
781
  return []
772
782
 
773
783
  # iuild args list based on options
@@ -778,10 +788,6 @@ class P4Client(object):
778
788
  # run the shelve command
779
789
  info_dicts = self.run_cmd("shelve", args=args, file_list=file_list)
780
790
 
781
- for info_dict in info_dicts:
782
- if self.__get_dict_value(info_dict, "code") == "error" and not self.silent:
783
- logging.error(self.__get_dict_value(info_dict, "data"))
784
-
785
791
  # revert files if requested
786
792
  if revert_after_shelve and file_list:
787
793
  revert_info_dicts = self.revert_files(file_list)
@@ -822,35 +828,20 @@ class P4Client(object):
822
828
  # prepare file list if provided
823
829
  if file_list is not None:
824
830
  file_list = convert_to_list(file_list)
825
- if not self.silent:
826
- self.__validate_file_list(file_list)
827
831
  else:
828
832
  file_list = []
829
833
 
830
834
  # run the unshelve command
831
835
  info_dicts = self.run_cmd("unshelve", args=args, file_list=file_list)
832
836
 
833
- # check for errors in the unshelve operation
834
- has_error = False
835
- for info_dict in info_dicts:
836
- if self.__get_dict_value(info_dict, "code") == "error" and not self.silent:
837
- logging.error(self.__get_dict_value(info_dict, "data"))
838
- has_error = True
837
+ has_error = any(
838
+ self.__get_dict_value(d, "code") == "error" for d in info_dicts
839
+ )
839
840
 
840
841
  # delete shelved files if requested and unshelve was successful
841
842
  if delete_shelved_files and not has_error:
842
- # build the arguments for the shelve -d command
843
843
  delete_args = ["-d", "-c", str(source_changelist)]
844
-
845
- # if file_list provided, use it for the delete operation as well
846
844
  delete_info_dicts = self.run_cmd("shelve", args=delete_args, file_list=file_list)
847
-
848
- # check for errors in the delete operation
849
- for info_dict in delete_info_dicts:
850
- if self.__get_dict_value(info_dict, "code") == "error" and not self.silent:
851
- logging.error(self.__get_dict_value(info_dict, "data"))
852
-
853
- # add the delete info to the return data
854
845
  info_dicts.extend(delete_info_dicts)
855
846
 
856
847
  return info_dicts
@@ -868,8 +859,7 @@ class P4Client(object):
868
859
 
869
860
  # Don't allow deleting shelves from the default changelist
870
861
  if changelist == "default":
871
- if not self.silent:
872
- logging.error("Cannot delete shelved files from the default changelist")
862
+ _log.error("Cannot delete shelved files from the default changelist")
873
863
  return [{"code": "error", "data": "Cannot delete shelved files from the default changelist"}]
874
864
 
875
865
  # Check if the changelist has shelved files
@@ -884,8 +874,7 @@ class P4Client(object):
884
874
  break
885
875
 
886
876
  if not has_shelved_files:
887
- if not self.silent:
888
- logging.warning(f"No shelved files found in changelist {changelist}")
877
+ _log.warning(f"No shelved files found in changelist {changelist}")
889
878
  return [{"code": "info", "data": f"No shelved files found in changelist {changelist}"}]
890
879
 
891
880
  # Delete all shelved files in the changelist
@@ -893,14 +882,10 @@ class P4Client(object):
893
882
  return info_dicts
894
883
 
895
884
  except subprocess.CalledProcessError as e:
896
- logging.debug(f"p4 command failed: {e}")
897
- if not self.silent:
898
- logging.error(f"Failed to delete shelved files: {e}")
885
+ _log.error(f"Failed to delete shelved files: {e}")
899
886
  return [{"code": "error", "data": str(e)}]
900
887
  except OSError as e:
901
- logging.debug(f"OS error: {e}")
902
- if not self.silent:
903
- logging.error(f"Failed to delete shelved files: {e}")
888
+ _log.error(f"Failed to delete shelved files: {e}")
904
889
  return [{"code": "error", "data": str(e)}]
905
890
 
906
891
  @validate_not_empty
@@ -938,8 +923,6 @@ class P4Client(object):
938
923
  :return: *list* of info dictionaries
939
924
  """
940
925
  file_list = convert_to_list(file_list)
941
- if not self.silent:
942
- self.__validate_file_list(file_list)
943
926
 
944
927
  files_for_add = []
945
928
  files_for_checkout = []
@@ -985,15 +968,9 @@ class P4Client(object):
985
968
  :return: *list* of info dictionaries
986
969
  """
987
970
  file_list = convert_to_list(file_list)
988
- if not self.silent:
989
- self.__validate_file_list(file_list)
990
-
991
971
  changelist = self.__ensure_changelist(changelist)
992
972
 
993
973
  info_dicts = self.run_cmd("edit", args=["-c", changelist], file_list=file_list)
994
- for info_dict in info_dicts:
995
- if self.__get_dict_value(info_dict, "code") == "error" and not self.silent:
996
- logging.error(self.__get_dict_value(info_dict, "data"))
997
974
  return info_dicts
998
975
 
999
976
  @validate_not_empty
@@ -1005,9 +982,6 @@ class P4Client(object):
1005
982
  :return: *list* of info dictionaries
1006
983
  """
1007
984
  file_list = convert_to_list(file_list)
1008
- if not self.silent:
1009
- self.__validate_file_list(file_list)
1010
-
1011
985
  changelist = self.__ensure_changelist(changelist)
1012
986
 
1013
987
  info_dicts = self.run_cmd("add", args=["-c", changelist], file_list=file_list)
@@ -1071,7 +1045,7 @@ class P4Client(object):
1071
1045
  cl_description = desc_value.rstrip("\n")
1072
1046
 
1073
1047
  if not cl_description:
1074
- logging.warning(f"The CL description is empty in this return object!\n{pformat(info_dict)}")
1048
+ _log.warning(f"The CL description is empty in this return object!\n{pformat(info_dict)}")
1075
1049
 
1076
1050
  if not case_sensitive:
1077
1051
  cl_description = cl_description.lower()
@@ -1211,13 +1185,13 @@ class P4Client(object):
1211
1185
  with socket.create_connection((host, port), timeout=timeout):
1212
1186
  return True
1213
1187
  except socket.timeout:
1214
- logging.warning(f"Connection to {host} on port {port} timed out after {timeout} seconds, the running command might not complete correctly")
1188
+ _log.warning(f"Connection to {host} on port {port} timed out after {timeout} seconds, the running command might not complete correctly")
1215
1189
  return False
1216
1190
  except ConnectionRefusedError:
1217
- logging.warning(f"Connection to {host} on port {port} refused to connect, is the server still up?")
1191
+ _log.warning(f"Connection to {host} on port {port} refused to connect, is the server still up?")
1218
1192
  return False
1219
1193
  except OSError as e:
1220
- logging.warning(f"oserror: {e.strerror or e}")
1194
+ _log.warning(f"oserror: {e.strerror or e}")
1221
1195
  return False
1222
1196
 
1223
1197
  def __server_address(self):
@@ -1344,7 +1318,7 @@ class P4Client(object):
1344
1318
  while last_dir != current_dir:
1345
1319
  if os.path.exists(current_dir):
1346
1320
  if ".p4config" in os.listdir(current_dir):
1347
- logging.info(".p4config found in %s" % current_dir)
1321
+ _log.info(".p4config found in %s" % current_dir)
1348
1322
  self.perforce_root = current_dir
1349
1323
  return True
1350
1324
 
@@ -1352,22 +1326,3 @@ class P4Client(object):
1352
1326
  current_dir = os.path.dirname(last_dir)
1353
1327
  return False
1354
1328
 
1355
- def __validate_file_list(self, file_list):
1356
- """
1357
- Validation function to ensure correct files are being synced to correct workspaces & clients etc.
1358
- Is an extendable function
1359
- :param file_list: List of files to iterate
1360
- :return:
1361
- """
1362
- if not self.perforce_root:
1363
- raise p4errors.WorkSpaceError(f"self.perforce_root (value: {self.perforce_root}) is not set!")
1364
-
1365
- file_list = convert_to_list(file_list)
1366
-
1367
- # Easy utility to check that the file is underneath the correct perforce root
1368
- # Quicker than waiting for the result of a p4 fstat
1369
- for f in file_list:
1370
- if not f.lower().startswith(self.perforce_root.lower()) and not f.lower().startswith(
1371
- self.depot_root.lower()):
1372
- raise Exception(f'{f} is not under perforce root: {self.perforce_root}, {self.depot_root}')
1373
-
@@ -1,6 +1,8 @@
1
1
  import logging
2
2
  import functools
3
3
 
4
+ _log = logging.getLogger(__name__)
5
+
4
6
 
5
7
  def split_list_into_strings_of_length(input_list, max_length=100):
6
8
  """
@@ -66,9 +68,9 @@ def validate_not_empty(func):
66
68
  file_or_folder_list = convert_to_list(file_or_folder_list)
67
69
 
68
70
  if not file_or_folder_list:
69
- logging.warning(f"Empty file list provided to {func.__name__}, operation skipped.")
70
- logging.warning(f"args: {args}")
71
- logging.warning(f"kwargs: {kwargs}")
71
+ _log.warning(f"Empty file list provided to {func.__name__}, operation skipped.")
72
+ _log.warning(f"args: {args}")
73
+ _log.warning(f"kwargs: {kwargs}")
72
74
  return []
73
75
 
74
76
  return func(*args, **kwargs)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: p4cmd
3
- Version: 2.10.2
3
+ Version: 2.11.0
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
 
@@ -57,6 +60,33 @@ p4 = p4cmd.P4Client.from_env()
57
60
 
58
61
  If the path you pass doesn't contain a `.p4config` file, P4Client will walk up the directory tree until it finds one and use that as `perforce_root`.
59
62
 
63
+ ### Quieting log output
64
+
65
+ `p4cmd` logs warnings and errors through the standard `logging` module under the `p4cmd` logger. Setting the level on the `p4cmd` parent flows down to all submodules (`p4cmd.p4cmd`, `p4cmd.utils`).
66
+
67
+ **Drop everything except criticals** — matches the old `silent=True` behavior:
68
+
69
+ ```python
70
+ import logging
71
+ logging.getLogger("p4cmd").setLevel(logging.CRITICAL)
72
+ ```
73
+
74
+ **Drop literally everything from p4cmd** (including criticals):
75
+
76
+ ```python
77
+ logging.getLogger("p4cmd").disabled = True
78
+ ```
79
+
80
+ **Keep the messages but route them somewhere other than stderr** — e.g. a log file:
81
+
82
+ ```python
83
+ p4_logger = logging.getLogger("p4cmd")
84
+ p4_logger.propagate = False # don't bubble to root/stderr
85
+ p4_logger.addHandler(logging.FileHandler("p4cmd.log"))
86
+ ```
87
+
88
+ The old `silent=True` constructor kwarg is deprecated and now a no-op — configure the logger instead.
89
+
60
90
  ## P4Client API
61
91
 
62
92
  ### Connection & settings
@@ -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 = [
@@ -696,3 +742,52 @@ def test_get_local_paths_missing_path(p4client):
696
742
  ["//depot/project/file.txt", "//depot/project/unmapped.txt"]
697
743
  )
698
744
  assert result == ["C:/workspace/file.txt"]
745
+
746
+
747
+ # ---------------------------------------------------------------------------
748
+ # Regression: issue #14 — files mapped from outside perforce_root/depot_root
749
+ # must not raise on edit (previously failed when silent=False due to a buggy
750
+ # prefix-based pre-validation that ignored multi-mapping client views).
751
+ # ---------------------------------------------------------------------------
752
+
753
+ def test_edit_files_accepts_paths_outside_perforce_root_prefix(p4client):
754
+ """A file mapped from a different depot path (e.g. shared Raw assets) must
755
+ not be rejected before p4 sees it. perforce_root is /fake/project and
756
+ depot_root is //depot/project — neither prefix matches this file."""
757
+ outside_file = "//depot/shared_audio/Raw/voice_line.wav"
758
+ with patch.object(p4client, "_P4Client__ensure_changelist", return_value="default"):
759
+ with patch.object(p4client, "run_cmd", return_value=[]) as mock_cmd:
760
+ p4client.edit_files([outside_file])
761
+ mock_cmd.assert_called_once()
762
+ args, kwargs = mock_cmd.call_args
763
+ assert outside_file in kwargs["file_list"]
764
+
765
+
766
+ # ---------------------------------------------------------------------------
767
+ # silent kwarg — deprecated
768
+ # ---------------------------------------------------------------------------
769
+
770
+ def test_silent_kwarg_emits_deprecation_warning(tmp_path):
771
+ """Passing silent= (True or False) emits DeprecationWarning."""
772
+ import warnings as _warnings
773
+ (tmp_path / ".p4config").write_text("P4PORT=ssl:example:1666\n")
774
+ with patch.object(P4Client, "host_online", return_value=True):
775
+ with patch.object(P4Client, "run_cmd", return_value=[{b"depotFile": b"//depot/x/...", b"path": b"/fake/...", b"unmap": b""}]):
776
+ with _warnings.catch_warnings(record=True) as caught:
777
+ _warnings.simplefilter("always")
778
+ P4Client(str(tmp_path), user="u", client="c", server="s", silent=True)
779
+ assert any(issubclass(w.category, DeprecationWarning) and "silent" in str(w.message)
780
+ for w in caught), f"expected DeprecationWarning, got: {[str(w.message) for w in caught]}"
781
+
782
+
783
+ def test_silent_kwarg_default_no_warning(tmp_path):
784
+ """Not passing silent at all must not emit a DeprecationWarning."""
785
+ import warnings as _warnings
786
+ (tmp_path / ".p4config").write_text("P4PORT=ssl:example:1666\n")
787
+ with patch.object(P4Client, "host_online", return_value=True):
788
+ with patch.object(P4Client, "run_cmd", return_value=[{b"depotFile": b"//depot/x/...", b"path": b"/fake/...", b"unmap": b""}]):
789
+ with _warnings.catch_warnings(record=True) as caught:
790
+ _warnings.simplefilter("always")
791
+ P4Client(str(tmp_path), user="u", client="c", server="s")
792
+ assert not any(issubclass(w.category, DeprecationWarning) and "silent" in str(w.message)
793
+ for w in caught), f"unexpected silent warning: {[str(w.message) for w in caught]}"
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