knickknacks 0.5.0__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.
- knickknacks/__init__.py +37 -0
- knickknacks/_version.py +1 -0
- knickknacks/backports.py +91 -0
- knickknacks/databytes.py +187 -0
- knickknacks/iterables.py +101 -0
- knickknacks/numbers.py +80 -0
- knickknacks/platforms.py +80 -0
- knickknacks/py.typed +0 -0
- knickknacks/strings.py +242 -0
- knickknacks/testing.py +56 -0
- knickknacks/typedef.py +73 -0
- knickknacks/utils.py +87 -0
- knickknacks/xml.py +115 -0
- knickknacks-0.5.0.dist-info/METADATA +61 -0
- knickknacks-0.5.0.dist-info/RECORD +18 -0
- knickknacks-0.5.0.dist-info/WHEEL +4 -0
- knickknacks-0.5.0.dist-info/entry_points.txt +4 -0
- knickknacks-0.5.0.dist-info/licenses/LICENSE.txt +21 -0
knickknacks/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Copyright (c) 2025 Nick Stockton
|
|
2
|
+
# -----------------------------------------------------------------------------
|
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
# furnished to do so, subject to the following conditions:
|
|
9
|
+
# -----------------------------------------------------------------------------
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
# copies or substantial portions of the Software.
|
|
12
|
+
# -----------------------------------------------------------------------------
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
# SOFTWARE.
|
|
20
|
+
|
|
21
|
+
# Future Modules:
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
# Built-in Modules:
|
|
25
|
+
from contextlib import suppress
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__version__: str = "0.0.0"
|
|
30
|
+
if not TYPE_CHECKING:
|
|
31
|
+
with suppress(ImportError):
|
|
32
|
+
from ._version import __version__
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__: list[str] = [
|
|
36
|
+
"__version__",
|
|
37
|
+
]
|
knickknacks/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__: str = "0.5.0"
|
knickknacks/backports.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Copyright (c) 2025 Nick Stockton
|
|
2
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
3
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
|
|
6
|
+
"""Backported classes and functions."""
|
|
7
|
+
|
|
8
|
+
# Future Modules:
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# Built-in Modules:
|
|
12
|
+
import io
|
|
13
|
+
import pathlib
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if sys.version_info >= (3, 11):
|
|
19
|
+
from enum import StrEnum
|
|
20
|
+
else:
|
|
21
|
+
from backports.strenum import StrEnum
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Path(pathlib.Path):
|
|
25
|
+
"""
|
|
26
|
+
Backported pathlib.Path functionality.
|
|
27
|
+
|
|
28
|
+
Currently backports the newline argument From 3.13 read_text, and 3.10 write_text.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def read_text(
|
|
32
|
+
self, encoding: Optional[str] = None, errors: Optional[str] = None, newline: Optional[str] = None
|
|
33
|
+
) -> str:
|
|
34
|
+
"""
|
|
35
|
+
Open the file in text mode, read it, and close the file.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
encoding: The character encoding to use.
|
|
39
|
+
errors: How encoding errors should be handled.
|
|
40
|
+
newline: How newlines should be handled.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The contents of the file.
|
|
44
|
+
"""
|
|
45
|
+
if sys.version_info >= (3, 13):
|
|
46
|
+
text = super().read_text(encoding, errors, newline)
|
|
47
|
+
else:
|
|
48
|
+
if hasattr(io, "text_encoding"):
|
|
49
|
+
encoding = io.text_encoding(encoding)
|
|
50
|
+
with self.open(mode="r", encoding=encoding, errors=errors, newline=newline) as f:
|
|
51
|
+
text = f.read()
|
|
52
|
+
return text
|
|
53
|
+
|
|
54
|
+
def write_text(
|
|
55
|
+
self,
|
|
56
|
+
data: str,
|
|
57
|
+
encoding: Optional[str] = None,
|
|
58
|
+
errors: Optional[str] = None,
|
|
59
|
+
newline: Optional[str] = None,
|
|
60
|
+
) -> int:
|
|
61
|
+
"""
|
|
62
|
+
Open the file in text mode, write to it, and close the file.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
data: The data to be written.
|
|
66
|
+
encoding: The character encoding to use.
|
|
67
|
+
errors: How encoding errors should be handled.
|
|
68
|
+
newline: How newlines should be handled.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The number of bytes written.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
TypeError: Data is not an instance of `str`.
|
|
75
|
+
"""
|
|
76
|
+
if sys.version_info >= (3, 10):
|
|
77
|
+
num_written = super().write_text(data, encoding, errors, newline)
|
|
78
|
+
else:
|
|
79
|
+
if not isinstance(data, str):
|
|
80
|
+
raise TypeError(f"data must be str, not {data.__class__.__name__}")
|
|
81
|
+
if hasattr(io, "text_encoding"):
|
|
82
|
+
encoding = io.text_encoding(encoding)
|
|
83
|
+
with self.open(mode="w", encoding=encoding, errors=errors, newline=newline) as f:
|
|
84
|
+
num_written = f.write(data)
|
|
85
|
+
return num_written
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__all__: list[str] = [
|
|
89
|
+
"Path",
|
|
90
|
+
"StrEnum",
|
|
91
|
+
]
|
knickknacks/databytes.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Copyright (c) 2025 Nick Stockton
|
|
2
|
+
# -----------------------------------------------------------------------------
|
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
# furnished to do so, subject to the following conditions:
|
|
9
|
+
# -----------------------------------------------------------------------------
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
# copies or substantial portions of the Software.
|
|
12
|
+
# -----------------------------------------------------------------------------
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
# SOFTWARE.
|
|
20
|
+
|
|
21
|
+
"""Stuff to do with bytes type objects."""
|
|
22
|
+
|
|
23
|
+
# Future Modules:
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
# Built-in Modules:
|
|
27
|
+
import codecs
|
|
28
|
+
from collections.abc import Generator
|
|
29
|
+
from contextlib import suppress
|
|
30
|
+
from typing import Union
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Latin-1 replacement values taken from the MUME help page.
|
|
34
|
+
# https://mume.org/help/latin1
|
|
35
|
+
LATIN_ENCODING_REPLACEMENTS: dict[str, bytes] = {
|
|
36
|
+
"\u00a0": b" ",
|
|
37
|
+
"\u00a1": b"!",
|
|
38
|
+
"\u00a2": b"c",
|
|
39
|
+
"\u00a3": b"L",
|
|
40
|
+
"\u00a4": b"$",
|
|
41
|
+
"\u00a5": b"Y",
|
|
42
|
+
"\u00a6": b"|",
|
|
43
|
+
"\u00a7": b"P",
|
|
44
|
+
"\u00a8": b'"',
|
|
45
|
+
"\u00a9": b"C",
|
|
46
|
+
"\u00aa": b"a",
|
|
47
|
+
"\u00ab": b"<",
|
|
48
|
+
"\u00ac": b",",
|
|
49
|
+
"\u00ad": b"-",
|
|
50
|
+
"\u00ae": b"R",
|
|
51
|
+
"\u00af": b"-",
|
|
52
|
+
"\u00b0": b"d",
|
|
53
|
+
"\u00b1": b"+",
|
|
54
|
+
"\u00b2": b"2",
|
|
55
|
+
"\u00b3": b"3",
|
|
56
|
+
"\u00b4": b"'",
|
|
57
|
+
"\u00b5": b"u",
|
|
58
|
+
"\u00b6": b"P",
|
|
59
|
+
"\u00b7": b"*",
|
|
60
|
+
"\u00b8": b",",
|
|
61
|
+
"\u00b9": b"1",
|
|
62
|
+
"\u00ba": b"o",
|
|
63
|
+
"\u00bb": b">",
|
|
64
|
+
"\u00bc": b"4",
|
|
65
|
+
"\u00bd": b"2",
|
|
66
|
+
"\u00be": b"3",
|
|
67
|
+
"\u00bf": b"?",
|
|
68
|
+
"\u00c0": b"A",
|
|
69
|
+
"\u00c1": b"A",
|
|
70
|
+
"\u00c2": b"A",
|
|
71
|
+
"\u00c3": b"A",
|
|
72
|
+
"\u00c4": b"A",
|
|
73
|
+
"\u00c5": b"A",
|
|
74
|
+
"\u00c6": b"A",
|
|
75
|
+
"\u00c7": b"C",
|
|
76
|
+
"\u00c8": b"E",
|
|
77
|
+
"\u00c9": b"E",
|
|
78
|
+
"\u00ca": b"E",
|
|
79
|
+
"\u00cb": b"E",
|
|
80
|
+
"\u00cc": b"I",
|
|
81
|
+
"\u00cd": b"I",
|
|
82
|
+
"\u00ce": b"I",
|
|
83
|
+
"\u00cf": b"I",
|
|
84
|
+
"\u00d0": b"D",
|
|
85
|
+
"\u00d1": b"N",
|
|
86
|
+
"\u00d2": b"O",
|
|
87
|
+
"\u00d3": b"O",
|
|
88
|
+
"\u00d4": b"O",
|
|
89
|
+
"\u00d5": b"O",
|
|
90
|
+
"\u00d6": b"O",
|
|
91
|
+
"\u00d7": b"*",
|
|
92
|
+
"\u00d8": b"O",
|
|
93
|
+
"\u00d9": b"U",
|
|
94
|
+
"\u00da": b"U",
|
|
95
|
+
"\u00db": b"U",
|
|
96
|
+
"\u00dc": b"U",
|
|
97
|
+
"\u00dd": b"Y",
|
|
98
|
+
"\u00de": b"T",
|
|
99
|
+
"\u00df": b"s",
|
|
100
|
+
"\u00e0": b"a",
|
|
101
|
+
"\u00e1": b"a",
|
|
102
|
+
"\u00e2": b"a",
|
|
103
|
+
"\u00e3": b"a",
|
|
104
|
+
"\u00e4": b"a",
|
|
105
|
+
"\u00e5": b"a",
|
|
106
|
+
"\u00e6": b"a",
|
|
107
|
+
"\u00e7": b"c",
|
|
108
|
+
"\u00e8": b"e",
|
|
109
|
+
"\u00e9": b"e",
|
|
110
|
+
"\u00ea": b"e",
|
|
111
|
+
"\u00eb": b"e",
|
|
112
|
+
"\u00ec": b"i",
|
|
113
|
+
"\u00ed": b"i",
|
|
114
|
+
"\u00ee": b"i",
|
|
115
|
+
"\u00ef": b"i",
|
|
116
|
+
"\u00f0": b"d",
|
|
117
|
+
"\u00f1": b"n",
|
|
118
|
+
"\u00f2": b"o",
|
|
119
|
+
"\u00f3": b"o",
|
|
120
|
+
"\u00f4": b"o",
|
|
121
|
+
"\u00f5": b"o",
|
|
122
|
+
"\u00f6": b"o",
|
|
123
|
+
"\u00f7": b"/",
|
|
124
|
+
"\u00f8": b"o",
|
|
125
|
+
"\u00f9": b"u",
|
|
126
|
+
"\u00fa": b"u",
|
|
127
|
+
"\u00fb": b"u",
|
|
128
|
+
"\u00fc": b"u",
|
|
129
|
+
"\u00fd": b"y",
|
|
130
|
+
"\u00fe": b"t",
|
|
131
|
+
"\u00ff": b"y",
|
|
132
|
+
}
|
|
133
|
+
LATIN_DECODING_REPLACEMENTS: dict[int, str] = {
|
|
134
|
+
ord(k): str(v, "us-ascii") for k, v in LATIN_ENCODING_REPLACEMENTS.items()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def decode_bytes(data: bytes) -> str:
|
|
139
|
+
"""
|
|
140
|
+
Decodes bytes into a string.
|
|
141
|
+
|
|
142
|
+
If data contains Latin-1 characters, they will be replaced with ASCII equivalents.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
data: The data to be decoded.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
The decoded string.
|
|
149
|
+
"""
|
|
150
|
+
# Try to decode ASCII first, for speed.
|
|
151
|
+
with suppress(UnicodeDecodeError):
|
|
152
|
+
return str(data, "us-ascii")
|
|
153
|
+
# Translate non-ASCII characters to their ASCII equivalents.
|
|
154
|
+
try:
|
|
155
|
+
# If encoded UTF-8, re-encode the data before decoding because of multi-byte code points.
|
|
156
|
+
return data.decode("utf-8").encode("us-ascii", "latin_to_ascii").decode("us-ascii")
|
|
157
|
+
except UnicodeDecodeError:
|
|
158
|
+
# Assume data is encoded Latin-1.
|
|
159
|
+
return str(data, "us-ascii", "latin_to_ascii")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def iter_bytes(data: bytes) -> Generator[bytes, None, None]:
|
|
163
|
+
"""
|
|
164
|
+
A generator which yields each byte of a bytes-like object.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
data: The data to process.
|
|
168
|
+
|
|
169
|
+
Yields:
|
|
170
|
+
Each byte of data as a bytes object.
|
|
171
|
+
"""
|
|
172
|
+
for i in range(len(data)):
|
|
173
|
+
yield data[i : i + 1]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _latin_to_ascii(error: UnicodeError) -> tuple[Union[bytes, str], int]:
|
|
177
|
+
if isinstance(error, UnicodeEncodeError):
|
|
178
|
+
# Return value can be bytes or a string.
|
|
179
|
+
return LATIN_ENCODING_REPLACEMENTS.get(error.object[error.start], b"?"), error.start + 1
|
|
180
|
+
if isinstance(error, UnicodeDecodeError):
|
|
181
|
+
# Return value must be a string.
|
|
182
|
+
return LATIN_DECODING_REPLACEMENTS.get(error.object[error.start], "?"), error.start + 1
|
|
183
|
+
# Probably UnicodeTranslateError.
|
|
184
|
+
raise NotImplementedError("How'd you manage this?") from error
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
codecs.register_error("latin_to_ascii", _latin_to_ascii)
|
knickknacks/iterables.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Copyright (c) 2025 Nick Stockton
|
|
2
|
+
# -----------------------------------------------------------------------------
|
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
# furnished to do so, subject to the following conditions:
|
|
9
|
+
# -----------------------------------------------------------------------------
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
# copies or substantial portions of the Software.
|
|
12
|
+
# -----------------------------------------------------------------------------
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
# SOFTWARE.
|
|
20
|
+
|
|
21
|
+
"""Stuff to do with iterables."""
|
|
22
|
+
|
|
23
|
+
# Future Modules:
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
# Built-in Modules:
|
|
27
|
+
import re
|
|
28
|
+
import statistics
|
|
29
|
+
from collections.abc import Iterable, Sequence
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def average(items: Iterable[float]) -> float:
|
|
34
|
+
"""
|
|
35
|
+
Calculates the average item length of an iterable.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
items: The iterable of items.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The average item length.
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
return statistics.mean(items)
|
|
45
|
+
except statistics.StatisticsError:
|
|
46
|
+
# No items.
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def human_sort(lst: Sequence[str]) -> list[str]:
|
|
51
|
+
"""
|
|
52
|
+
Sorts a list of strings, with numbers sorted according to their numeric value.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
lst: The list of strings to be sorted.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The items of the list, with strings containing numbers sorted according to their numeric value.
|
|
59
|
+
"""
|
|
60
|
+
return sorted(
|
|
61
|
+
lst,
|
|
62
|
+
key=lambda item: [
|
|
63
|
+
int(text) if text.isdigit() else text for text in re.split(r"(\d+)", item, flags=re.UNICODE)
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def lpad_list(lst: Sequence[Any], padding: Any, count: int, *, fixed: bool = False) -> list[Any]:
|
|
69
|
+
"""
|
|
70
|
+
Pad the left side of a list.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
lst: The list to be padded.
|
|
74
|
+
padding: The item to use for padding.
|
|
75
|
+
count: The minimum size of the returned list.
|
|
76
|
+
fixed: True if the maximum size of the returned list should be restricted to count, False otherwise.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
A padded copy of the list.
|
|
80
|
+
"""
|
|
81
|
+
if fixed:
|
|
82
|
+
return [*[padding] * (count - len(lst)), *lst][:count]
|
|
83
|
+
return [*[padding] * (count - len(lst)), *lst]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def pad_list(lst: Sequence[Any], padding: Any, count: int, *, fixed: bool = False) -> list[Any]:
|
|
87
|
+
"""
|
|
88
|
+
Pad the right side of a list.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
lst: The list to be padded.
|
|
92
|
+
padding: The item to use for padding.
|
|
93
|
+
count: The minimum size of the returned list.
|
|
94
|
+
fixed: True if the maximum size of the returned list should be restricted to count, False otherwise.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
A padded copy of the list.
|
|
98
|
+
"""
|
|
99
|
+
if fixed:
|
|
100
|
+
return [*lst, *[padding] * (count - len(lst))][:count]
|
|
101
|
+
return [*lst, *[padding] * (count - len(lst))]
|
knickknacks/numbers.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Copyright (c) 2025 Nick Stockton
|
|
2
|
+
# -----------------------------------------------------------------------------
|
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
# furnished to do so, subject to the following conditions:
|
|
9
|
+
# -----------------------------------------------------------------------------
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
# copies or substantial portions of the Software.
|
|
12
|
+
# -----------------------------------------------------------------------------
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
# SOFTWARE.
|
|
20
|
+
|
|
21
|
+
"""Stuff to do with numbers."""
|
|
22
|
+
|
|
23
|
+
# Future Modules:
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
# Built-in Modules:
|
|
27
|
+
import fractions
|
|
28
|
+
import math
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def clamp(value: float, minimum: float, maximum: float) -> float:
|
|
32
|
+
"""
|
|
33
|
+
Clamps the given value between the given minimum and maximum values.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
value: The value to restrict inside the range defined by minimum and maximum.
|
|
37
|
+
minimum: The minimum value to compare against.
|
|
38
|
+
maximum: The maximum value to compare against.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The result between minimum and maximum.
|
|
42
|
+
"""
|
|
43
|
+
# Note the ignore to the linter.
|
|
44
|
+
# The linter would have me use a combination of min and max functions inside each other.
|
|
45
|
+
# Using a ternary operator is much more readable, and according to timeit, faster.
|
|
46
|
+
return minimum if value < minimum else maximum if value > maximum else value # NOQA: FURB136
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def float_to_fraction(number: float) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Converts a float to a fraction.
|
|
52
|
+
|
|
53
|
+
Note:
|
|
54
|
+
https://stackoverflow.com/questions/23344185/how-to-convert-a-decimal-number-into-fraction
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
number: The number to convert.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
A string containing the number as a fraction.
|
|
61
|
+
"""
|
|
62
|
+
return str(fractions.Fraction(number).limit_denominator())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def round_half_away_from_zero(number: float, decimals: int = 0) -> float:
|
|
66
|
+
"""
|
|
67
|
+
Rounds a float away from 0 if the fractional is 5 or more.
|
|
68
|
+
|
|
69
|
+
Note:
|
|
70
|
+
https://realpython.com/python-rounding
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
number: The number to round.
|
|
74
|
+
decimals: The number of fractional decimal places to round to.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The number after rounding.
|
|
78
|
+
"""
|
|
79
|
+
multiplier = 10**decimals
|
|
80
|
+
return math.copysign(math.floor(abs(number) * multiplier + 0.5) / multiplier, number)
|
knickknacks/platforms.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Copyright (c) 2025 Nick Stockton
|
|
2
|
+
# -----------------------------------------------------------------------------
|
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
# furnished to do so, subject to the following conditions:
|
|
9
|
+
# -----------------------------------------------------------------------------
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
# copies or substantial portions of the Software.
|
|
12
|
+
# -----------------------------------------------------------------------------
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
# SOFTWARE.
|
|
20
|
+
|
|
21
|
+
"""Stuff to do with platforms."""
|
|
22
|
+
|
|
23
|
+
# Future Modules:
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
# Built-in Modules:
|
|
27
|
+
import _imp # NOQA: PLC2701
|
|
28
|
+
import inspect
|
|
29
|
+
import sys
|
|
30
|
+
from functools import cache
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
# Local Modules:
|
|
34
|
+
from .utils import get_function_field
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@cache
|
|
38
|
+
def get_directory_path(*args: str) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Retrieves the path of the directory where the program is located.
|
|
41
|
+
|
|
42
|
+
If frozen, path is based on the location of the executable.
|
|
43
|
+
If not frozen, path is based on the location of the module which called this function.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
*args: Positional arguments to be passed to Path.joinpath after the directory path.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The path.
|
|
50
|
+
"""
|
|
51
|
+
if is_frozen():
|
|
52
|
+
path = Path(sys.executable).parent
|
|
53
|
+
else:
|
|
54
|
+
frame = get_function_field(1)
|
|
55
|
+
path = Path(inspect.getabsfile(frame)).parent
|
|
56
|
+
return str(path.joinpath(*args).resolve())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@cache
|
|
60
|
+
def is_frozen() -> bool:
|
|
61
|
+
"""
|
|
62
|
+
Determines whether the program is running from a frozen copy or from source.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
True if frozen, False otherwise.
|
|
66
|
+
"""
|
|
67
|
+
return bool(getattr(sys, "frozen", False) or hasattr(sys, "importers") or _imp.is_frozen("__main__"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def touch(name: str) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Touches a file.
|
|
73
|
+
|
|
74
|
+
I.E. creates the file if it doesn't exist, or updates the modified time of the file if it does.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
name: the file name to touch.
|
|
78
|
+
"""
|
|
79
|
+
path: Path = Path(name).resolve()
|
|
80
|
+
path.touch()
|
knickknacks/py.typed
ADDED
|
File without changes
|
knickknacks/strings.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# Copyright (c) 2025 Nick Stockton
|
|
2
|
+
# -----------------------------------------------------------------------------
|
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
# furnished to do so, subject to the following conditions:
|
|
9
|
+
# -----------------------------------------------------------------------------
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
# copies or substantial portions of the Software.
|
|
12
|
+
# -----------------------------------------------------------------------------
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
# SOFTWARE.
|
|
20
|
+
|
|
21
|
+
"""Stuff to do with strings."""
|
|
22
|
+
|
|
23
|
+
# Future Modules:
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
# Built-in Modules:
|
|
27
|
+
import re
|
|
28
|
+
import textwrap
|
|
29
|
+
from collections.abc import Callable, Sequence
|
|
30
|
+
from typing import Any, Optional, Union
|
|
31
|
+
|
|
32
|
+
# Local Modules:
|
|
33
|
+
from .typedef import BytesOrStrType, RePatternType
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
ANSI_COLOR_REGEX: RePatternType = re.compile(r"\x1b\[[\d;]+m")
|
|
37
|
+
INDENT_REGEX: RePatternType = re.compile(r"^(?P<indent>\s*)(?P<text>.*)", flags=re.UNICODE)
|
|
38
|
+
WHITE_SPACE_REGEX: RePatternType = re.compile(r"\s+", flags=re.UNICODE)
|
|
39
|
+
# Use negative look-ahead to exclude the space character from the \s character class.
|
|
40
|
+
# Another way to accomplish this would be to use negation (I.E. [^\S ]+).
|
|
41
|
+
WHITE_SPACE_EXCEPT_SPACE_REGEX: RePatternType = re.compile(r"(?:(?![ ])\s+)", flags=re.UNICODE)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def camel_case(text: str, delimiter: str) -> str:
|
|
45
|
+
"""
|
|
46
|
+
Converts text to camel case.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
text: The text to be converted.
|
|
50
|
+
delimiter: The delimiter between words.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The text in camel case.
|
|
54
|
+
"""
|
|
55
|
+
words = text.split(delimiter)
|
|
56
|
+
return "".join((*map(str.lower, words[:1]), *map(str.title, words[1:])))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def format_docstring(
|
|
60
|
+
function_or_string: Union[str, Callable[..., Any]], width: int = 79, prefix: Optional[str] = None
|
|
61
|
+
) -> str:
|
|
62
|
+
"""
|
|
63
|
+
Formats a docstring for displaying.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
function_or_string: The function containing the docstring, or the docstring its self.
|
|
67
|
+
width: The number of characters to word wrap each line to.
|
|
68
|
+
prefix: One or more characters to use for indention.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The formatted docstring.
|
|
72
|
+
"""
|
|
73
|
+
docstring = (
|
|
74
|
+
getattr(function_or_string, "__doc__", "") if callable(function_or_string) else function_or_string
|
|
75
|
+
)
|
|
76
|
+
# Remove any empty lines from the beginning, while keeping indention.
|
|
77
|
+
docstring = docstring.lstrip("\r\n")
|
|
78
|
+
match = INDENT_REGEX.search(docstring)
|
|
79
|
+
if match is not None and not match.group("indent"):
|
|
80
|
+
# The first line was not indented.
|
|
81
|
+
# Prefix the first line with the white space from the subsequent, non-empty
|
|
82
|
+
# line with the least amount of indention.
|
|
83
|
+
# This is needed so that textwrap.dedent will work.
|
|
84
|
+
docstring = min_indent("\n".join(docstring.splitlines()[1:])) + docstring
|
|
85
|
+
docstring = textwrap.dedent(docstring) # Remove common indention from lines.
|
|
86
|
+
docstring = docstring.rstrip() # Remove trailing white space from the end of the docstring.
|
|
87
|
+
# Word wrap long lines, while maintaining existing structure.
|
|
88
|
+
wrapped_lines = []
|
|
89
|
+
indent_level = 0
|
|
90
|
+
last_indent = ""
|
|
91
|
+
for line in docstring.splitlines():
|
|
92
|
+
match = INDENT_REGEX.search(line)
|
|
93
|
+
if match is None: # pragma: no cover
|
|
94
|
+
continue
|
|
95
|
+
indent, text = match.groups()
|
|
96
|
+
if len(indent) > len(last_indent):
|
|
97
|
+
indent_level += 1
|
|
98
|
+
elif len(indent) < len(last_indent):
|
|
99
|
+
indent_level -= 1
|
|
100
|
+
last_indent = indent
|
|
101
|
+
line_prefix = prefix * indent_level if prefix else indent
|
|
102
|
+
lines = textwrap.wrap(
|
|
103
|
+
text, width=width - len(line_prefix), break_long_words=False, break_on_hyphens=False
|
|
104
|
+
)
|
|
105
|
+
wrapped_lines.append(line_prefix + f"\n{line_prefix}".join(lines))
|
|
106
|
+
# Indent docstring lines with the prefix.
|
|
107
|
+
return textwrap.indent("\n".join(wrapped_lines), prefix=prefix or "")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def has_white_space(text: str) -> bool:
|
|
111
|
+
"""
|
|
112
|
+
Determines if string contains white space.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
text: The text to process.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
True if found, False otherwise.
|
|
119
|
+
"""
|
|
120
|
+
return WHITE_SPACE_REGEX.search(text) is not None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def has_white_space_except_space(text: str) -> bool:
|
|
124
|
+
"""
|
|
125
|
+
Determines if string contains white space other than space.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
text: The text to process.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
True if found, False otherwise.
|
|
132
|
+
"""
|
|
133
|
+
return WHITE_SPACE_EXCEPT_SPACE_REGEX.search(text) is not None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def min_indent(text: str) -> str:
|
|
137
|
+
"""
|
|
138
|
+
Retrieves the indention characters from the line with the least indention.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
text: the text to process.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The indention characters of the line with the least amount of indention.
|
|
145
|
+
"""
|
|
146
|
+
lines = []
|
|
147
|
+
for line in text.splitlines():
|
|
148
|
+
if line.strip("\r\n"):
|
|
149
|
+
match = INDENT_REGEX.search(line)
|
|
150
|
+
if match is not None:
|
|
151
|
+
lines.append(match.group("indent"))
|
|
152
|
+
return min(lines, default="", key=len)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def multi_replace(data: BytesOrStrType, replacements: Sequence[Sequence[BytesOrStrType]]) -> BytesOrStrType:
|
|
156
|
+
"""
|
|
157
|
+
Performs multiple replacement operations on a string or bytes-like object.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
data: The text to perform the replacements on.
|
|
161
|
+
replacements: A sequence of tuples, each containing the text to match and the replacement.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
The text with all the replacements applied.
|
|
165
|
+
"""
|
|
166
|
+
for old, new in replacements:
|
|
167
|
+
data = data.replace(old, new)
|
|
168
|
+
return data
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def regex_fuzzy(text: Union[str, Sequence[str]]) -> str:
|
|
172
|
+
"""
|
|
173
|
+
Creates a regular expression matching all or part of a string or sequence.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
text: The text to be converted.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
A regular expression string matching all or part of the text.
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
TypeError: If text is neither a string nor sequence of strings.
|
|
183
|
+
"""
|
|
184
|
+
if not isinstance(text, (str, Sequence)):
|
|
185
|
+
raise TypeError("Text must be either a string or sequence of strings.")
|
|
186
|
+
if not text:
|
|
187
|
+
return ""
|
|
188
|
+
if isinstance(text, str):
|
|
189
|
+
return "(".join(list(text)) + ")?" * (len(text) - 1)
|
|
190
|
+
return "|".join("(".join(list(item)) + ")?" * (len(item) - 1) for item in text)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def remove_white_space(text: str) -> str:
|
|
194
|
+
"""
|
|
195
|
+
Removes all white space characters.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
text: The text to process.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
The simplified version of the text.
|
|
202
|
+
"""
|
|
203
|
+
return WHITE_SPACE_REGEX.sub("", text)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def remove_white_space_except_space(text: str) -> str:
|
|
207
|
+
"""
|
|
208
|
+
Removes all white space characters except for space.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
text: The text to process.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
The simplified version of the text.
|
|
215
|
+
"""
|
|
216
|
+
return WHITE_SPACE_EXCEPT_SPACE_REGEX.sub("", text)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def simplified(text: str) -> str:
|
|
220
|
+
"""
|
|
221
|
+
Replaces one or more consecutive white space characters with a single space, and trims beginning and end.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
text: The text to process.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
The simplified version of the text.
|
|
228
|
+
"""
|
|
229
|
+
return WHITE_SPACE_REGEX.sub(" ", text).strip()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def strip_ansi(text: str) -> str:
|
|
233
|
+
"""
|
|
234
|
+
Strips ANSI escape sequences from text.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
text: The text to strip ANSI sequences from.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
The text with ANSI escape sequences stripped.
|
|
241
|
+
"""
|
|
242
|
+
return ANSI_COLOR_REGEX.sub("", text)
|
knickknacks/testing.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Copyright (c) 2025 Nick Stockton
|
|
2
|
+
# -----------------------------------------------------------------------------
|
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
# furnished to do so, subject to the following conditions:
|
|
9
|
+
# -----------------------------------------------------------------------------
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
# copies or substantial portions of the Software.
|
|
12
|
+
# -----------------------------------------------------------------------------
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
# SOFTWARE.
|
|
20
|
+
|
|
21
|
+
"""Stuff to do with testing."""
|
|
22
|
+
|
|
23
|
+
# Future Modules:
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
# Built-in Modules:
|
|
27
|
+
from collections.abc import Callable, Container
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ContainerEmptyMixin:
|
|
32
|
+
"""A mixin class to be used in unit tests."""
|
|
33
|
+
|
|
34
|
+
assertIsInstance: Callable[..., Any]
|
|
35
|
+
assertTrue: Callable[..., Any]
|
|
36
|
+
assertFalse: Callable[..., Any]
|
|
37
|
+
|
|
38
|
+
def assertContainerEmpty(self, obj: Container[Any]) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Asserts whether the given object is an empty container.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
obj: The object to test.
|
|
44
|
+
"""
|
|
45
|
+
self.assertIsInstance(obj, Container)
|
|
46
|
+
self.assertFalse(obj)
|
|
47
|
+
|
|
48
|
+
def assertContainerNotEmpty(self, obj: Container[Any]) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Asserts whether the given object is a non-empty container.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
obj: The object to test.
|
|
54
|
+
"""
|
|
55
|
+
self.assertIsInstance(obj, Container)
|
|
56
|
+
self.assertTrue(obj)
|
knickknacks/typedef.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Copyright (c) 2025 Nick Stockton
|
|
2
|
+
# -----------------------------------------------------------------------------
|
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
# furnished to do so, subject to the following conditions:
|
|
9
|
+
# -----------------------------------------------------------------------------
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
# copies or substantial portions of the Software.
|
|
12
|
+
# -----------------------------------------------------------------------------
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
# SOFTWARE.
|
|
20
|
+
|
|
21
|
+
"""Shared type definitions."""
|
|
22
|
+
|
|
23
|
+
# Future Modules:
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
# Built-in Modules:
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
from collections.abc import Mapping
|
|
30
|
+
from typing import Any, TypeVar, Union
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if sys.version_info >= (3, 12):
|
|
34
|
+
from typing import override
|
|
35
|
+
else:
|
|
36
|
+
from typing_extensions import override
|
|
37
|
+
if sys.version_info >= (3, 11):
|
|
38
|
+
from typing import Self
|
|
39
|
+
else:
|
|
40
|
+
from typing_extensions import Self
|
|
41
|
+
if sys.version_info >= (3, 10):
|
|
42
|
+
from typing import ParamSpec, TypeAlias
|
|
43
|
+
else:
|
|
44
|
+
from typing_extensions import ParamSpec, TypeAlias
|
|
45
|
+
# Literal from typing module has various issues in different Python versions, see:
|
|
46
|
+
# https://typing-extensions.readthedocs.io/en/latest/#Literal
|
|
47
|
+
if sys.version_info >= (3, 10, 1) or (3, 9, 8) <= sys.version_info < (3, 10):
|
|
48
|
+
from typing import Literal
|
|
49
|
+
else:
|
|
50
|
+
from typing_extensions import Literal # type: ignore[assignment]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
AnyMappingType: TypeAlias = Mapping[Any, Any]
|
|
54
|
+
BytesOrStrType = TypeVar("BytesOrStrType", bytes, str)
|
|
55
|
+
ReBytesMatchType: TypeAlias = Union[re.Match[bytes], None]
|
|
56
|
+
ReBytesPatternType: TypeAlias = re.Pattern[bytes]
|
|
57
|
+
ReMatchType: TypeAlias = Union[re.Match[str], None]
|
|
58
|
+
RePatternType: TypeAlias = re.Pattern[str]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__: list[str] = [
|
|
62
|
+
"AnyMappingType",
|
|
63
|
+
"BytesOrStrType",
|
|
64
|
+
"Literal",
|
|
65
|
+
"ParamSpec",
|
|
66
|
+
"ReBytesMatchType",
|
|
67
|
+
"ReBytesPatternType",
|
|
68
|
+
"ReMatchType",
|
|
69
|
+
"RePatternType",
|
|
70
|
+
"Self",
|
|
71
|
+
"TypeAlias",
|
|
72
|
+
"override",
|
|
73
|
+
]
|
knickknacks/utils.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Copyright (c) 2025 Nick Stockton
|
|
2
|
+
# -----------------------------------------------------------------------------
|
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
# furnished to do so, subject to the following conditions:
|
|
9
|
+
# -----------------------------------------------------------------------------
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
# copies or substantial portions of the Software.
|
|
12
|
+
# -----------------------------------------------------------------------------
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
# SOFTWARE.
|
|
20
|
+
|
|
21
|
+
"""Misc utilities."""
|
|
22
|
+
|
|
23
|
+
# Future Modules:
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
# Built-in Modules:
|
|
27
|
+
import inspect
|
|
28
|
+
import itertools
|
|
29
|
+
import shutil
|
|
30
|
+
import textwrap
|
|
31
|
+
from collections.abc import Sequence
|
|
32
|
+
from pydoc import pager
|
|
33
|
+
from types import FrameType
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_function_field(back: int = 0) -> FrameType:
|
|
37
|
+
"""
|
|
38
|
+
Retrieves the stack field for the function which called this function.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
back: The number of frames to go back.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The function stack field.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
AttributeError: Unable to get reference to function.
|
|
48
|
+
"""
|
|
49
|
+
counter = itertools.count()
|
|
50
|
+
frame = inspect.currentframe()
|
|
51
|
+
while frame is not None: # Note that this will always perform at least 1 loop.
|
|
52
|
+
if next(counter) > back:
|
|
53
|
+
return frame
|
|
54
|
+
frame = frame.f_back
|
|
55
|
+
raise AttributeError("Unable to get reference to function.")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_function_name(back: int = 0) -> str:
|
|
59
|
+
"""
|
|
60
|
+
Retrieves the name of the function which called this function.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
back: The number of frames to go back.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The function name, or an empty string if not found.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
return get_function_field(back + 1).f_code.co_name
|
|
70
|
+
except AttributeError:
|
|
71
|
+
return ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def page(lines: Sequence[str]) -> None:
|
|
75
|
+
"""
|
|
76
|
+
Displays lines using the pager if necessary.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
lines: The lines to be displayed.
|
|
80
|
+
"""
|
|
81
|
+
# This is necessary in order for lines with embedded new line characters to be properly handled.
|
|
82
|
+
lines = "\n".join(lines).splitlines()
|
|
83
|
+
width, _ = shutil.get_terminal_size()
|
|
84
|
+
# Word wrapping to 1 less than the terminal width is necessary to prevent
|
|
85
|
+
# occasional blank lines in the terminal output.
|
|
86
|
+
text = "\n".join(textwrap.fill(line.strip(), width - 1) for line in lines)
|
|
87
|
+
pager(text)
|
knickknacks/xml.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Copyright (c) 2025 Nick Stockton
|
|
2
|
+
# -----------------------------------------------------------------------------
|
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
# furnished to do so, subject to the following conditions:
|
|
9
|
+
# -----------------------------------------------------------------------------
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
# copies or substantial portions of the Software.
|
|
12
|
+
# -----------------------------------------------------------------------------
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
# SOFTWARE.
|
|
20
|
+
|
|
21
|
+
"""Stuff to do with XML."""
|
|
22
|
+
|
|
23
|
+
# Future Modules:
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
# Built-in Modules:
|
|
27
|
+
import re
|
|
28
|
+
from typing import Union
|
|
29
|
+
|
|
30
|
+
# Local Modules:
|
|
31
|
+
from .strings import multi_replace
|
|
32
|
+
from .typedef import ReBytesPatternType, RePatternType
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
ESCAPE_XML_STR_ENTITIES: tuple[tuple[str, str], ...] = (
|
|
36
|
+
("&", "&"), # & must always be first when escaping.
|
|
37
|
+
("<", "<"),
|
|
38
|
+
(">", ">"),
|
|
39
|
+
)
|
|
40
|
+
UNESCAPE_XML_STR_ENTITIES: tuple[tuple[str, str], ...] = tuple(
|
|
41
|
+
reversed( # & must always be last when unescaping.
|
|
42
|
+
tuple((second, first) for first, second in ESCAPE_XML_STR_ENTITIES)
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
ESCAPE_XML_BYTES_ENTITIES: tuple[tuple[bytes, bytes], ...] = tuple(
|
|
47
|
+
(bytes(first, "us-ascii"), bytes(second, "us-ascii")) for first, second in ESCAPE_XML_STR_ENTITIES
|
|
48
|
+
)
|
|
49
|
+
UNESCAPE_XML_BYTES_ENTITIES: tuple[tuple[bytes, bytes], ...] = tuple(
|
|
50
|
+
(bytes(first, "us-ascii"), bytes(second, "us-ascii")) for first, second in UNESCAPE_XML_STR_ENTITIES
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
UNESCAPE_XML_NUMERIC_BYTES_REGEX: ReBytesPatternType = re.compile(rb"&#(?P<hex>x?)(?P<value>[0-9a-zA-Z]+);")
|
|
54
|
+
XML_ATTRIBUTE_REGEX: RePatternType = re.compile(r"([\w-]+)(\s*=+\s*('[^']*'|\"[^\"]*\"|(?!['\"])[^\s]*))?")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def escape_xml_string(text: str) -> str:
|
|
58
|
+
"""
|
|
59
|
+
Escapes XML entities in a string.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
text: The string to escape.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
A copy of the string with XML entities escaped.
|
|
66
|
+
"""
|
|
67
|
+
return multi_replace(text, ESCAPE_XML_STR_ENTITIES)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_xml_attributes(text: str) -> dict[str, Union[str, None]]:
|
|
71
|
+
"""
|
|
72
|
+
Extracts XML attributes from a tag.
|
|
73
|
+
|
|
74
|
+
The supplied string must only contain attributes, not the tag name.
|
|
75
|
+
|
|
76
|
+
Note:
|
|
77
|
+
Adapted from the html.parser module of the Python standard library.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
text: The text to be parsed.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The extracted attributes.
|
|
84
|
+
"""
|
|
85
|
+
attributes: dict[str, Union[str, None]] = {}
|
|
86
|
+
for name, rest, value in XML_ATTRIBUTE_REGEX.findall(text):
|
|
87
|
+
if not rest:
|
|
88
|
+
attributes[name.lower()] = None
|
|
89
|
+
elif value[:1] == "'" == value[-1:] or value[:1] == '"' == value[-1:]:
|
|
90
|
+
# The value is enclosed in single or double quotes.
|
|
91
|
+
attributes[name.lower()] = value[1:-1] # Strip the quotes from beginning and end.
|
|
92
|
+
else:
|
|
93
|
+
attributes[name.lower()] = value
|
|
94
|
+
return attributes
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def unescape_xml_bytes(data: bytes) -> bytes:
|
|
98
|
+
"""
|
|
99
|
+
Unescapes XML entities in a bytes-like object.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
data: The data to unescape.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
A copy of the data with XML entities unescaped.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def reference_to_bytes(match: re.Match[bytes]) -> bytes:
|
|
109
|
+
is_hex: bytes = match.group("hex")
|
|
110
|
+
value: bytes = match.group("value")
|
|
111
|
+
return bytes((int(value, 16 if is_hex else 10),))
|
|
112
|
+
|
|
113
|
+
return multi_replace(
|
|
114
|
+
UNESCAPE_XML_NUMERIC_BYTES_REGEX.sub(reference_to_bytes, data), UNESCAPE_XML_BYTES_ENTITIES
|
|
115
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: knickknacks
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Small, reusable, miscellaneous pieces of code.
|
|
5
|
+
Keywords: utilities,misc,snippets,reusable
|
|
6
|
+
Author-Email: Nick Stockton <nstockton@users.noreply.github.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: Implementation
|
|
20
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
21
|
+
Classifier: Operating System :: MacOS
|
|
22
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
23
|
+
Classifier: Operating System :: Microsoft
|
|
24
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
25
|
+
Classifier: Operating System :: OS Independent
|
|
26
|
+
Classifier: Operating System :: POSIX
|
|
27
|
+
Classifier: Operating System :: POSIX :: BSD
|
|
28
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
29
|
+
Classifier: Operating System :: Unix
|
|
30
|
+
Classifier: Topic :: Software Development
|
|
31
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
32
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
33
|
+
Project-URL: homepage, https://github.com/nstockton/knickknacks
|
|
34
|
+
Project-URL: repository, https://github.com/nstockton/knickknacks
|
|
35
|
+
Project-URL: documentation, https://nstockton.github.io/knickknacks
|
|
36
|
+
Requires-Python: <4.0,>=3.9
|
|
37
|
+
Requires-Dist: backports-strenum<2.0,>=1.3; python_version < "3.11"
|
|
38
|
+
Requires-Dist: typing-extensions<5.0,>=4.8; python_version < "3.12"
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# Knickknacks
|
|
42
|
+
|
|
43
|
+
Small, reusable, miscellaneous pieces of code.
|
|
44
|
+
|
|
45
|
+
## License And Credits
|
|
46
|
+
|
|
47
|
+
Knickknacks is licensed under the terms of the [MIT License.](https://raw.githubusercontent.com/nstockton/knickknacks/master/LICENSE.txt "Knickknacks License")
|
|
48
|
+
|
|
49
|
+
### Running From Source
|
|
50
|
+
|
|
51
|
+
Install the [Python interpreter,](https://python.org "Python Home Page") and make sure it's in your path before running this package.
|
|
52
|
+
|
|
53
|
+
After Python is installed, execute the following commands from the top level directory of this repository to install the module dependencies.
|
|
54
|
+
```
|
|
55
|
+
python -m venv .venv
|
|
56
|
+
source .venv/bin/activate
|
|
57
|
+
pip install --upgrade --require-hashes --requirement requirements-uv.txt
|
|
58
|
+
uv sync
|
|
59
|
+
pre-commit install -t pre-commit
|
|
60
|
+
pre-commit install -t pre-push
|
|
61
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
knickknacks-0.5.0.dist-info/METADATA,sha256=LW4asf8LZ4R3VpKsSkuJ6TAq7TR5R5xhcvMYMPKiAvo,2636
|
|
2
|
+
knickknacks-0.5.0.dist-info/WHEEL,sha256=thaaA2w1JzcGC48WYufAs8nrYZjJm8LqNfnXFOFyCC4,90
|
|
3
|
+
knickknacks-0.5.0.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
|
|
4
|
+
knickknacks-0.5.0.dist-info/licenses/LICENSE.txt,sha256=sgXchWyijpvqc8wqAnRu-7zzpQTMpEDyevem_zmjLpo,1091
|
|
5
|
+
knickknacks/__init__.py,sha256=jHWvsPqbcu0Sqe27lyzl8UR2jc4sfSor-O8BmmAGxLA,1662
|
|
6
|
+
knickknacks/_version.py,sha256=MhBd0uNaxZ4MqDAgOH-FGDAdsHEU7g_lUS7ZKYr5n4I,28
|
|
7
|
+
knickknacks/backports.py,sha256=5fPHkDOBEWSm81y0R-Jx07vXv2EdySroTJpqpu3PtFo,2441
|
|
8
|
+
knickknacks/databytes.py,sha256=145ZllfCKPK-OrqSkPy9ceyG0jYcc_7paHGM4KlQeAc,5200
|
|
9
|
+
knickknacks/iterables.py,sha256=oHzd2512FXJ0Hd8vKF6w59MLRNCw_w78eYAxhn4dNx4,3419
|
|
10
|
+
knickknacks/numbers.py,sha256=hfgrWlmXgPk2q9JVurrwVAW1yIp6l7I_-8R7MFQTu94,3037
|
|
11
|
+
knickknacks/platforms.py,sha256=ZUO9ShBN6VzF5MtBNjAnH43CK_vsXJ1YLoqc5tXgw1A,2778
|
|
12
|
+
knickknacks/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
knickknacks/strings.py,sha256=QmifrjR6ddT81mLFSdq1yX2Ks0S7eJgn9bhyx4xqxpA,7738
|
|
14
|
+
knickknacks/testing.py,sha256=pOo3UhL0aCGhEip6i4xwjFYtDQoAHGQLnMIgfB0Uyo8,2214
|
|
15
|
+
knickknacks/typedef.py,sha256=eQAAtM2iKOer3org7NfrHIu6vuZQfK_Q7ycpvCE9x28,2777
|
|
16
|
+
knickknacks/utils.py,sha256=rQRSHHWAw05-AzcTs5oPLcrOIk2OLyqUwhSFBYQGsvY,3083
|
|
17
|
+
knickknacks/xml.py,sha256=qCy6lYxBO49inasCSwVvDwjEMOMIoz78pwR44LU6-Bw,4125
|
|
18
|
+
knickknacks-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Nick Stockton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|