pytest-subprocess 1.5.2__tar.gz → 1.5.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 (39) hide show
  1. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/HISTORY.rst +13 -0
  2. {pytest_subprocess-1.5.2/pytest_subprocess.egg-info → pytest_subprocess-1.5.3}/PKG-INFO +50 -5
  3. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/README.rst +34 -3
  4. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/pytest_subprocess/__init__.py +1 -0
  5. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/pytest_subprocess/fake_popen.py +75 -19
  6. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/pytest_subprocess/fake_process.py +1 -0
  7. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/pytest_subprocess/process_dispatcher.py +15 -2
  8. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3/pytest_subprocess.egg-info}/PKG-INFO +50 -5
  9. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/pytest_subprocess.egg-info/requires.txt +0 -1
  10. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/setup.py +3 -2
  11. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/tests/example_script.py +1 -0
  12. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/tests/test_asyncio.py +43 -8
  13. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/tests/test_subprocess.py +53 -5
  14. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/tests/test_typing.py +1 -0
  15. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/LICENSE +0 -0
  16. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/MANIFEST.in +0 -0
  17. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/docs/Makefile +0 -0
  18. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/docs/api.rst +0 -0
  19. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/docs/conf.py +0 -0
  20. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/docs/index.rst +0 -0
  21. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/docs/make.bat +0 -0
  22. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/docs/usage.rst +0 -0
  23. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/pytest.ini +0 -0
  24. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/pytest_subprocess/asyncio_subprocess.py +0 -0
  25. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/pytest_subprocess/exceptions.py +0 -0
  26. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/pytest_subprocess/fixtures.py +0 -0
  27. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/pytest_subprocess/process_recorder.py +0 -0
  28. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/pytest_subprocess/py.typed +0 -0
  29. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/pytest_subprocess/types.py +0 -0
  30. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/pytest_subprocess/utils.py +0 -0
  31. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/pytest_subprocess.egg-info/SOURCES.txt +0 -0
  32. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/pytest_subprocess.egg-info/dependency_links.txt +0 -0
  33. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/pytest_subprocess.egg-info/entry_points.txt +0 -0
  34. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/pytest_subprocess.egg-info/top_level.txt +0 -0
  35. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/setup.cfg +0 -0
  36. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/tests/__init__.py +0 -0
  37. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/tests/conftest.py +0 -0
  38. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/tests/test_examples.py +0 -0
  39. {pytest_subprocess-1.5.2 → pytest_subprocess-1.5.3}/tests/test_utils.py +0 -0
@@ -1,6 +1,19 @@
1
1
  History
2
2
  =======
3
3
 
4
+ 1.5.3 (2025-01-04)
5
+ ------------------
6
+
7
+ Features
8
+ ~~~~~~~~
9
+ * `#171 <https://github.com/aklajnert/pytest-subprocess/pull/171>`_, `#178 <https://github.com/aklajnert/pytest-subprocess/pull/178>`_: Allow to access keyword arguments passed to Popen.
10
+
11
+ Bug fixes
12
+ ~~~~~~~~~
13
+ * `#180 <https://github.com/aklajnert/pytest-subprocess/pull/180>`_: Fixed an incorrect wait timeout calculation.
14
+ * `#170 <https://github.com/aklajnert/pytest-subprocess/pull/170>`_: Wrapped ProcessDispatcher.dispatch into FakePopenWrapper as it was causing TypeError when Popen is used as a type.
15
+ * `#169 <https://github.com/aklajnert/pytest-subprocess/pull/169>`_: Get rid of using thread in AsyncFakePopen as it causes thread.join() to hang indefinitely.
16
+
4
17
  1.5.2 (2024-07-24)
5
18
  ------------------
6
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pytest-subprocess
3
- Version: 1.5.2
3
+ Version: 1.5.3
4
4
  Summary: A plugin to fake subprocess for pytest
5
5
  Author: Andrzej Klajnert
6
6
  Author-email: python@aklajnert.pl
@@ -22,6 +22,8 @@ Classifier: Programming Language :: Python :: 3.8
22
22
  Classifier: Programming Language :: Python :: 3.9
23
23
  Classifier: Programming Language :: Python :: 3.10
24
24
  Classifier: Programming Language :: Python :: 3.11
25
+ Classifier: Programming Language :: Python :: 3.12
26
+ Classifier: Programming Language :: Python :: 3.13
25
27
  Classifier: Programming Language :: Python :: Implementation :: CPython
26
28
  Classifier: Programming Language :: Python :: Implementation :: PyPy
27
29
  Classifier: Operating System :: OS Independent
@@ -31,7 +33,6 @@ License-File: LICENSE
31
33
  Requires-Dist: pytest>=4.0.0
32
34
  Provides-Extra: test
33
35
  Requires-Dist: pytest>=4.0; extra == "test"
34
- Requires-Dist: coverage; extra == "test"
35
36
  Requires-Dist: docutils>=0.12; extra == "test"
36
37
  Requires-Dist: Pygments>=2.0; extra == "test"
37
38
  Requires-Dist: pytest-rerunfailures; extra == "test"
@@ -60,9 +61,6 @@ pytest-subprocess
60
61
  :target: https://pypi.org/project/pytest-subprocess
61
62
  :alt: Python versions
62
63
 
63
- .. image:: https://codecov.io/gh/aklajnert/pytest-subprocess/branch/master/graph/badge.svg?token=JAU1cGoYL8
64
- :target: https://codecov.io/gh/aklajnert/pytest-subprocess
65
-
66
64
  .. image:: https://readthedocs.org/projects/pytest-subprocess/badge/?version=latest
67
65
  :target: https://pytest-subprocess.readthedocs.io/en/latest/?badge=latest
68
66
  :alt: Documentation Status
@@ -402,6 +400,40 @@ how many a command has been called. The latter supports ``fp.any()``.
402
400
  assert fp.call_count(["cp", fp.any()]) == 3
403
401
 
404
402
 
403
+ Check Popen arguments
404
+ ---------------------
405
+
406
+ You can use the recorded calls functionality to introspect the keyword
407
+ arguments that were passed to `Popen`.
408
+
409
+ .. code-block:: python
410
+
411
+ def test_process_recorder_kwargs(fp):
412
+ fp.keep_last_process(True)
413
+ recorder = fp.register(["test_script", fp.any()])
414
+
415
+ subprocess.run(
416
+ ("test_script", "arg1"), env={"foo": "bar"}, cwd="/home/user"
417
+ )
418
+ subprocess.Popen(
419
+ ["test_script", "arg2"],
420
+ env={"foo": "bar1"},
421
+ executable="test_script",
422
+ shell=True,
423
+ )
424
+
425
+ assert recorder.calls[0].args == ("test_script", "arg1")
426
+ assert recorder.calls[0].kwargs == {
427
+ "cwd": "/home/user",
428
+ "env": {"foo": "bar"},
429
+ }
430
+ assert recorder.calls[1].args == ["test_script", "arg2"]
431
+ assert recorder.calls[1].kwargs == {
432
+ "env": {"foo": "bar1"},
433
+ "executable": "test_script",
434
+ "shell": True,
435
+ }
436
+
405
437
  Handling signals
406
438
  ----------------
407
439
 
@@ -507,6 +539,19 @@ This `pytest`_ plugin was generated with `Cookiecutter`_ along with `@hackebrot`
507
539
  History
508
540
  =======
509
541
 
542
+ 1.5.3 (2025-01-04)
543
+ ------------------
544
+
545
+ Features
546
+ ~~~~~~~~
547
+ * `#171 <https://github.com/aklajnert/pytest-subprocess/pull/171>`_, `#178 <https://github.com/aklajnert/pytest-subprocess/pull/178>`_: Allow to access keyword arguments passed to Popen.
548
+
549
+ Bug fixes
550
+ ~~~~~~~~~
551
+ * `#180 <https://github.com/aklajnert/pytest-subprocess/pull/180>`_: Fixed an incorrect wait timeout calculation.
552
+ * `#170 <https://github.com/aklajnert/pytest-subprocess/pull/170>`_: Wrapped ProcessDispatcher.dispatch into FakePopenWrapper as it was causing TypeError when Popen is used as a type.
553
+ * `#169 <https://github.com/aklajnert/pytest-subprocess/pull/169>`_: Get rid of using thread in AsyncFakePopen as it causes thread.join() to hang indefinitely.
554
+
510
555
  1.5.2 (2024-07-24)
511
556
  ------------------
512
557
 
@@ -10,9 +10,6 @@ pytest-subprocess
10
10
  :target: https://pypi.org/project/pytest-subprocess
11
11
  :alt: Python versions
12
12
 
13
- .. image:: https://codecov.io/gh/aklajnert/pytest-subprocess/branch/master/graph/badge.svg?token=JAU1cGoYL8
14
- :target: https://codecov.io/gh/aklajnert/pytest-subprocess
15
-
16
13
  .. image:: https://readthedocs.org/projects/pytest-subprocess/badge/?version=latest
17
14
  :target: https://pytest-subprocess.readthedocs.io/en/latest/?badge=latest
18
15
  :alt: Documentation Status
@@ -352,6 +349,40 @@ how many a command has been called. The latter supports ``fp.any()``.
352
349
  assert fp.call_count(["cp", fp.any()]) == 3
353
350
 
354
351
 
352
+ Check Popen arguments
353
+ ---------------------
354
+
355
+ You can use the recorded calls functionality to introspect the keyword
356
+ arguments that were passed to `Popen`.
357
+
358
+ .. code-block:: python
359
+
360
+ def test_process_recorder_kwargs(fp):
361
+ fp.keep_last_process(True)
362
+ recorder = fp.register(["test_script", fp.any()])
363
+
364
+ subprocess.run(
365
+ ("test_script", "arg1"), env={"foo": "bar"}, cwd="/home/user"
366
+ )
367
+ subprocess.Popen(
368
+ ["test_script", "arg2"],
369
+ env={"foo": "bar1"},
370
+ executable="test_script",
371
+ shell=True,
372
+ )
373
+
374
+ assert recorder.calls[0].args == ("test_script", "arg1")
375
+ assert recorder.calls[0].kwargs == {
376
+ "cwd": "/home/user",
377
+ "env": {"foo": "bar"},
378
+ }
379
+ assert recorder.calls[1].args == ["test_script", "arg2"]
380
+ assert recorder.calls[1].kwargs == {
381
+ "env": {"foo": "bar1"},
382
+ "executable": "test_script",
383
+ "shell": True,
384
+ }
385
+
355
386
  Handling signals
356
387
  ----------------
357
388
 
@@ -1,4 +1,5 @@
1
1
  """Main module"""
2
+
2
3
  from . import exceptions
3
4
  from .fake_process import FakeProcess
4
5
 
@@ -1,6 +1,9 @@
1
1
  """FakePopen class declaration"""
2
+
2
3
  import asyncio
3
4
  import collections.abc
5
+ import concurrent.futures
6
+ import copy
4
7
  import io
5
8
  import os
6
9
  import signal
@@ -66,16 +69,22 @@ class FakePopen:
66
69
  msg = f"argument of type {arg.__class__.__name__!r} is not iterable"
67
70
  raise TypeError(msg)
68
71
  self.args = command
72
+ self.__kwargs: Optional[Dict[str, AnyType]] = None
69
73
  self.__stdout: OPTIONAL_TEXT_OR_ITERABLE = stdout
70
74
  self.__stderr: OPTIONAL_TEXT_OR_ITERABLE = stderr
71
- self.__returncode: Optional[int] = returncode
72
- self.__wait: Optional[float] = wait
73
75
  self.__thread: Optional[Thread] = None
74
- self.__callback: Optional[Optional[Callable]] = callback
75
- self.__callback_kwargs: Optional[Dict[str, AnyType]] = callback_kwargs
76
76
  self.__signal_callback: Optional[Callable] = signal_callback
77
77
  self.__stdin_callable: Optional[Optional[Callable]] = stdin_callable
78
+ self.__universal_newlines: Optional[Dict[AnyType, AnyType]] = None
78
79
  self._signals: List[int] = []
80
+ self._returncode: Optional[int] = returncode
81
+ self._wait_timeout: Optional[float] = wait
82
+ self._callback: Optional[Optional[Callable]] = callback
83
+ self._callback_kwargs: Optional[Dict[str, AnyType]] = callback_kwargs
84
+
85
+ @property
86
+ def kwargs(self) -> Optional[Dict[str, AnyType]]:
87
+ return self.__kwargs
79
88
 
80
89
  def __enter__(self) -> "FakePopen":
81
90
  return self
@@ -115,8 +124,8 @@ class FakePopen:
115
124
  if self.__thread is None:
116
125
  return
117
126
  self.__thread.join(timeout)
118
- if self.returncode is None and self.__returncode is not None:
119
- self.returncode = self.__returncode
127
+ if self.returncode is None and self._returncode is not None:
128
+ self.returncode = self._returncode
120
129
  if self.__thread.exception:
121
130
  raise self.__thread.exception
122
131
 
@@ -132,9 +141,10 @@ class FakePopen:
132
141
  return self.returncode
133
142
 
134
143
  def wait(self, timeout: Optional[float] = None) -> int:
135
- if timeout and self.__wait and timeout < self.__wait:
136
- self.__wait -= timeout
137
- raise subprocess.TimeoutExpired(self.args, timeout)
144
+ if timeout and self._wait_timeout:
145
+ self._wait_timeout -= timeout
146
+ if timeout < self._wait_timeout:
147
+ raise subprocess.TimeoutExpired(self.args, timeout)
138
148
  self._finalize_thread(timeout)
139
149
  if self.returncode is None:
140
150
  raise exceptions.PluginInternalError
@@ -156,6 +166,7 @@ class FakePopen:
156
166
 
157
167
  def configure(self, **kwargs: Optional[Dict]) -> None:
158
168
  """Setup the FakePopen instance based on a real Popen arguments."""
169
+ self.__kwargs = self.safe_copy(kwargs)
159
170
  self.__universal_newlines = kwargs.get("universal_newlines", None)
160
171
  text = kwargs.get("text", None)
161
172
  encoding = kwargs.get("encoding", None)
@@ -192,6 +203,16 @@ class FakePopen:
192
203
  elif isinstance(stderr, (io.BufferedWriter, io.TextIOWrapper)):
193
204
  self._write_to_buffer(self.__stderr, stderr)
194
205
 
206
+ @staticmethod
207
+ def safe_copy(kwargs: Dict[str, AnyType]) -> Dict[str, AnyType]:
208
+ """
209
+ Deepcopy can fail if the value is not serializable, fallback to shallow copy.
210
+ """
211
+ try:
212
+ return copy.deepcopy(kwargs)
213
+ except TypeError:
214
+ return dict(**kwargs)
215
+
195
216
  def _prepare_buffer(
196
217
  self,
197
218
  input: OPTIONAL_TEXT_OR_ITERABLE,
@@ -284,21 +305,21 @@ class FakePopen:
284
305
 
285
306
  def run_thread(self) -> None:
286
307
  """Run the user-defined callback or wait in a thread."""
287
- if self.__wait is None and self.__callback is None:
308
+ if self._wait_timeout is None and self._callback is None:
288
309
  self._finish_process()
289
310
  else:
290
- if self.__callback:
311
+ if self._callback:
291
312
  self.__thread = Thread(
292
- target=self.__callback,
313
+ target=self._callback,
293
314
  args=(self,),
294
- kwargs=self.__callback_kwargs or {},
315
+ kwargs=self._callback_kwargs or {},
295
316
  )
296
317
  else:
297
- self.__thread = Thread(target=self._wait, args=(self.__wait,))
318
+ self.__thread = Thread(target=self._wait, args=(self._wait_timeout,))
298
319
  self.__thread.start()
299
320
 
300
321
  def _finish_process(self) -> None:
301
- self.returncode = self.__returncode
322
+ self.returncode = self._returncode
302
323
 
303
324
  self._finalize_streams()
304
325
 
@@ -334,16 +355,20 @@ class AsyncFakePopen(FakePopen):
334
355
 
335
356
  # feed eof one more time as streams were opened
336
357
  self._finalize_streams()
337
-
338
- self._finalize_thread(timeout)
339
-
358
+ await self._finalize(timeout)
340
359
  return (
341
360
  await self.stdout.read() if self.stdout else None,
342
361
  await self.stderr.read() if self.stderr else None,
343
362
  )
344
363
 
345
364
  async def wait(self, timeout: Optional[float] = None) -> int: # type: ignore
346
- return super().wait(timeout)
365
+ if timeout and self._wait_timeout and timeout < self._wait_timeout:
366
+ self._wait_timeout -= timeout
367
+ raise subprocess.TimeoutExpired(self.args, timeout)
368
+ await self._finalize(timeout)
369
+ if self.returncode is None:
370
+ raise exceptions.PluginInternalError
371
+ return self.returncode
347
372
 
348
373
  def _get_empty_buffer(self, _: bool) -> asyncio.StreamReader:
349
374
  return asyncio.StreamReader()
@@ -361,3 +386,34 @@ class AsyncFakePopen(FakePopen):
361
386
  fresh_stream.feed_data(data)
362
387
  return fresh_stream
363
388
  return None
389
+
390
+ def run_thread(self) -> None:
391
+ """Async impl should not contain any thread based implementation"""
392
+
393
+ def evaluate(self) -> None:
394
+ """Check if process needs to be finished."""
395
+ if self._wait_timeout is None and self._callback is None:
396
+ self._finish_process()
397
+
398
+ async def _run_callback_in_executor(self) -> None:
399
+ """Run in executor the user-defined callback or wait."""
400
+ loop = asyncio.get_running_loop()
401
+ with concurrent.futures.ThreadPoolExecutor() as pool:
402
+ if self._callback:
403
+ kwargs = self._callback_kwargs or {}
404
+ cbk = partial(self._callback, **kwargs)
405
+ await loop.run_in_executor(pool, cbk, self)
406
+ elif self._wait_timeout is not None:
407
+ await loop.run_in_executor(pool, self._wait, self._wait_timeout)
408
+
409
+ async def _finalize(self, timeout: Optional[float] = None) -> None:
410
+ """Run the user-defined callback or wait. Finish process"""
411
+ if self.returncode is not None:
412
+ return
413
+ if timeout is not None:
414
+ await asyncio.wait_for(self._run_callback_in_executor(), timeout=timeout)
415
+ else:
416
+ await self._run_callback_in_executor()
417
+ if self.returncode is None:
418
+ self.returncode = self._returncode
419
+ self._finalize_streams()
@@ -1,4 +1,5 @@
1
1
  """FakeProcess class declaration"""
2
+
2
3
  from collections import defaultdict
3
4
  from collections import deque
4
5
  from typing import Any as AnyType
@@ -1,4 +1,5 @@
1
1
  """ProcessDispatcher class declaration"""
2
+
2
3
  import asyncio
3
4
  import subprocess
4
5
  import sys
@@ -7,10 +8,12 @@ from collections import deque
7
8
  from copy import deepcopy
8
9
  from functools import partial
9
10
  from typing import Any as AnyType
11
+ from typing import AnyStr
10
12
  from typing import Awaitable
11
13
  from typing import Callable
12
14
  from typing import Deque
13
15
  from typing import Dict
16
+ from typing import Generic
14
17
  from typing import List
15
18
  from typing import Optional
16
19
  from typing import Tuple
@@ -28,6 +31,9 @@ if typing.TYPE_CHECKING:
28
31
  from .fake_process import FakeProcess
29
32
 
30
33
 
34
+ __all__ = ["ProcessDispatcher"]
35
+
36
+
31
37
  class ProcessDispatcher:
32
38
  """Main class for handling processes."""
33
39
 
@@ -43,7 +49,7 @@ class ProcessDispatcher:
43
49
  def register(cls, process: "FakeProcess") -> None:
44
50
  if not cls.process_list:
45
51
  cls.built_in_popen = subprocess.Popen
46
- subprocess.Popen = cls.dispatch # type: ignore
52
+ subprocess.Popen = FakePopenWrapper # type: ignore
47
53
 
48
54
  cls.built_in_async_subprocess = asyncio.subprocess
49
55
  asyncio.create_subprocess_shell = cls.async_shell # type: ignore
@@ -161,7 +167,7 @@ class ProcessDispatcher:
161
167
  result = cls._prepare_instance(AsyncFakePopen, command, kwargs, process)
162
168
  if not isinstance(result, AsyncFakePopen):
163
169
  raise exceptions.PluginInternalError
164
- result.run_thread()
170
+ result.evaluate()
165
171
  return result
166
172
 
167
173
  @classmethod
@@ -237,3 +243,10 @@ class ProcessDispatcher:
237
243
  if processes and isinstance(processes, deque):
238
244
  return command_instance, processes, process_instance
239
245
  return None, None, None
246
+
247
+
248
+ class FakePopenWrapper(Generic[AnyStr]):
249
+ def __new__( # type: ignore
250
+ cls, command: COMMAND, **kwargs: Optional[Dict]
251
+ ) -> FakePopen:
252
+ return ProcessDispatcher.dispatch(command, **kwargs) # type: ignore
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pytest-subprocess
3
- Version: 1.5.2
3
+ Version: 1.5.3
4
4
  Summary: A plugin to fake subprocess for pytest
5
5
  Author: Andrzej Klajnert
6
6
  Author-email: python@aklajnert.pl
@@ -22,6 +22,8 @@ Classifier: Programming Language :: Python :: 3.8
22
22
  Classifier: Programming Language :: Python :: 3.9
23
23
  Classifier: Programming Language :: Python :: 3.10
24
24
  Classifier: Programming Language :: Python :: 3.11
25
+ Classifier: Programming Language :: Python :: 3.12
26
+ Classifier: Programming Language :: Python :: 3.13
25
27
  Classifier: Programming Language :: Python :: Implementation :: CPython
26
28
  Classifier: Programming Language :: Python :: Implementation :: PyPy
27
29
  Classifier: Operating System :: OS Independent
@@ -31,7 +33,6 @@ License-File: LICENSE
31
33
  Requires-Dist: pytest>=4.0.0
32
34
  Provides-Extra: test
33
35
  Requires-Dist: pytest>=4.0; extra == "test"
34
- Requires-Dist: coverage; extra == "test"
35
36
  Requires-Dist: docutils>=0.12; extra == "test"
36
37
  Requires-Dist: Pygments>=2.0; extra == "test"
37
38
  Requires-Dist: pytest-rerunfailures; extra == "test"
@@ -60,9 +61,6 @@ pytest-subprocess
60
61
  :target: https://pypi.org/project/pytest-subprocess
61
62
  :alt: Python versions
62
63
 
63
- .. image:: https://codecov.io/gh/aklajnert/pytest-subprocess/branch/master/graph/badge.svg?token=JAU1cGoYL8
64
- :target: https://codecov.io/gh/aklajnert/pytest-subprocess
65
-
66
64
  .. image:: https://readthedocs.org/projects/pytest-subprocess/badge/?version=latest
67
65
  :target: https://pytest-subprocess.readthedocs.io/en/latest/?badge=latest
68
66
  :alt: Documentation Status
@@ -402,6 +400,40 @@ how many a command has been called. The latter supports ``fp.any()``.
402
400
  assert fp.call_count(["cp", fp.any()]) == 3
403
401
 
404
402
 
403
+ Check Popen arguments
404
+ ---------------------
405
+
406
+ You can use the recorded calls functionality to introspect the keyword
407
+ arguments that were passed to `Popen`.
408
+
409
+ .. code-block:: python
410
+
411
+ def test_process_recorder_kwargs(fp):
412
+ fp.keep_last_process(True)
413
+ recorder = fp.register(["test_script", fp.any()])
414
+
415
+ subprocess.run(
416
+ ("test_script", "arg1"), env={"foo": "bar"}, cwd="/home/user"
417
+ )
418
+ subprocess.Popen(
419
+ ["test_script", "arg2"],
420
+ env={"foo": "bar1"},
421
+ executable="test_script",
422
+ shell=True,
423
+ )
424
+
425
+ assert recorder.calls[0].args == ("test_script", "arg1")
426
+ assert recorder.calls[0].kwargs == {
427
+ "cwd": "/home/user",
428
+ "env": {"foo": "bar"},
429
+ }
430
+ assert recorder.calls[1].args == ["test_script", "arg2"]
431
+ assert recorder.calls[1].kwargs == {
432
+ "env": {"foo": "bar1"},
433
+ "executable": "test_script",
434
+ "shell": True,
435
+ }
436
+
405
437
  Handling signals
406
438
  ----------------
407
439
 
@@ -507,6 +539,19 @@ This `pytest`_ plugin was generated with `Cookiecutter`_ along with `@hackebrot`
507
539
  History
508
540
  =======
509
541
 
542
+ 1.5.3 (2025-01-04)
543
+ ------------------
544
+
545
+ Features
546
+ ~~~~~~~~
547
+ * `#171 <https://github.com/aklajnert/pytest-subprocess/pull/171>`_, `#178 <https://github.com/aklajnert/pytest-subprocess/pull/178>`_: Allow to access keyword arguments passed to Popen.
548
+
549
+ Bug fixes
550
+ ~~~~~~~~~
551
+ * `#180 <https://github.com/aklajnert/pytest-subprocess/pull/180>`_: Fixed an incorrect wait timeout calculation.
552
+ * `#170 <https://github.com/aklajnert/pytest-subprocess/pull/170>`_: Wrapped ProcessDispatcher.dispatch into FakePopenWrapper as it was causing TypeError when Popen is used as a type.
553
+ * `#169 <https://github.com/aklajnert/pytest-subprocess/pull/169>`_: Get rid of using thread in AsyncFakePopen as it causes thread.join() to hang indefinitely.
554
+
510
555
  1.5.2 (2024-07-24)
511
556
  ------------------
512
557
 
@@ -13,7 +13,6 @@ changelogd
13
13
 
14
14
  [test]
15
15
  pytest>=4.0
16
- coverage
17
16
  docutils>=0.12
18
17
  Pygments>=2.0
19
18
  pytest-rerunfailures
@@ -16,7 +16,7 @@ requirements = ["pytest>=4.0.0"]
16
16
 
17
17
  setup(
18
18
  name="pytest-subprocess",
19
- version="1.5.2",
19
+ version="1.5.3",
20
20
  author="Andrzej Klajnert",
21
21
  author_email="python@aklajnert.pl",
22
22
  maintainer="Andrzej Klajnert",
@@ -34,7 +34,6 @@ setup(
34
34
  extras_require={
35
35
  "test": [
36
36
  "pytest>=4.0",
37
- "coverage",
38
37
  "docutils>=0.12",
39
38
  "Pygments>=2.0",
40
39
  "pytest-rerunfailures",
@@ -66,6 +65,8 @@ setup(
66
65
  "Programming Language :: Python :: 3.9",
67
66
  "Programming Language :: Python :: 3.10",
68
67
  "Programming Language :: Python :: 3.11",
68
+ "Programming Language :: Python :: 3.12",
69
+ "Programming Language :: Python :: 3.13",
69
70
  "Programming Language :: Python :: Implementation :: CPython",
70
71
  "Programming Language :: Python :: Implementation :: PyPy",
71
72
  "Operating System :: OS Independent",
@@ -1,4 +1,5 @@
1
1
  """An example script to test subprocess."""
2
+
2
3
  import sys
3
4
  import time
4
5
 
@@ -338,10 +338,7 @@ async def test_popen_recorder(fp):
338
338
  pytest.param(None, id="no-callback"),
339
339
  pytest.param(
340
340
  lambda process: process,
341
- id="noop-callback-causes-infinite-loop",
342
- marks=pytest.mark.xfail(
343
- strict=True, raises=asyncio.TimeoutError, reason="Github #120"
344
- ),
341
+ id="with-callback",
345
342
  ),
346
343
  ],
347
344
  )
@@ -353,15 +350,53 @@ async def test_asyncio_subprocess_using_callback(callback, fp):
353
350
  stderr=asyncio.subprocess.PIPE,
354
351
  )
355
352
  await process.wait()
356
-
357
- # This reads forever when passing a callback to fp.register
358
- # Add a timeout to abort test when condition occurs.
359
- return await asyncio.wait_for(process.stdout.read(), timeout=1)
353
+ return await process.stdout.read()
360
354
 
361
355
  fp.register(["test"], stdout=b"fizz", callback=callback)
362
356
  assert await my_async_func() == b"fizz"
363
357
 
364
358
 
359
+ @pytest.mark.asyncio
360
+ async def test_asyncio_subprocess_using_communicate_with_callback_kwargs(fp):
361
+ expected_some_value = 2
362
+
363
+ def cbk(fake_obj, some_value=None):
364
+ assert expected_some_value == some_value
365
+ return fake_obj
366
+
367
+ async def my_async_func():
368
+ process = await asyncio.create_subprocess_exec(
369
+ "test",
370
+ stdout=asyncio.subprocess.PIPE,
371
+ stderr=asyncio.subprocess.PIPE,
372
+ )
373
+ out, _ = await process.communicate()
374
+ return out
375
+
376
+ fp.register(
377
+ ["test"],
378
+ stdout=b"fizz",
379
+ callback=cbk,
380
+ callback_kwargs={"some_value": expected_some_value},
381
+ )
382
+ assert await my_async_func() == b"fizz"
383
+
384
+
385
+ @pytest.mark.asyncio
386
+ async def test_process_recorder_args(fp):
387
+ fp.keep_last_process(True)
388
+ recorder = fp.register(["test_script", fp.any()])
389
+ await asyncio.create_subprocess_exec(
390
+ "test_script",
391
+ "arg1",
392
+ env={"foo": "bar"},
393
+ )
394
+
395
+ assert recorder.call_count() == 1
396
+ assert recorder.calls[0].args == ["test_script", "arg1"]
397
+ assert recorder.calls[0].kwargs == {"env": {"foo": "bar"}}
398
+
399
+
365
400
  @pytest.fixture(autouse=True)
366
401
  def skip_on_pypy():
367
402
  """Async test for some reason crash on pypy 3.6 on Windows"""
@@ -482,15 +482,15 @@ def test_ambiguous_input(fp, fake):
482
482
  @pytest.mark.parametrize("fake", [False, True])
483
483
  def test_multiple_wait(fp, fake):
484
484
  """
485
- Wait multiple times for 0.2 seconds with process lasting for 0.5.
486
- Third wait shall is a bit longer and will not raise an exception,
485
+ Wait multiple times for 0.2 seconds with process lasting for 1s.
486
+ Third wait shall be a bit longer and will not raise an exception,
487
487
  due to exceeding the subprocess runtime.
488
488
  """
489
489
  fp.allow_unregistered(not fake)
490
490
  if fake:
491
491
  fp.register(
492
492
  [PYTHON, "example_script.py", "wait"],
493
- wait=0.5,
493
+ wait=1,
494
494
  )
495
495
 
496
496
  process = subprocess.Popen(
@@ -500,9 +500,9 @@ def test_multiple_wait(fp, fake):
500
500
  process.wait(timeout=0.2)
501
501
 
502
502
  with pytest.raises(subprocess.TimeoutExpired):
503
- process.wait(timeout=0.1)
503
+ process.wait(timeout=0.2)
504
504
 
505
- process.wait(0.4)
505
+ process.wait(0.9)
506
506
 
507
507
  assert process.returncode == 0
508
508
 
@@ -1232,3 +1232,51 @@ def test_process_recorder(fp):
1232
1232
  recorder.clear()
1233
1233
 
1234
1234
  assert not recorder.was_called()
1235
+
1236
+
1237
+ def test_process_recorder_args(fp):
1238
+ fp.keep_last_process(True)
1239
+ recorder = fp.register(["test_script", fp.any()])
1240
+
1241
+ subprocess.call(("test_script", "arg1"))
1242
+ subprocess.run(("test_script", "arg2"), env={"foo": "bar"}, cwd="/home/user")
1243
+ subprocess.Popen(
1244
+ ["test_script", "arg3"],
1245
+ env={"foo": "bar1"},
1246
+ executable="test_script",
1247
+ shell=True,
1248
+ )
1249
+
1250
+ assert recorder.call_count() == 3
1251
+ assert recorder.calls[0].args == ("test_script", "arg1")
1252
+ assert recorder.calls[0].kwargs == {}
1253
+ assert recorder.calls[1].args == ("test_script", "arg2")
1254
+ assert recorder.calls[1].kwargs == {"cwd": "/home/user", "env": {"foo": "bar"}}
1255
+ assert recorder.calls[2].args == ["test_script", "arg3"]
1256
+ assert recorder.calls[2].kwargs == {
1257
+ "env": {"foo": "bar1"},
1258
+ "executable": "test_script",
1259
+ "shell": True,
1260
+ }
1261
+
1262
+
1263
+ def test_fake_popen_is_typed(fp):
1264
+ fp.allow_unregistered(True)
1265
+ fp.register(
1266
+ [PYTHON, "example_script.py"],
1267
+ stdout=b"Stdout line 1\r\nStdout line 2\r\n",
1268
+ )
1269
+
1270
+ def spawn_process() -> subprocess.Popen[str]:
1271
+ import subprocess
1272
+
1273
+ return subprocess.Popen(
1274
+ (PYTHON, "example_script.py"),
1275
+ universal_newlines=True,
1276
+ stdout=subprocess.PIPE,
1277
+ )
1278
+
1279
+ proc = spawn_process()
1280
+ proc.wait()
1281
+
1282
+ assert proc.stdout.read() == "Stdout line 1\nStdout line 2\n"
@@ -1,4 +1,5 @@
1
1
  """Additional target for mypy type checking"""
2
+
2
3
  from pytest_subprocess import FakeProcess
3
4
 
4
5