pytest-subprocess 1.5.4__tar.gz → 1.6.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 (40) hide show
  1. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/HISTORY.rst +12 -0
  2. {pytest_subprocess-1.5.4/pytest_subprocess.egg-info → pytest_subprocess-1.6.0}/PKG-INFO +57 -2
  3. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/README.rst +44 -1
  4. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/pytest_subprocess/fake_popen.py +14 -4
  5. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/pytest_subprocess/fake_process.py +2 -0
  6. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/pytest_subprocess/utils.py +53 -1
  7. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0/pytest_subprocess.egg-info}/PKG-INFO +57 -2
  8. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/setup.py +1 -1
  9. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/tests/test_subprocess.py +188 -0
  10. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/LICENSE +0 -0
  11. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/MANIFEST.in +0 -0
  12. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/docs/Makefile +0 -0
  13. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/docs/api.rst +0 -0
  14. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/docs/conf.py +0 -0
  15. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/docs/index.rst +0 -0
  16. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/docs/make.bat +0 -0
  17. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/docs/usage.rst +0 -0
  18. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/pytest.ini +0 -0
  19. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/pytest_subprocess/__init__.py +0 -0
  20. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/pytest_subprocess/asyncio_subprocess.py +0 -0
  21. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/pytest_subprocess/exceptions.py +0 -0
  22. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/pytest_subprocess/fixtures.py +0 -0
  23. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/pytest_subprocess/process_dispatcher.py +0 -0
  24. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/pytest_subprocess/process_recorder.py +0 -0
  25. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/pytest_subprocess/py.typed +0 -0
  26. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/pytest_subprocess/types.py +0 -0
  27. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/pytest_subprocess.egg-info/SOURCES.txt +0 -0
  28. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/pytest_subprocess.egg-info/dependency_links.txt +0 -0
  29. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/pytest_subprocess.egg-info/entry_points.txt +0 -0
  30. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/pytest_subprocess.egg-info/requires.txt +0 -0
  31. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/pytest_subprocess.egg-info/top_level.txt +0 -0
  32. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/setup.cfg +0 -0
  33. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/tests/__init__.py +0 -0
  34. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/tests/conftest.py +0 -0
  35. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/tests/example_script.py +0 -0
  36. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/tests/imported_popen_fixture.py +0 -0
  37. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/tests/test_asyncio.py +0 -0
  38. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/tests/test_examples.py +0 -0
  39. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/tests/test_typing.py +0 -0
  40. {pytest_subprocess-1.5.4 → pytest_subprocess-1.6.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,18 @@
1
1
  History
2
2
  =======
3
3
 
4
+ 1.6.0 (2026-05-10)
5
+ ------------------
6
+
7
+ Features
8
+ ~~~~~~~~
9
+ * `#206 <https://github.com/aklajnert/pytest-subprocess/pull/206>`_: Add fp.regex() for regex-based command argument matching.
10
+
11
+ Bug fixes
12
+ ~~~~~~~~~
13
+ * `#207 <https://github.com/aklajnert/pytest-subprocess/pull/207>`_: Fix poll() not reflecting returncode when a callback is registered.
14
+ * `#205 <https://github.com/aklajnert/pytest-subprocess/pull/205>`_: Fix stdin not respecting text mode when text=True or encoding is set.
15
+
4
16
  1.5.4 (2026-03-21)
5
17
  ------------------
6
18
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-subprocess
3
- Version: 1.5.4
3
+ Version: 1.6.0
4
4
  Summary: A plugin to fake subprocess for pytest
5
5
  Author: Andrzej Klajnert
6
6
  Author-email: python@aklajnert.pl
@@ -377,10 +377,53 @@ the same name, regardless of the location. This is accomplished with
377
377
 
378
378
  def test_any_matching_program(fp):
379
379
  # define a command that can come from anywhere
380
- fp.register([fp.program("python")])
380
+ executable_name = os.path.basename(sys.executable)
381
+ fp.register([fp.program(executable_name)])
381
382
  assert subprocess.check_call(sys.executable) == 0
382
383
 
383
384
 
385
+ Regex argument matching
386
+ -----------------------
387
+
388
+ When a command argument has a predictable *structure* but an unpredictable
389
+ *value* (e.g. a path determined at runtime), you can match it with a regular
390
+ expression using ``fp.regex()``:
391
+
392
+ .. code-block:: python
393
+
394
+ def test_cmake_varying_paths(fp):
395
+ # Register once; the pattern matches any -S<path> and -B<path> argument.
396
+ fp.register(
397
+ ["cmake", fp.regex(r"-S.+"), fp.regex(r"-B.+")],
398
+ occurrences=2,
399
+ )
400
+
401
+ # Both calls are matched, even though the paths differ.
402
+ subprocess.run(
403
+ ["cmake", "-S/tmp/generated_data", "-B/tmp/build_1234"]
404
+ )
405
+ subprocess.run(["cmake", "-S/other/source", "-B/other/build_5678"])
406
+
407
+ # This would raise ProcessNotRegisteredError — different subcommand:
408
+ # subprocess.run(["cmake", "--build", "/tmp/build_1234"])
409
+
410
+ ``fp.regex()`` accepts an optional second argument for ``re`` module flags:
411
+
412
+ .. code-block:: python
413
+
414
+ import re
415
+
416
+
417
+ def test_case_insensitive(fp):
418
+ fp.register(["git", fp.regex(r"commit", re.IGNORECASE)])
419
+ subprocess.run(["git", "COMMIT"]) # matches
420
+
421
+ .. note::
422
+ ``fp.regex()`` uses ``re.fullmatch()``, so the pattern must cover the
423
+ **entire** argument string. Use ``.*`` at the start or end if you need
424
+ partial matching.
425
+
426
+
384
427
  Check if process was called
385
428
  ---------------------------
386
429
 
@@ -555,6 +598,18 @@ This `pytest`_ plugin was generated with `Cookiecutter`_ along with `@hackebrot`
555
598
  History
556
599
  =======
557
600
 
601
+ 1.6.0 (2026-05-10)
602
+ ------------------
603
+
604
+ Features
605
+ ~~~~~~~~
606
+ * `#206 <https://github.com/aklajnert/pytest-subprocess/pull/206>`_: Add fp.regex() for regex-based command argument matching.
607
+
608
+ Bug fixes
609
+ ~~~~~~~~~
610
+ * `#207 <https://github.com/aklajnert/pytest-subprocess/pull/207>`_: Fix poll() not reflecting returncode when a callback is registered.
611
+ * `#205 <https://github.com/aklajnert/pytest-subprocess/pull/205>`_: Fix stdin not respecting text mode when text=True or encoding is set.
612
+
558
613
  1.5.4 (2026-03-21)
559
614
  ------------------
560
615
 
@@ -311,10 +311,53 @@ the same name, regardless of the location. This is accomplished with
311
311
 
312
312
  def test_any_matching_program(fp):
313
313
  # define a command that can come from anywhere
314
- fp.register([fp.program("python")])
314
+ executable_name = os.path.basename(sys.executable)
315
+ fp.register([fp.program(executable_name)])
315
316
  assert subprocess.check_call(sys.executable) == 0
316
317
 
317
318
 
319
+ Regex argument matching
320
+ -----------------------
321
+
322
+ When a command argument has a predictable *structure* but an unpredictable
323
+ *value* (e.g. a path determined at runtime), you can match it with a regular
324
+ expression using ``fp.regex()``:
325
+
326
+ .. code-block:: python
327
+
328
+ def test_cmake_varying_paths(fp):
329
+ # Register once; the pattern matches any -S<path> and -B<path> argument.
330
+ fp.register(
331
+ ["cmake", fp.regex(r"-S.+"), fp.regex(r"-B.+")],
332
+ occurrences=2,
333
+ )
334
+
335
+ # Both calls are matched, even though the paths differ.
336
+ subprocess.run(
337
+ ["cmake", "-S/tmp/generated_data", "-B/tmp/build_1234"]
338
+ )
339
+ subprocess.run(["cmake", "-S/other/source", "-B/other/build_5678"])
340
+
341
+ # This would raise ProcessNotRegisteredError — different subcommand:
342
+ # subprocess.run(["cmake", "--build", "/tmp/build_1234"])
343
+
344
+ ``fp.regex()`` accepts an optional second argument for ``re`` module flags:
345
+
346
+ .. code-block:: python
347
+
348
+ import re
349
+
350
+
351
+ def test_case_insensitive(fp):
352
+ fp.register(["git", fp.regex(r"commit", re.IGNORECASE)])
353
+ subprocess.run(["git", "COMMIT"]) # matches
354
+
355
+ .. note::
356
+ ``fp.regex()`` uses ``re.fullmatch()``, so the pattern must cover the
357
+ **entire** argument string. Use ``.*`` at the start or end if you need
358
+ partial matching.
359
+
360
+
318
361
  Check if process was called
319
362
  ---------------------------
320
363
 
@@ -139,6 +139,16 @@ class FakePopen:
139
139
  return None
140
140
 
141
141
  def poll(self) -> Optional[int]:
142
+ if (
143
+ self.returncode is None
144
+ and self.__thread is not None
145
+ and not self.__thread.is_alive()
146
+ ):
147
+ # The callback/wait thread has finished but _finalize_thread() has
148
+ # not been called yet (e.g. the caller uses poll() instead of
149
+ # wait()/communicate()). Finalise now so returncode is updated,
150
+ # mirroring real subprocess.Popen.poll() behaviour.
151
+ self._finalize_thread(None)
142
152
  return self.returncode
143
153
 
144
154
  def wait(self, timeout: Optional[float] = None) -> int:
@@ -170,10 +180,6 @@ class FakePopen:
170
180
  self.__kwargs = self.safe_copy(kwargs)
171
181
  self.__universal_newlines = kwargs.get("universal_newlines", None)
172
182
 
173
- stdin = kwargs.get("stdin")
174
- if stdin == subprocess.PIPE:
175
- self.stdin = self._get_empty_buffer(False)
176
-
177
183
  text = kwargs.get("text", None)
178
184
  encoding = kwargs.get("encoding", None)
179
185
  errors = kwargs.get("errors", None)
@@ -183,6 +189,10 @@ class FakePopen:
183
189
 
184
190
  self.text_mode = bool(text or self.__universal_newlines or encoding or errors)
185
191
 
192
+ stdin = kwargs.get("stdin")
193
+ if stdin == subprocess.PIPE:
194
+ self.stdin = self._get_empty_buffer(self.text_mode)
195
+
186
196
  # validation taken from the real subprocess
187
197
  if (
188
198
  text is not None
@@ -21,6 +21,7 @@ from .types import OPTIONAL_TEXT_OR_ITERABLE
21
21
  from .utils import Any
22
22
  from .utils import Command
23
23
  from .utils import Program
24
+ from .utils import Regex
24
25
 
25
26
 
26
27
  class FakeProcess:
@@ -28,6 +29,7 @@ class FakeProcess:
28
29
 
29
30
  any: ClassVar[Type[Any]] = Any
30
31
  program: ClassVar[Type[Program]] = Program
32
+ regex: ClassVar[Type[Regex]] = Regex
31
33
 
32
34
  def __init__(self) -> None:
33
35
  self.definitions: DefaultDict[Command, Deque[Union[Dict, bool]]] = defaultdict(
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import re
2
3
  import shlex
3
4
  import sys
4
5
  import threading
@@ -15,7 +16,7 @@ from typing import Union
15
16
  if TYPE_CHECKING:
16
17
  from .types import COMMAND
17
18
 
18
- ARGUMENT = Union[str, "Any", os.PathLike, "Program"]
19
+ ARGUMENT = Union[str, "Any", "Regex", os.PathLike, "Program"]
19
20
 
20
21
 
21
22
  class Thread(threading.Thread):
@@ -189,3 +190,54 @@ class Program:
189
190
 
190
191
  def __hash__(self) -> int:
191
192
  return hash(self.program)
193
+
194
+
195
+ class Regex:
196
+ """Match a single command argument against a regular expression.
197
+
198
+ Uses :func:`re.fullmatch`, so the pattern must cover the *entire* argument
199
+ string. Use ``.*`` at the start or end if partial matching is desired.
200
+
201
+ Example::
202
+
203
+ def test_cmake_varying_paths(fp):
204
+ fp.register(["cmake", fp.regex(r"-S.+"), fp.regex(r"-B.+")])
205
+
206
+ # All of these would match:
207
+ subprocess.run(["cmake", "-S/tmp/src", "-B/tmp/build"])
208
+ subprocess.run(["cmake", "-S/other/src", "-B/other/build"])
209
+
210
+ # This would NOT match (wrong argument order or missing args):
211
+ # subprocess.run(["cmake", "-B/tmp/build", "-S/tmp/src"])
212
+
213
+ Args:
214
+ pattern: Regular expression pattern to match against.
215
+ flags: Optional :mod:`re` flags (e.g. ``re.IGNORECASE``).
216
+ """
217
+
218
+ def __init__(self, pattern: str, flags: int = 0) -> None:
219
+ self._compiled = re.compile(pattern, flags)
220
+
221
+ @property
222
+ def pattern(self) -> str:
223
+ """The original pattern string."""
224
+ return self._compiled.pattern
225
+
226
+ @property
227
+ def flags(self) -> int:
228
+ """The compiled regex flags."""
229
+ return self._compiled.flags
230
+
231
+ def __eq__(self, other: AnyType) -> bool:
232
+ if isinstance(other, str):
233
+ return bool(self._compiled.fullmatch(other))
234
+ return NotImplemented
235
+
236
+ def __hash__(self) -> int:
237
+ return hash((self._compiled.pattern, self._compiled.flags))
238
+
239
+ def __repr__(self) -> str:
240
+ return f"Regex({self._compiled.pattern!r})"
241
+
242
+ def __str__(self) -> str:
243
+ return repr(self)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-subprocess
3
- Version: 1.5.4
3
+ Version: 1.6.0
4
4
  Summary: A plugin to fake subprocess for pytest
5
5
  Author: Andrzej Klajnert
6
6
  Author-email: python@aklajnert.pl
@@ -377,10 +377,53 @@ the same name, regardless of the location. This is accomplished with
377
377
 
378
378
  def test_any_matching_program(fp):
379
379
  # define a command that can come from anywhere
380
- fp.register([fp.program("python")])
380
+ executable_name = os.path.basename(sys.executable)
381
+ fp.register([fp.program(executable_name)])
381
382
  assert subprocess.check_call(sys.executable) == 0
382
383
 
383
384
 
385
+ Regex argument matching
386
+ -----------------------
387
+
388
+ When a command argument has a predictable *structure* but an unpredictable
389
+ *value* (e.g. a path determined at runtime), you can match it with a regular
390
+ expression using ``fp.regex()``:
391
+
392
+ .. code-block:: python
393
+
394
+ def test_cmake_varying_paths(fp):
395
+ # Register once; the pattern matches any -S<path> and -B<path> argument.
396
+ fp.register(
397
+ ["cmake", fp.regex(r"-S.+"), fp.regex(r"-B.+")],
398
+ occurrences=2,
399
+ )
400
+
401
+ # Both calls are matched, even though the paths differ.
402
+ subprocess.run(
403
+ ["cmake", "-S/tmp/generated_data", "-B/tmp/build_1234"]
404
+ )
405
+ subprocess.run(["cmake", "-S/other/source", "-B/other/build_5678"])
406
+
407
+ # This would raise ProcessNotRegisteredError — different subcommand:
408
+ # subprocess.run(["cmake", "--build", "/tmp/build_1234"])
409
+
410
+ ``fp.regex()`` accepts an optional second argument for ``re`` module flags:
411
+
412
+ .. code-block:: python
413
+
414
+ import re
415
+
416
+
417
+ def test_case_insensitive(fp):
418
+ fp.register(["git", fp.regex(r"commit", re.IGNORECASE)])
419
+ subprocess.run(["git", "COMMIT"]) # matches
420
+
421
+ .. note::
422
+ ``fp.regex()`` uses ``re.fullmatch()``, so the pattern must cover the
423
+ **entire** argument string. Use ``.*`` at the start or end if you need
424
+ partial matching.
425
+
426
+
384
427
  Check if process was called
385
428
  ---------------------------
386
429
 
@@ -555,6 +598,18 @@ This `pytest`_ plugin was generated with `Cookiecutter`_ along with `@hackebrot`
555
598
  History
556
599
  =======
557
600
 
601
+ 1.6.0 (2026-05-10)
602
+ ------------------
603
+
604
+ Features
605
+ ~~~~~~~~
606
+ * `#206 <https://github.com/aklajnert/pytest-subprocess/pull/206>`_: Add fp.regex() for regex-based command argument matching.
607
+
608
+ Bug fixes
609
+ ~~~~~~~~~
610
+ * `#207 <https://github.com/aklajnert/pytest-subprocess/pull/207>`_: Fix poll() not reflecting returncode when a callback is registered.
611
+ * `#205 <https://github.com/aklajnert/pytest-subprocess/pull/205>`_: Fix stdin not respecting text mode when text=True or encoding is set.
612
+
558
613
  1.5.4 (2026-03-21)
559
614
  ------------------
560
615
 
@@ -16,7 +16,7 @@ requirements = ["pytest>=4.0.0"]
16
16
 
17
17
  setup(
18
18
  name="pytest-subprocess",
19
- version="1.5.4",
19
+ version="1.6.0",
20
20
  author="Andrzej Klajnert",
21
21
  author_email="python@aklajnert.pl",
22
22
  maintainer="Andrzej Klajnert",
@@ -1,5 +1,6 @@
1
1
  import contextlib
2
2
  import getpass
3
+ import io
3
4
  import os
4
5
  import platform
5
6
  import signal
@@ -913,6 +914,49 @@ def test_callback_and_return_code(fp):
913
914
  assert process.returncode == 5
914
915
 
915
916
 
917
+ def test_poll_reflects_returncode_after_callback(fp):
918
+ """
919
+ poll() must return the registered returncode once the callback thread has
920
+ finished, without requiring the caller to invoke wait() or communicate().
921
+
922
+ Before the fix, poll() always returned None when a callback was registered.
923
+ """
924
+
925
+ def slow_callback(process):
926
+ time.sleep(0.1)
927
+
928
+ fp.register(["my-tool"], returncode=1, callback=slow_callback)
929
+ proc = subprocess.Popen(["my-tool"])
930
+
931
+ # While the callback is still running, poll() must return None.
932
+ assert proc.poll() is None
933
+
934
+ # Wait for the callback thread to finish, then poll() must return the
935
+ # registered returncode.
936
+ proc.wait()
937
+ assert proc.poll() == 1
938
+
939
+
940
+ def test_poll_reflects_returncode_after_callback_polling_loop(fp):
941
+ """
942
+ Callers that spin on proc.poll() is None (a common real-subprocess idiom)
943
+ must eventually observe a non-None returncode once the callback finishes.
944
+ """
945
+
946
+ def callback_with_delay(process):
947
+ time.sleep(0.05)
948
+
949
+ fp.register(["my-tool"], returncode=42, callback=callback_with_delay)
950
+ proc = subprocess.Popen(["my-tool"])
951
+
952
+ deadline = time.monotonic() + 5.0 # generous upper bound
953
+ while proc.poll() is None:
954
+ assert time.monotonic() < deadline, "poll() never reflected returncode"
955
+ time.sleep(0.01)
956
+
957
+ assert proc.returncode == 42
958
+
959
+
916
960
  @pytest.mark.skipif(
917
961
  sys.version_info <= (3, 6),
918
962
  reason="encoding and errors has been introduced in 3.6",
@@ -1351,6 +1395,62 @@ def test_stdin_pipe(fp):
1351
1395
  process.stdin.write(b"more data")
1352
1396
 
1353
1397
 
1398
+ def test_stdin_pipe_text_mode(fp):
1399
+ """
1400
+ Test that stdin is a StringIO (text) buffer when using
1401
+ subprocess.PIPE with text=True.
1402
+
1403
+ From GitHub #204
1404
+ """
1405
+ fp.register(["my-command"], stdout="hello\n")
1406
+
1407
+ process = subprocess.Popen(
1408
+ ["my-command"],
1409
+ stdin=subprocess.PIPE,
1410
+ stdout=subprocess.PIPE,
1411
+ stderr=subprocess.PIPE,
1412
+ text=True,
1413
+ )
1414
+
1415
+ assert process.stdin is not None
1416
+ assert isinstance(process.stdin, io.StringIO)
1417
+ assert process.stdin.writable()
1418
+
1419
+ # Should accept str, not bytes, in text mode.
1420
+ process.stdin.write("some input")
1421
+ process.stdin.flush()
1422
+
1423
+ process.stdin.seek(0)
1424
+ assert process.stdin.read() == "some input"
1425
+
1426
+ process.stdin.close()
1427
+ with pytest.raises(ValueError):
1428
+ process.stdin.write("more data")
1429
+
1430
+
1431
+ def test_stdin_pipe_encoding_mode(fp):
1432
+ """
1433
+ Test that stdin is a StringIO (text) buffer when
1434
+ using subprocess.PIPE with encoding set.
1435
+
1436
+ From GitHub #204
1437
+ """
1438
+ fp.register(["my-command"])
1439
+
1440
+ process = subprocess.Popen(
1441
+ ["my-command"],
1442
+ stdin=subprocess.PIPE,
1443
+ encoding="utf-8",
1444
+ )
1445
+
1446
+ assert process.stdin is not None
1447
+ assert isinstance(process.stdin, io.StringIO)
1448
+ assert process.stdin.writable()
1449
+
1450
+ process.stdin.write("some input")
1451
+ process.stdin.close()
1452
+
1453
+
1354
1454
  def test_stdout_stderr_as_file_bug(fp):
1355
1455
  """
1356
1456
  Test that no TypeError is raised when stdout/stderr is a file
@@ -1401,3 +1501,91 @@ def test_imported_popen_is_patched(fp):
1401
1501
 
1402
1502
  assert process.returncode == 0
1403
1503
  assert out == b"\x00"
1504
+
1505
+
1506
+ # ---------------------------------------------------------------------------
1507
+ # Regex command matching – issue #154
1508
+ # ---------------------------------------------------------------------------
1509
+
1510
+
1511
+ def test_regex_matches_varying_argument(fp):
1512
+ """
1513
+ fp.regex() must match a single command argument against a regex pattern,
1514
+ allowing commands with variable argument values to be registered once.
1515
+ """
1516
+ fp.register(["cmake", fp.regex(r"-S.+"), fp.regex(r"-B.+")], occurrences=2)
1517
+
1518
+ # Both calls use the same registration; argument values differ.
1519
+ proc1 = subprocess.run(["cmake", "-S/tmp/source1", "-B/tmp/build1"])
1520
+ proc2 = subprocess.run(["cmake", "-S/other/source", "-B/other/build"])
1521
+
1522
+ assert proc1.returncode == 0
1523
+ assert proc2.returncode == 0
1524
+
1525
+
1526
+ def test_regex_does_not_match_wrong_argument(fp):
1527
+ """
1528
+ Commands whose arguments do NOT match the registered regex pattern must
1529
+ raise ProcessNotRegisteredError, not silently succeed.
1530
+ """
1531
+ fp.register(["cmake", fp.regex(r"-S.+"), fp.regex(r"-B.+")])
1532
+
1533
+ # Wrong subcommand — does not match the -S/-B pattern.
1534
+ with pytest.raises(fp.exceptions.ProcessNotRegisteredError):
1535
+ subprocess.run(["cmake", "--build", "/tmp/build"])
1536
+
1537
+
1538
+ def test_regex_case_insensitive_flag(fp):
1539
+ """
1540
+ fp.regex() must accept re module flags such as re.IGNORECASE.
1541
+ """
1542
+ import re
1543
+
1544
+ fp.register(["git", fp.regex(r"commit", re.IGNORECASE)], stdout="sha: abc123\n")
1545
+
1546
+ # Exact case
1547
+ proc1 = subprocess.run(["git", "commit"], capture_output=True)
1548
+ assert proc1.stdout == b"sha: abc123\n"
1549
+
1550
+ # Different case
1551
+ fp.register(["git", fp.regex(r"commit", re.IGNORECASE)], stdout="sha: abc123\n")
1552
+ proc2 = subprocess.run(["git", "COMMIT"], capture_output=True)
1553
+ assert proc2.stdout == b"sha: abc123\n"
1554
+
1555
+
1556
+ def test_regex_fullmatch_semantics(fp):
1557
+ """
1558
+ fp.regex() uses fullmatch: the pattern must cover the *entire* argument
1559
+ string. A pattern that only matches a substring must NOT match.
1560
+ """
1561
+ # "-S" alone does not fullmatch "-S/some/path" (pattern too short)
1562
+ fp.register(["cmake", fp.regex(r"-S"), fp.regex(r"-B")])
1563
+ with pytest.raises(fp.exceptions.ProcessNotRegisteredError):
1564
+ # "-S/tmp/source" does not fullmatch the pattern "-S" (no extra chars)
1565
+ subprocess.run(["cmake", "-S/tmp/source", "-B/tmp/build"])
1566
+
1567
+
1568
+ def test_regex_repr(fp):
1569
+ """
1570
+ fp.regex() instances have a helpful repr for debugging.
1571
+ """
1572
+ r = fp.regex(r"-S.+")
1573
+ assert "-S.+" in repr(r)
1574
+
1575
+
1576
+ def test_regex_combined_with_any(fp):
1577
+ """
1578
+ fp.regex() can be combined freely with fp.any() and exact strings in
1579
+ the same command registration.
1580
+ """
1581
+ # Exact "cmake", then any number of arguments matching the -D prefix
1582
+ fp.register(
1583
+ ["cmake", fp.regex(r"-D.+=.+"), fp.any()],
1584
+ stdout="configured\n",
1585
+ )
1586
+
1587
+ proc = subprocess.run(
1588
+ ["cmake", "-DCMAKE_BUILD_TYPE=Release", "-S/tmp/src", "-B/tmp/build"],
1589
+ capture_output=True,
1590
+ )
1591
+ assert proc.stdout == b"configured\n"