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.
@@ -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
+ ]
@@ -0,0 +1 @@
1
+ __version__: str = "0.5.0"
@@ -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
+ ]
@@ -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)
@@ -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)
@@ -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
+ ("&", "&amp;"), # & must always be first when escaping.
37
+ ("<", "&lt;"),
38
+ (">", "&gt;"),
39
+ )
40
+ UNESCAPE_XML_STR_ENTITIES: tuple[tuple[str, str], ...] = tuple(
41
+ reversed( # &amp; 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: pdm-backend (2.4.3)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+
3
+ [gui_scripts]
4
+
@@ -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.