provide-testkit 0.5.0__tar.gz → 0.5.2__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 (121) hide show
  1. {provide_testkit-0.5.0/src/provide_testkit.egg-info → provide_testkit-0.5.2}/PKG-INFO +1 -1
  2. provide_testkit-0.5.2/VERSION +1 -0
  3. provide_testkit-0.5.2/src/provide/testkit/_capture.py +81 -0
  4. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/_early_init.py +3 -1
  5. provide_testkit-0.5.2/src/provide/testkit/_streams.py +73 -0
  6. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/pytest_plugin.py +31 -11
  7. {provide_testkit-0.5.0 → provide_testkit-0.5.2/src/provide_testkit.egg-info}/PKG-INFO +1 -1
  8. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide_testkit.egg-info/SOURCES.txt +5 -1
  9. provide_testkit-0.5.2/tests/test_capture_stream_swap.py +142 -0
  10. provide_testkit-0.5.2/tests/test_stream_encoding.py +109 -0
  11. provide_testkit-0.5.0/VERSION +0 -1
  12. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/LICENSE +0 -0
  13. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/README.md +0 -0
  14. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/pyproject.toml +0 -0
  15. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/setup.cfg +0 -0
  16. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/__init__.py +0 -0
  17. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/__init__.py +0 -0
  18. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/__main__.py +0 -0
  19. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/_blocker.py +0 -0
  20. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/_install_blocker.py +0 -0
  21. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/_install_pth.py +0 -0
  22. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/base/__init__.py +0 -0
  23. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/base/foundation.py +0 -0
  24. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/base/harness.py +0 -0
  25. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/base/minimal.py +0 -0
  26. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/chaos/__init__.py +0 -0
  27. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/chaos/concurrency_strategies.py +0 -0
  28. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/chaos/fixtures.py +0 -0
  29. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/chaos/io_strategies.py +0 -0
  30. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/chaos/strategies.py +0 -0
  31. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/chaos/time_strategies.py +0 -0
  32. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/cli/__init__.py +0 -0
  33. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/cli/runner.py +0 -0
  34. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/cli/testing.py +0 -0
  35. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/common/__init__.py +0 -0
  36. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/common/fixtures.py +0 -0
  37. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/conftest.py +0 -0
  38. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/crypto/__init__.py +0 -0
  39. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/crypto/fixtures.py +0 -0
  40. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/file/__init__.py +0 -0
  41. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/file/content_fixtures.py +0 -0
  42. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/file/directory_fixtures.py +0 -0
  43. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/file/fixtures.py +0 -0
  44. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/file/special_fixtures.py +0 -0
  45. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/file/temp.py +0 -0
  46. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/hub/__init__.py +0 -0
  47. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/hub/fixtures.py +0 -0
  48. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/logger/__init__.py +0 -0
  49. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/logger/hooks.py +0 -0
  50. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/logger/mocks.py +0 -0
  51. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/logger/reset.py +0 -0
  52. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/main.py +0 -0
  53. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/mocking/__init__.py +0 -0
  54. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/mocking/fixtures.py +0 -0
  55. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/mocking/time.py +0 -0
  56. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/process/__init__.py +0 -0
  57. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/process/async_fixtures.py +0 -0
  58. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/process/fixtures.py +0 -0
  59. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/process/script_assertions.py +0 -0
  60. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/process/script_fixtures.py +0 -0
  61. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/process/subprocess_fixtures.py +0 -0
  62. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/process/system_fixtures.py +0 -0
  63. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/provide_testkit_init.pth +0 -0
  64. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/py.typed +0 -0
  65. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/__init__.py +0 -0
  66. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/artifacts.py +0 -0
  67. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/base.py +0 -0
  68. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/cli.py +0 -0
  69. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/complexity/__init__.py +0 -0
  70. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/complexity/analyzer.py +0 -0
  71. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/complexity/fixture.py +0 -0
  72. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/coverage/__init__.py +0 -0
  73. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/coverage/fixture.py +0 -0
  74. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/coverage/reporter.py +0 -0
  75. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/coverage/tracker.py +0 -0
  76. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/decorators.py +0 -0
  77. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/documentation/__init__.py +0 -0
  78. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/documentation/checker.py +0 -0
  79. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/documentation/fixture.py +0 -0
  80. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/profiling/__init__.py +0 -0
  81. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/profiling/fixture.py +0 -0
  82. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/profiling/profiler.py +0 -0
  83. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/report.py +0 -0
  84. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/runner.py +0 -0
  85. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/security/__init__.py +0 -0
  86. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/security/fixture.py +0 -0
  87. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/security/gitleaks_scanner.py +0 -0
  88. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/security/pip_audit_scanner.py +0 -0
  89. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/security/safety_scanner.py +0 -0
  90. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/security/scanner.py +0 -0
  91. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/security/semgrep_scanner.py +0 -0
  92. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/quality/security/trufflehog_scanner.py +0 -0
  93. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/streams/__init__.py +0 -0
  94. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/streams/testing.py +0 -0
  95. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/threading/__init__.py +0 -0
  96. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/threading/basic_fixtures.py +0 -0
  97. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/threading/data_fixtures.py +0 -0
  98. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/threading/execution_fixtures.py +0 -0
  99. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/threading/fixtures.py +0 -0
  100. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/threading/sync_fixtures.py +0 -0
  101. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/time/__init__.py +0 -0
  102. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/time/classes.py +0 -0
  103. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/time/controlled.py +0 -0
  104. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/time/fixtures.py +0 -0
  105. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/time/freezing.py +0 -0
  106. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/time/measurement.py +0 -0
  107. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/time/mocking.py +0 -0
  108. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/time/rate_limiting.py +0 -0
  109. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/transport/__init__.py +0 -0
  110. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/transport/fixtures.py +0 -0
  111. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/utils/__init__.py +0 -0
  112. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/utils/environment.py +0 -0
  113. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide/testkit/utils/temp_env.py +0 -0
  114. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide_testkit.egg-info/dependency_links.txt +0 -0
  115. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide_testkit.egg-info/entry_points.txt +0 -0
  116. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide_testkit.egg-info/requires.txt +0 -0
  117. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/src/provide_testkit.egg-info/top_level.txt +0 -0
  118. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/tests/test_async_fixtures.py +0 -0
  119. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/tests/test_early_init.py +0 -0
  120. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/tests/test_placeholder.py +0 -0
  121. {provide_testkit-0.5.0 → provide_testkit-0.5.2}/tests/test_pth_installation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: provide-testkit
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Testing utilities and fixtures for the provide ecosystem.
5
5
  Author-email: Tim Perkins <code@tim.life>
6
6
  Maintainer-email: "provide.io" <code@provide.io>
@@ -0,0 +1 @@
1
+ 0.5.2
@@ -0,0 +1,81 @@
1
+ #
2
+ # SPDX-FileCopyrightText: Copyright (c) provide.io llc. All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+
6
+ """Capture that gives back the stream it took, not the one it remembers.
7
+
8
+ ``SysCaptureBase.suspend`` hands ``sys.stdout`` back to the stream pytest saved
9
+ before capture started, and ``resume`` reinstates ``tmpfile`` -- the stream
10
+ capture installed at ``start``. Neither looks at what is actually there. A test
11
+ that swaps the stream after capture started owns something pytest has no record
12
+ of, so the swap is discarded on the first suspend and every write after it lands
13
+ in pytest's buffer instead of the caller's.
14
+
15
+ ``log_cli = true`` makes that ordinary: pytest's live-log handler sits on the
16
+ root logger and suspends global capture around each record. One log line emitted
17
+ from inside ``CliRunner.invoke`` -- from any library, at any depth -- leaves
18
+ ``result.output`` empty while the text itself is real and lands in pytest's own
19
+ capture. ``contextlib.redirect_stdout`` and hand-rolled stream fixtures lose
20
+ their swap the same way.
21
+
22
+ The pair below remembers the stream that was in place at suspend and returns it
23
+ at resume, which is what the two methods already claim to do. Teardown is
24
+ untouched: ``done`` still restores the stream capture replaced, so a swap left
25
+ behind by a test cannot outlive it.
26
+
27
+ This belongs in pytest, and the tests in ``tests/test_capture_stream_swap.py``
28
+ drive the real ``SysCapture``, so they fail loudly once pytest changes the
29
+ methods underneath -- whether it fixes this or reshapes the class. Drop this
30
+ module when the pytest floor carries the fix.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import sys
36
+ from typing import Any
37
+
38
+ from _pytest.capture import SysCaptureBase
39
+
40
+ __all__ = ["install_capture_swap_fix"]
41
+
42
+ _INSTALLED_MARKER = "_provide_testkit_swap_safe"
43
+ # The stream is parked on pytest's object, so the name says whose it is.
44
+ _SWAPPED_IN = "_provide_testkit_swapped_in"
45
+
46
+
47
+ def _suspend(self: SysCaptureBase[Any]) -> None:
48
+ self._assert_state("suspend", ("started", "suspended"))
49
+ # Suspending while suspended is legal, and the stream sitting there then is
50
+ # the one suspend itself installed -- not a swap worth remembering.
51
+ if self._state == "started":
52
+ setattr(self, _SWAPPED_IN, getattr(sys, self.name))
53
+ setattr(sys, self.name, self._old)
54
+ self._state = "suspended"
55
+
56
+
57
+ def _resume(self: SysCaptureBase[Any]) -> None:
58
+ self._assert_state("resume", ("started", "suspended"))
59
+ if self._state == "started":
60
+ return
61
+ # A capture resumed without ever suspending has nothing recorded, and the
62
+ # stream capture installed is the right answer for it.
63
+ setattr(sys, self.name, getattr(self, _SWAPPED_IN, self.tmpfile))
64
+ self._state = "started"
65
+
66
+
67
+ def install_capture_swap_fix() -> bool:
68
+ """Make pytest's stream capture preserve a swap made after it started.
69
+
70
+ Returns whether this call is the one that installed it. Declining leaves
71
+ pytest exactly as found: the fix is worth having, and silently layering it
72
+ over a patch someone else installed is not.
73
+ """
74
+ if getattr(SysCaptureBase, _INSTALLED_MARKER, False):
75
+ return False
76
+ if any(getattr(SysCaptureBase, name).__module__ != "_pytest.capture" for name in ("suspend", "resume")):
77
+ return False
78
+ SysCaptureBase.suspend = _suspend # type: ignore[method-assign]
79
+ SysCaptureBase.resume = _resume # type: ignore[method-assign]
80
+ setattr(SysCaptureBase, _INSTALLED_MARKER, True)
81
+ return True
@@ -124,6 +124,8 @@ def _configure_structlog_for_testing() -> None:
124
124
  event_dict.pop("_foundation_level_hint", None)
125
125
  return event_dict
126
126
 
127
+ from provide.testkit._streams import unicode_safe
128
+
127
129
  structlog.configure(
128
130
  processors=[
129
131
  structlog.processors.TimeStamper(fmt="iso"),
@@ -134,7 +136,7 @@ def _configure_structlog_for_testing() -> None:
134
136
  wrapper_class=structlog.BoundLogger,
135
137
  context_class=dict,
136
138
  # Use stderr instead of stdout to avoid polluting pytest-xdist pipe communication
137
- logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
139
+ logger_factory=structlog.PrintLoggerFactory(file=unicode_safe(sys.stderr)),
138
140
  cache_logger_on_first_use=False, # Disable caching for test isolation
139
141
  )
140
142
  except Exception:
@@ -0,0 +1,73 @@
1
+ #
2
+ # SPDX-FileCopyrightText: Copyright (c) provide.io llc. All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+
6
+ """Keeping test logging off the path where a console encoding can raise.
7
+
8
+ This module deliberately imports nothing from Foundation. The plugin that uses
9
+ it configures structlog before any Foundation module is imported, and reaching
10
+ for Foundation here would defeat that ordering.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ __all__ = ["unicode_safe"]
18
+
19
+ _UTF8_NAMES = frozenset({"utf8", "utf_8", "u8", "cp65001"})
20
+
21
+
22
+ class _UnicodeSafeStream:
23
+ """A stream whose writes cannot fail on a character it cannot encode.
24
+
25
+ The replacement is written through the same text layer rather than the byte
26
+ layer underneath, so pytest's capture of that stream still sees the output.
27
+
28
+ Everything other than `write` is delegated, so the wrapped stream answers
29
+ for its own `isatty`, `flush` and `encoding`.
30
+ """
31
+
32
+ # structlog takes a weak reference to the file it logs through, so the
33
+ # slotted class has to allow one.
34
+ __slots__ = ("__weakref__", "_stream")
35
+
36
+ def __init__(self, stream: Any) -> None:
37
+ self._stream = stream
38
+
39
+ def write(self, text: str) -> int:
40
+ try:
41
+ written = self._stream.write(text)
42
+ except UnicodeEncodeError:
43
+ encoding = getattr(self._stream, "encoding", None) or "ascii"
44
+ # backslashreplace names the character (\U0001f40d) where replace
45
+ # would erase it ('?'), and a test log line is read by a person.
46
+ safe = text.encode(encoding, "backslashreplace").decode(encoding, "replace")
47
+ self._stream.write(safe)
48
+ return len(text)
49
+ # colorama's Windows wrapper returns None rather than a count, so the
50
+ # length of what was handed over stands in for it.
51
+ return written if isinstance(written, int) else len(text)
52
+
53
+ def __getattr__(self, name: str) -> Any:
54
+ return getattr(self._stream, name)
55
+
56
+
57
+ def _encodes_everything(stream: Any) -> bool:
58
+ """Whether the stream takes any str without raising."""
59
+ encoding = getattr(stream, "encoding", None)
60
+ if not isinstance(encoding, str):
61
+ # StringIO and friends hold str directly.
62
+ return True
63
+ return encoding.lower().replace("-", "").replace("_", "") in _UTF8_NAMES
64
+
65
+
66
+ def unicode_safe(stream: Any) -> Any:
67
+ """The stream, wrapped only if a character could make it raise.
68
+
69
+ Idempotent, so a stream reconfigured repeatedly does not accumulate proxies.
70
+ """
71
+ if isinstance(stream, _UnicodeSafeStream) or _encodes_everything(stream):
72
+ return stream
73
+ return _UnicodeSafeStream(stream)
@@ -29,6 +29,9 @@ import sys
29
29
 
30
30
  import structlog
31
31
 
32
+ from provide.testkit._capture import install_capture_swap_fix
33
+ from provide.testkit._streams import unicode_safe
34
+
32
35
  # Configure structlog with test-safe defaults BEFORE Foundation imports.
33
36
  # This ensures that even if Foundation's get_logger() falls back to structlog.get_logger()
34
37
  # during circular import resolution, the returned loggers will have proper configuration
@@ -50,17 +53,28 @@ def _strip_foundation_context(
50
53
  return event_dict
51
54
 
52
55
 
53
- structlog.configure(
54
- processors=[
55
- structlog.processors.TimeStamper(fmt="iso"),
56
- _strip_foundation_context, # type: ignore[list-item]
57
- structlog.dev.ConsoleRenderer(),
58
- ],
59
- wrapper_class=structlog.BoundLogger,
60
- context_class=dict,
61
- logger_factory=structlog.PrintLoggerFactory(file=sys.stdout),
62
- cache_logger_on_first_use=False, # Disable caching for test isolation
63
- )
56
+ def _configure_test_structlog() -> None:
57
+ """Configure structlog for the test session.
58
+
59
+ The stream is read at call time and wrapped: on a legacy console this is a
60
+ cp1252 stream behind colorama, and a log line carrying an emoji would
61
+ otherwise raise UnicodeEncodeError out of the logging call and into
62
+ whatever the test was doing.
63
+ """
64
+ structlog.configure(
65
+ processors=[
66
+ structlog.processors.TimeStamper(fmt="iso"),
67
+ _strip_foundation_context, # type: ignore[list-item]
68
+ structlog.dev.ConsoleRenderer(),
69
+ ],
70
+ wrapper_class=structlog.BoundLogger,
71
+ context_class=dict,
72
+ logger_factory=structlog.PrintLoggerFactory(file=unicode_safe(sys.stdout)),
73
+ cache_logger_on_first_use=False, # Disable caching for test isolation
74
+ )
75
+
76
+
77
+ _configure_test_structlog()
64
78
 
65
79
  # Install the import hook unconditionally
66
80
  # This module is a pytest plugin (registered via pytest11 entry point) that ONLY
@@ -71,6 +85,12 @@ from provide.testkit._install_blocker import install_setproctitle_blocker # noq
71
85
 
72
86
  install_setproctitle_blocker(force=True)
73
87
 
88
+ # Pytest hands a suspended stream back to whatever it saved at start, so a test
89
+ # that swaps sys.stdout after that -- CliRunner.isolation, redirect_stdout --
90
+ # loses the swap the first time a live log record suspends capture, and its
91
+ # output lands in pytest's buffer instead. See provide.testkit._capture.
92
+ install_capture_swap_fix()
93
+
74
94
  __all__ = ["SetproctitleImportBlocker", "pytest_load_initial_conftests"]
75
95
 
76
96
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: provide-testkit
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Testing utilities and fixtures for the provide ecosystem.
5
5
  Author-email: Tim Perkins <code@tim.life>
6
6
  Maintainer-email: "provide.io" <code@provide.io>
@@ -6,9 +6,11 @@ src/provide/__init__.py
6
6
  src/provide/testkit/__init__.py
7
7
  src/provide/testkit/__main__.py
8
8
  src/provide/testkit/_blocker.py
9
+ src/provide/testkit/_capture.py
9
10
  src/provide/testkit/_early_init.py
10
11
  src/provide/testkit/_install_blocker.py
11
12
  src/provide/testkit/_install_pth.py
13
+ src/provide/testkit/_streams.py
12
14
  src/provide/testkit/conftest.py
13
15
  src/provide/testkit/main.py
14
16
  src/provide/testkit/provide_testkit_init.pth
@@ -109,6 +111,8 @@ src/provide_testkit.egg-info/entry_points.txt
109
111
  src/provide_testkit.egg-info/requires.txt
110
112
  src/provide_testkit.egg-info/top_level.txt
111
113
  tests/test_async_fixtures.py
114
+ tests/test_capture_stream_swap.py
112
115
  tests/test_early_init.py
113
116
  tests/test_placeholder.py
114
- tests/test_pth_installation.py
117
+ tests/test_pth_installation.py
118
+ tests/test_stream_encoding.py
@@ -0,0 +1,142 @@
1
+ #
2
+ # SPDX-FileCopyrightText: Copyright (c) provide.io llc. All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+
6
+ """Pytest's capture must give back the stream it took, not the one it remembers.
7
+
8
+ ``SysCaptureBase.resume`` reinstates the stream capture installed at ``start``.
9
+ Anything that swapped ``sys.stdout`` after that point -- ``CliRunner.isolation``,
10
+ ``redirect_stdout``, a fixture of one's own -- is dropped the first time capture
11
+ suspends, and every write after that lands in pytest's buffer. Live logging
12
+ (``log_cli = true``) suspends capture per record, so one log line emitted from
13
+ inside a Click command empties ``result.output`` while the text itself is real.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Iterator
19
+ import io
20
+ import sys
21
+
22
+ from _pytest.capture import SysCapture
23
+ import pytest
24
+
25
+ from provide.testkit._capture import install_capture_swap_fix
26
+
27
+ STDOUT_FD = 1
28
+
29
+
30
+ @pytest.fixture
31
+ def stdout_restored() -> Iterator[None]:
32
+ """Put ``sys.stdout`` back, however the test under it leaves things."""
33
+ saved = sys.stdout
34
+ try:
35
+ yield
36
+ finally:
37
+ sys.stdout = saved
38
+
39
+
40
+ @pytest.mark.usefixtures("stdout_restored")
41
+ def test_resume_returns_a_swap_made_after_capture_started() -> None:
42
+ """The case behind an empty ``CliRunner.result.output``."""
43
+ capture = SysCapture(STDOUT_FD)
44
+ capture.start()
45
+ swapped_in = io.StringIO()
46
+ sys.stdout = swapped_in
47
+
48
+ capture.suspend()
49
+ capture.resume()
50
+
51
+ assert sys.stdout is swapped_in
52
+ capture.done()
53
+
54
+
55
+ @pytest.mark.usefixtures("stdout_restored")
56
+ def test_resume_returns_the_capture_stream_when_nothing_swapped_it() -> None:
57
+ """The ordinary path stays exactly as pytest wrote it."""
58
+ capture = SysCapture(STDOUT_FD)
59
+ capture.start()
60
+
61
+ capture.suspend()
62
+ capture.resume()
63
+
64
+ assert sys.stdout is capture.tmpfile
65
+ capture.done()
66
+
67
+
68
+ @pytest.mark.usefixtures("stdout_restored")
69
+ def test_a_second_suspend_does_not_overwrite_the_remembered_swap() -> None:
70
+ """Suspending while suspended is legal, and must not record ``_old`` as the swap."""
71
+ capture = SysCapture(STDOUT_FD)
72
+ capture.start()
73
+ swapped_in = io.StringIO()
74
+ sys.stdout = swapped_in
75
+
76
+ capture.suspend()
77
+ capture.suspend()
78
+ capture.resume()
79
+
80
+ assert sys.stdout is swapped_in
81
+ capture.done()
82
+
83
+
84
+ @pytest.mark.usefixtures("stdout_restored")
85
+ def test_done_restores_the_stream_capture_replaced() -> None:
86
+ """Teardown belongs to pytest: a foreign swap does not outlive the capture."""
87
+ original = sys.stdout
88
+ capture = SysCapture(STDOUT_FD)
89
+ capture.start()
90
+ sys.stdout = io.StringIO()
91
+
92
+ capture.done()
93
+
94
+ assert sys.stdout is original
95
+
96
+
97
+ def test_installing_the_fix_again_reports_that_it_was_already_there() -> None:
98
+ """The plugin installs it at import; a second call must not stack a layer."""
99
+ assert install_capture_swap_fix() is False
100
+
101
+
102
+ CLI_UNDER_LIVE_LOGGING = """
103
+ import logging
104
+
105
+ import click
106
+ from click.testing import CliRunner
107
+
108
+
109
+ @click.group(invoke_without_command=True)
110
+ @click.pass_context
111
+ def cli(ctx: click.Context) -> None:
112
+ # Any library logging from inside the command does this; the record only has
113
+ # to reach the root logger, where live logging is listening.
114
+ logging.getLogger("some.library").warning("a record reaches the root logger")
115
+
116
+
117
+ @cli.command()
118
+ def sub() -> None:
119
+ click.echo("SUBCOMMAND OUTPUT")
120
+
121
+
122
+ def test_the_runner_sees_what_the_command_printed() -> None:
123
+ assert CliRunner().invoke(cli, ["sub"]).output != ""
124
+ """
125
+
126
+
127
+ def test_a_cli_runner_keeps_its_output_across_a_live_log_record(pytester: pytest.Pytester) -> None:
128
+ """The reported symptom, through the real Click and the real live-log handler."""
129
+ pytester.makepyfile(CLI_UNDER_LIVE_LOGGING)
130
+
131
+ result = pytester.runpytest_subprocess("-o", "log_cli=true")
132
+
133
+ result.assert_outcomes(passed=1)
134
+
135
+
136
+ def test_the_same_run_loses_its_output_without_this_plugin(pytester: pytest.Pytester) -> None:
137
+ """Without this, the test above could pass for the wrong reason."""
138
+ pytester.makepyfile(CLI_UNDER_LIVE_LOGGING)
139
+
140
+ result = pytester.runpytest_subprocess("-o", "log_cli=true", "-p", "no:provide_testkit")
141
+
142
+ result.assert_outcomes(failed=1)
@@ -0,0 +1,109 @@
1
+ #
2
+ # SPDX-FileCopyrightText: Copyright (c) provide.io llc. All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+
6
+ """Test logging must not raise on a console that cannot encode the message.
7
+
8
+ The plugin configures structlog before Foundation is imported, so the stream it
9
+ hands structlog is whatever pytest is holding at that moment. On Windows that is
10
+ a cp1252 console behind colorama, and a log line carrying an emoji raised
11
+ UnicodeEncodeError out of the logging call and into the test.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import io
17
+
18
+ from provide.testkit._streams import unicode_safe
19
+
20
+
21
+ class _Cp1252Stream(io.TextIOWrapper):
22
+ """Stands in for the Windows console pytest hands the plugin."""
23
+
24
+ def __init__(self) -> None:
25
+ super().__init__(io.BytesIO(), encoding="cp1252", newline="")
26
+
27
+ def written(self) -> str:
28
+ self.flush()
29
+ return self.buffer.getvalue().decode("cp1252") # type: ignore[attr-defined]
30
+
31
+
32
+ def test_the_stand_in_really_does_raise() -> None:
33
+ """Without this, every test below could pass for the wrong reason."""
34
+ try:
35
+ _Cp1252Stream().write("\U0001f40d")
36
+ except UnicodeEncodeError:
37
+ return
38
+ raise AssertionError("the cp1252 stand-in accepted an emoji")
39
+
40
+
41
+ def test_an_unencodable_character_does_not_raise() -> None:
42
+ unicode_safe(_Cp1252Stream()).write("repo \U0001f40d state\n")
43
+
44
+
45
+ def test_the_surrounding_text_survives() -> None:
46
+ stream = _Cp1252Stream()
47
+
48
+ unicode_safe(stream).write("repo \U0001f40d state\n")
49
+
50
+ written = stream.written()
51
+ assert "repo " in written
52
+ assert "state" in written
53
+
54
+
55
+ def test_the_character_is_named_rather_than_dropped() -> None:
56
+ """backslashreplace keeps the emoji readable; 'replace' would give '?'."""
57
+ stream = _Cp1252Stream()
58
+
59
+ unicode_safe(stream).write("\U0001f40d\n")
60
+
61
+ assert "\\U0001f40d" in stream.written()
62
+
63
+
64
+ def test_a_stream_that_can_encode_is_handed_back_unchanged() -> None:
65
+ stream = io.StringIO()
66
+
67
+ assert unicode_safe(stream) is stream
68
+
69
+
70
+ def test_wrapping_is_idempotent() -> None:
71
+ wrapped = unicode_safe(_Cp1252Stream())
72
+
73
+ assert unicode_safe(wrapped) is wrapped
74
+
75
+
76
+ def test_the_wrapper_delegates_what_it_does_not_define() -> None:
77
+ stream = _Cp1252Stream()
78
+
79
+ assert unicode_safe(stream).encoding == "cp1252"
80
+
81
+
82
+ class _NoReturnStream:
83
+ """colorama's Windows wrapper: write() returns None, not a count."""
84
+
85
+ encoding = "cp1252"
86
+
87
+ def __init__(self) -> None:
88
+ self.text = ""
89
+
90
+ def write(self, text: str) -> None:
91
+ self.text += text.encode("cp1252", "strict").decode("cp1252")
92
+
93
+
94
+ def test_a_stream_whose_write_returns_none_is_accepted() -> None:
95
+ """colorama returns None from write; coercing that to int raised TypeError."""
96
+ assert unicode_safe(_NoReturnStream()).write("plain\n") == len("plain\n")
97
+
98
+
99
+ def test_the_plugin_configures_a_stream_that_cannot_raise(monkeypatch) -> None:
100
+ """The regression: the plugin handed structlog the raw console."""
101
+ import structlog
102
+
103
+ monkeypatch.setattr("sys.stdout", _Cp1252Stream())
104
+
105
+ import provide.testkit.pytest_plugin as plugin
106
+
107
+ plugin._configure_test_structlog()
108
+
109
+ structlog.get_config()["logger_factory"]().msg("repo \U0001f40d state")
@@ -1 +0,0 @@
1
- 0.5.0
File without changes