cmd2 2.4.2__py3-none-any.whl → 2.5.9__py3-none-any.whl
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.
- cmd2/__init__.py +2 -6
- cmd2/ansi.py +16 -16
- cmd2/argparse_completer.py +1 -10
- cmd2/argparse_custom.py +29 -83
- cmd2/clipboard.py +3 -22
- cmd2/cmd2.py +658 -361
- cmd2/command_definition.py +43 -8
- cmd2/constants.py +3 -0
- cmd2/decorators.py +135 -128
- cmd2/exceptions.py +0 -1
- cmd2/history.py +52 -23
- cmd2/parsing.py +54 -49
- cmd2/plugin.py +8 -6
- cmd2/py_bridge.py +15 -4
- cmd2/rl_utils.py +57 -23
- cmd2/table_creator.py +1 -0
- cmd2/transcript.py +3 -2
- cmd2/utils.py +75 -35
- {cmd2-2.4.2.dist-info → cmd2-2.5.9.dist-info}/LICENSE +1 -1
- cmd2-2.5.9.dist-info/METADATA +218 -0
- cmd2-2.5.9.dist-info/RECORD +24 -0
- {cmd2-2.4.2.dist-info → cmd2-2.5.9.dist-info}/WHEEL +1 -1
- cmd2-2.4.2.dist-info/METADATA +0 -225
- cmd2-2.4.2.dist-info/RECORD +0 -24
- {cmd2-2.4.2.dist-info → cmd2-2.5.9.dist-info}/top_level.txt +0 -0
cmd2/rl_utils.py
CHANGED
@@ -2,6 +2,7 @@
|
|
2
2
|
"""
|
3
3
|
Imports the proper Readline for the platform and provides utility functions for it
|
4
4
|
"""
|
5
|
+
|
5
6
|
import sys
|
6
7
|
from enum import (
|
7
8
|
Enum,
|
@@ -28,19 +29,17 @@ from typing import (
|
|
28
29
|
|
29
30
|
# Prefer statically linked gnureadline if installed due to compatibility issues with libedit
|
30
31
|
try:
|
31
|
-
# noinspection PyPackageRequirements
|
32
32
|
import gnureadline as readline # type: ignore[import]
|
33
33
|
except ImportError:
|
34
34
|
# Note: If this actually fails, you should install gnureadline on Linux/Mac or pyreadline3 on Windows.
|
35
35
|
try:
|
36
|
-
# noinspection PyUnresolvedReferences
|
37
36
|
import readline # type: ignore[no-redef]
|
38
37
|
except ImportError: # pragma: no cover
|
39
38
|
pass
|
40
39
|
|
41
40
|
|
42
41
|
class RlType(Enum):
|
43
|
-
"""Readline library types we
|
42
|
+
"""Readline library types we support"""
|
44
43
|
|
45
44
|
GNU = 1
|
46
45
|
PYREADLINE = 2
|
@@ -70,8 +69,8 @@ if 'pyreadline3' in sys.modules:
|
|
70
69
|
)
|
71
70
|
|
72
71
|
# Check if we are running in a terminal
|
73
|
-
if sys.stdout.isatty(): # pragma: no cover
|
74
|
-
|
72
|
+
if sys.stdout is not None and sys.stdout.isatty(): # pragma: no cover
|
73
|
+
|
75
74
|
def enable_win_vt100(handle: HANDLE) -> bool:
|
76
75
|
"""
|
77
76
|
Enables VT100 character sequences in a Windows console
|
@@ -101,27 +100,18 @@ if 'pyreadline3' in sys.modules:
|
|
101
100
|
# Enable VT100 sequences for stdout and stderr
|
102
101
|
STD_OUT_HANDLE = -11
|
103
102
|
STD_ERROR_HANDLE = -12
|
104
|
-
# noinspection PyUnresolvedReferences
|
105
103
|
vt100_stdout_support = enable_win_vt100(readline.rl.console.GetStdHandle(STD_OUT_HANDLE))
|
106
|
-
# noinspection PyUnresolvedReferences
|
107
104
|
vt100_stderr_support = enable_win_vt100(readline.rl.console.GetStdHandle(STD_ERROR_HANDLE))
|
108
105
|
vt100_support = vt100_stdout_support and vt100_stderr_support
|
109
106
|
|
110
107
|
############################################################################################################
|
111
108
|
# pyreadline3 is incomplete in terms of the Python readline API. Add the missing functions we need.
|
112
109
|
############################################################################################################
|
113
|
-
# readline.redisplay()
|
114
|
-
try:
|
115
|
-
getattr(readline, 'redisplay')
|
116
|
-
except AttributeError:
|
117
|
-
# noinspection PyProtectedMember,PyUnresolvedReferences
|
118
|
-
readline.redisplay = readline.rl.mode._update_line
|
119
|
-
|
120
110
|
# readline.remove_history_item()
|
121
111
|
try:
|
122
112
|
getattr(readline, 'remove_history_item')
|
123
113
|
except AttributeError:
|
124
|
-
|
114
|
+
|
125
115
|
def pyreadline_remove_history_item(pos: int) -> None:
|
126
116
|
"""
|
127
117
|
An implementation of remove_history_item() for pyreadline3
|
@@ -141,7 +131,7 @@ if 'pyreadline3' in sys.modules:
|
|
141
131
|
|
142
132
|
elif 'gnureadline' in sys.modules or 'readline' in sys.modules:
|
143
133
|
# We don't support libedit. See top of this file for why.
|
144
|
-
if 'libedit' not in readline.__doc__:
|
134
|
+
if readline.__doc__ is not None and 'libedit' not in readline.__doc__:
|
145
135
|
try:
|
146
136
|
# Load the readline lib so we can access members of it
|
147
137
|
import ctypes
|
@@ -156,7 +146,7 @@ elif 'gnureadline' in sys.modules or 'readline' in sys.modules:
|
|
156
146
|
rl_type = RlType.GNU
|
157
147
|
vt100_support = sys.stdout.isatty()
|
158
148
|
|
159
|
-
# Check if
|
149
|
+
# Check if we loaded a supported version of readline
|
160
150
|
if rl_type == RlType.NONE: # pragma: no cover
|
161
151
|
if not _rl_warn_reason:
|
162
152
|
_rl_warn_reason = (
|
@@ -168,7 +158,6 @@ else:
|
|
168
158
|
rl_warning = ''
|
169
159
|
|
170
160
|
|
171
|
-
# noinspection PyProtectedMember,PyUnresolvedReferences
|
172
161
|
def rl_force_redisplay() -> None: # pragma: no cover
|
173
162
|
"""
|
174
163
|
Causes readline to display the prompt and input text wherever the cursor is and start
|
@@ -191,7 +180,6 @@ def rl_force_redisplay() -> None: # pragma: no cover
|
|
191
180
|
readline.rl.mode._update_line()
|
192
181
|
|
193
182
|
|
194
|
-
# noinspection PyProtectedMember, PyUnresolvedReferences
|
195
183
|
def rl_get_point() -> int: # pragma: no cover
|
196
184
|
"""
|
197
185
|
Returns the offset of the current cursor position in rl_line_buffer
|
@@ -206,9 +194,8 @@ def rl_get_point() -> int: # pragma: no cover
|
|
206
194
|
return 0
|
207
195
|
|
208
196
|
|
209
|
-
# noinspection PyUnresolvedReferences
|
210
197
|
def rl_get_prompt() -> str: # pragma: no cover
|
211
|
-
"""
|
198
|
+
"""Get Readline's prompt"""
|
212
199
|
if rl_type == RlType.GNU:
|
213
200
|
encoded_prompt = ctypes.c_char_p.in_dll(readline_lib, "rl_prompt").value
|
214
201
|
if encoded_prompt is None:
|
@@ -229,7 +216,24 @@ def rl_get_prompt() -> str: # pragma: no cover
|
|
229
216
|
return rl_unescape_prompt(prompt)
|
230
217
|
|
231
218
|
|
232
|
-
#
|
219
|
+
def rl_get_display_prompt() -> str: # pragma: no cover
|
220
|
+
"""
|
221
|
+
Get Readline's currently displayed prompt.
|
222
|
+
|
223
|
+
In GNU Readline, the displayed prompt sometimes differs from the prompt.
|
224
|
+
This occurs in functions that use the prompt string as a message area, such as incremental search.
|
225
|
+
"""
|
226
|
+
if rl_type == RlType.GNU:
|
227
|
+
encoded_prompt = ctypes.c_char_p.in_dll(readline_lib, "rl_display_prompt").value
|
228
|
+
if encoded_prompt is None:
|
229
|
+
prompt = ''
|
230
|
+
else:
|
231
|
+
prompt = encoded_prompt.decode(encoding='utf-8')
|
232
|
+
return rl_unescape_prompt(prompt)
|
233
|
+
else:
|
234
|
+
return rl_get_prompt()
|
235
|
+
|
236
|
+
|
233
237
|
def rl_set_prompt(prompt: str) -> None: # pragma: no cover
|
234
238
|
"""
|
235
239
|
Sets Readline's prompt
|
@@ -246,7 +250,8 @@ def rl_set_prompt(prompt: str) -> None: # pragma: no cover
|
|
246
250
|
|
247
251
|
|
248
252
|
def rl_escape_prompt(prompt: str) -> str:
|
249
|
-
"""
|
253
|
+
"""
|
254
|
+
Overcome bug in GNU Readline in relation to calculation of prompt length in presence of ANSI escape codes
|
250
255
|
|
251
256
|
:param prompt: original prompt
|
252
257
|
:return: prompt safe to pass to GNU Readline
|
@@ -285,3 +290,32 @@ def rl_unescape_prompt(prompt: str) -> str:
|
|
285
290
|
prompt = prompt.replace(escape_start, "").replace(escape_end, "")
|
286
291
|
|
287
292
|
return prompt
|
293
|
+
|
294
|
+
|
295
|
+
def rl_in_search_mode() -> bool: # pragma: no cover
|
296
|
+
"""Check if readline is doing either an incremental (e.g. Ctrl-r) or non-incremental (e.g. Esc-p) search"""
|
297
|
+
if rl_type == RlType.GNU:
|
298
|
+
# GNU Readline defines constants that we can use to determine if in search mode.
|
299
|
+
# RL_STATE_ISEARCH 0x0000080
|
300
|
+
# RL_STATE_NSEARCH 0x0000100
|
301
|
+
IN_SEARCH_MODE = 0x0000180
|
302
|
+
|
303
|
+
readline_state = ctypes.c_int.in_dll(readline_lib, "rl_readline_state").value
|
304
|
+
return bool(IN_SEARCH_MODE & readline_state)
|
305
|
+
elif rl_type == RlType.PYREADLINE:
|
306
|
+
from pyreadline3.modes.emacs import ( # type: ignore[import]
|
307
|
+
EmacsMode,
|
308
|
+
)
|
309
|
+
|
310
|
+
# These search modes only apply to Emacs mode, which is the default.
|
311
|
+
if not isinstance(readline.rl.mode, EmacsMode):
|
312
|
+
return False
|
313
|
+
|
314
|
+
# While in search mode, the current keyevent function is set one of the following.
|
315
|
+
search_funcs = (
|
316
|
+
readline.rl.mode._process_incremental_search_keyevent,
|
317
|
+
readline.rl.mode._process_non_incremental_search_keyevent,
|
318
|
+
)
|
319
|
+
return readline.rl.mode.process_keyevent_queue[-1] in search_funcs
|
320
|
+
else:
|
321
|
+
return False
|
cmd2/table_creator.py
CHANGED
@@ -5,6 +5,7 @@ This API is built upon two core classes: Column and TableCreator
|
|
5
5
|
The general use case is to inherit from TableCreator to create a table class with custom formatting options.
|
6
6
|
There are already implemented and ready-to-use examples of this below TableCreator's code.
|
7
7
|
"""
|
8
|
+
|
8
9
|
import copy
|
9
10
|
import io
|
10
11
|
from collections import (
|
cmd2/transcript.py
CHANGED
@@ -9,6 +9,7 @@ a unit test, comparing the expected output to the actual output.
|
|
9
9
|
This file contains the class necessary to make that work. This
|
10
10
|
class is used in cmd2.py::run_transcript_tests()
|
11
11
|
"""
|
12
|
+
|
12
13
|
import re
|
13
14
|
import unittest
|
14
15
|
from typing import (
|
@@ -60,7 +61,7 @@ class Cmd2TestCase(unittest.TestCase):
|
|
60
61
|
def runTest(self) -> None: # was testall
|
61
62
|
if self.cmdapp:
|
62
63
|
its = sorted(self.transcripts.items())
|
63
|
-
for
|
64
|
+
for fname, transcript in its:
|
64
65
|
self._test_transcript(fname, transcript)
|
65
66
|
|
66
67
|
def _fetchTranscripts(self) -> None:
|
@@ -204,7 +205,7 @@ class Cmd2TestCase(unittest.TestCase):
|
|
204
205
|
# escaped. We found it.
|
205
206
|
break
|
206
207
|
else:
|
207
|
-
# check if the slash is
|
208
|
+
# check if the slash is preceded by a backslash
|
208
209
|
if s[pos - 1 : pos] == '\\':
|
209
210
|
# it is.
|
210
211
|
if in_regex:
|
cmd2/utils.py
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
# coding=utf-8
|
2
2
|
"""Shared utility functions"""
|
3
|
+
|
3
4
|
import argparse
|
4
5
|
import collections
|
5
6
|
import functools
|
@@ -12,6 +13,9 @@ import subprocess
|
|
12
13
|
import sys
|
13
14
|
import threading
|
14
15
|
import unicodedata
|
16
|
+
from difflib import (
|
17
|
+
SequenceMatcher,
|
18
|
+
)
|
15
19
|
from enum import (
|
16
20
|
Enum,
|
17
21
|
)
|
@@ -41,12 +45,10 @@ from .argparse_custom import (
|
|
41
45
|
if TYPE_CHECKING: # pragma: no cover
|
42
46
|
import cmd2 # noqa: F401
|
43
47
|
|
44
|
-
PopenTextIO = subprocess.Popen[
|
45
|
-
|
48
|
+
PopenTextIO = subprocess.Popen[str]
|
46
49
|
else:
|
47
50
|
PopenTextIO = subprocess.Popen
|
48
51
|
|
49
|
-
|
50
52
|
_T = TypeVar('_T')
|
51
53
|
|
52
54
|
|
@@ -91,11 +93,14 @@ def strip_quotes(arg: str) -> str:
|
|
91
93
|
return arg
|
92
94
|
|
93
95
|
|
94
|
-
def
|
95
|
-
"""Converts
|
96
|
+
def to_bool(val: Any) -> bool:
|
97
|
+
"""Converts anything to a boolean based on its value.
|
98
|
+
|
99
|
+
Strings like "True", "true", "False", and "false" return True, True, False, and False
|
100
|
+
respectively. All other values are converted using bool()
|
96
101
|
|
97
|
-
:param val:
|
98
|
-
:return: boolean value expressed in the
|
102
|
+
:param val: value being converted
|
103
|
+
:return: boolean value expressed in the passed in value
|
99
104
|
:raises: ValueError if the string does not contain a value corresponding to a boolean value
|
100
105
|
"""
|
101
106
|
if isinstance(val, str):
|
@@ -103,7 +108,11 @@ def str_to_bool(val: str) -> bool:
|
|
103
108
|
return True
|
104
109
|
elif val.capitalize() == str(False):
|
105
110
|
return False
|
106
|
-
|
111
|
+
raise ValueError("must be True or False (case-insensitive)")
|
112
|
+
elif isinstance(val, bool):
|
113
|
+
return val
|
114
|
+
else:
|
115
|
+
return bool(val)
|
107
116
|
|
108
117
|
|
109
118
|
class Settable:
|
@@ -128,7 +137,7 @@ class Settable:
|
|
128
137
|
:param name: name of the instance attribute being made settable
|
129
138
|
:param val_type: callable used to cast the string value from the command line into its proper type and
|
130
139
|
even validate its value. Setting this to bool provides tab completion for true/false and
|
131
|
-
validation using
|
140
|
+
validation using to_bool(). The val_type function should raise an exception if it fails.
|
132
141
|
This exception will be caught and printed by Cmd.do_set().
|
133
142
|
:param description: string describing this setting
|
134
143
|
:param settable_object: object to which the instance attribute belongs (e.g. self)
|
@@ -149,13 +158,13 @@ class Settable:
|
|
149
158
|
:param choices_provider: function that provides choices for this argument
|
150
159
|
:param completer: tab completion function that provides choices for this argument
|
151
160
|
"""
|
152
|
-
if val_type
|
161
|
+
if val_type is bool:
|
153
162
|
|
154
163
|
def get_bool_choices(_) -> List[str]: # type: ignore[no-untyped-def]
|
155
164
|
"""Used to tab complete lowercase boolean values"""
|
156
165
|
return ['true', 'false']
|
157
166
|
|
158
|
-
val_type =
|
167
|
+
val_type = to_bool
|
159
168
|
choices_provider = cast(ChoicesProviderFunc, get_bool_choices)
|
160
169
|
|
161
170
|
self.name = name
|
@@ -169,17 +178,14 @@ class Settable:
|
|
169
178
|
self.completer = completer
|
170
179
|
|
171
180
|
def get_value(self) -> Any:
|
172
|
-
"""
|
173
|
-
Get the value of the settable attribute
|
174
|
-
:return:
|
175
|
-
"""
|
181
|
+
"""Get the value of the settable attribute."""
|
176
182
|
return getattr(self.settable_obj, self.settable_attrib_name)
|
177
183
|
|
178
|
-
def set_value(self, value: Any) ->
|
184
|
+
def set_value(self, value: Any) -> None:
|
179
185
|
"""
|
180
|
-
Set the settable attribute on the specified destination object
|
181
|
-
|
182
|
-
:
|
186
|
+
Set the settable attribute on the specified destination object.
|
187
|
+
|
188
|
+
:param value: new value to set
|
183
189
|
"""
|
184
190
|
# Run the value through its type function to handle any conversion or validation
|
185
191
|
new_value = self.val_type(value)
|
@@ -196,7 +202,6 @@ class Settable:
|
|
196
202
|
# Check if we need to call an onchange callback
|
197
203
|
if orig_value != new_value and self.onchange_cb:
|
198
204
|
self.onchange_cb(self.name, orig_value, new_value)
|
199
|
-
return new_value
|
200
205
|
|
201
206
|
|
202
207
|
def is_text_file(file_path: str) -> bool:
|
@@ -663,19 +668,21 @@ class ProcReader:
|
|
663
668
|
|
664
669
|
# Run until process completes
|
665
670
|
while self._proc.poll() is None:
|
666
|
-
# noinspection PyUnresolvedReferences
|
667
671
|
available = read_stream.peek() # type: ignore[attr-defined]
|
668
672
|
if available:
|
669
673
|
read_stream.read(len(available))
|
670
674
|
self._write_bytes(write_stream, available)
|
671
675
|
|
672
676
|
@staticmethod
|
673
|
-
def _write_bytes(stream: Union[StdSim, TextIO], to_write: bytes) -> None:
|
677
|
+
def _write_bytes(stream: Union[StdSim, TextIO], to_write: Union[bytes, str]) -> None:
|
674
678
|
"""
|
675
679
|
Write bytes to a stream
|
676
680
|
:param stream: the stream being written to
|
677
681
|
:param to_write: the bytes being written
|
678
682
|
"""
|
683
|
+
if isinstance(to_write, str):
|
684
|
+
to_write = to_write.encode()
|
685
|
+
|
679
686
|
try:
|
680
687
|
stream.buffer.write(to_write)
|
681
688
|
except BrokenPipeError:
|
@@ -863,7 +870,8 @@ def align_text(
|
|
863
870
|
)
|
864
871
|
|
865
872
|
if width is None:
|
866
|
-
|
873
|
+
# Prior to Python 3.11 this can return 0, so use a fallback if needed.
|
874
|
+
width = shutil.get_terminal_size().columns or constants.DEFAULT_TERMINAL_WIDTH
|
867
875
|
|
868
876
|
if width < 1:
|
869
877
|
raise ValueError("width must be at least 1")
|
@@ -1144,17 +1152,18 @@ def categorize(func: Union[Callable[..., Any], Iterable[Callable[..., Any]]], ca
|
|
1144
1152
|
:param func: function or list of functions to categorize
|
1145
1153
|
:param category: category to put it in
|
1146
1154
|
|
1147
|
-
|
1155
|
+
Example:
|
1156
|
+
|
1157
|
+
```py
|
1158
|
+
import cmd2
|
1159
|
+
class MyApp(cmd2.Cmd):
|
1160
|
+
def do_echo(self, arglist):
|
1161
|
+
self.poutput(' '.join(arglist)
|
1148
1162
|
|
1149
|
-
|
1150
|
-
|
1151
|
-
>>> def do_echo(self, arglist):
|
1152
|
-
>>> self.poutput(' '.join(arglist)
|
1153
|
-
>>>
|
1154
|
-
>>> cmd2.utils.categorize(do_echo, "Text Processing")
|
1163
|
+
cmd2.utils.categorize(do_echo, "Text Processing")
|
1164
|
+
```
|
1155
1165
|
|
1156
|
-
For an alternative approach to categorizing commands using a decorator, see
|
1157
|
-
:func:`~cmd2.decorators.with_category`
|
1166
|
+
For an alternative approach to categorizing commands using a decorator, see [cmd2.decorators.with_category][]
|
1158
1167
|
"""
|
1159
1168
|
if isinstance(func, Iterable):
|
1160
1169
|
for item in func:
|
@@ -1179,9 +1188,7 @@ def get_defining_class(meth: Callable[..., Any]) -> Optional[Type[Any]]:
|
|
1179
1188
|
if isinstance(meth, functools.partial):
|
1180
1189
|
return get_defining_class(meth.func)
|
1181
1190
|
if inspect.ismethod(meth) or (
|
1182
|
-
inspect.isbuiltin(meth)
|
1183
|
-
and getattr(meth, '__self__') is not None
|
1184
|
-
and getattr(meth.__self__, '__class__') # type: ignore[attr-defined]
|
1191
|
+
inspect.isbuiltin(meth) and getattr(meth, '__self__') is not None and getattr(meth.__self__, '__class__')
|
1185
1192
|
):
|
1186
1193
|
for cls in inspect.getmro(meth.__self__.__class__): # type: ignore[attr-defined]
|
1187
1194
|
if meth.__name__ in cls.__dict__:
|
@@ -1254,3 +1261,36 @@ def strip_doc_annotations(doc: str) -> str:
|
|
1254
1261
|
elif found_first:
|
1255
1262
|
break
|
1256
1263
|
return cmd_desc
|
1264
|
+
|
1265
|
+
|
1266
|
+
def similarity_function(s1: str, s2: str) -> float:
|
1267
|
+
# The ratio from s1,s2 may be different to s2,s1. We keep the max.
|
1268
|
+
# See https://docs.python.org/3/library/difflib.html#difflib.SequenceMatcher.ratio
|
1269
|
+
return max(SequenceMatcher(None, s1, s2).ratio(), SequenceMatcher(None, s2, s1).ratio())
|
1270
|
+
|
1271
|
+
|
1272
|
+
MIN_SIMIL_TO_CONSIDER = 0.7
|
1273
|
+
|
1274
|
+
|
1275
|
+
def suggest_similar(
|
1276
|
+
requested_command: str, options: Iterable[str], similarity_function_to_use: Optional[Callable[[str, str], float]] = None
|
1277
|
+
) -> Optional[str]:
|
1278
|
+
"""
|
1279
|
+
Given a requested command and an iterable of possible options returns the most similar (if any is similar)
|
1280
|
+
|
1281
|
+
:param requested_command: The command entered by the user
|
1282
|
+
:param options: The list of available commands to search for the most similar
|
1283
|
+
:param similarity_function_to_use: An optional callable to use to compare commands
|
1284
|
+
:return: The most similar command or None if no one is similar
|
1285
|
+
"""
|
1286
|
+
|
1287
|
+
proposed_command = None
|
1288
|
+
best_simil = MIN_SIMIL_TO_CONSIDER
|
1289
|
+
requested_command_to_compare = requested_command.lower()
|
1290
|
+
similarity_function_to_use = similarity_function_to_use or similarity_function
|
1291
|
+
for each in options:
|
1292
|
+
simil = similarity_function_to_use(each.lower(), requested_command_to_compare)
|
1293
|
+
if best_simil < simil:
|
1294
|
+
best_simil = simil
|
1295
|
+
proposed_command = each
|
1296
|
+
return proposed_command
|
@@ -1,6 +1,6 @@
|
|
1
1
|
The MIT License (MIT)
|
2
2
|
|
3
|
-
Copyright (c) 2008-
|
3
|
+
Copyright (c) 2008-2024 Catherine Devlin and others
|
4
4
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|