Skip to content

Commit 91ee164

Browse files
authored
Merge pull request #320 from dagansandler/add_support_for_thread_logs
Add support for thread logs
2 parents d05200f + 95662d6 commit 91ee164

File tree

9 files changed

+170
-4
lines changed

9 files changed

+170
-4
lines changed

README.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ The following parameters are optional:
113113
- :code:`rp_issue_id_marks = True` - Enables adding marks for issue ids (e.g. "issue:123456")
114114
- :code:`rp_verify_ssl = True` - Verify SSL when connecting to the server
115115
- :code:`rp_mode = DEFAULT` - DEBUG or DEFAULT launch mode. DEBUG launches are displayed in a separate tab and not visible to anyone except owner
116+
- :code:`rp_thread_logging` - EXPERIMENTAL - Enables support for reporting logs from threads by patching the builtin Thread class. Use with caution.
116117

117118

118119
If you like to override the above parameters from command line, or from CI environment based on your build, then pass

examples/threads/__init__.py

Whitespace-only changes.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import logging
2+
import threading
3+
4+
from reportportal_client.steps import step
5+
6+
log = logging.getLogger(__name__)
7+
8+
9+
def worker():
10+
log.info("TEST_INFO")
11+
log.debug("TEST_DEBUG")
12+
13+
14+
def test_log():
15+
t = threading.Thread(target=worker)
16+
log.info("TEST_BEFORE_THREADING")
17+
with step("Some nesting where the thread logs should go"):
18+
t.start()
19+
t.join()

pytest_reportportal/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ def __init__(self, pytest_config):
6060
self.rp_log_level = get_actual_log_level(pytest_config, 'rp_log_level')
6161
self.rp_log_format = self.find_option(pytest_config, 'rp_log_format')
6262
self.rp_mode = self.find_option(pytest_config, 'rp_mode')
63+
self.rp_thread_logging = self.find_option(
64+
pytest_config, 'rp_thread_logging'
65+
)
6366
self.rp_parent_item_id = self.find_option(pytest_config,
6467
'rp_parent_item_id')
6568
self.rp_project = self.find_option(pytest_config,

pytest_reportportal/plugin.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from pytest_reportportal import LAUNCH_WAIT_TIMEOUT
1818
from .config import AgentConfig
19-
from .rp_logging import patching_logger_class
19+
from .rp_logging import patching_logger_class, patching_thread_class
2020
from .service import PyTestServiceClass
2121

2222
log = logging.getLogger(__name__)
@@ -204,6 +204,24 @@ def pytest_configure(config):
204204
config.workerinput['py_test_service'])
205205

206206

207+
@pytest.hookimpl(hookwrapper=True)
208+
def pytest_runtestloop(session):
209+
"""
210+
Control start and finish of all test items in the session.
211+
212+
:param session: pytest.Session
213+
:return: generator object
214+
"""
215+
config = session.config
216+
if not config._rp_enabled:
217+
yield
218+
return
219+
220+
agent_config = config._reporter_config
221+
with patching_thread_class(agent_config):
222+
yield
223+
224+
207225
@pytest.hookimpl(hookwrapper=True)
208226
def pytest_runtest_protocol(item):
209227
"""
@@ -341,6 +359,14 @@ def add_shared_option(name, help, default=None, action='store'):
341359
help='Visibility of current launch [DEFAULT, DEBUG]',
342360
default='DEFAULT'
343361
)
362+
add_shared_option(
363+
name='rp_thread_logging',
364+
help='EXPERIMENTAL: Report logs from threads. '
365+
'This option applies a patch to the builtin Thread class, '
366+
'and so it is turned off by default. Use with caution.',
367+
default=False,
368+
action='store_true'
369+
)
344370

345371
parser.addini(
346372
'rp_launch_attributes',

pytest_reportportal/rp_logging.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,94 @@
11
"""RPLogger class for low-level logging in tests."""
22

33
import logging
4+
import threading
45
from contextlib import contextmanager
56
from functools import wraps
7+
8+
from reportportal_client.client import RPClient
9+
10+
from reportportal_client._local import current, set_current
611
from reportportal_client import RPLogger
712

813

14+
@contextmanager
15+
def patching_thread_class(config):
16+
"""
17+
Add patch for Thread class.
18+
19+
Set the parent thread client as the child thread's local client
20+
"""
21+
if not config.rp_thread_logging:
22+
# Do nothing
23+
yield
24+
else:
25+
original_start = threading.Thread.start
26+
original_run = threading.Thread.run
27+
try:
28+
def wrap_start(original_func):
29+
@wraps(original_func)
30+
def _start(self, *args, **kwargs):
31+
"""Save the invoking thread's client if there is one."""
32+
# Prevent an endless loop of workers being spawned
33+
if "_monitor" not in self.name:
34+
current_client = current()
35+
self.parent_rp_client = current_client
36+
return original_func(self, *args, **kwargs)
37+
38+
return _start
39+
40+
def wrap_run(original_func):
41+
@wraps(original_func)
42+
def _run(self, *args, **kwargs):
43+
"""Create a new client for the invoked thread."""
44+
client = None
45+
if (
46+
hasattr(self, "parent_rp_client")
47+
and self.parent_rp_client
48+
and not current()
49+
):
50+
parent = self.parent_rp_client
51+
client = RPClient(
52+
endpoint=parent.endpoint,
53+
project=parent.project,
54+
token=parent.token,
55+
log_batch_size=parent.log_batch_size,
56+
is_skipped_an_issue=parent.is_skipped_an_issue,
57+
verify_ssl=parent.verify_ssl,
58+
retries=config.rp_retries,
59+
launch_id=parent.launch_id
60+
)
61+
if parent.current_item():
62+
client._item_stack.append(
63+
parent.current_item()
64+
)
65+
client.start()
66+
try:
67+
return original_func(self, *args, **kwargs)
68+
finally:
69+
if client:
70+
# Stop the client and remove any references
71+
client.terminate()
72+
self.parent_rp_client = None
73+
del self.parent_rp_client
74+
set_current(None)
75+
76+
return _run
77+
78+
if not hasattr(threading.Thread, "patched"):
79+
# patch
80+
threading.Thread.patched = True
81+
threading.Thread.start = wrap_start(original_start)
82+
threading.Thread.run = wrap_run(original_run)
83+
yield
84+
85+
finally:
86+
if hasattr(threading.Thread, "patched"):
87+
threading.Thread.start = original_start
88+
threading.Thread.run = original_run
89+
del threading.Thread.patched
90+
91+
992
@contextmanager
1093
def patching_logger_class():
1194
"""

tests/integration/test_empty_run.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ def test_empty_run(mock_client_init):
3434
3535
:param mock_client_init: Pytest fixture
3636
"""
37-
result = utils.run_pytest_tests(tests=['examples/epmty/'])
37+
result = utils.run_pytest_tests(tests=['examples/empty/'])
3838

39-
assert int(result) == 4, 'Exit code should be 4 (no tests)'
39+
assert int(result) == 5, 'Exit code should be 5 (no tests)'
4040

4141
mock_client = mock_client_init.return_value
4242
expect(mock_client.start_launch.call_count == 1,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from six.moves import mock
2+
3+
from tests import REPORT_PORTAL_SERVICE
4+
from tests.helpers import utils
5+
6+
7+
@mock.patch('pytest_reportportal.rp_logging.RPClient')
8+
@mock.patch(REPORT_PORTAL_SERVICE)
9+
def test_rp_thread_logs_reporting(mock_client_init, mock_thread_client_init):
10+
"""Verify logs from threads are sent to correct items`.
11+
12+
:param mock_client_init: Pytest fixture
13+
"""
14+
mock_client = mock_client_init.return_value
15+
mock_thread_client = mock_thread_client_init.return_value
16+
17+
def init_thread_client(*_, **__):
18+
from reportportal_client._local import set_current
19+
set_current(mock_thread_client)
20+
return mock_thread_client
21+
mock_thread_client_init.side_effect = init_thread_client
22+
result = utils.run_tests_with_client(
23+
mock_client,
24+
['examples/threads/'],
25+
args=["--rp-thread-logging"]
26+
)
27+
28+
assert int(result) == 0, 'Exit code should be 0 (no errors)'
29+
assert mock_client.start_launch.call_count == 1, \
30+
'"start_launch" method was not called'
31+
assert mock_client.log.call_count == 1
32+
assert mock_thread_client.log.call_count == 2

tests/unit/test_plugin.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ def test_pytest_addoption_adds_correct_ini_file_arguments():
318318
'rp_uuid',
319319
'rp_endpoint',
320320
'rp_mode',
321+
'rp_thread_logging',
321322
'rp_launch_attributes',
322323
'rp_tests_attributes',
323324
'rp_log_batch_size',
@@ -361,7 +362,8 @@ def test_pytest_addoption_adds_correct_command_line_arguments():
361362
'--rp-parent-item-id',
362363
'--rp-uuid',
363364
'--rp-endpoint',
364-
'--rp-mode'
365+
'--rp-mode',
366+
'--rp-thread-logging'
365367
)
366368
mock_parser = mock.MagicMock(spec=Parser)
367369
mock_reporting_group = mock_parser.getgroup.return_value

0 commit comments

Comments
 (0)