Skip to content

Commit f120cae

Browse files
authored
Subprocess is not singleton anymore: only lock property is shared. (#96)
`SingleLock` metaclass added. `Singleton` saved for historical reasons. All Subprocess tests and SSHAuth tests ported to pytest.
1 parent 038ba13 commit f120cae

File tree

11 files changed

+188
-314
lines changed

11 files changed

+188
-314
lines changed

doc/source/Subprocess.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ API: Subprocess
1616
:type log_mask_re: typing.Optional[str]
1717

1818
.. versionchanged:: 1.2.0 log_mask_re regex rule for masking cmd
19+
.. versionchanged:: 3.1.0 Not singleton anymore. Only lock is shared between all instances.
1920

2021
.. py:attribute:: log_mask_re
2122

exec_helpers/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"async_api",
5252
)
5353

54-
__version__ = "3.0.0"
54+
__version__ = "3.1.0"
5555
__author__ = "Alexey Stepanov"
5656
__author_email__ = "penguinolog@gmail.com"
5757
__maintainers__ = {

exec_helpers/async_api/subprocess_runner.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def stdout(self) -> typing.Optional[asyncio.StreamReader]: # type: ignore
6363
return super(SubprocessExecuteAsyncResult, self).stdout
6464

6565

66-
class Subprocess(api.ExecHelper, metaclass=metaclasses.SingletonMeta):
66+
class Subprocess(api.ExecHelper, metaclass=metaclasses.SingleLock):
6767
"""Subprocess helper with timeouts and lock-free FIFO."""
6868

6969
__slots__ = ()
@@ -76,6 +76,8 @@ def __init__(self, logger: logging.Logger = logger, log_mask_re: typing.Optional
7676
:param log_mask_re: regex lookup rule to mask command for logger.
7777
all MATCHED groups will be replaced by '<*masked*>'
7878
:type log_mask_re: typing.Optional[str]
79+
80+
.. versionchanged:: 3.1.0 Not singleton anymore. Only lock is shared between all instances.
7981
"""
8082
super(Subprocess, self).__init__(logger=logger, log_mask_re=log_mask_re)
8183

exec_helpers/metaclasses.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,34 @@ def __prepare__( # pylint: disable=unused-argument
4747
.. versionadded:: 1.2.0
4848
"""
4949
return collections.OrderedDict()
50+
51+
52+
class SingleLock(abc.ABCMeta):
53+
"""Metaclass for creating classes with single lock instance per class."""
54+
55+
def __init__(cls, name: str, bases: typing.Tuple[type, ...], namespace: typing.Dict[str, typing.Any]) -> None:
56+
"""Create lock object for class."""
57+
super(SingleLock, cls).__init__(name, bases, namespace)
58+
cls.__lock = threading.RLock()
59+
60+
def __new__(
61+
mcs, name: str, bases: typing.Tuple[type, ...], namespace: typing.Dict[str, typing.Any], **kwargs: typing.Any
62+
) -> typing.Type:
63+
"""Create lock property for class instances."""
64+
namespace["lock"] = property(fget=lambda self: self.__class__.lock)
65+
return super().__new__(mcs, name, bases, namespace, **kwargs) # type: ignore
66+
67+
@property
68+
def lock(cls) -> threading.RLock:
69+
"""Lock property for class."""
70+
return cls.__lock
71+
72+
@classmethod
73+
def __prepare__( # pylint: disable=unused-argument
74+
mcs: typing.Type["SingleLock"], name: str, bases: typing.Iterable[typing.Type], **kwargs: typing.Any
75+
) -> collections.OrderedDict:
76+
"""Metaclass magic for object storage.
77+
78+
.. versionadded:: 1.2.0
79+
"""
80+
return collections.OrderedDict()

exec_helpers/subprocess_runner.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def stdout(self) -> typing.Optional[typing.IO]: # type: ignore
6161
return super(SubprocessExecuteAsyncResult, self).stdout
6262

6363

64-
class Subprocess(api.ExecHelper, metaclass=metaclasses.SingletonMeta):
64+
class Subprocess(api.ExecHelper, metaclass=metaclasses.SingleLock):
6565
"""Subprocess helper with timeouts and lock-free FIFO."""
6666

6767
def __init__(self, log_mask_re: typing.Optional[str] = None) -> None:
@@ -74,6 +74,7 @@ def __init__(self, log_mask_re: typing.Optional[str] = None) -> None:
7474
:type log_mask_re: typing.Optional[str]
7575
7676
.. versionchanged:: 1.2.0 log_mask_re regex rule for masking cmd
77+
.. versionchanged:: 3.1.0 Not singleton anymore. Only lock is shared between all instances.
7778
"""
7879
super(Subprocess, self).__init__(logger=logger, log_mask_re=log_mask_re)
7980

test/async_api/test_subprocess.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
import pytest
2323

2424
import exec_helpers
25-
from exec_helpers import metaclasses
2625
from exec_helpers import _subprocess_helpers
2726

2827
# All test coroutines will be treated as marked.
@@ -256,7 +255,6 @@ async def test_002_execute(create_subprocess_shell, logger, exec_result, run_par
256255

257256

258257
async def test_003_context_manager(monkeypatch, create_subprocess_shell, logger, exec_result, run_parameters) -> None:
259-
metaclasses.SingletonMeta._instances.clear() # prepare
260258
lock = asynctest.CoroutineMock()
261259
lock.attach_mock(asynctest.CoroutineMock("acquire"), "acquire")
262260
lock.attach_mock(mock.Mock("release"), "release")

test/async_api/test_subprocess_special.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@ async def read_stream(stream: FakeFileStream):
9191
"stdin_error": dict(ec=(0xDEADBEEF,), stdout=(), stdin="Failed", write=OSError(), expect_exc=OSError),
9292
"stdin_close_closed": dict(stdout=(b" \n", b"2\n", b"3\n", b" \n"), stdin="Stdin", stdin_close=eshutdown_exc),
9393
"stdin_close_fail": dict(ec=(0xDEADBEEF,), stdout=(), stdin="Failed", stdin_close=OSError(), expect_exc=OSError),
94+
"mask_global": dict(
95+
command="USE='secret=secret_pass' do task",
96+
init_log_mask_re=r"secret\s*=\s*([A-Z-a-z0-9_\-]+)",
97+
masked_cmd="USE='secret=<*masked*>' do task",
98+
),
99+
"mask_local": dict(
100+
command="USE='secret=secret_pass' do task",
101+
log_mask_re=r"secret\s*=\s*([A-Z-a-z0-9_\-]+)",
102+
masked_cmd="USE='secret=<*masked*>' do task",
103+
),
94104
}
95105

96106

@@ -112,6 +122,8 @@ def pytest_generate_tests(metafunc):
112122
"stdin_error",
113123
"stdin_close_closed",
114124
"stdin_close_fail",
125+
"mask_global",
126+
"mask_local",
115127
],
116128
indirect=True,
117129
)
@@ -131,7 +143,7 @@ def exec_result(run_parameters):
131143
stdout_res = tuple([elem for elem in run_parameters["stdout"] if isinstance(elem, bytes)])
132144

133145
return exec_helpers.async_api.ExecResult(
134-
cmd=command,
146+
cmd=run_parameters.get("masked_cmd", command),
135147
stdin=run_parameters.get("stdin", None),
136148
stdout=stdout_res,
137149
stderr=(),
@@ -190,16 +202,24 @@ def logger(mocker):
190202

191203

192204
async def test_special_cases(create_subprocess_shell, exec_result, logger, run_parameters) -> None:
193-
runner = exec_helpers.async_api.Subprocess()
205+
runner = exec_helpers.async_api.Subprocess(log_mask_re=run_parameters.get("init_log_mask_re", None))
194206
if "expect_exc" not in run_parameters:
195207
res = await runner.execute(
196-
command, stdin=run_parameters.get("stdin", None), verbose=run_parameters.get("verbose", None)
208+
command=run_parameters.get("command", command),
209+
stdin=run_parameters.get("stdin", None),
210+
verbose=run_parameters.get("verbose", None),
211+
log_mask_re=run_parameters.get("log_mask_re", None),
197212
)
198213
level = logging.INFO if run_parameters.get("verbose", False) else logging.DEBUG
199-
assert logger.mock_calls[0] == mock.call.log(level=level, msg=command_log)
200-
assert logger.mock_calls[-1] == mock.call.log(
201-
level=level, msg="Command {result.cmd!r} exit code: {result.exit_code!s}".format(result=res)
214+
215+
command_for_log = run_parameters.get("masked_cmd", command)
216+
command_log = "Executing command:\n{!r}\n".format(command_for_log.rstrip())
217+
result_log = "Command {command!r} exit code: {result.exit_code!s}".format(
218+
command=command_for_log.rstrip(), result=res
202219
)
220+
221+
assert logger.mock_calls[0] == mock.call.log(level=level, msg=command_log)
222+
assert logger.mock_calls[-1] == mock.call.log(level=level, msg=result_log)
203223
assert res == exec_result
204224
else:
205225
with pytest.raises(run_parameters["expect_exc"]):

test/test_ssh_client_init.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ def __iter__(self):
5353
port = 22
5454
username = "user"
5555
password = "pass"
56-
private_keys = []
5756

5857

5958
# noinspection PyTypeChecker

0 commit comments

Comments
 (0)