klaatu-python 2.3.0__py3-none-any.whl → 2.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.
klaatu_python/case.py CHANGED
@@ -1,23 +1,23 @@
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
- def split(value: str) -> list[str]:
9
- ...
8
+ def split(value: str) -> list[str]: ...
10
9
 
11
10
  @staticmethod
12
11
  @abstractmethod
13
- def join(parts: list[str]) -> str:
14
- ...
12
+ def join(parts: list[str]) -> str: ...
15
13
 
16
14
 
17
15
  class CamelCase(CaseType):
16
+ """camelCase"""
17
+
18
18
  @staticmethod
19
19
  def split(value: str) -> list[str]:
20
- return [m.group() for m in re.finditer(r"(^[a-z]*)|([A-Z][a-z]*)", value)]
20
+ return [m.group() for m in re.finditer(r"(^[a-z]+)|([A-Z][a-z]*)", value)]
21
21
 
22
22
  @staticmethod
23
23
  def join(parts: list[str]) -> str:
@@ -27,6 +27,8 @@ class CamelCase(CaseType):
27
27
 
28
28
 
29
29
  class ConstantCase(CaseType):
30
+ """CONSTANT_CASE"""
31
+
30
32
  @staticmethod
31
33
  def split(value: str) -> list[str]:
32
34
  return value.split("_")
@@ -37,6 +39,8 @@ class ConstantCase(CaseType):
37
39
 
38
40
 
39
41
  class KebabCase(CaseType):
42
+ """kebab-case"""
43
+
40
44
  @staticmethod
41
45
  def split(value: str) -> list[str]:
42
46
  return value.split("-")
@@ -47,9 +51,11 @@ class KebabCase(CaseType):
47
51
 
48
52
 
49
53
  class PascalCase(CaseType):
54
+ """PascalCase"""
55
+
50
56
  @staticmethod
51
57
  def split(value: str) -> list[str]:
52
- return [m.group() for m in re.finditer(r"(^[a-z]*)|([A-Z][a-z]*)", value)]
58
+ return [m.group() for m in re.finditer(r"(^[a-z]+)|([A-Z][a-z]*)", value)]
53
59
 
54
60
  @staticmethod
55
61
  def join(parts: list[str]) -> str:
@@ -57,6 +63,8 @@ class PascalCase(CaseType):
57
63
 
58
64
 
59
65
  class SnakeCase(CaseType):
66
+ """snake_case"""
67
+
60
68
  @staticmethod
61
69
  def split(value: str) -> list[str]:
62
70
  return value.split("_")
klaatu_python/lock.py CHANGED
@@ -2,12 +2,12 @@ import functools
2
2
  import os
3
3
 
4
4
 
5
- class LockException(Exception):
6
- ...
5
+ class LockException(Exception): ...
7
6
 
8
7
 
9
8
  class Lock:
10
9
  """Does the same as `lock`, but as a context manager."""
10
+
11
11
  def __init__(self, lockfile: str):
12
12
  self.lockfile = lockfile
13
13
 
@@ -26,6 +26,7 @@ class Lock:
26
26
 
27
27
  def lock(lockfile: str):
28
28
  """Primitive mechanism to avoid concurrent execution of a function."""
29
+
29
30
  def decorator(func):
30
31
  @functools.wraps(func)
31
32
  def wrapper(*args, **kwargs):
@@ -44,5 +45,7 @@ def lock(lockfile: str):
44
45
  remote_attempts += 1
45
46
 
46
47
  return result
48
+
47
49
  return wrapper
50
+
48
51
  return decorator
@@ -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,24 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: klaatu-python
3
- Version: 2.3.0
3
+ Version: 2.5.0
4
+ Author: Robert Huselius
4
5
  Author-email: Robert Huselius <robert@huseli.us>
5
6
  License-Expression: GPL-3.0-only
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
6
9
  Project-URL: Homepage, https://github.com/Eboreg/klaatu-python
7
10
  Project-URL: Issues, https://github.com/Eboreg/klaatu-python/issues
8
11
  Project-URL: Source, https://github.com/Eboreg/klaatu-python
9
- Requires-Python: >=3.7
10
12
  Description-Content-Type: text/markdown
11
- License-File: LICENSE
12
- Provides-Extra: dev
13
- Requires-Dist: ipython; extra == "dev"
14
- Requires-Dist: ipdb; extra == "dev"
15
- Requires-Dist: isort; extra == "dev"
16
- Requires-Dist: pylint; extra == "dev"
17
- Dynamic: license-file
18
13
 
19
14
  # klaatu-python
20
15
 
21
- 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.
22
17
 
23
18
  ## Development install
24
19
 
@@ -0,0 +1,16 @@
1
+ klaatu_python/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ klaatu_python/case.py,sha256=6FJuXXOWsJg786ftEafunjngjeOvpB3F3WP1a2wlQ9Y,1798
3
+ klaatu_python/lock.py,sha256=D--5HjqAgtflOtNjp0klF8C_gXUjOrrT9YpKx4vFWn8,1393
4
+ klaatu_python/utils/__init__.py,sha256=UlRL41R0cA-oHFpYideyIs9tO064imhth4tzsFNHIhw,1492
5
+ klaatu_python/utils/dicts.py,sha256=xeYvL_4ox9ykrUv964JwpGhacmGbm1Oyz03sq78CyoI,2702
6
+ klaatu_python/utils/iterables.py,sha256=QCIOh4EiLE67460yMuwm5wyJl8CNOm8jz3VjrJbf6DM,3803
7
+ klaatu_python/utils/numbers.py,sha256=b0_Eiwumw8-zoKrjNx70KDO-jGGKNbG8gGHQ_ACitwo,4787
8
+ klaatu_python/utils/paths.py,sha256=8Yr92R5EE8tU_nE3_mjCmhZ3ftkl8FilMoCp9agT71c,1935
9
+ klaatu_python/utils/strings.py,sha256=GTZb6gUeqhS-7rjwkrwynZ9kLFj0f55mSmFwEI1d5wQ,469
10
+ klaatu_python/utils/time.py,sha256=sssUDMrD78T4EBpX85ds6iQySBkIC2GPbeuVhypAxGc,829
11
+ klaatu_python/utils/urls.py,sha256=vV10t9nsX6bFCtHwtOqo0TEbsdFPJ73P2PALw_hyT8s,931
12
+ klaatu_python/utils/various.py,sha256=cBNtf5AC7T7tCcIvxb5Q1AYQsGZMUlPFdu27wwQ_up0,775
13
+ klaatu_python-2.5.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
14
+ klaatu_python-2.5.0.dist-info/WHEEL,sha256=s49dN1sxqzkgPplo4QuUaKomil-_cbDzeLK4-pZKD-A,81
15
+ klaatu_python-2.5.0.dist-info/METADATA,sha256=mz9hjzW-K72e_Sd0ew56XK0LfQzqzEXuLQaceLyctHo,763
16
+ klaatu_python-2.5.0.dist-info/RECORD,,
@@ -1,5 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: uv 0.11.24
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
-
klaatu_python/utils.py DELETED
@@ -1,356 +0,0 @@
1
- import re
2
- from datetime import date, timedelta
3
- from locale import format_string, localeconv
4
- from math import ceil, floor, log10
5
- from typing import Any, Callable, Iterable, Iterator, Literal, Sequence, TypeVar, overload
6
- from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
7
-
8
-
9
- _T = TypeVar("_T")
10
- _NT = TypeVar("_NT", int, float)
11
-
12
-
13
- def append_query_to_url(url: str, params: dict, conditional_params: dict | None = None, safe: str = '') -> str:
14
- """
15
- Adds GET query from `params` to `url`, or appends it if there already is
16
- one.
17
-
18
- `conditional_params` will only be used if GET params with those keys are
19
- not already present in the original url or in `params`.
20
-
21
- Return the new url.
22
- """
23
- parts = urlsplit(url)
24
- conditional_params = conditional_params or {}
25
- qs = {
26
- **conditional_params,
27
- **parse_qs(parts.query),
28
- **params,
29
- }
30
- parts = parts._replace(query=urlencode(qs, doseq=True, safe=safe))
31
- return urlunsplit(parts)
32
-
33
-
34
- def can_coerce_to_int(value: Any) -> bool:
35
- return to_int(value) is not None
36
-
37
-
38
- def circulate(lst: Iterable[_T], rounds: int) -> list[_T]:
39
- """
40
- Shifts `lst` left `rounds` times. Good for e.g. circulating colours in
41
- a graph.
42
- """
43
- if not isinstance(lst, list):
44
- lst = list(lst)
45
- if lst and rounds:
46
- for _ in range(rounds):
47
- val = lst.pop(0)
48
- lst.append(val)
49
- return lst
50
-
51
-
52
- def coerce_between(value: _NT, min_value: _NT, max_value: _NT) -> _NT:
53
- if value < min_value:
54
- return min_value
55
- if value > max_value:
56
- return max_value
57
- return value
58
-
59
-
60
- def daterange(start_date: date, end_date: date) -> Iterator[date]:
61
- """
62
- Iterates the dates between `start_date` (inclusive) and `end_date`
63
- (exclusive).
64
- """
65
- for n in range(int((end_date - start_date).days)):
66
- yield start_date + timedelta(days=n)
67
-
68
-
69
- def filter_values_not_null(d: dict) -> dict:
70
- return {k: v for k, v in d.items() if v is not None}
71
-
72
-
73
- def first_not_null(*values: _T | None) -> _T:
74
- ret = first_not_null_or_null(*values)
75
- if ret is None:
76
- raise TypeError("All values are None")
77
- return ret
78
-
79
-
80
- def first_not_null_or_null(*values: _T | None) -> _T | None:
81
- for value in values:
82
- if value is not None:
83
- return value
84
- return None
85
-
86
-
87
- def getitem_nullable(seq: Iterable[_T], idx: int, cond: Callable[[_T], bool] | None = None) -> _T | None:
88
- """
89
- If `seq` has an item at position `idx`, return that item. Otherwise return
90
- None. Similar to how QuerySet's first() & last() operate.
91
-
92
- With `cond` set, it first filters `seq` for items where this function
93
- evaluates as True, then tries to get item `idx` from the resulting list.
94
-
95
- Example:
96
-
97
- seq = [23, 43, 12, 56, 75, 1]
98
- second_even = getitem_nullable(seq, 1, lambda item: item % 2 == 0)
99
- # second_even == 56
100
- seq = [1, 2, 3, 5, 7]
101
- second_even = getitem_nullable(seq, 1, lambda item: item % 2 == 0)
102
- # second_even == None
103
- """
104
- try:
105
- if cond is None:
106
- return list(seq)[idx]
107
- return [item for item in seq if cond(item)][idx]
108
- except IndexError:
109
- return None
110
-
111
-
112
- @overload
113
- def getitem0(seq: Iterable[_T], cond: Callable[[_T], bool] | None, nullable: Literal[False]) -> _T: ...
114
-
115
-
116
- @overload
117
- def getitem0(seq: Iterable[_T], cond: Callable[[_T], bool] | None, nullable: Literal[True]) -> _T | None: ...
118
-
119
-
120
- def getitem0(seq, cond=None, nullable=False):
121
- """
122
- @raises IndexError
123
- """
124
- try:
125
- if cond is None:
126
- return list(seq)[0]
127
- return [item for item in seq if cond(item)][0]
128
- except IndexError as e:
129
- if nullable:
130
- return None
131
- raise e
132
-
133
-
134
- def getitem0_nullable(seq: Iterable[_T], cond: Callable[[_T], bool] | None = None) -> _T | None:
135
- return getitem0(seq, cond, True)
136
-
137
-
138
- def group_by(sequence: Sequence[_T], pred: Callable[[_T], Any]) -> dict[Any, list[_T]]:
139
- """
140
- Groups `sequence` by the result of `pred` on each item. Returns dict with
141
- those results as keys and sublists of `sequence` as values.
142
- """
143
- result: dict[Any, list[_T]] = {}
144
- for item in sequence:
145
- key = pred(item)
146
- if key not in result:
147
- result[key] = [item]
148
- else:
149
- result[key].append(item)
150
- return result
151
-
152
-
153
- def group_dicts(
154
- dicts: Iterable[dict[str, _T]],
155
- keys: list[str],
156
- data_key: str = "data",
157
- ) -> list[dict[str, _T | list[dict[str, _T]]]]:
158
- """
159
- In:
160
- dicts = [
161
- {"slug": "musikensmakt", "name": "Musikens Makt", "date": "2025-04-01", "count": 60},
162
- {"slug": "musikensmakt", "name": "Musikens Makt", "date": "2025-04-02", "count": 64},
163
- {"slug": "apanap", "name": "Apan Ap", "date": "2025-04-01", "count": 2},
164
- ]
165
- keys = ["slug", "name"]
166
- data_key = "dätä"
167
- Out: [
168
- {
169
- "slug": "musikensmakt",
170
- "name": "Musikens Makt",
171
- "dätä": [{"date": "2025-04-01", "count": 60}, {"date": "2025-04-02", "count": 64}],
172
- },
173
- {
174
- "slug": "apanap",
175
- "name": "Apan Ap",
176
- "dätä": [{"date": "2025-04-01", "count": 2}],
177
- },
178
- ]
179
- """
180
- result: dict[tuple[_T, ...], list] = {}
181
-
182
- for d in dicts:
183
- dd = d.copy()
184
- d_key = tuple(d[key] for key in keys)
185
- if d_key not in result:
186
- result[d_key] = []
187
- for key in keys:
188
- del dd[key]
189
- result[d_key].append(dd)
190
-
191
- return [{data_key: v, **{keys[i]: k[i] for i in range(len(keys))}} for k, v in result.items()]
192
-
193
-
194
- def index_of_first(sequence: Sequence[_T], pred: Callable[[_T], bool]) -> int:
195
- """
196
- Tries to return the index of the first item in `sequence` for which the
197
- function `pred` returns True. If no such item is found, return -1.
198
- """
199
- try:
200
- return sequence.index(next(filter(pred, sequence)))
201
- except StopIteration:
202
- return -1
203
-
204
-
205
- def int_to_string(value: int | None, locale: str, nbsp: bool = False) -> str:
206
- """
207
- Formats an integer with locale-specific thousand separators, which are at
208
- least correct for Swedish and English locales. (Usage of the functions in
209
- the built-in `locale` package requires you to run setlocale() multiple
210
- times, which is not thread safe and may be resource heavy, so we don't do
211
- that.)
212
- """
213
- if value is None:
214
- return ""
215
- if locale.lower().startswith("sv"):
216
- separator = " "
217
- else:
218
- separator = ","
219
- if separator == " " and nbsp:
220
- separator = "&nbsp;"
221
- # Neat line of code, huh? :)
222
- # 1. Use absolute value to get rid of minus sign
223
- # 2. Reverse str(value) in order to group characters from the end
224
- # 3. Split it by groups of 3 digits
225
- # 4. Remove empty values generated by re.split()
226
- # 5. Re-reverse the digits in each group & join them with separator string
227
- # 6. Re-reverse the order of the groups
228
- # 7. Add minus sign if value was negative
229
- return (
230
- ("-" if value < 0 else "") +
231
- separator.join([v[::-1] for v in re.split(r"(\d{3})", str(abs(value))[::-1]) if v][::-1])
232
- )
233
-
234
-
235
- def is_truthy(value: Any) -> bool:
236
- """
237
- Basically does `bool(value)`, except it also returns False for string
238
- values "false", "no", and "0" (case insensitive).
239
- """
240
- if isinstance(value, str) and value.lower() in ("false", "no", "0"):
241
- return False
242
- return bool(value)
243
-
244
-
245
- def localize_float(
246
- value: float,
247
- grouping: bool = True,
248
- min_decimals: int | None = None,
249
- max_decimals: int | None = None,
250
- ) -> str:
251
- if max_decimals is None:
252
- max_decimals = 15
253
-
254
- if min_decimals is not None and min_decimals > max_decimals:
255
- raise ValueError("min_decimals must be <= max_decimals")
256
- if 15 < (min_decimals or 0) < 0:
257
- raise ValueError("min_decimals must be >= 0 and <= 15")
258
- if 15 < (max_decimals or 0) < 0:
259
- raise ValueError("max_decimals must be >= 0 and <= 15")
260
-
261
- decimal_separator = localeconv()["decimal_point"]
262
- ret = format_string("%.*f", (max_decimals, value), grouping=grouping)
263
-
264
- if decimal_separator in ret:
265
- base, decimals = ret.split(decimal_separator)
266
- decimals = decimals.rstrip("0").ljust(min_decimals or 0, "0")
267
- if not decimals:
268
- return base
269
- return base + decimal_separator + decimals
270
-
271
- return ret
272
-
273
-
274
- def nonulls(items: Iterable[_T | None]) -> list[_T]:
275
- """Just filters away None values from `items`."""
276
- return [item for item in items if item is not None]
277
-
278
-
279
- def partition(items: Sequence[_T], length: int) -> Iterator[Sequence[_T]]:
280
- """Simply splits `items` into subsequences of max `length` items."""
281
- offset = 0
282
- while offset == 0 or offset < len(items):
283
- yield items[offset:offset + length]
284
- offset += length
285
-
286
-
287
- def percent_rounded(part: int | float, whole: int | float) -> int:
288
- if not whole:
289
- return 0
290
- return round(part / whole * 100)
291
-
292
-
293
- def round_to_n(x: int | float, n: int) -> int | float:
294
- """
295
- Rounds x to n significant digits. If the result is a whole number, it is
296
- cast to int, otherwise float is returned.
297
- """
298
- if x == 0:
299
- return x
300
- result = round(x, -int(floor(log10(abs(x)))) + (n - 1))
301
- return int(result) if not result % 1 else result
302
-
303
-
304
- def round_up_timedelta(td: timedelta) -> timedelta:
305
- """
306
- If td > 30 min, round up to nearest hour. Otherwise, to nearest 10
307
- minute mark. Could be extended for higher time units, but nevermind now.
308
- """
309
- td_minutes = td.total_seconds() / 60
310
- if td_minutes > 30:
311
- return timedelta(hours=ceil(td_minutes / 60))
312
- if td_minutes >= 10:
313
- return timedelta(minutes=int(td_minutes / 10) * 10 + 10)
314
- return timedelta(minutes=10)
315
-
316
-
317
- def rounded_percentage(part: int | float, whole: int | float, digits=3) -> int | float:
318
- """Percentage rounded to `digits` significant digits."""
319
- return round_to_n((part / whole) * 100, digits) if whole != 0 else 0
320
-
321
-
322
- def strip_url_query(url: str) -> str:
323
- """Just returns `url` stripped of any GET parameters."""
324
- parts = urlsplit(url)
325
- return urlunsplit((parts.scheme, parts.netloc, parts.path, '', parts.fragment))
326
-
327
-
328
- def to_int(value: Any, default: int | None = None) -> int | None:
329
- """Like int() but returns a default value instead of raising exception."""
330
- try:
331
- return int(value)
332
- except (ValueError, TypeError):
333
- return default
334
-
335
-
336
- def zip_dicts(*dicts: dict) -> dict:
337
- """
338
- Combines dicts into one dict. On duplicate keys, dicts later in the
339
- sequence have priority.
340
- """
341
- return {k: v for d in dicts for k, v in d.items()}
342
-
343
-
344
- def zip_dict_lists(dict_lists: Sequence[Sequence[dict]]) -> Iterator[dict]:
345
- """
346
- Zips a list of dict lists and combines them into single dicts.
347
- I.e. given dict_lists = [
348
- [{a1: 1, a2: 2}, {b1: 3, b2: 4}],
349
- [{a3: 5, a4: 6}, {b3: 7, b4: 8}],
350
- ],
351
- these dicts will be yielded:
352
- {a1: 1, a2: 2, a3: 5, a4: 6}
353
- {b1: 3, b2: 4, b3: 7, b4: 8}
354
- """
355
- for dicts in zip(*dict_lists):
356
- yield zip_dicts(*dicts)
@@ -1,9 +0,0 @@
1
- klaatu_python/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- klaatu_python/case.py,sha256=2Tya4dlBrqkwhQZu9PTLEA0SiWSIoKw12Ar2UJZssd4,1692
3
- klaatu_python/lock.py,sha256=STJpYdpYxHJDges9DFv08kC4hGa1IPtHMNxanrQlioM,1393
4
- klaatu_python/utils.py,sha256=4YJGV4gc1svrU_M6TfxsQlDFp9UG_DFCV4LurXqgGdQ,11097
5
- klaatu_python-2.3.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
6
- klaatu_python-2.3.0.dist-info/METADATA,sha256=EDL3degZoyTe-EAKpl79agfpbSoelLHHCLebXlG3BIE,928
7
- klaatu_python-2.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
- klaatu_python-2.3.0.dist-info/top_level.txt,sha256=JxX5CCI_SC5L4jbAG7acjKwIPVWmCl46ofDZNZtnV1o,14
9
- klaatu_python-2.3.0.dist-info/RECORD,,
@@ -1 +0,0 @@
1
- klaatu_python