Skip to content

Commit 675a9ca

Browse files
Merge pull request #170 from feature-exit-code-handling
[STYLE] Refactor exceptions and exit-code utilities (- WIP #117 & #157 -)
2 parents 13f2427 + ee71589 commit 675a9ca

File tree

5 files changed

+325
-72
lines changed

5 files changed

+325
-72
lines changed

multicast/__init__.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,15 @@
2727
# skipcq
2828
__all__ = [
2929
"""__package__""", """__module__""", """__name__""", """__version__""", """__prologue__""",
30-
"""__doc__""", """skt""", """skt.__package__""", """skt.__module__""", """skt.__name__""",
30+
"""__doc__""", """exceptions""", """exceptions.CommandExecutionError""",
31+
"""exceptions.get_exit_code_from_exception""",
32+
"""exceptions.get_exit_code_from_exception.__func__""",
33+
"""exceptions.exit_on_exception""", """exceptions.exit_on_exception.__func__""",
34+
"""get_exit_code_from_exception""", """exit_on_exception""",
35+
"""skt""", """skt.__package__""", """skt.__module__""", """skt.__name__""",
3136
"""skt.__file__""", """skt.genSocket""", """skt.genSocket.__func__""", """genSocket""",
3237
"""skt.endSocket""", """skt.endSocket.__func__""", """endSocket""",
38+
"""EXIT_CODES""", """EXCEPTION_EXIT_CODES""",
3339
"""_BLANK""", """_MCAST_DEFAULT_PORT""", """_MCAST_DEFAULT_GROUP""",
3440
"""_MCAST_DEFAULT_TTL""", """mtool""", """recv""", """send""", """hear""",
3541
"""recv.McastRECV""", """send.McastSAY""", """hear.McastHEAR""",
@@ -352,6 +358,25 @@
352358
raise ModuleNotFoundError("FAIL: we could not import Abstract base class. ABORT.") from None
353359

354360

361+
global EXIT_CODES # skipcq: PYL-W0604
362+
global EXCEPTION_EXIT_CODES # skipcq: PYL-W0604
363+
if 'multicast.exceptions' not in sys.modules:
364+
# pylint: disable=cyclic-import - skipcq: PYL-R0401, PYL-C0414
365+
from . import exceptions # pylint: disable=cyclic-import - skipcq: PYL-R0401, PYL-C0414
366+
else: # pragma: no branch
367+
exceptions = sys.modules["""multicast.exceptions"""]
368+
369+
EXIT_CODES = exceptions.EXIT_CODES
370+
371+
EXCEPTION_EXIT_CODES = exceptions.EXCEPTION_EXIT_CODES
372+
373+
CommandExecutionError = exceptions.CommandExecutionError
374+
375+
get_exit_code_from_exception = exceptions.get_exit_code_from_exception
376+
377+
exit_on_exception = exceptions.exit_on_exception
378+
379+
355380
class mtool(abc.ABC):
356381
"""
357382
Class for Multicast tools.

multicast/exceptions.py

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
#! /usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
4+
# Python Multicast Repo
5+
# ..................................
6+
# Copyright (c) 2017-2024, Mr. Walls
7+
# ..................................
8+
# Licensed under MIT (the "License");
9+
# you may not use this file except in compliance with the License.
10+
# You may obtain a copy of the License at
11+
# ..........................................
12+
# http://www.github.com/reactive-firewall/python-repo/LICENSE.md
13+
# ..........................................
14+
# Unless required by applicable law or agreed to in writing, software
15+
# distributed under the License is distributed on an "AS IS" BASIS,
16+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
# See the License for the specific language governing permissions and
18+
# limitations under the License.
19+
20+
"""Provides multicast exception features.
21+
22+
Use for handling exit codes and exceptions from multicast. Contains classes and functions to
23+
handle exceptions and errors for/from the multicast module.
24+
25+
Caution: See details regarding dynamic imports [documented](../__init__.py) in this module.
26+
27+
Minimal Acceptance Testing:
28+
29+
First set up test fixtures by importing multicast.
30+
31+
Testcase 0: Multicast should be importable.
32+
33+
>>> import multicast
34+
>>> multicast.exceptions is not None
35+
True
36+
>>> multicast.__doc__ is not None
37+
True
38+
>>>
39+
40+
Testcase 1: Recv should be automatically imported.
41+
A: Test that the multicast component is initialized.
42+
B: Test that the exceptions component is initialized.
43+
C: Test that the exceptions component has __doc__
44+
45+
>>> multicast is not None
46+
True
47+
>>> multicast.exceptions is not None
48+
True
49+
>>> multicast.exceptions.__doc__ is not None
50+
True
51+
>>> type(multicast.exceptions.__doc__) == type(str(''''''))
52+
True
53+
>>>
54+
55+
Testcase 2: Exceptions should be detailed with some metadata.
56+
A: Test that the __MAGIC__ variables are initialized.
57+
B: Test that the __MAGIC__ variables are strings.
58+
59+
>>> multicast.exceptions is not None
60+
True
61+
>>> multicast.exceptions.__module__ is not None
62+
True
63+
>>> multicast.exceptions.__package__ is not None
64+
True
65+
>>> type(multicast.exceptions.__doc__) == type(multicast.exceptions.__module__)
66+
True
67+
>>>
68+
69+
"""
70+
71+
72+
__package__ = """multicast""" # skipcq: PYL-W0622
73+
"""
74+
The package of this program.
75+
76+
Minimal Acceptance Testing:
77+
78+
First set up test fixtures by importing multicast.
79+
80+
Testcase 0: Multicast should be importable.
81+
82+
>>> import multicast
83+
>>>
84+
85+
Testcase 1: Recv should be automatically imported.
86+
87+
>>> multicast.recv.__package__ is not None
88+
True
89+
>>>
90+
>>> multicast.recv.__package__ == multicast.__package__
91+
True
92+
>>>
93+
94+
"""
95+
96+
97+
__module__ = """multicast.exceptions"""
98+
"""
99+
The module of this program.
100+
101+
Minimal Acceptance Testing:
102+
103+
First set up test fixtures by importing multicast.
104+
105+
Testcase 0: Multicast should be importable.
106+
107+
>>> import multicast
108+
>>>
109+
110+
Testcase 1: Exceptions should be automatically imported.
111+
112+
>>> multicast.exceptions.__module__ is not None
113+
True
114+
>>>
115+
116+
"""
117+
118+
119+
__file__ = """multicast/exceptions.py"""
120+
"""The file of this component."""
121+
122+
123+
__name__ = """multicast.exceptions""" # skipcq: PYL-W0622
124+
"""The name of this component.
125+
126+
Minimal Acceptance Testing:
127+
128+
First set up test fixtures by importing multicast.
129+
130+
Testcase 0: Multicast should be importable.
131+
132+
>>> import multicast
133+
>>>
134+
135+
Testcase 1: Recv should be automatically imported.
136+
137+
>>> multicast.exceptions.__name__ is not None
138+
True
139+
>>>
140+
141+
"""
142+
143+
144+
try:
145+
from . import sys # skipcq: PYL-C0414
146+
from . import argparse # skipcq: PYL-C0414
147+
except Exception as err:
148+
baton = ImportError(err, str("[CWE-758] Module failed completely."))
149+
baton.module = __module__
150+
baton.path = __file__
151+
baton.__cause__ = err
152+
raise baton from err
153+
154+
155+
class CommandExecutionError(RuntimeError):
156+
"""
157+
Exception raised when a command execution fails.
158+
159+
Attributes:
160+
message (str) -- Description of the error.
161+
exit_code (int) -- The exit code associated with the error.
162+
163+
Meta-Testing:
164+
165+
Testcase 1: Initialization with message and exit code.
166+
A. - Initializes the error.
167+
B. - checks inheritance.
168+
C. - checks each attribute.
169+
170+
>>> error = CommandExecutionError("Failed to execute command", exit_code=1)
171+
>>> isinstance(error, RuntimeError)
172+
True
173+
>>> error.message
174+
'Failed to execute command'
175+
>>> error.exit_code
176+
1
177+
"""
178+
179+
def __init__(self, *args, **kwargs):
180+
"""
181+
Initialize CommandExecutionError with a message and exit code.
182+
183+
Parameters:
184+
message (str) -- Description of the error.
185+
exit_code (int) -- The exit code associated with the error.
186+
*args: Variable length argument list.
187+
**kwargs: Arbitrary keyword arguments.
188+
189+
Meta-Testing:
190+
191+
Testcase 1: Initialization with different exit code:
192+
A. - Initializes a CommandExecutionError with a specific exit code.
193+
B. - Checks the message is still set, as super class would.
194+
C. - check the specific exit code is 2.
195+
196+
>>> error = CommandExecutionError("Error message", 2)
197+
>>> error.message
198+
'Error message'
199+
>>> error.exit_code
200+
2
201+
"""
202+
if len(args) > 0 and isinstance(args[-1], int):
203+
exit_code = args[-1]
204+
args = args[:-1]
205+
else:
206+
exit_code = kwargs.pop("exit_code", 1)
207+
super().__init__(*args, **kwargs)
208+
self.message = args[0] if args else kwargs.get("message", "An error occurred")
209+
self.exit_code = exit_code
210+
211+
212+
EXIT_CODES = {
213+
0: (None, 'Success'),
214+
1: (RuntimeError, 'General Error'),
215+
2: (OSError, 'Misuse of Shell Builtins'),
216+
64: (argparse.ArgumentError, 'Usage Error'),
217+
65: (ValueError, 'Data Error'),
218+
66: (FileNotFoundError, 'No Input'),
219+
69: (ConnectionError, 'Unavailable Service'),
220+
70: (Exception, 'Internal Software Error'),
221+
77: (PermissionError, 'Permission Denied'),
222+
125: (BaseException, 'Critical Failure'),
223+
126: (AssertionError, 'Command Invoked Cannot Execute'),
224+
127: (ModuleNotFoundError, 'Command Not Found'),
225+
129: (None, 'Hangup (SIGHUP)'),
226+
130: (KeyboardInterrupt, 'Interrupt (SIGINT)'),
227+
134: (None, 'Abort (SIGABRT)'),
228+
137: (None, 'Killed (SIGKILL)'),
229+
141: (BrokenPipeError, 'Broken Pipe (SIGPIPE)'),
230+
143: (SystemExit, 'Terminated (SIGTERM)'),
231+
255: (None, 'Exit Status Out of Range'),
232+
}
233+
234+
235+
EXCEPTION_EXIT_CODES = {exc: code for code, (exc, _) in EXIT_CODES.items() if exc}
236+
237+
238+
def get_exit_code_from_exception(exc):
239+
for exc_class in EXCEPTION_EXIT_CODES:
240+
if isinstance(exc, exc_class):
241+
return EXCEPTION_EXIT_CODES[exc_class]
242+
return 70 # Default to 'Internal Software Error'
243+
244+
245+
def exit_on_exception(func):
246+
def wrapper(*args, **kwargs):
247+
try:
248+
return func(*args, **kwargs)
249+
except SystemExit as exc:
250+
# Handle SystemExit exceptions, possibly from argparse
251+
exit_code = exc.code if isinstance(exc.code, int) else 2
252+
if (sys.stdout.isatty()):
253+
print(
254+
f"{EXIT_CODES.get(exit_code, (1, 'General Error'))[1]}: {exc}",
255+
file=sys.stderr
256+
)
257+
raise SystemExit(code=exit_code) from exc
258+
# otherwise sys.exit(exit_code)
259+
except BaseException as exc:
260+
exit_code = get_exit_code_from_exception(exc)
261+
if (sys.stdout.isatty()):
262+
print(
263+
f"{EXIT_CODES[exit_code][1]}: {exc}",
264+
file=sys.stderr
265+
)
266+
raise SystemExit(code=exit_code) from exc
267+
# otherwise sys.exit(exit_code)
268+
return wrapper

tests/check_spelling

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ check_command git ;
127127
hash -p ./.github/tool_shlock_helper.sh shlock || exit 255 ;
128128
check_command shlock ;
129129

130+
131+
SCRIPT_FILE="test/check_spelling"
132+
130133
# Set codespell options
131134
CODESPELL_OPTIONS="--quiet-level=4 --builtin clear,rare,code -L assertIn"
132135

@@ -153,6 +156,19 @@ else
153156
exit 126 ;
154157
fi
155158

159+
function report_summary() {
160+
printf "::group::%s\n" "Results" ;
161+
# Improved reporting based on EXIT_CODE
162+
case "${EXIT_CODE}" in
163+
0) printf "::notice title=OK::%s\n" "OK: Found no detected spelling errors." ;;
164+
1) printf "::error file=${SCRIPT_FILE},line=${BASH_LINENO:-0},title=CHECK-SPELLING::%s\n" "FAIL: General failure during script execution." >&2 ;;
165+
3) printf "::error file=${SCRIPT_FILE},line=${BASH_LINENO:-0},title=CONFIGURATION::%s\n" "FAIL: Gathering repostory's requirements failed." >&2 ;; # git ls-tree command failed
166+
126) printf "::warning file=${SCRIPT_FILE},line=${BASH_LINENO:-0},title=SKIPPED::%s\n" "SKIP: Unable to continue script execution." >&2 ;;
167+
*) printf "::error file=${SCRIPT_FILE},line=${BASH_LINENO:-0},title=FAILED::%s\n" "FAIL: Detected spelling errors." >&2 ;;
168+
esac
169+
printf "::endgroup::\n" ;
170+
}
171+
156172
# this is how test files are found:
157173

158174
# THIS IS THE ACTUAL TEST
@@ -161,8 +177,9 @@ if _TEST_ROOT_DIR=$(git rev-parse --show-superproject-working-tree 2>/dev/null);
161177
if [ -z "${_TEST_ROOT_DIR}" ]; then
162178
_TEST_ROOT_DIR=$(git rev-parse --show-toplevel 2>/dev/null)
163179
fi
164-
else
165-
printf "\t%s\n" "FAIL: missing valid repository or source structure" >&2
180+
printf "::debug::%s\n" "Found ${_TEST_ROOT_DIR} ..." ;
181+
else
182+
printf "::error file=${SCRIPT_FILE},line=${BASH_LINENO:-0},title=${FUNCNAME:-$0}::%s\n" "FAIL: missing valid repository or source structure" >&2
166183
EXIT_CODE=40
167184
fi
168185

@@ -172,23 +189,19 @@ FILES_TO_CHECK=$(git ls-tree -r --full-tree --name-only HEAD -- *.md *.py *.txt
172189
# Enable auto-correction if '--fix' argument is provided
173190
if [[ "$1" == "--fix" ]]; then
174191
CODESPELL_OPTIONS="--write-changes --interactive 2 ${CODESPELL_OPTIONS}"
175-
printf "%s\n" "Auto-correction enabled."
192+
printf "::debug::%s\n" "Auto-correction enabled."
176193
fi
177194

178195
# THIS IS THE ACTUAL TEST
179196
# Iterate over files and run codespell
180197
for FILE in $FILES_TO_CHECK; do
181-
printf "%s\n" "Checking ${FILE}" ;
198+
printf "::group::%s\n" "Checking ${FILE}" ;
182199
{ codespell $CODESPELL_OPTIONS "${FILE}" || EXIT_CODE=$? ;} 2>/dev/null ; wait ;
200+
printf "::endgroup::\n" ;
183201
done
184202

185203
# cleaning up and reporting
186-
187-
if [[ ("${EXIT_CODE}" -eq 0) ]] ; then
188-
printf "%s\n" "OK: Found no detected spelling errors." ;
189-
else
190-
printf "%s\n" "FAIL: Found spelling errors." ;
191-
fi
204+
report_summary
192205

193206
cleanup || rm -f ${LOCK_FILE} 2>/dev/null || : ;
194207

0 commit comments

Comments
 (0)