klaatu-python 2.4.0__tar.gz → 2.5.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: klaatu-python
3
- Version: 2.4.0
3
+ Version: 2.5.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,7 @@ Description-Content-Type: text/markdown
13
13
 
14
14
  # klaatu-python
15
15
 
16
- A bunch of more or less useful Python utils without any 3rd party dependencies.
16
+ A bunch of more or less useful Python utils without _any_ 3rd party dependencies.
17
17
 
18
18
  ## Development install
19
19
 
@@ -1,6 +1,6 @@
1
1
  # klaatu-python
2
2
 
3
- A bunch of more or less useful Python utils without any 3rd party dependencies.
3
+ A bunch of more or less useful Python utils without _any_ 3rd party dependencies.
4
4
 
5
5
  ## Development install
6
6
 
@@ -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.4.0"
14
+ version = "2.5.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
@@ -1,8 +1,8 @@
1
1
  import re
2
- from abc import abstractmethod
2
+ from abc import ABC, abstractmethod
3
3
 
4
4
 
5
- class CaseType:
5
+ class CaseType(ABC):
6
6
  @staticmethod
7
7
  @abstractmethod
8
8
  def split(value: str) -> list[str]: ...
@@ -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,130 @@
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
@@ -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 = "&nbsp;"
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 = "&nbsp;"
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