Skip to content

Commit e0f409b

Browse files
committed
Add a decorator for our long running operations
This way we can relatively cheaply avoid printing the timeout error message and return a zero exit code on the timeout. Signed-off-by: mulhern <amulhern@redhat.com>
1 parent 38d8a42 commit e0f409b

5 files changed

Lines changed: 147 additions & 13 deletions

File tree

src/stratis_cli/_actions/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@
3535
from ._stratis import StratisActions
3636
from ._stratisd_version import check_stratisd_version
3737
from ._top import TopActions
38+
from ._utils import get_errors

src/stratis_cli/_actions/_crypt.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from .._stratisd_constants import StratisdErrors
3030
from ._connection import get_object
3131
from ._constants import TOP_OBJECT
32+
from ._utils import long_running_operation
3233

3334

3435
class CryptActions:
@@ -37,6 +38,7 @@ class CryptActions:
3738
"""
3839

3940
@staticmethod
41+
@long_running_operation(method_names=["EncryptPool"])
4042
def encrypt(namespace: Namespace):
4143
"""
4244
Encrypt a previously unencrypted pool.
@@ -97,6 +99,7 @@ def encrypt(namespace: Namespace):
9799
)
98100

99101
@staticmethod
102+
@long_running_operation(method_names=["DecryptPool"])
100103
def unencrypt(namespace: Namespace):
101104
"""
102105
Unencrypt a previously encrypted pool.
@@ -138,6 +141,7 @@ def unencrypt(namespace: Namespace):
138141
)
139142

140143
@staticmethod
144+
@long_running_operation(method_names=["ReencryptPool"])
141145
def reencrypt(namespace: Namespace):
142146
"""
143147
Reencrypt an already encrypted pool with a new key.

src/stratis_cli/_actions/_utils.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,21 @@
2222
import os
2323
import sys
2424
import termios
25+
from argparse import Namespace
2526
from enum import Enum
26-
from typing import Any, Optional, Tuple
27+
from functools import wraps
28+
from typing import Any, Callable, Generator, Optional, Sequence, Tuple
2729
from uuid import UUID
2830

2931
# isort: THIRDPARTY
3032
from dbus import Dictionary, Struct
33+
from dbus.exceptions import DBusException
3134
from dbus.proxies import ProxyObject
3235
from justbytes import Range
3336

37+
# isort: FIRSTPARTY
38+
from dbus_python_client_gen import DPClientInvocationError, DPClientMethodCallContext
39+
3440
from .._errors import (
3541
StratisCliKeyfileNotFoundError,
3642
StratisCliPassphraseEmptyError,
@@ -300,3 +306,58 @@ def free(self) -> Optional[Range]:
300306
Total - used.
301307
"""
302308
return None if self._used is None else self._total - self._used
309+
310+
311+
def get_errors(exc: BaseException) -> Generator[BaseException, None, None]:
312+
"""
313+
Generates a sequence of exceptions starting with exc and following the chain
314+
of causes.
315+
"""
316+
yield exc
317+
while exc.__cause__ is not None:
318+
yield exc.__cause__
319+
exc = exc.__cause__
320+
321+
322+
def long_running_operation(
323+
*, method_names: Sequence[str]
324+
) -> Callable[[Callable], Callable]:
325+
"""
326+
Mark a function as a long running operation and catch and ignore NoReply
327+
D-Bus exception so long as the method raising the exception is one
328+
of the methods specified in method_names.
329+
"""
330+
331+
def decorator(func: Callable[[Namespace], None]) -> Callable[[Namespace], None]:
332+
"""
333+
Decorator
334+
"""
335+
336+
@wraps(func)
337+
def wrapper(namespace: Namespace):
338+
"""
339+
Wrapper
340+
"""
341+
try:
342+
func(namespace)
343+
except DPClientInvocationError as err:
344+
if not any(
345+
isinstance(e, DBusException)
346+
and e.get_dbus_name() == "org.freedesktop.DBus.Error.NoReply"
347+
for e in get_errors(err)
348+
):
349+
raise err
350+
351+
context = err.context
352+
if (
353+
isinstance(context, DPClientMethodCallContext)
354+
and context.method_name in method_names
355+
):
356+
print("Operation initiated", file=sys.stderr)
357+
358+
else:
359+
raise err
360+
361+
return wrapper
362+
363+
return decorator

src/stratis_cli/_error_reporting.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
# isort: STDLIB
1818
import os
1919
import sys
20-
from collections.abc import Iterator
2120
from typing import List, Optional
2221

2322
# isort: THIRDPARTY
@@ -41,6 +40,7 @@
4140
FILESYSTEM_INTERFACE,
4241
MANAGER_0_INTERFACE,
4342
POOL_INTERFACE,
43+
get_errors,
4444
)
4545
from ._errors import (
4646
StratisCliActionError,
@@ -87,17 +87,6 @@ def _interface_name_to_common_name(interface_name: str) -> str:
8787
raise StratisCliUnknownInterfaceError(interface_name) # pragma: no cover
8888

8989

90-
def get_errors(exc: BaseException) -> Iterator[BaseException]:
91-
"""
92-
Generates a sequence of exceptions starting with exc and following the chain
93-
of causes.
94-
"""
95-
yield exc
96-
while exc.__cause__ is not None:
97-
yield exc.__cause__
98-
exc = exc.__cause__
99-
100-
10190
def _interpret_errors_0(
10291
error: dbus.exceptions.DBusException,
10392
) -> Optional[str]:

tests/unit/test_running.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Copyright 2025 Red Hat, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""
15+
Test 'long_running_operation'.
16+
"""
17+
18+
# isort: STDLIB
19+
import unittest
20+
21+
# isort: THIRDPARTY
22+
import dbus
23+
24+
# isort: FIRSTPARTY
25+
from dbus_python_client_gen import DPClientInvocationError, DPClientMethodCallContext
26+
27+
# isort: LOCAL
28+
from stratis_cli._actions._utils import long_running_operation
29+
30+
31+
class LongRunningOperationTestCase(unittest.TestCase):
32+
"""
33+
Test long_running_operation error paths that don't show up in the sim
34+
engine.
35+
"""
36+
37+
def test_catch_dbus_exception(self):
38+
"""
39+
Should succeed because it catches the distinguishing NoReply D-Bus
40+
error from the identified method.
41+
"""
42+
43+
def raises_error(_):
44+
raise DPClientInvocationError(
45+
"fake", "intf", DPClientMethodCallContext("MethodName", [])
46+
) from dbus.exceptions.DBusException(
47+
name="org.freedesktop.DBus.Error.NoReply"
48+
)
49+
50+
self.assertIsNone(
51+
long_running_operation(method_names=["MethodName"])(raises_error)(None)
52+
)
53+
54+
def test_raise_dbus_exception_no_name_match(self):
55+
"""
56+
Should raise an exception because the method to be matched is not
57+
MethodName.
58+
"""
59+
60+
def raises_error(_):
61+
raise DPClientInvocationError(
62+
"fake", "intf", DPClientMethodCallContext("MethodName", [])
63+
) from dbus.exceptions.DBusException(
64+
name="org.freedesktop.DBus.Error.NoReply"
65+
)
66+
67+
with self.assertRaises(DPClientInvocationError):
68+
long_running_operation(method_names=["OtherMethodName"])(raises_error)(None)
69+
70+
def test_no_dbus_exception(self):
71+
"""
72+
Should raise an exception that was previously raised.
73+
"""
74+
75+
def raises_error(_):
76+
raise DPClientInvocationError("fake", "intf", None)
77+
78+
with self.assertRaises(DPClientInvocationError):
79+
long_running_operation(method_names=["OtherMethodName"])(raises_error)(None)

0 commit comments

Comments
 (0)