klaatu-python 2.4.0__tar.gz → 2.6.0__tar.gz
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.
- {klaatu_python-2.4.0 → klaatu_python-2.6.0}/PKG-INFO +4 -2
- {klaatu_python-2.4.0 → klaatu_python-2.6.0}/README.md +3 -1
- {klaatu_python-2.4.0 → klaatu_python-2.6.0}/pyproject.toml +2 -6
- {klaatu_python-2.4.0 → klaatu_python-2.6.0}/src/klaatu_python/case.py +2 -2
- klaatu_python-2.6.0/src/klaatu_python/utils/__init__.py +68 -0
- klaatu_python-2.6.0/src/klaatu_python/utils/dicts.py +89 -0
- klaatu_python-2.6.0/src/klaatu_python/utils/iterables.py +139 -0
- klaatu_python-2.6.0/src/klaatu_python/utils/numbers.py +165 -0
- klaatu_python-2.6.0/src/klaatu_python/utils/paths.py +63 -0
- klaatu_python-2.6.0/src/klaatu_python/utils/strings.py +16 -0
- klaatu_python-2.6.0/src/klaatu_python/utils/time.py +25 -0
- klaatu_python-2.6.0/src/klaatu_python/utils/urls.py +28 -0
- klaatu_python-2.6.0/src/klaatu_python/utils/various.py +28 -0
- klaatu_python-2.4.0/src/klaatu_python/utils.py +0 -436
- {klaatu_python-2.4.0 → klaatu_python-2.6.0}/LICENSE +0 -0
- {klaatu_python-2.4.0 → klaatu_python-2.6.0}/src/klaatu_python/__init__.py +0 -0
- {klaatu_python-2.4.0 → klaatu_python-2.6.0}/src/klaatu_python/lock.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: klaatu-python
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.0
|
|
4
4
|
Author: Robert Huselius
|
|
5
5
|
Author-email: Robert Huselius <robert@huseli.us>
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -13,7 +13,9 @@ Description-Content-Type: text/markdown
|
|
|
13
13
|
|
|
14
14
|
# klaatu-python
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
<img width="500" alt="thisprojectwascodedbyahumanbeing-wordart" src="https://github.com/user-attachments/assets/83071cfd-5aec-4a4d-897d-8aa4a933fa31" />
|
|
17
|
+
|
|
18
|
+
A bunch of more or less useful Python utils without _any_ 3rd party dependencies.
|
|
17
19
|
|
|
18
20
|
## Development install
|
|
19
21
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# klaatu-python
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<img width="500" alt="thisprojectwascodedbyahumanbeing-wordart" src="https://github.com/user-attachments/assets/83071cfd-5aec-4a4d-897d-8aa4a933fa31" />
|
|
4
|
+
|
|
5
|
+
A bunch of more or less useful Python utils without _any_ 3rd party dependencies.
|
|
4
6
|
|
|
5
7
|
## Development install
|
|
6
8
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["uv_build>=0.9.7
|
|
2
|
+
requires = ["uv_build>=0.9.7"]
|
|
3
3
|
build-backend = "uv_build"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
@@ -11,7 +11,7 @@ license-files = ["LICENSE"]
|
|
|
11
11
|
name = "klaatu-python"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.10"
|
|
14
|
-
version = "2.
|
|
14
|
+
version = "2.6.0"
|
|
15
15
|
|
|
16
16
|
[project.urls]
|
|
17
17
|
Homepage = "https://github.com/Eboreg/klaatu-python"
|
|
@@ -41,7 +41,3 @@ section-order = [
|
|
|
41
41
|
|
|
42
42
|
[tool.ruff.format]
|
|
43
43
|
skip-magic-trailing-comma = false
|
|
44
|
-
|
|
45
|
-
[tool.flake8]
|
|
46
|
-
max-doc-length = 79
|
|
47
|
-
max-line-length = 119
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from .dicts import filter_values_not_null, group_by, group_dicts, zip_dict_lists, zip_dicts
|
|
2
|
+
from .iterables import (
|
|
3
|
+
circulate,
|
|
4
|
+
first_not_null,
|
|
5
|
+
first_not_null_or_null,
|
|
6
|
+
getitem0,
|
|
7
|
+
getitem0_nullable,
|
|
8
|
+
getitem_nullable,
|
|
9
|
+
index_of_first,
|
|
10
|
+
most_common,
|
|
11
|
+
nonulls,
|
|
12
|
+
partition,
|
|
13
|
+
)
|
|
14
|
+
from .numbers import (
|
|
15
|
+
can_coerce_to_int,
|
|
16
|
+
coerce_between,
|
|
17
|
+
format_file_size,
|
|
18
|
+
int_to_string,
|
|
19
|
+
localize_float,
|
|
20
|
+
percent_rounded,
|
|
21
|
+
roman,
|
|
22
|
+
round_to_n,
|
|
23
|
+
rounded_percentage,
|
|
24
|
+
to_int,
|
|
25
|
+
)
|
|
26
|
+
from .paths import deepest_common_path, sanitize_path
|
|
27
|
+
from .strings import fancy_join
|
|
28
|
+
from .time import daterange, round_up_timedelta
|
|
29
|
+
from .urls import append_query_to_url, strip_url_query
|
|
30
|
+
from .various import get_binary_reply, is_truthy
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"append_query_to_url",
|
|
35
|
+
"can_coerce_to_int",
|
|
36
|
+
"circulate",
|
|
37
|
+
"coerce_between",
|
|
38
|
+
"daterange",
|
|
39
|
+
"deepest_common_path",
|
|
40
|
+
"fancy_join",
|
|
41
|
+
"filter_values_not_null",
|
|
42
|
+
"first_not_null_or_null",
|
|
43
|
+
"first_not_null",
|
|
44
|
+
"format_file_size",
|
|
45
|
+
"get_binary_reply",
|
|
46
|
+
"getitem_nullable",
|
|
47
|
+
"getitem0_nullable",
|
|
48
|
+
"getitem0",
|
|
49
|
+
"group_by",
|
|
50
|
+
"group_dicts",
|
|
51
|
+
"index_of_first",
|
|
52
|
+
"int_to_string",
|
|
53
|
+
"is_truthy",
|
|
54
|
+
"localize_float",
|
|
55
|
+
"most_common",
|
|
56
|
+
"nonulls",
|
|
57
|
+
"partition",
|
|
58
|
+
"percent_rounded",
|
|
59
|
+
"roman",
|
|
60
|
+
"round_to_n",
|
|
61
|
+
"round_up_timedelta",
|
|
62
|
+
"rounded_percentage",
|
|
63
|
+
"sanitize_path",
|
|
64
|
+
"strip_url_query",
|
|
65
|
+
"to_int",
|
|
66
|
+
"zip_dict_lists",
|
|
67
|
+
"zip_dicts",
|
|
68
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
2
|
+
from typing import TypeVar
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
_T = TypeVar("_T")
|
|
6
|
+
_T2 = TypeVar("_T2")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def filter_values_not_null(d: dict[_T, _T2 | None]) -> dict[_T, _T2]:
|
|
10
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def group_by(sequence: Sequence[_T], pred: Callable[[_T], _T2]) -> dict[_T2, list[_T]]:
|
|
14
|
+
"""
|
|
15
|
+
Groups `sequence` by the result of `pred` on each item. Returns dict with
|
|
16
|
+
those results as keys and sublists of `sequence` as values.
|
|
17
|
+
"""
|
|
18
|
+
result: dict[_T2, list[_T]] = {}
|
|
19
|
+
for item in sequence:
|
|
20
|
+
key = pred(item)
|
|
21
|
+
if key not in result:
|
|
22
|
+
result[key] = [item]
|
|
23
|
+
else:
|
|
24
|
+
result[key].append(item)
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def group_dicts(
|
|
29
|
+
dicts: Iterable[dict[str, _T]],
|
|
30
|
+
keys: list[str],
|
|
31
|
+
data_key: str = "data",
|
|
32
|
+
) -> list[dict[str, _T | list[dict[str, _T]]]]:
|
|
33
|
+
"""
|
|
34
|
+
In:
|
|
35
|
+
dicts = [
|
|
36
|
+
{"slug": "musikensmakt", "name": "Musikens Makt", "date": "2025-04-01", "count": 60},
|
|
37
|
+
{"slug": "musikensmakt", "name": "Musikens Makt", "date": "2025-04-02", "count": 64},
|
|
38
|
+
{"slug": "apanap", "name": "Apan Ap", "date": "2025-04-01", "count": 2},
|
|
39
|
+
]
|
|
40
|
+
keys = ["slug", "name"]
|
|
41
|
+
data_key = "dätä"
|
|
42
|
+
Out: [
|
|
43
|
+
{
|
|
44
|
+
"slug": "musikensmakt",
|
|
45
|
+
"name": "Musikens Makt",
|
|
46
|
+
"dätä": [{"date": "2025-04-01", "count": 60}, {"date": "2025-04-02", "count": 64}],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"slug": "apanap",
|
|
50
|
+
"name": "Apan Ap",
|
|
51
|
+
"dätä": [{"date": "2025-04-01", "count": 2}],
|
|
52
|
+
},
|
|
53
|
+
]
|
|
54
|
+
"""
|
|
55
|
+
result: dict[tuple[_T, ...], list] = {}
|
|
56
|
+
|
|
57
|
+
for d in dicts:
|
|
58
|
+
dd = d.copy()
|
|
59
|
+
d_key = tuple(d[key] for key in keys)
|
|
60
|
+
if d_key not in result:
|
|
61
|
+
result[d_key] = []
|
|
62
|
+
for key in keys:
|
|
63
|
+
del dd[key]
|
|
64
|
+
result[d_key].append(dd)
|
|
65
|
+
|
|
66
|
+
return [{data_key: v, **{keys[i]: k[i] for i in range(len(keys))}} for k, v in result.items()]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def zip_dicts(*dicts: dict) -> dict:
|
|
70
|
+
"""
|
|
71
|
+
Combines dicts into one dict. On duplicate keys, dicts later in the
|
|
72
|
+
sequence have priority.
|
|
73
|
+
"""
|
|
74
|
+
return {k: v for d in dicts for k, v in d.items()}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def zip_dict_lists(dict_lists: Iterable[Iterable[dict]], strict: bool = False) -> Iterator[dict]:
|
|
78
|
+
"""
|
|
79
|
+
Zips a list of dict lists and combines them into single dicts.
|
|
80
|
+
I.e. given dict_lists = [
|
|
81
|
+
[{a1: 1, a2: 2}, {b1: 3, b2: 4}],
|
|
82
|
+
[{a3: 5, a4: 6}, {b3: 7, b4: 8}],
|
|
83
|
+
],
|
|
84
|
+
these dicts will be yielded:
|
|
85
|
+
{a1: 1, a2: 2, a3: 5, a4: 6}
|
|
86
|
+
{b1: 3, b2: 4, b3: 7, b4: 8}
|
|
87
|
+
"""
|
|
88
|
+
for dicts in zip(*dict_lists, strict=strict):
|
|
89
|
+
yield zip_dicts(*dicts)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from collections.abc import Callable, Hashable, Iterable, Iterator, Sequence
|
|
2
|
+
from typing import Literal, TypeVar, overload
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
_T = TypeVar("_T")
|
|
6
|
+
_HashableT = TypeVar("_HashableT", bound=Hashable)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def circulate(lst: Iterable[_T], rounds: int) -> list[_T]:
|
|
10
|
+
"""
|
|
11
|
+
Shifts `lst` left `rounds` times. Good for e.g. circulating colours in
|
|
12
|
+
a graph.
|
|
13
|
+
"""
|
|
14
|
+
if not isinstance(lst, list):
|
|
15
|
+
lst = list(lst)
|
|
16
|
+
if lst and rounds:
|
|
17
|
+
for _ in range(rounds):
|
|
18
|
+
val = lst.pop(0)
|
|
19
|
+
lst.append(val)
|
|
20
|
+
return lst
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def first_not_null(*values: _T | None) -> _T:
|
|
24
|
+
ret = first_not_null_or_null(*values)
|
|
25
|
+
if ret is None:
|
|
26
|
+
raise TypeError("All values are None")
|
|
27
|
+
return ret
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def first_not_null_or_null(*values: _T | None) -> _T | None:
|
|
31
|
+
for value in values:
|
|
32
|
+
if value is not None:
|
|
33
|
+
return value
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@overload
|
|
38
|
+
def getitem0(seq: Iterable[_T], cond: Callable[[_T], bool] | None, nullable: Literal[False]) -> _T: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@overload
|
|
42
|
+
def getitem0(seq: Iterable[_T], cond: Callable[[_T], bool] | None, nullable: Literal[True]) -> _T | None: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def getitem0(seq, cond=None, nullable=False):
|
|
46
|
+
"""
|
|
47
|
+
@raises IndexError
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
if cond is None:
|
|
51
|
+
return list(seq)[0]
|
|
52
|
+
return [item for item in seq if cond(item)][0]
|
|
53
|
+
except IndexError as e:
|
|
54
|
+
if nullable:
|
|
55
|
+
return None
|
|
56
|
+
raise e
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def getitem0_nullable(seq: Iterable[_T], cond: Callable[[_T], bool] | None = None) -> _T | None:
|
|
60
|
+
return getitem0(seq, cond, True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def getitem_nullable(seq: Iterable[_T], idx: int, cond: Callable[[_T], bool] | None = None) -> _T | None:
|
|
64
|
+
"""
|
|
65
|
+
If `seq` has an item at position `idx`, return that item. Otherwise return
|
|
66
|
+
None. Similar to how QuerySet's first() & last() operate.
|
|
67
|
+
|
|
68
|
+
With `cond` set, it first filters `seq` for items where this function
|
|
69
|
+
evaluates as True, then tries to get item `idx` from the resulting list.
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
|
|
73
|
+
seq = [23, 43, 12, 56, 75, 1]
|
|
74
|
+
second_even = getitem_nullable(seq, 1, lambda item: item % 2 == 0)
|
|
75
|
+
# second_even == 56
|
|
76
|
+
seq = [1, 2, 3, 5, 7]
|
|
77
|
+
second_even = getitem_nullable(seq, 1, lambda item: item % 2 == 0)
|
|
78
|
+
# second_even == None
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
if cond is None:
|
|
82
|
+
return list(seq)[idx]
|
|
83
|
+
return [item for item in seq if cond(item)][idx]
|
|
84
|
+
except IndexError:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def index_of_first(sequence: Sequence[_T], pred: Callable[[_T], bool]) -> int:
|
|
89
|
+
"""
|
|
90
|
+
Tries to return the index of the first item in `sequence` for which the
|
|
91
|
+
function `pred` returns True. If no such item is found, return -1.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
return sequence.index(next(filter(pred, sequence)))
|
|
95
|
+
except StopIteration:
|
|
96
|
+
return -1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def most_common(values: Iterable[_HashableT]) -> tuple[_HashableT | None, int]:
|
|
100
|
+
"""
|
|
101
|
+
Given a non-empty iterable, this will return a 2-tuple with the most
|
|
102
|
+
commonly occurring value in said iterable, and the number of such
|
|
103
|
+
occurances.
|
|
104
|
+
|
|
105
|
+
Given an _empty_ iterable, it will return `(None, 0)`.
|
|
106
|
+
"""
|
|
107
|
+
occurrences: dict[_HashableT, int] = {}
|
|
108
|
+
|
|
109
|
+
for value in values:
|
|
110
|
+
if value not in occurrences:
|
|
111
|
+
occurrences[value] = 0
|
|
112
|
+
occurrences[value] += 1
|
|
113
|
+
|
|
114
|
+
if not occurrences:
|
|
115
|
+
return None, 0
|
|
116
|
+
|
|
117
|
+
return max(occurrences.items(), key=lambda tup: tup[1])
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def nonulls(items: Iterable[_T | None]) -> list[_T]:
|
|
121
|
+
"""Just filters away None values from `items`."""
|
|
122
|
+
return [item for item in items if item is not None]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def partition(items: Sequence[_T], length: int) -> Iterator[Sequence[_T]]:
|
|
126
|
+
"""Simply splits `items` into subsequences of max `length` items."""
|
|
127
|
+
offset = 0
|
|
128
|
+
while offset == 0 or offset < len(items):
|
|
129
|
+
yield items[offset : offset + length]
|
|
130
|
+
offset += length
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def seq_contains_seq(container: Sequence, containee: Sequence) -> bool:
|
|
134
|
+
if len(containee) > len(container):
|
|
135
|
+
return False
|
|
136
|
+
for idx in range(len(container) - len(containee) + 1):
|
|
137
|
+
if container[idx : idx + len(containee)] == containee:
|
|
138
|
+
return True
|
|
139
|
+
return False
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import locale
|
|
2
|
+
import re
|
|
3
|
+
from locale import format_string, localeconv
|
|
4
|
+
from math import floor, log10
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
|
+
from warnings import deprecated
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_NumberT = TypeVar("_NumberT", int, float)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
KILOBYTE = pow(2, 10)
|
|
13
|
+
MEGABYTE = pow(2, 20)
|
|
14
|
+
GIGABYTE = pow(2, 30)
|
|
15
|
+
TERABYTE = pow(2, 40)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def can_coerce_to_int(value: Any) -> bool:
|
|
19
|
+
return to_int(value) is not None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def coerce_between(value: _NumberT, min_value: _NumberT, max_value: _NumberT) -> _NumberT:
|
|
23
|
+
if value < min_value:
|
|
24
|
+
return min_value
|
|
25
|
+
if value > max_value:
|
|
26
|
+
return max_value
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_file_size(size: int, separator: str = "") -> str:
|
|
31
|
+
if size >= TERABYTE:
|
|
32
|
+
return locale.str(round(size / TERABYTE, 1)) + separator + "T"
|
|
33
|
+
if size >= GIGABYTE:
|
|
34
|
+
return locale.str(round(size / GIGABYTE, 1)) + separator + "G"
|
|
35
|
+
if size >= MEGABYTE:
|
|
36
|
+
return locale.str(round(size / MEGABYTE, 1)) + separator + "M"
|
|
37
|
+
if size >= KILOBYTE:
|
|
38
|
+
return locale.str(round(size / KILOBYTE, 1)) + separator + "K"
|
|
39
|
+
return str(size)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def int_to_string(value: int | None, locale: str, nbsp: bool = False) -> str:
|
|
43
|
+
"""
|
|
44
|
+
Formats an integer with locale-specific thousand separators, which are at
|
|
45
|
+
least correct for Swedish and English locales. (Usage of the functions in
|
|
46
|
+
the built-in `locale` package requires you to run setlocale() multiple
|
|
47
|
+
times, which is not thread safe and may be resource heavy, so we don't do
|
|
48
|
+
that.)
|
|
49
|
+
"""
|
|
50
|
+
if value is None:
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
if locale.lower().startswith("sv"):
|
|
54
|
+
separator = " "
|
|
55
|
+
else:
|
|
56
|
+
separator = ","
|
|
57
|
+
|
|
58
|
+
if separator == " " and nbsp:
|
|
59
|
+
separator = " "
|
|
60
|
+
|
|
61
|
+
# Neat line of code, huh? :)
|
|
62
|
+
# 1. Use absolute value to get rid of minus sign
|
|
63
|
+
# 2. Reverse str(value) in order to group characters from the end
|
|
64
|
+
# 3. Split it by groups of 3 digits
|
|
65
|
+
# 4. Remove empty values generated by re.split()
|
|
66
|
+
# 5. Re-reverse the digits in each group & join them with separator string
|
|
67
|
+
# 6. Re-reverse the order of the groups
|
|
68
|
+
# 7. Add minus sign if value was negative
|
|
69
|
+
return ("-" if value < 0 else "") + separator.join(
|
|
70
|
+
[v[::-1] for v in re.split(r"(\d{3})", str(abs(value))[::-1]) if v][::-1]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def localize_float(
|
|
75
|
+
value: float,
|
|
76
|
+
grouping: bool = True,
|
|
77
|
+
min_decimals: int | None = None,
|
|
78
|
+
max_decimals: int | None = None,
|
|
79
|
+
) -> str:
|
|
80
|
+
if max_decimals is None:
|
|
81
|
+
max_decimals = 15
|
|
82
|
+
|
|
83
|
+
if min_decimals is not None and min_decimals > max_decimals:
|
|
84
|
+
raise ValueError("min_decimals must be <= max_decimals")
|
|
85
|
+
if 15 < (min_decimals or 0) < 0:
|
|
86
|
+
raise ValueError("min_decimals must be >= 0 and <= 15")
|
|
87
|
+
if 15 < (max_decimals or 0) < 0:
|
|
88
|
+
raise ValueError("max_decimals must be >= 0 and <= 15")
|
|
89
|
+
|
|
90
|
+
decimal_separator = localeconv()["decimal_point"]
|
|
91
|
+
ret = format_string("%.*f", (max_decimals, value), grouping=grouping)
|
|
92
|
+
|
|
93
|
+
if decimal_separator in ret:
|
|
94
|
+
base, decimals = ret.split(decimal_separator)
|
|
95
|
+
decimals = decimals.rstrip("0").ljust(min_decimals or 0, "0")
|
|
96
|
+
if not decimals:
|
|
97
|
+
return base
|
|
98
|
+
return base + decimal_separator + decimals
|
|
99
|
+
|
|
100
|
+
return ret
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@deprecated("There's rounded_percentage() instead.")
|
|
104
|
+
def percent_rounded(part: int | float, whole: int | float) -> int:
|
|
105
|
+
if not whole:
|
|
106
|
+
return 0
|
|
107
|
+
return round(part / whole * 100)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def roman(n: int) -> str:
|
|
111
|
+
if n % 1:
|
|
112
|
+
raise ValueError("roman(): fractions not allowed")
|
|
113
|
+
if n >= 4000 or n < 0:
|
|
114
|
+
raise ValueError("roman(): value must be >= 0 and < 4000")
|
|
115
|
+
|
|
116
|
+
m = {
|
|
117
|
+
1000: "M",
|
|
118
|
+
900: "CM",
|
|
119
|
+
500: "D",
|
|
120
|
+
400: "CD",
|
|
121
|
+
100: "C",
|
|
122
|
+
90: "XC",
|
|
123
|
+
50: "L",
|
|
124
|
+
40: "XL",
|
|
125
|
+
10: "X",
|
|
126
|
+
9: "IX",
|
|
127
|
+
5: "V",
|
|
128
|
+
4: "IV",
|
|
129
|
+
1: "I",
|
|
130
|
+
}
|
|
131
|
+
r = ""
|
|
132
|
+
|
|
133
|
+
for k, v in m.items():
|
|
134
|
+
while n >= k:
|
|
135
|
+
r += v
|
|
136
|
+
n -= k
|
|
137
|
+
|
|
138
|
+
return r
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def round_to_n(x: int | float, n: int) -> int | float:
|
|
142
|
+
"""
|
|
143
|
+
Rounds x to n significant digits. If the result is a whole number, it is
|
|
144
|
+
cast to int, otherwise float is returned.
|
|
145
|
+
"""
|
|
146
|
+
if x == 0:
|
|
147
|
+
return x
|
|
148
|
+
result = round(x, -int(floor(log10(abs(x)))) + (n - 1))
|
|
149
|
+
return int(result) if not result % 1 else result
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def rounded_percentage(part: int | float, whole: int | float, digits: int = 3) -> int | float:
|
|
153
|
+
"""
|
|
154
|
+
Percentage rounded to `digits` significant digits. Also division-by-zero
|
|
155
|
+
safe!
|
|
156
|
+
"""
|
|
157
|
+
return round_to_n((part / whole) * 100, digits) if whole != 0 else 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def to_int(value: Any, default: int | None = None) -> int | None:
|
|
161
|
+
"""Like int() but returns a default value instead of raising exception."""
|
|
162
|
+
try:
|
|
163
|
+
return int(value)
|
|
164
|
+
except (ValueError, TypeError):
|
|
165
|
+
return default
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def deepest_common_path(path1: Path, *paths: Path) -> Path:
|
|
7
|
+
"""
|
|
8
|
+
Finds and returns the deepest (as in "deep in the directory structure")
|
|
9
|
+
path that all parameters have in common.
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
>>> deepest_common_path(Path("/usr/local/bin/"), Path("/usr/share/"))
|
|
13
|
+
PosixPath('/usr')
|
|
14
|
+
"""
|
|
15
|
+
path1 = path1.resolve()
|
|
16
|
+
paths = tuple(p.resolve() for p in paths)
|
|
17
|
+
|
|
18
|
+
# Returned iterator will contain None unless all paths are identical:
|
|
19
|
+
if all(itertools.accumulate([path1, *paths], lambda p1, p2: p1 if p1 == p2 else None)):
|
|
20
|
+
return path1
|
|
21
|
+
|
|
22
|
+
paths_parts = [[p, *p.parents] for p in paths]
|
|
23
|
+
|
|
24
|
+
for part in [path1, *path1.parents]:
|
|
25
|
+
if all(part in parts for parts in paths_parts):
|
|
26
|
+
return part
|
|
27
|
+
|
|
28
|
+
raise ValueError("No common paths")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sanitize_path(s: str) -> str:
|
|
32
|
+
"""
|
|
33
|
+
Returns a string that _should_ work as a path on modern operating systems.
|
|
34
|
+
Forgot where I got all the rules from, though.
|
|
35
|
+
"""
|
|
36
|
+
def repl(m: re.Match[str]):
|
|
37
|
+
c = m.group()
|
|
38
|
+
# Different kinds of quotation marks:
|
|
39
|
+
if c in '\u2019\u2018\u201a\u201b\u201c\u201d\u201e\u201f\u2039\u203a\u2e42"«»':
|
|
40
|
+
return "'"
|
|
41
|
+
# Different kinds of dashes:
|
|
42
|
+
if c in "\u2010\u2011\u2012\u2013\u2014\u2015":
|
|
43
|
+
return "-"
|
|
44
|
+
# Ellipsis:
|
|
45
|
+
if c == "\u2026":
|
|
46
|
+
return "..."
|
|
47
|
+
# Don't bother replacing these:
|
|
48
|
+
if c in "¿¡":
|
|
49
|
+
return ""
|
|
50
|
+
if c.isalnum() or c in ",;-_.'()=&%$#£¢€¥°@!<>[]{}€ ":
|
|
51
|
+
return c
|
|
52
|
+
return " - "
|
|
53
|
+
|
|
54
|
+
# Basic character replacement:
|
|
55
|
+
s = re.sub(".", repl, s)
|
|
56
|
+
# Replace multiple spaces with single space:
|
|
57
|
+
s = re.sub(" {2,}", " ", s)
|
|
58
|
+
# Replace trailing " - " with empty string:
|
|
59
|
+
s = re.sub(" - $", "", s)
|
|
60
|
+
# Replace leading " - " with empty string:
|
|
61
|
+
s = re.sub("^ - ", "", s)
|
|
62
|
+
|
|
63
|
+
return s
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def fancy_join(parts: Iterable, operator: str = "and", oxford_comma: bool = True):
|
|
5
|
+
strings = [str(part) for part in parts]
|
|
6
|
+
|
|
7
|
+
if len(strings) == 0:
|
|
8
|
+
return ""
|
|
9
|
+
if len(strings) == 1:
|
|
10
|
+
return str(strings[0])
|
|
11
|
+
if len(strings) == 2:
|
|
12
|
+
return f"{strings[0]} {operator} {strings[1]}"
|
|
13
|
+
|
|
14
|
+
last_part = f"{',' if oxford_comma else ''} {operator} {strings.pop()}"
|
|
15
|
+
|
|
16
|
+
return ", ".join(strings) + last_part
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from datetime import date, timedelta
|
|
3
|
+
from math import ceil
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def daterange(start_date: date, end_date: date) -> Iterator[date]:
|
|
7
|
+
"""
|
|
8
|
+
Iterates the dates between `start_date` (inclusive) and `end_date`
|
|
9
|
+
(exclusive).
|
|
10
|
+
"""
|
|
11
|
+
for n in range(int((end_date - start_date).days)):
|
|
12
|
+
yield start_date + timedelta(days=n)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def round_up_timedelta(td: timedelta) -> timedelta:
|
|
16
|
+
"""
|
|
17
|
+
If td > 30 min, round up to nearest hour. Otherwise, to nearest 10
|
|
18
|
+
minute mark. Could be extended for higher time units, but nevermind now.
|
|
19
|
+
"""
|
|
20
|
+
td_minutes = td.total_seconds() / 60
|
|
21
|
+
if td_minutes > 30:
|
|
22
|
+
return timedelta(hours=ceil(td_minutes / 60))
|
|
23
|
+
if td_minutes >= 10:
|
|
24
|
+
return timedelta(minutes=int(td_minutes / 10) * 10 + 10)
|
|
25
|
+
return timedelta(minutes=10)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def append_query_to_url(url: str, params: dict, conditional_params: dict | None = None, safe: str = "") -> str:
|
|
5
|
+
"""
|
|
6
|
+
Adds GET query from `params` to `url`, or appends it if there already is
|
|
7
|
+
one.
|
|
8
|
+
|
|
9
|
+
`conditional_params` will only be used if GET params with those keys are
|
|
10
|
+
not already present in the original url or in `params`.
|
|
11
|
+
|
|
12
|
+
Return the new url.
|
|
13
|
+
"""
|
|
14
|
+
parts = urlsplit(url)
|
|
15
|
+
conditional_params = conditional_params or {}
|
|
16
|
+
qs = {
|
|
17
|
+
**conditional_params,
|
|
18
|
+
**parse_qs(parts.query),
|
|
19
|
+
**params,
|
|
20
|
+
}
|
|
21
|
+
parts = parts._replace(query=urlencode(qs, doseq=True, safe=safe))
|
|
22
|
+
return urlunsplit(parts)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def strip_url_query(url: str) -> str:
|
|
26
|
+
"""Just returns `url` stripped of any GET parameters."""
|
|
27
|
+
parts = urlsplit(url)
|
|
28
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", parts.fragment))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_binary_reply(prompt: str, default: bool | None = None) -> bool:
|
|
5
|
+
if default:
|
|
6
|
+
prompt += " [Y/n] "
|
|
7
|
+
elif default is None:
|
|
8
|
+
prompt += " [y/n] "
|
|
9
|
+
else:
|
|
10
|
+
prompt += " [y/N] "
|
|
11
|
+
|
|
12
|
+
while True:
|
|
13
|
+
reply = input(prompt).lower().strip()
|
|
14
|
+
if reply == "" and default is not None:
|
|
15
|
+
return default
|
|
16
|
+
if reply in ("y", "n"):
|
|
17
|
+
return reply == "y"
|
|
18
|
+
print("Wrong answer, try again.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_truthy(value: Any) -> bool:
|
|
22
|
+
"""
|
|
23
|
+
Basically does `bool(value)`, except it also returns False for string
|
|
24
|
+
values "false", "no", and "0" (case insensitive).
|
|
25
|
+
"""
|
|
26
|
+
if isinstance(value, str) and value.lower() in ("false", "no", "0"):
|
|
27
|
+
return False
|
|
28
|
+
return bool(value)
|
|
@@ -1,436 +0,0 @@
|
|
|
1
|
-
import itertools
|
|
2
|
-
import re
|
|
3
|
-
from collections.abc import Callable, Hashable, Iterable, Iterator, Sequence
|
|
4
|
-
from datetime import date, timedelta
|
|
5
|
-
from locale import format_string, localeconv
|
|
6
|
-
from math import ceil, floor, log10
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from typing import Any, Literal, TypeVar, overload
|
|
9
|
-
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
_T = TypeVar("_T")
|
|
13
|
-
_NT = TypeVar("_NT", int, float)
|
|
14
|
-
_HashableT = TypeVar("_HashableT", bound=Hashable)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def append_query_to_url(url: str, params: dict, conditional_params: dict | None = None, safe: str = "") -> str:
|
|
18
|
-
"""
|
|
19
|
-
Adds GET query from `params` to `url`, or appends it if there already is
|
|
20
|
-
one.
|
|
21
|
-
|
|
22
|
-
`conditional_params` will only be used if GET params with those keys are
|
|
23
|
-
not already present in the original url or in `params`.
|
|
24
|
-
|
|
25
|
-
Return the new url.
|
|
26
|
-
"""
|
|
27
|
-
parts = urlsplit(url)
|
|
28
|
-
conditional_params = conditional_params or {}
|
|
29
|
-
qs = {
|
|
30
|
-
**conditional_params,
|
|
31
|
-
**parse_qs(parts.query),
|
|
32
|
-
**params,
|
|
33
|
-
}
|
|
34
|
-
parts = parts._replace(query=urlencode(qs, doseq=True, safe=safe))
|
|
35
|
-
return urlunsplit(parts)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def can_coerce_to_int(value: Any) -> bool:
|
|
39
|
-
return to_int(value) is not None
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def circulate(lst: Iterable[_T], rounds: int) -> list[_T]:
|
|
43
|
-
"""
|
|
44
|
-
Shifts `lst` left `rounds` times. Good for e.g. circulating colours in
|
|
45
|
-
a graph.
|
|
46
|
-
"""
|
|
47
|
-
if not isinstance(lst, list):
|
|
48
|
-
lst = list(lst)
|
|
49
|
-
if lst and rounds:
|
|
50
|
-
for _ in range(rounds):
|
|
51
|
-
val = lst.pop(0)
|
|
52
|
-
lst.append(val)
|
|
53
|
-
return lst
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def coerce_between(value: _NT, min_value: _NT, max_value: _NT) -> _NT:
|
|
57
|
-
if value < min_value:
|
|
58
|
-
return min_value
|
|
59
|
-
if value > max_value:
|
|
60
|
-
return max_value
|
|
61
|
-
return value
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def daterange(start_date: date, end_date: date) -> Iterator[date]:
|
|
65
|
-
"""
|
|
66
|
-
Iterates the dates between `start_date` (inclusive) and `end_date`
|
|
67
|
-
(exclusive).
|
|
68
|
-
"""
|
|
69
|
-
for n in range(int((end_date - start_date).days)):
|
|
70
|
-
yield start_date + timedelta(days=n)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def deepest_common_path(path1: Path, *paths: Path) -> Path:
|
|
74
|
-
"""
|
|
75
|
-
Finds and returns the deepest (as in "deep in the directory structure")
|
|
76
|
-
path that all parameters have in common.
|
|
77
|
-
|
|
78
|
-
Example:
|
|
79
|
-
>>> deepest_common_path(Path("/usr/local/bin/"), Path("/usr/share/"))
|
|
80
|
-
PosixPath('/usr')
|
|
81
|
-
"""
|
|
82
|
-
path1 = path1.resolve()
|
|
83
|
-
paths = tuple(p.resolve() for p in paths)
|
|
84
|
-
|
|
85
|
-
# Returned iterator will contain None unless all paths are identical:
|
|
86
|
-
if all(itertools.accumulate([path1, *paths], lambda p1, p2: p1 if p1 == p2 else None)):
|
|
87
|
-
return path1
|
|
88
|
-
|
|
89
|
-
paths_parts = [[p, *p.parents] for p in paths]
|
|
90
|
-
|
|
91
|
-
for part in [path1, *path1.parents]:
|
|
92
|
-
if all(part in parts for parts in paths_parts):
|
|
93
|
-
return part
|
|
94
|
-
|
|
95
|
-
raise ValueError("No common paths")
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def filter_values_not_null(d: dict) -> dict:
|
|
99
|
-
return {k: v for k, v in d.items() if v is not None}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def first_not_null(*values: _T | None) -> _T:
|
|
103
|
-
ret = first_not_null_or_null(*values)
|
|
104
|
-
if ret is None:
|
|
105
|
-
raise TypeError("All values are None")
|
|
106
|
-
return ret
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def first_not_null_or_null(*values: _T | None) -> _T | None:
|
|
110
|
-
for value in values:
|
|
111
|
-
if value is not None:
|
|
112
|
-
return value
|
|
113
|
-
return None
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def getitem_nullable(seq: Iterable[_T], idx: int, cond: Callable[[_T], bool] | None = None) -> _T | None:
|
|
117
|
-
"""
|
|
118
|
-
If `seq` has an item at position `idx`, return that item. Otherwise return
|
|
119
|
-
None. Similar to how QuerySet's first() & last() operate.
|
|
120
|
-
|
|
121
|
-
With `cond` set, it first filters `seq` for items where this function
|
|
122
|
-
evaluates as True, then tries to get item `idx` from the resulting list.
|
|
123
|
-
|
|
124
|
-
Example:
|
|
125
|
-
|
|
126
|
-
seq = [23, 43, 12, 56, 75, 1]
|
|
127
|
-
second_even = getitem_nullable(seq, 1, lambda item: item % 2 == 0)
|
|
128
|
-
# second_even == 56
|
|
129
|
-
seq = [1, 2, 3, 5, 7]
|
|
130
|
-
second_even = getitem_nullable(seq, 1, lambda item: item % 2 == 0)
|
|
131
|
-
# second_even == None
|
|
132
|
-
"""
|
|
133
|
-
try:
|
|
134
|
-
if cond is None:
|
|
135
|
-
return list(seq)[idx]
|
|
136
|
-
return [item for item in seq if cond(item)][idx]
|
|
137
|
-
except IndexError:
|
|
138
|
-
return None
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
@overload
|
|
142
|
-
def getitem0(seq: Iterable[_T], cond: Callable[[_T], bool] | None, nullable: Literal[False]) -> _T: ...
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
@overload
|
|
146
|
-
def getitem0(seq: Iterable[_T], cond: Callable[[_T], bool] | None, nullable: Literal[True]) -> _T | None: ...
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def getitem0(seq, cond=None, nullable=False):
|
|
150
|
-
"""
|
|
151
|
-
@raises IndexError
|
|
152
|
-
"""
|
|
153
|
-
try:
|
|
154
|
-
if cond is None:
|
|
155
|
-
return list(seq)[0]
|
|
156
|
-
return [item for item in seq if cond(item)][0]
|
|
157
|
-
except IndexError as e:
|
|
158
|
-
if nullable:
|
|
159
|
-
return None
|
|
160
|
-
raise e
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def getitem0_nullable(seq: Iterable[_T], cond: Callable[[_T], bool] | None = None) -> _T | None:
|
|
164
|
-
return getitem0(seq, cond, True)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def group_by(sequence: Sequence[_T], pred: Callable[[_T], Any]) -> dict[Any, list[_T]]:
|
|
168
|
-
"""
|
|
169
|
-
Groups `sequence` by the result of `pred` on each item. Returns dict with
|
|
170
|
-
those results as keys and sublists of `sequence` as values.
|
|
171
|
-
"""
|
|
172
|
-
result: dict[Any, list[_T]] = {}
|
|
173
|
-
for item in sequence:
|
|
174
|
-
key = pred(item)
|
|
175
|
-
if key not in result:
|
|
176
|
-
result[key] = [item]
|
|
177
|
-
else:
|
|
178
|
-
result[key].append(item)
|
|
179
|
-
return result
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def group_dicts(
|
|
183
|
-
dicts: Iterable[dict[str, _T]],
|
|
184
|
-
keys: list[str],
|
|
185
|
-
data_key: str = "data",
|
|
186
|
-
) -> list[dict[str, _T | list[dict[str, _T]]]]:
|
|
187
|
-
"""
|
|
188
|
-
In:
|
|
189
|
-
dicts = [
|
|
190
|
-
{"slug": "musikensmakt", "name": "Musikens Makt", "date": "2025-04-01", "count": 60},
|
|
191
|
-
{"slug": "musikensmakt", "name": "Musikens Makt", "date": "2025-04-02", "count": 64},
|
|
192
|
-
{"slug": "apanap", "name": "Apan Ap", "date": "2025-04-01", "count": 2},
|
|
193
|
-
]
|
|
194
|
-
keys = ["slug", "name"]
|
|
195
|
-
data_key = "dätä"
|
|
196
|
-
Out: [
|
|
197
|
-
{
|
|
198
|
-
"slug": "musikensmakt",
|
|
199
|
-
"name": "Musikens Makt",
|
|
200
|
-
"dätä": [{"date": "2025-04-01", "count": 60}, {"date": "2025-04-02", "count": 64}],
|
|
201
|
-
},
|
|
202
|
-
{
|
|
203
|
-
"slug": "apanap",
|
|
204
|
-
"name": "Apan Ap",
|
|
205
|
-
"dätä": [{"date": "2025-04-01", "count": 2}],
|
|
206
|
-
},
|
|
207
|
-
]
|
|
208
|
-
"""
|
|
209
|
-
result: dict[tuple[_T, ...], list] = {}
|
|
210
|
-
|
|
211
|
-
for d in dicts:
|
|
212
|
-
dd = d.copy()
|
|
213
|
-
d_key = tuple(d[key] for key in keys)
|
|
214
|
-
if d_key not in result:
|
|
215
|
-
result[d_key] = []
|
|
216
|
-
for key in keys:
|
|
217
|
-
del dd[key]
|
|
218
|
-
result[d_key].append(dd)
|
|
219
|
-
|
|
220
|
-
return [{data_key: v, **{keys[i]: k[i] for i in range(len(keys))}} for k, v in result.items()]
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def index_of_first(sequence: Sequence[_T], pred: Callable[[_T], bool]) -> int:
|
|
224
|
-
"""
|
|
225
|
-
Tries to return the index of the first item in `sequence` for which the
|
|
226
|
-
function `pred` returns True. If no such item is found, return -1.
|
|
227
|
-
"""
|
|
228
|
-
try:
|
|
229
|
-
return sequence.index(next(filter(pred, sequence)))
|
|
230
|
-
except StopIteration:
|
|
231
|
-
return -1
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
def int_to_string(value: int | None, locale: str, nbsp: bool = False) -> str:
|
|
235
|
-
"""
|
|
236
|
-
Formats an integer with locale-specific thousand separators, which are at
|
|
237
|
-
least correct for Swedish and English locales. (Usage of the functions in
|
|
238
|
-
the built-in `locale` package requires you to run setlocale() multiple
|
|
239
|
-
times, which is not thread safe and may be resource heavy, so we don't do
|
|
240
|
-
that.)
|
|
241
|
-
"""
|
|
242
|
-
if value is None:
|
|
243
|
-
return ""
|
|
244
|
-
if locale.lower().startswith("sv"):
|
|
245
|
-
separator = " "
|
|
246
|
-
else:
|
|
247
|
-
separator = ","
|
|
248
|
-
if separator == " " and nbsp:
|
|
249
|
-
separator = " "
|
|
250
|
-
# Neat line of code, huh? :)
|
|
251
|
-
# 1. Use absolute value to get rid of minus sign
|
|
252
|
-
# 2. Reverse str(value) in order to group characters from the end
|
|
253
|
-
# 3. Split it by groups of 3 digits
|
|
254
|
-
# 4. Remove empty values generated by re.split()
|
|
255
|
-
# 5. Re-reverse the digits in each group & join them with separator string
|
|
256
|
-
# 6. Re-reverse the order of the groups
|
|
257
|
-
# 7. Add minus sign if value was negative
|
|
258
|
-
return ("-" if value < 0 else "") + separator.join(
|
|
259
|
-
[v[::-1] for v in re.split(r"(\d{3})", str(abs(value))[::-1]) if v][::-1]
|
|
260
|
-
)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
def is_truthy(value: Any) -> bool:
|
|
264
|
-
"""
|
|
265
|
-
Basically does `bool(value)`, except it also returns False for string
|
|
266
|
-
values "false", "no", and "0" (case insensitive).
|
|
267
|
-
"""
|
|
268
|
-
if isinstance(value, str) and value.lower() in ("false", "no", "0"):
|
|
269
|
-
return False
|
|
270
|
-
return bool(value)
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
def localize_float(
|
|
274
|
-
value: float,
|
|
275
|
-
grouping: bool = True,
|
|
276
|
-
min_decimals: int | None = None,
|
|
277
|
-
max_decimals: int | None = None,
|
|
278
|
-
) -> str:
|
|
279
|
-
if max_decimals is None:
|
|
280
|
-
max_decimals = 15
|
|
281
|
-
|
|
282
|
-
if min_decimals is not None and min_decimals > max_decimals:
|
|
283
|
-
raise ValueError("min_decimals must be <= max_decimals")
|
|
284
|
-
if 15 < (min_decimals or 0) < 0:
|
|
285
|
-
raise ValueError("min_decimals must be >= 0 and <= 15")
|
|
286
|
-
if 15 < (max_decimals or 0) < 0:
|
|
287
|
-
raise ValueError("max_decimals must be >= 0 and <= 15")
|
|
288
|
-
|
|
289
|
-
decimal_separator = localeconv()["decimal_point"]
|
|
290
|
-
ret = format_string("%.*f", (max_decimals, value), grouping=grouping)
|
|
291
|
-
|
|
292
|
-
if decimal_separator in ret:
|
|
293
|
-
base, decimals = ret.split(decimal_separator)
|
|
294
|
-
decimals = decimals.rstrip("0").ljust(min_decimals or 0, "0")
|
|
295
|
-
if not decimals:
|
|
296
|
-
return base
|
|
297
|
-
return base + decimal_separator + decimals
|
|
298
|
-
|
|
299
|
-
return ret
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
def most_common(values: Iterable[_HashableT]) -> tuple[_HashableT | None, int]:
|
|
303
|
-
"""
|
|
304
|
-
Given a non-empty iterable, this will return a 2-tuple with the most
|
|
305
|
-
commonly occurring value in said iterable, and the number of such
|
|
306
|
-
occurances.
|
|
307
|
-
|
|
308
|
-
Given an _empty_ iterable, it will return `(None, 0)`.
|
|
309
|
-
"""
|
|
310
|
-
occurrences: dict[_HashableT, int] = {}
|
|
311
|
-
|
|
312
|
-
for value in values:
|
|
313
|
-
if value not in occurrences:
|
|
314
|
-
occurrences[value] = 0
|
|
315
|
-
occurrences[value] += 1
|
|
316
|
-
|
|
317
|
-
if not occurrences:
|
|
318
|
-
return None, 0
|
|
319
|
-
|
|
320
|
-
return max(occurrences.items(), key=lambda tup: tup[1])
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
def nonulls(items: Iterable[_T | None]) -> list[_T]:
|
|
324
|
-
"""Just filters away None values from `items`."""
|
|
325
|
-
return [item for item in items if item is not None]
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
def partition(items: Sequence[_T], length: int) -> Iterator[Sequence[_T]]:
|
|
329
|
-
"""Simply splits `items` into subsequences of max `length` items."""
|
|
330
|
-
offset = 0
|
|
331
|
-
while offset == 0 or offset < len(items):
|
|
332
|
-
yield items[offset : offset + length]
|
|
333
|
-
offset += length
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
def percent_rounded(part: int | float, whole: int | float) -> int:
|
|
337
|
-
if not whole:
|
|
338
|
-
return 0
|
|
339
|
-
return round(part / whole * 100)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def roman(n: int):
|
|
343
|
-
if n % 1:
|
|
344
|
-
raise ValueError("roman(): fractions not allowed")
|
|
345
|
-
if n >= 4000 or n < 0:
|
|
346
|
-
raise ValueError("roman(): value must be >= 0 and < 4000")
|
|
347
|
-
|
|
348
|
-
m = {
|
|
349
|
-
1000: "M",
|
|
350
|
-
900: "CM",
|
|
351
|
-
500: "D",
|
|
352
|
-
400: "CD",
|
|
353
|
-
100: "C",
|
|
354
|
-
90: "XC",
|
|
355
|
-
50: "L",
|
|
356
|
-
40: "XL",
|
|
357
|
-
10: "X",
|
|
358
|
-
9: "IX",
|
|
359
|
-
5: "V",
|
|
360
|
-
4: "IV",
|
|
361
|
-
1: "I",
|
|
362
|
-
}
|
|
363
|
-
r = ""
|
|
364
|
-
|
|
365
|
-
for k, v in m.items():
|
|
366
|
-
while n >= k:
|
|
367
|
-
r += v
|
|
368
|
-
n -= k
|
|
369
|
-
|
|
370
|
-
return r
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
def round_to_n(x: int | float, n: int) -> int | float:
|
|
374
|
-
"""
|
|
375
|
-
Rounds x to n significant digits. If the result is a whole number, it is
|
|
376
|
-
cast to int, otherwise float is returned.
|
|
377
|
-
"""
|
|
378
|
-
if x == 0:
|
|
379
|
-
return x
|
|
380
|
-
result = round(x, -int(floor(log10(abs(x)))) + (n - 1))
|
|
381
|
-
return int(result) if not result % 1 else result
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
def round_up_timedelta(td: timedelta) -> timedelta:
|
|
385
|
-
"""
|
|
386
|
-
If td > 30 min, round up to nearest hour. Otherwise, to nearest 10
|
|
387
|
-
minute mark. Could be extended for higher time units, but nevermind now.
|
|
388
|
-
"""
|
|
389
|
-
td_minutes = td.total_seconds() / 60
|
|
390
|
-
if td_minutes > 30:
|
|
391
|
-
return timedelta(hours=ceil(td_minutes / 60))
|
|
392
|
-
if td_minutes >= 10:
|
|
393
|
-
return timedelta(minutes=int(td_minutes / 10) * 10 + 10)
|
|
394
|
-
return timedelta(minutes=10)
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
def rounded_percentage(part: int | float, whole: int | float, digits=3) -> int | float:
|
|
398
|
-
"""Percentage rounded to `digits` significant digits."""
|
|
399
|
-
return round_to_n((part / whole) * 100, digits) if whole != 0 else 0
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
def strip_url_query(url: str) -> str:
|
|
403
|
-
"""Just returns `url` stripped of any GET parameters."""
|
|
404
|
-
parts = urlsplit(url)
|
|
405
|
-
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", parts.fragment))
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
def to_int(value: Any, default: int | None = None) -> int | None:
|
|
409
|
-
"""Like int() but returns a default value instead of raising exception."""
|
|
410
|
-
try:
|
|
411
|
-
return int(value)
|
|
412
|
-
except (ValueError, TypeError):
|
|
413
|
-
return default
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
def zip_dicts(*dicts: dict) -> dict:
|
|
417
|
-
"""
|
|
418
|
-
Combines dicts into one dict. On duplicate keys, dicts later in the
|
|
419
|
-
sequence have priority.
|
|
420
|
-
"""
|
|
421
|
-
return {k: v for d in dicts for k, v in d.items()}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
def zip_dict_lists(dict_lists: Sequence[Sequence[dict]], strict: bool = False) -> Iterator[dict]:
|
|
425
|
-
"""
|
|
426
|
-
Zips a list of dict lists and combines them into single dicts.
|
|
427
|
-
I.e. given dict_lists = [
|
|
428
|
-
[{a1: 1, a2: 2}, {b1: 3, b2: 4}],
|
|
429
|
-
[{a3: 5, a4: 6}, {b3: 7, b4: 8}],
|
|
430
|
-
],
|
|
431
|
-
these dicts will be yielded:
|
|
432
|
-
{a1: 1, a2: 2, a3: 5, a4: 6}
|
|
433
|
-
{b1: 3, b2: 4, b3: 7, b4: 8}
|
|
434
|
-
"""
|
|
435
|
-
for dicts in zip(*dict_lists, strict=strict):
|
|
436
|
-
yield zip_dicts(*dicts)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|