lib.helpers

  1from collections import UserList
  2from collections.abc import MappingView, Sequence
  3from typing import Any, Literal, TypeVar, TypeAlias, Generator
  4import sys
  5import random
  6from math import ceil
  7from itertools import groupby
  8from copy import deepcopy
  9from itertools import islice
 10import loguru
 11from icecream import ic
 12
 13T = TypeVar("T")
 14Strategy: TypeAlias = Literal["pick", "omit"]
 15"""Strategy for filtering items in a list."""
 16
 17
 18def setLogLevel(
 19    instance: "loguru.Logger",
 20    level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG",
 21):
 22    """Set the log level and format for a loguru.Logger instance.
 23
 24    Args:
 25        instance: The loguru.Logger instance to configure.
 26        level: The log level to set.
 27    """
 28    instance.remove()
 29    instance.add(
 30        sink=sys.stderr,
 31        level=level.upper(),
 32        format="<level>{level: ^8}</level> <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> <level>{message}</level>",
 33    )
 34
 35
 36# Numbers
 37# region
 38def isInRange(start: int, end: int, value: int):
 39    """Returns True if value is in the inclusive range [start, end]."""
 40    return start <= value <= end
 41
 42
 43# endregion
 44
 45
 46# Strings
 47# region
 48def truncateString(string: str, length=10, suffix="...") -> str:
 49    """
 50    Truncate a string to a given length and append a suffix.
 51
 52    Example:
 53        `(5)` `Hello there!`  => `Hello...`
 54    """
 55    return string[:length] + suffix
 56
 57
 58# endregion
 59
 60
 61# Iterables
 62# region
 63def coerceList(value, separate=False, strict=False) -> list:
 64    """Coerce a value to a list.
 65
 66    Args:
 67        value: The value to coerce.
 68        separate: If True, split str to characters.
 69            - (False) `"abc"` => `["abc"]`
 70            - (True) `"abc"` => `["a", "b", "c"]`
 71        strict: If True, force tuple to list.
 72            - (False) `(a, b)` => `(a, b)`
 73            - (True) `(a, b)` => `[(a, b)]`
 74
 75    Returns:
 76        The coerced list.
 77
 78    Example:
 79    - `1` or `[1]` => `[1]`
 80    - `1, 2` => `[1, 2]`
 81    """
 82    passingTypes = list if strict else (list, tuple, UserList)
 83    # Check if not None, can be []
 84    if value is not None:
 85        if isinstance(value, passingTypes):
 86            return value
 87        # Handle mapping views, e.g. dict.keys(), dict.values()
 88        elif isinstance(value, MappingView):
 89            return list(value)
 90        elif isSequence(value):
 91            return [value]
 92        else:
 93            return list(value) if separate else [value]
 94
 95
 96def coerceNumericList(value, separator=" ") -> list[int]:
 97    """Coerce a value to a list of integers.
 98
 99    Args:
100        value: The value to coerce.
101        separator: Separator for splitting strings.
102
103    Returns:
104        List of integers.
105
106    Example:
107    - (int) `5` => `[5]`
108    - (str) `"5mm"` => `["5mm"]`
109    - (str) `"5 5"` => `[5, 5]`
110    """
111    if isSequence(value):
112        return value
113
114    if isinstance(value, str):
115        if separator in value:
116            parts = value.split(separator)
117            try:
118                return [int(side) for side in parts]
119            except ValueError:
120                return parts
121        try:
122            return [int(value)]
123        except ValueError:
124            pass
125
126    return [value]
127
128
129def coerceTuple(value) -> tuple:
130    """Coerce a value to a tuple.
131
132    Args:
133        value: The value to coerce.
134
135    Returns:
136        The coerced tuple.
137
138    Example:
139    - `1` or `(1,)` => `(1,)`
140    - `1, 2` => `(1, 2)`
141    """
142    if isinstance(value, tuple):
143        return value
144    elif value is not None:
145        if isinstance(value, list):
146            return tuple(value)
147        if isinstance(value, str) and " " in value:
148            return tuple(value.split())
149        return (value,)
150
151
152def expand(
153    input, n=2, defaultValue=None, format: Literal["auto", "list", "tuple"] = "auto"
154) -> list | tuple:
155    """Expand to iterable with length of `n`.
156
157    Args:
158        input: The input value(s) to expand.
159        n: Desired length.
160        defaultValue: Fallback value for cycling.
161        format: Output type.
162
163    Returns:
164        Iterable of length n.
165
166    Example:
167    - `n=3`: `a` => `[a, a, a]`
168    - `n=4`: `[1, 2]` => `[1, 2, 1, 2]`
169    """
170
171    def _getValueAtIndex(i: int):
172        try:
173            return input[i]
174        except IndexError:
175            return defaultValue if defaultValue is not None else input[i % len(input)]
176
177    input = coerceList(input)
178    if format == "auto":
179        outputFormat = tuple if isinstance(input, tuple) else list
180    else:
181        outputFormat = tuple if format == "tuple" else list
182    return outputFormat([_getValueAtIndex(i) for i in range(n)])
183
184
185def reverseList(input: list[T]) -> list[T]:
186    """Inverse values in iterable.
187
188    Returns:
189        The reversed list.
190    """
191    return input[::-1]
192
193
194def previewList(input: list, length=3) -> str:
195    """
196    Get a preview of the list with a specified number of items and a count of remaining items.
197
198    Example:
199        (2) `[A, B, C, D]`  => `[A, B] and 2 more items`
200    """
201    remainder = len(input) - length
202    noun = "items" if remainder > 1 else "item"
203
204    output = f"{input[:length]}"
205
206    if remainder > 0:
207        output += f" and {remainder} more {noun}"
208
209    return output
210
211
212def sampleList(input: list[T], size: int, shuffle=True) -> list[T]:
213    """Get random sample of specified size from list.
214
215    Args:
216        input: List to sample from.
217        size: Number of items to sample.
218        shuffle: If True, shuffle the list before sampling.
219
220    Returns:
221        Random sample of specified size.
222    """
223    if shuffle:
224        return random.sample(input, min(size, len(input)))
225    return input[:size]
226
227
228def inverseValues(values: list[Any]) -> list[Any]:
229    """Negate all values in a list.
230
231    Example:
232       - `[1, 2]` => `[-1, -2]`
233       - ["a", "b"]` => `["-a", "-b"]`
234    """
235
236    def _negate(value):
237        if isinstance(value, (int, float)):
238            return -value
239        elif isinstance(value, str):
240            return f"-{value}"
241        else:
242            raise TypeError(f"Unsupported type for negation: {type(value).__name__}")
243
244    return [_negate(value) for value in coerceList(values)]
245
246
247def flatten(items: list):
248    """Flatten a list of lists or tuples.
249
250    Returns:
251        Flattened list or original input if not all items are lists/tuples.
252    """
253    areItemsLists = all([isinstance(item, list) for item in items])
254    areItemsTuples = all([isinstance(item, tuple) for item in items])
255
256    if areItemsLists or areItemsTuples:
257        start = [] if areItemsLists else ()
258        return list(sum(items, start))
259    # Otherwise pass through unchanged
260    else:
261        isIterable = isinstance(items, (list, tuple))
262        if not isIterable:
263            loguru.logger.debug("Unable to flatten: {}", items)
264        return items
265
266
267def isFlat(items: list) -> bool:
268    """Returns True if all items in the list are not lists or tuples.
269
270    Example:
271        `[1, 2, 3]` => `True`
272        `[[1, 2], 3]` => `False`
273    """
274    return all([not isinstance(item, (list, tuple)) for item in items])
275
276
277def flattenTuples(input):
278    """Flatten nested tuples and lists into a single list of tuples.
279
280    Example:
281        `[(1, 2), [(3, 4), (5, 6)]]` => `[(1, 2), (3, 4), (5, 6)]`
282    """
283    output = []
284
285    if isinstance(input, tuple):
286        input = [input]
287
288    for item in input:
289        if isinstance(item, list):
290            for subitem in item:
291                output.append(subitem)
292        else:
293            output.append(item)
294    return output
295
296
297def intersect(lists: list[list], retainOrder=True) -> list:
298    """Return intersection of multiple lists.
299
300    Args:
301        lists: List of lists to intersect.
302        retainOrder: If True, order based on first list.
303
304    Returns:
305        List of intersected items.
306
307    Example:
308        `[a, b, c]` + `[b]` => `[b]`
309    """
310    result = set.intersection(*map(set, lists))
311    listA = lists[0]
312
313    return sorted(result, key=lambda x: listA.index(x)) if retainOrder else list(result)
314
315
316def zipWithMode(
317    groups: list[list], mode: Literal["drop", "fill"] = "fill"
318) -> list[list]:
319    """Zip lists with different modes.
320
321    Args:
322        groups: List of lists to zip.
323        mode: 'drop' to zip to shortest, 'fill' to zip to longest.
324
325    Returns:
326        Zipped list of lists.
327    """
328
329    def _fillGroup(group):
330        isShorter = len(group) < longest
331        return [group[i % len(group)] for i in range(longest)] if isShorter else group
332
333    if mode == "fill":
334        groups = [group if group else [group] for group in groups]  # None => [None]
335        longest = max([len(group) for group in groups])
336        groups = [_fillGroup(group) for group in groups]
337
338    return list(zip(*groups))
339
340
341def removeFalsy(
342    input: list, mode: Literal[None, False, "empty", "either"] = "either"
343) -> list:
344    """Remove falsy values from a list.
345
346    Args:
347        input: List to filter.
348        mode: 'None', 'False', 'empty', or 'either'.
349
350    Returns:
351        Filtered list.
352    """
353
354    def _checkItem(item):
355        isNotNone = item is not None
356        isNotFalse = item is not False
357        isNotEmpty = isinstance(item, list) and len(item)
358        isNotEither = isNotNone and isNotFalse and isNotEmpty
359        if mode is None:
360            return isNotNone
361        elif mode is False:
362            return isNotFalse
363        else:
364            return isNotEither
365
366    return list(filter(_checkItem, input))
367
368
369def removeNone(input: list) -> list:
370    """Remove `None` only from a list.
371
372    Returns:
373        List with None values removed.
374    """
375    return removeFalsy(input=input, mode=None)
376
377
378def removeFromList(input: list, remove: list) -> list:
379    """Remove specified items from a list.
380
381    Example:
382        `[a, b, c]` + `[b]` => `[a, c]`
383
384    Returns:
385        List with specified items removed.
386    """
387    output = []
388    remove = flatten(coerceList(remove))
389
390    for element in input:
391        if element not in remove:
392            output.append(element)
393
394    return output
395
396
397def dedupe(
398    input: list[str], sortAlphabetically=False, caseSensitive=False, debug=False
399):
400    """Remove duplicates in list (= uniq).
401
402    Args:
403        input: List to deduplicate.
404        sortAlphabetically: If True, sort output.
405        caseSensitive: If False, dedupe case-insensitively.
406        debug: If True, log duplicates.
407
408    Returns:
409        Deduplicated list.
410    """
411    input = [item for item in input if item]  # Remove None
412    output = list()
413    uniq = set()
414
415    for item in input:
416        itemCase = (
417            item if caseSensitive or isinstance(item, (int, float)) else item.casefold()
418        )
419        if itemCase not in uniq:
420            uniq.add(itemCase)
421            output.append(item)
422        elif debug:
423            loguru.logger.trace("[Dedupe] {}", item)
424
425    if sortAlphabetically:
426        output = sorted(output)
427
428    return output
429
430
431def findClosestValue(values: list, value: int, sort=True, discardLarger=False) -> int:
432    """Return closest value in a sorted list.
433
434    If two values are equally close, return the smallest value.
435
436    Args:
437        values: List of values.
438        value: Value to compare.
439        sort: If True, sort the list.
440        discardLarger: If True, discard values larger than input.
441
442    Returns:
443        Closest value or None if list is empty.
444    """
445    from bisect import bisect_left
446
447    # Discard larger values
448    if discardLarger:
449        values = [v for v in values if v <= value]
450
451    if sort:
452        values = sorted(values)
453
454    pos = bisect_left(values, value)
455    if pos == 0:
456        return values[0] if len(values) else None
457    if pos == len(values):
458        return values[-1]
459    before = values[pos - 1]
460    after = values[pos]
461
462    if after - value < value - before:
463        return after
464    else:
465        return before
466
467
468def findClosestIndex(values: list, value: str) -> int:
469    """Find index of closest matching string in a list.
470
471    Useful for finding `sort()` key.
472
473    Example:
474        `["B", "F"], "Foo"` => returns `1`
475
476    Returns:
477        Index of closest match or None.
478    """
479    if not value:
480        return None
481
482    indexFound = None
483
484    while len(value) and not isinstance(indexFound, int):
485        try:
486            indexFound = values.index(value)
487        except ValueError:
488            pass
489
490        value = value[:-1]
491
492    return indexFound
493
494
495def isSequence(value) -> bool:
496    """Check if value is a sequence (list or tuple) but not a string."""
497    return isinstance(value, Sequence) and not isinstance(value, (str, bytes))
498
499
500def isListOfOne(value) -> bool:
501    """Returns True if the list contains exactly one item.
502
503    Example:
504    - `[1, 2]` => `false`
505    - `1`      => `false`
506    - `[1]`    => `true`
507    """
508    return isSequence(value) and len(value) == 1
509
510
511def isFirst(
512    parent: list | int,
513    child: list | int,
514    mode: Literal["indices", "lists"] = "indices",
515):
516    """Returns True if child is the first element in parent.
517
518    Args:
519        parent: Iterable or count.
520        child: Item or index.
521        mode: 'indices' or 'lists'.
522    """
523    childIndex = (
524        child if isinstance(child, int) and mode == "indices" else parent.index(child)
525    )
526    return childIndex == 0
527
528
529def isLast(
530    parent: list | int,
531    child: list | int,
532    mode: Literal["indices", "lists"] = "indices",
533):
534    """Returns True if child is the last element in parent.
535
536    Args:
537        parent: Iterable or count.
538        child: Item or index.
539        mode: 'indices' or 'lists'.
540    """
541    childIndex = (
542        child if isinstance(child, int) and mode == "indices" else parent.index(child)
543    )
544    parentCount = len(parent) if not isinstance(parent, int) else parent
545    return childIndex == parentCount - 1
546
547
548def isNth(index: int, nth: int = 3):
549    """Returns True if index is every nth element.
550
551    Args:
552        index: Index to check.
553        nth: Nth interval.
554
555    Example:
556        `nth=3` => Every third
557    """
558    return (index + 1) % nth == 0
559
560
561def pickFirst(value):
562    """Get first value from iterable if iterable.
563
564    Returns:
565        First value or value itself if not iterable.
566    """
567    if isSequence(value):
568        return value[0]
569    else:
570        return value
571
572
573def pickLast(value):
574    """Get last value from iterable if iterable.
575
576    Returns:
577        Last value or value itself if not iterable.
578    """
579    if isSequence(value):
580        return value[-1]
581    else:
582        return value
583
584
585def pickRandom(items: list) -> tuple:
586    """Pick 1 item at random and return remaining items.
587
588    Returns:
589        Tuple of (item, remainingItems).
590    """
591    item = random.choice(items)
592    itemIndex = items.index(item)
593    items.pop(itemIndex)
594
595    return item, items
596
597
598def pickExtremes(values: list):
599    """Get first and last from list.
600
601    Example:
602        `[1, 2, 3, 4]` => `(1, 4)`
603
604    Returns:
605        Tuple of (first, last) or values itself if length <= 1.
606    """
607    if len(values) > 1:
608        return values[0], values[-1]
609    else:
610        return values
611
612
613def pushToEnd(items: list[T], index=0):
614    """Move item at index to the end and return modified list.
615
616    Example:
617        `ABCD` `index=0` => `BCDA`
618
619    Returns:
620        Modified list.
621    """
622    result = items.copy()  # Avoid mutating original
623    result.append(result.pop(index))
624    return result
625
626
627def _splitToGroups(input: list | str, groups=2) -> Generator:
628    """Split input into groups of approximately equal size.
629
630    Args:
631        input: List or string to split.
632        groups: Number of groups.
633
634    Yields:
635        Groups as tuples or strings.
636    """
637    it = iter(input)
638    while batch := tuple(islice(it, ceil(len(input) / groups))):
639        yield "".join(batch) if isinstance(input, str) else batch
640
641
642def halven(input: T) -> list[T]:
643    """Split input into two halves. Works for lists and strings.
644
645    - `even`: two equal halves
646    - `odd`: larger/smaller half
647
648    Returns:
649        List of two halves (even or odd sized).
650    """
651    return list(_splitToGroups(input, 2))
652
653
654def splitEvenly(input: T, groups: int = 3) -> list[T]:
655    """Split input into evenly sized groups. Works for lists and strings.
656
657    Args:
658        input: List or string to split.
659        groups: Number of groups.
660
661    Example:
662        `groups=2`, `ABCD` => `AB CD`
663
664    Returns:
665        List of groups.
666    """
667    return list(_splitToGroups(input, groups))
668
669
670def toChunks(items: list[T], groups=2) -> list[list[T]]:
671    """Combine groups in alternating manner.
672
673    Example:
674        `groups=2`, `ABCD` => `AC BD`
675
676    Returns:
677        List of chunked groups.
678    """
679    return [items[start::groups] for start in range(groups)]
680
681
682def shuffleAtRandomSegment(items: list[T]) -> list[T]:
683    """Shuffle list at a random segment to maintain order.
684
685    Returns:
686        List shuffled at a random index.
687    """
688    randomItem = random.choice(items)
689    randomIndex = items.index(randomItem)
690    return flatten([items[randomIndex:], items[:randomIndex]])
691
692
693def shuffleAtInterval(items: list[T], n: int = 2) -> list[T]:
694    """
695    Rearrange a list by grouping every nth item together, then flattening the result.
696
697    Example:
698    - For items `[A, B, C, D, E, F]` and n `3`:
699    - The list is split into 3 groups: `[A, D], [B, E], [C, F]`
700    - The result is: `[A, D, B, E, C, F]`
701
702    Returns:
703        A new list with items reordered by interval.
704    """
705    return sum(toChunks(items, n), [])
706
707
708def spaceEvenly(items: list, size=2):
709    """Get n items from list, evenly spaced.
710
711    Args:
712        items: List of items.
713        size: Number of items to get.
714
715    Example:
716    - 1 => get midpoint
717    - 2 => get first and last
718
719    Returns:
720        List of evenly spaced items.
721    """
722
723    def _getSpacedIndices(start, stop, length):
724        return [round(start + x * (stop - start) / (length - 1)) for x in range(length)]
725
726    itemCount = len(items)
727    if size == 1:
728        # Return midpoint
729        indices = [len(items) >> 1]
730    else:
731        # Never return size larger than itemCount
732        indices = _getSpacedIndices(0, itemCount - 1, min(itemCount, size))
733
734    return [items[i] for i in indices]
735
736
737# endregion
738
739
740# Object: dict
741# region
742def pick(object: dict, keys: list[str]) -> dict:
743    """Pick properties by `key`.
744
745    Args:
746        object: Dictionary to pick from.
747        keys: Keys to pick.
748
749    Returns:
750        Dictionary with only picked keys.
751    """
752    return {k: v for k, v in object.items() if k in coerceList(keys)}
753
754
755def omit(object: dict, keys: list[str]) -> dict:
756    """Remove properties by `key`.
757
758    Args:
759        object: Dictionary to omit from.
760        keys: Keys to omit.
761
762    Returns:
763        Dictionary with omitted keys.
764    """
765    return {k: v for k, v in object.items() if k not in coerceList(keys)}
766
767
768def omitBy(object: dict, omit=[0, None, []]) -> dict:
769    """Remove properties by `value`.
770
771    Remove falsy by default: `0`, `None`, `[]`.
772
773    Args:
774        object: Dictionary to filter.
775        omit: Values to omit.
776
777    Returns:
778        Dictionary with omitted values.
779    """
780    return {k: v for k, v in object.items() if v not in coerceList(omit)}
781
782
783# endregion
784
785
786# Collections: list[dict]
787# region
788def merge(dicts: list[dict]):
789    """Merge multiple instances of `dict`.
790
791    What happens to values:
792    - `str` gets overwritten by last occurrence
793    - `list` gets extended (`flatten` + `dedupe`)
794
795    Args:
796        dicts: List of dictionaries to merge.
797
798    Returns:
799        Merged dictionary.
800    """
801    result = dict()
802
803    for d in dicts:
804        for [key, value] in d.items():
805            hasKey = result.get(key)
806            if hasKey:
807                if isinstance(value, list):
808                    result[key] = dedupe(flatten([result[key], value]))
809                else:
810                    result[key] = value
811            else:
812                result[key] = value
813
814    return result
815
816
817def findByKey(items: list[dict], key: str, value, defaultValue=None) -> dict | None:
818    """Find item in list of dictionaries/objects by key.
819
820    Args:
821        items: List of dictionaries or objects.
822        key: Key to search for.
823        value: Value to match.
824        defaultValue: Value to return if not found.
825
826    Returns:
827        The found item or defaultValue.
828    """
829
830    def getValue(item, key):
831        # Use built-in method for dictionaries
832        if isinstance(item, dict):
833            return item.get(key)
834        # Otherwise assume it’s an object
835        else:
836            return getattr(item, key)
837
838    return next((item for item in items if getValue(item, key) == value), defaultValue)
839
840
841def sortByKey(items: list[dict], key: str, defaultValue: int | str = None):
842    """Sort list of dictionaries by key.
843
844    Args:
845        items: List of dictionaries.
846        key: Key to sort by.
847        defaultValue: Value to use if key is missing.
848
849    Returns:
850        Sorted list or original if sorting fails.
851    """
852    # May be []
853    try:
854        return sorted(items, key=lambda item: item.get(key, defaultValue))
855    except TypeError:
856        return items
857
858
859def groupByKey(items: list[dict], key: str) -> list[list[dict]]:
860    """Group list of dictionaries by `key`.
861
862    Args:
863        items: List of dictionaries.
864        key: Key to group by.
865
866    Returns:
867        Grouped items as an iterator.
868    """
869    return groupby(items, key=lambda item: item.get(key))
870
871
872def reject(items: list[dict], key: str, value):
873    """Remove items matching `key`:`value`.
874
875    Args:
876        items: List of dictionaries.
877        key: Key to check.
878        value: Value to reject.
879
880    Returns:
881        Filtered list.
882    """
883    return [item for item in items if item.get(key) != value]
884
885
886def mergeCollections(
887    main: list[dict],
888    additions: list[dict],
889    key: str = "id",
890    caseSensitive=True,
891    mode: Literal["merge", "replace"] = "merge",
892) -> list[dict]:
893    """Merge two instances of `list[dict]` and dedupe by key.
894
895    Args:
896        main: Main list of dictionaries.
897        additions: List of dictionaries to merge in.
898        key: Key to match for merging.
899        caseSensitive: If False, dedupe case-insensitively.
900        mode: When both values are lists:
901            - 'merge': Concatenate and dedupe (default)
902            - 'replace': Use addition's value, discard main's value
903
904    Returns:
905        Merged list of dictionaries.
906    """
907    # Avoid modifying original collection
908    merged = deepcopy(main)
909    for addition in additions:
910        foundMain = findByKey(merged, key, addition.get(key))
911
912        # Update existing dictionary
913        if foundMain:
914            for property in addition:
915                valueMain = foundMain.get(property)
916                valueAdd = addition.get(property)
917                # Update value if new
918                if not valueMain == valueAdd:
919                    # Concat if both are lists
920                    if all([isinstance(v, list) for v in [valueMain, valueAdd]]):
921                        if mode == "replace":
922                            valueNew = valueAdd
923                        else:
924                            valueNew = dedupe(
925                                flatten([*valueMain, *valueAdd]),
926                                caseSensitive=caseSensitive,
927                            )
928                    # Otherwise overwrite
929                    else:
930                        valueNew = valueAdd
931                    foundMain.update({property: valueNew})
932        # Otherwise insert as last
933        else:
934            merged.append(addition)
935    return merged
936
937
938def flatMap(collection: list[dict], key: str) -> list:
939    """Map all `key` values into a single list.
940
941    Args:
942        collection: List of dictionaries.
943        key: Key to extract.
944
945    Returns:
946        Flattened list of all key values.
947
948    Example:
949        >>> data = [
950        ...     {"id": "a", "items": [1, 2, 3]},
951        ...     {"id": "b", "items": [4, 5]},
952        ... ]
953        >>> flatMap(data, "items")
954        [1, 2, 3, 4, 5]
955    """
956    assert isinstance(collection, list), "Collection must be a list"
957    assert isFlat(collection), "Collection must be flat list of dicts"
958
959    result = []
960    for item in collection:
961        value = item.get(key, [])
962        # Handle strings (convert to list of characters)
963        if isinstance(value, str):
964            result.extend(list(value))
965        # Handle iterables
966        elif isinstance(value, (list, tuple)):
967            result.extend(value)
968        # Handle single non-iterable values
969        else:
970            result.append(value)
971    return result
972
973
974# endregion
Strategy: TypeAlias = Literal['pick', 'omit']

Strategy for filtering items in a list.

def setLogLevel( instance: 'loguru.Logger', level: Literal['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'DEBUG'):
19def setLogLevel(
20    instance: "loguru.Logger",
21    level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG",
22):
23    """Set the log level and format for a loguru.Logger instance.
24
25    Args:
26        instance: The loguru.Logger instance to configure.
27        level: The log level to set.
28    """
29    instance.remove()
30    instance.add(
31        sink=sys.stderr,
32        level=level.upper(),
33        format="<level>{level: ^8}</level> <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> <level>{message}</level>",
34    )

Set the log level and format for a loguru.Logger instance.

Arguments:
  • instance: The loguru.Logger instance to configure.
  • level: The log level to set.
def isInRange(start: int, end: int, value: int):
39def isInRange(start: int, end: int, value: int):
40    """Returns True if value is in the inclusive range [start, end]."""
41    return start <= value <= end

Returns True if value is in the inclusive range [start, end].

def truncateString(string: str, length=10, suffix='...') -> str:
49def truncateString(string: str, length=10, suffix="...") -> str:
50    """
51    Truncate a string to a given length and append a suffix.
52
53    Example:
54        `(5)` `Hello there!`  => `Hello...`
55    """
56    return string[:length] + suffix

Truncate a string to a given length and append a suffix.

Example:

(5) Hello there! => Hello...

def coerceList(value, separate=False, strict=False) -> list:
64def coerceList(value, separate=False, strict=False) -> list:
65    """Coerce a value to a list.
66
67    Args:
68        value: The value to coerce.
69        separate: If True, split str to characters.
70            - (False) `"abc"` => `["abc"]`
71            - (True) `"abc"` => `["a", "b", "c"]`
72        strict: If True, force tuple to list.
73            - (False) `(a, b)` => `(a, b)`
74            - (True) `(a, b)` => `[(a, b)]`
75
76    Returns:
77        The coerced list.
78
79    Example:
80    - `1` or `[1]` => `[1]`
81    - `1, 2` => `[1, 2]`
82    """
83    passingTypes = list if strict else (list, tuple, UserList)
84    # Check if not None, can be []
85    if value is not None:
86        if isinstance(value, passingTypes):
87            return value
88        # Handle mapping views, e.g. dict.keys(), dict.values()
89        elif isinstance(value, MappingView):
90            return list(value)
91        elif isSequence(value):
92            return [value]
93        else:
94            return list(value) if separate else [value]

Coerce a value to a list.

Arguments:
  • value: The value to coerce.
  • separate: If True, split str to characters.
    • (False) "abc" => ["abc"]
    • (True) "abc" => ["a", "b", "c"]
  • strict: If True, force tuple to list.
    • (False) (a, b) => (a, b)
    • (True) (a, b) => [(a, b)]
Returns:

The coerced list.

Example:

  • 1 or [1] => [1]
  • 1, 2 => [1, 2]
def coerceNumericList(value, separator=' ') -> list[int]:
 97def coerceNumericList(value, separator=" ") -> list[int]:
 98    """Coerce a value to a list of integers.
 99
100    Args:
101        value: The value to coerce.
102        separator: Separator for splitting strings.
103
104    Returns:
105        List of integers.
106
107    Example:
108    - (int) `5` => `[5]`
109    - (str) `"5mm"` => `["5mm"]`
110    - (str) `"5 5"` => `[5, 5]`
111    """
112    if isSequence(value):
113        return value
114
115    if isinstance(value, str):
116        if separator in value:
117            parts = value.split(separator)
118            try:
119                return [int(side) for side in parts]
120            except ValueError:
121                return parts
122        try:
123            return [int(value)]
124        except ValueError:
125            pass
126
127    return [value]

Coerce a value to a list of integers.

Arguments:
  • value: The value to coerce.
  • separator: Separator for splitting strings.
Returns:

List of integers.

Example:

  • (int) 5 => [5]
  • (str) "5mm" => ["5mm"]
  • (str) "5 5" => [5, 5]
def coerceTuple(value) -> tuple:
130def coerceTuple(value) -> tuple:
131    """Coerce a value to a tuple.
132
133    Args:
134        value: The value to coerce.
135
136    Returns:
137        The coerced tuple.
138
139    Example:
140    - `1` or `(1,)` => `(1,)`
141    - `1, 2` => `(1, 2)`
142    """
143    if isinstance(value, tuple):
144        return value
145    elif value is not None:
146        if isinstance(value, list):
147            return tuple(value)
148        if isinstance(value, str) and " " in value:
149            return tuple(value.split())
150        return (value,)

Coerce a value to a tuple.

Arguments:
  • value: The value to coerce.
Returns:

The coerced tuple.

Example:

  • 1 or (1,) => (1,)
  • 1, 2 => (1, 2)
def expand( input, n=2, defaultValue=None, format: Literal['auto', 'list', 'tuple'] = 'auto') -> list | tuple:
153def expand(
154    input, n=2, defaultValue=None, format: Literal["auto", "list", "tuple"] = "auto"
155) -> list | tuple:
156    """Expand to iterable with length of `n`.
157
158    Args:
159        input: The input value(s) to expand.
160        n: Desired length.
161        defaultValue: Fallback value for cycling.
162        format: Output type.
163
164    Returns:
165        Iterable of length n.
166
167    Example:
168    - `n=3`: `a` => `[a, a, a]`
169    - `n=4`: `[1, 2]` => `[1, 2, 1, 2]`
170    """
171
172    def _getValueAtIndex(i: int):
173        try:
174            return input[i]
175        except IndexError:
176            return defaultValue if defaultValue is not None else input[i % len(input)]
177
178    input = coerceList(input)
179    if format == "auto":
180        outputFormat = tuple if isinstance(input, tuple) else list
181    else:
182        outputFormat = tuple if format == "tuple" else list
183    return outputFormat([_getValueAtIndex(i) for i in range(n)])

Expand to iterable with length of n.

Arguments:
  • input: The input value(s) to expand.
  • n: Desired length.
  • defaultValue: Fallback value for cycling.
  • format: Output type.
Returns:

Iterable of length n.

Example:

  • n=3: a => [a, a, a]
  • n=4: [1, 2] => [1, 2, 1, 2]
def reverseList(input: list[~T]) -> list[~T]:
186def reverseList(input: list[T]) -> list[T]:
187    """Inverse values in iterable.
188
189    Returns:
190        The reversed list.
191    """
192    return input[::-1]

Inverse values in iterable.

Returns:

The reversed list.

def previewList(input: list, length=3) -> str:
195def previewList(input: list, length=3) -> str:
196    """
197    Get a preview of the list with a specified number of items and a count of remaining items.
198
199    Example:
200        (2) `[A, B, C, D]`  => `[A, B] and 2 more items`
201    """
202    remainder = len(input) - length
203    noun = "items" if remainder > 1 else "item"
204
205    output = f"{input[:length]}"
206
207    if remainder > 0:
208        output += f" and {remainder} more {noun}"
209
210    return output

Get a preview of the list with a specified number of items and a count of remaining items.

Example:

(2) [A, B, C, D] => [A, B] and 2 more items

def sampleList(input: list[~T], size: int, shuffle=True) -> list[~T]:
213def sampleList(input: list[T], size: int, shuffle=True) -> list[T]:
214    """Get random sample of specified size from list.
215
216    Args:
217        input: List to sample from.
218        size: Number of items to sample.
219        shuffle: If True, shuffle the list before sampling.
220
221    Returns:
222        Random sample of specified size.
223    """
224    if shuffle:
225        return random.sample(input, min(size, len(input)))
226    return input[:size]

Get random sample of specified size from list.

Arguments:
  • input: List to sample from.
  • size: Number of items to sample.
  • shuffle: If True, shuffle the list before sampling.
Returns:

Random sample of specified size.

def inverseValues(values: list[typing.Any]) -> list[typing.Any]:
229def inverseValues(values: list[Any]) -> list[Any]:
230    """Negate all values in a list.
231
232    Example:
233       - `[1, 2]` => `[-1, -2]`
234       - ["a", "b"]` => `["-a", "-b"]`
235    """
236
237    def _negate(value):
238        if isinstance(value, (int, float)):
239            return -value
240        elif isinstance(value, str):
241            return f"-{value}"
242        else:
243            raise TypeError(f"Unsupported type for negation: {type(value).__name__}")
244
245    return [_negate(value) for value in coerceList(values)]

Negate all values in a list.

Example:
  • [1, 2] => [-1, -2]
  • ["a", "b"]=>["-a", "-b"]`
def flatten(items: list):
248def flatten(items: list):
249    """Flatten a list of lists or tuples.
250
251    Returns:
252        Flattened list or original input if not all items are lists/tuples.
253    """
254    areItemsLists = all([isinstance(item, list) for item in items])
255    areItemsTuples = all([isinstance(item, tuple) for item in items])
256
257    if areItemsLists or areItemsTuples:
258        start = [] if areItemsLists else ()
259        return list(sum(items, start))
260    # Otherwise pass through unchanged
261    else:
262        isIterable = isinstance(items, (list, tuple))
263        if not isIterable:
264            loguru.logger.debug("Unable to flatten: {}", items)
265        return items

Flatten a list of lists or tuples.

Returns:

Flattened list or original input if not all items are lists/tuples.

def isFlat(items: list) -> bool:
268def isFlat(items: list) -> bool:
269    """Returns True if all items in the list are not lists or tuples.
270
271    Example:
272        `[1, 2, 3]` => `True`
273        `[[1, 2], 3]` => `False`
274    """
275    return all([not isinstance(item, (list, tuple)) for item in items])

Returns True if all items in the list are not lists or tuples.

Example:

[1, 2, 3] => True [[1, 2], 3] => False

def flattenTuples(input):
278def flattenTuples(input):
279    """Flatten nested tuples and lists into a single list of tuples.
280
281    Example:
282        `[(1, 2), [(3, 4), (5, 6)]]` => `[(1, 2), (3, 4), (5, 6)]`
283    """
284    output = []
285
286    if isinstance(input, tuple):
287        input = [input]
288
289    for item in input:
290        if isinstance(item, list):
291            for subitem in item:
292                output.append(subitem)
293        else:
294            output.append(item)
295    return output

Flatten nested tuples and lists into a single list of tuples.

Example:

[(1, 2), [(3, 4), (5, 6)]] => [(1, 2), (3, 4), (5, 6)]

def intersect(lists: list[list], retainOrder=True) -> list:
298def intersect(lists: list[list], retainOrder=True) -> list:
299    """Return intersection of multiple lists.
300
301    Args:
302        lists: List of lists to intersect.
303        retainOrder: If True, order based on first list.
304
305    Returns:
306        List of intersected items.
307
308    Example:
309        `[a, b, c]` + `[b]` => `[b]`
310    """
311    result = set.intersection(*map(set, lists))
312    listA = lists[0]
313
314    return sorted(result, key=lambda x: listA.index(x)) if retainOrder else list(result)

Return intersection of multiple lists.

Arguments:
  • lists: List of lists to intersect.
  • retainOrder: If True, order based on first list.
Returns:

List of intersected items.

Example:

[a, b, c] + [b] => [b]

def zipWithMode(groups: list[list], mode: Literal['drop', 'fill'] = 'fill') -> list[list]:
317def zipWithMode(
318    groups: list[list], mode: Literal["drop", "fill"] = "fill"
319) -> list[list]:
320    """Zip lists with different modes.
321
322    Args:
323        groups: List of lists to zip.
324        mode: 'drop' to zip to shortest, 'fill' to zip to longest.
325
326    Returns:
327        Zipped list of lists.
328    """
329
330    def _fillGroup(group):
331        isShorter = len(group) < longest
332        return [group[i % len(group)] for i in range(longest)] if isShorter else group
333
334    if mode == "fill":
335        groups = [group if group else [group] for group in groups]  # None => [None]
336        longest = max([len(group) for group in groups])
337        groups = [_fillGroup(group) for group in groups]
338
339    return list(zip(*groups))

Zip lists with different modes.

Arguments:
  • groups: List of lists to zip.
  • mode: 'drop' to zip to shortest, 'fill' to zip to longest.
Returns:

Zipped list of lists.

def removeFalsy( input: list, mode: Literal[None, False, 'empty', 'either'] = 'either') -> list:
342def removeFalsy(
343    input: list, mode: Literal[None, False, "empty", "either"] = "either"
344) -> list:
345    """Remove falsy values from a list.
346
347    Args:
348        input: List to filter.
349        mode: 'None', 'False', 'empty', or 'either'.
350
351    Returns:
352        Filtered list.
353    """
354
355    def _checkItem(item):
356        isNotNone = item is not None
357        isNotFalse = item is not False
358        isNotEmpty = isinstance(item, list) and len(item)
359        isNotEither = isNotNone and isNotFalse and isNotEmpty
360        if mode is None:
361            return isNotNone
362        elif mode is False:
363            return isNotFalse
364        else:
365            return isNotEither
366
367    return list(filter(_checkItem, input))

Remove falsy values from a list.

Arguments:
  • input: List to filter.
  • mode: 'None', 'False', 'empty', or 'either'.
Returns:

Filtered list.

def removeNone(input: list) -> list:
370def removeNone(input: list) -> list:
371    """Remove `None` only from a list.
372
373    Returns:
374        List with None values removed.
375    """
376    return removeFalsy(input=input, mode=None)

Remove None only from a list.

Returns:

List with None values removed.

def removeFromList(input: list, remove: list) -> list:
379def removeFromList(input: list, remove: list) -> list:
380    """Remove specified items from a list.
381
382    Example:
383        `[a, b, c]` + `[b]` => `[a, c]`
384
385    Returns:
386        List with specified items removed.
387    """
388    output = []
389    remove = flatten(coerceList(remove))
390
391    for element in input:
392        if element not in remove:
393            output.append(element)
394
395    return output

Remove specified items from a list.

Example:

[a, b, c] + [b] => [a, c]

Returns:

List with specified items removed.

def dedupe( input: list[str], sortAlphabetically=False, caseSensitive=False, debug=False):
398def dedupe(
399    input: list[str], sortAlphabetically=False, caseSensitive=False, debug=False
400):
401    """Remove duplicates in list (= uniq).
402
403    Args:
404        input: List to deduplicate.
405        sortAlphabetically: If True, sort output.
406        caseSensitive: If False, dedupe case-insensitively.
407        debug: If True, log duplicates.
408
409    Returns:
410        Deduplicated list.
411    """
412    input = [item for item in input if item]  # Remove None
413    output = list()
414    uniq = set()
415
416    for item in input:
417        itemCase = (
418            item if caseSensitive or isinstance(item, (int, float)) else item.casefold()
419        )
420        if itemCase not in uniq:
421            uniq.add(itemCase)
422            output.append(item)
423        elif debug:
424            loguru.logger.trace("[Dedupe] {}", item)
425
426    if sortAlphabetically:
427        output = sorted(output)
428
429    return output

Remove duplicates in list (= uniq).

Arguments:
  • input: List to deduplicate.
  • sortAlphabetically: If True, sort output.
  • caseSensitive: If False, dedupe case-insensitively.
  • debug: If True, log duplicates.
Returns:

Deduplicated list.

def findClosestValue(values: list, value: int, sort=True, discardLarger=False) -> int:
432def findClosestValue(values: list, value: int, sort=True, discardLarger=False) -> int:
433    """Return closest value in a sorted list.
434
435    If two values are equally close, return the smallest value.
436
437    Args:
438        values: List of values.
439        value: Value to compare.
440        sort: If True, sort the list.
441        discardLarger: If True, discard values larger than input.
442
443    Returns:
444        Closest value or None if list is empty.
445    """
446    from bisect import bisect_left
447
448    # Discard larger values
449    if discardLarger:
450        values = [v for v in values if v <= value]
451
452    if sort:
453        values = sorted(values)
454
455    pos = bisect_left(values, value)
456    if pos == 0:
457        return values[0] if len(values) else None
458    if pos == len(values):
459        return values[-1]
460    before = values[pos - 1]
461    after = values[pos]
462
463    if after - value < value - before:
464        return after
465    else:
466        return before

Return closest value in a sorted list.

If two values are equally close, return the smallest value.

Arguments:
  • values: List of values.
  • value: Value to compare.
  • sort: If True, sort the list.
  • discardLarger: If True, discard values larger than input.
Returns:

Closest value or None if list is empty.

def findClosestIndex(values: list, value: str) -> int:
469def findClosestIndex(values: list, value: str) -> int:
470    """Find index of closest matching string in a list.
471
472    Useful for finding `sort()` key.
473
474    Example:
475        `["B", "F"], "Foo"` => returns `1`
476
477    Returns:
478        Index of closest match or None.
479    """
480    if not value:
481        return None
482
483    indexFound = None
484
485    while len(value) and not isinstance(indexFound, int):
486        try:
487            indexFound = values.index(value)
488        except ValueError:
489            pass
490
491        value = value[:-1]
492
493    return indexFound

Find index of closest matching string in a list.

Useful for finding sort() key.

Example:

["B", "F"], "Foo" => returns 1

Returns:

Index of closest match or None.

def isSequence(value) -> bool:
496def isSequence(value) -> bool:
497    """Check if value is a sequence (list or tuple) but not a string."""
498    return isinstance(value, Sequence) and not isinstance(value, (str, bytes))

Check if value is a sequence (list or tuple) but not a string.

def isListOfOne(value) -> bool:
501def isListOfOne(value) -> bool:
502    """Returns True if the list contains exactly one item.
503
504    Example:
505    - `[1, 2]` => `false`
506    - `1`      => `false`
507    - `[1]`    => `true`
508    """
509    return isSequence(value) and len(value) == 1

Returns True if the list contains exactly one item.

Example:

  • [1, 2] => false
  • 1 => false
  • [1] => true
def isFirst( parent: list | int, child: list | int, mode: Literal['indices', 'lists'] = 'indices'):
512def isFirst(
513    parent: list | int,
514    child: list | int,
515    mode: Literal["indices", "lists"] = "indices",
516):
517    """Returns True if child is the first element in parent.
518
519    Args:
520        parent: Iterable or count.
521        child: Item or index.
522        mode: 'indices' or 'lists'.
523    """
524    childIndex = (
525        child if isinstance(child, int) and mode == "indices" else parent.index(child)
526    )
527    return childIndex == 0

Returns True if child is the first element in parent.

Arguments:
  • parent: Iterable or count.
  • child: Item or index.
  • mode: 'indices' or 'lists'.
def isLast( parent: list | int, child: list | int, mode: Literal['indices', 'lists'] = 'indices'):
530def isLast(
531    parent: list | int,
532    child: list | int,
533    mode: Literal["indices", "lists"] = "indices",
534):
535    """Returns True if child is the last element in parent.
536
537    Args:
538        parent: Iterable or count.
539        child: Item or index.
540        mode: 'indices' or 'lists'.
541    """
542    childIndex = (
543        child if isinstance(child, int) and mode == "indices" else parent.index(child)
544    )
545    parentCount = len(parent) if not isinstance(parent, int) else parent
546    return childIndex == parentCount - 1

Returns True if child is the last element in parent.

Arguments:
  • parent: Iterable or count.
  • child: Item or index.
  • mode: 'indices' or 'lists'.
def isNth(index: int, nth: int = 3):
549def isNth(index: int, nth: int = 3):
550    """Returns True if index is every nth element.
551
552    Args:
553        index: Index to check.
554        nth: Nth interval.
555
556    Example:
557        `nth=3` => Every third
558    """
559    return (index + 1) % nth == 0

Returns True if index is every nth element.

Arguments:
  • index: Index to check.
  • nth: Nth interval.
Example:

nth=3 => Every third

def pickFirst(value):
562def pickFirst(value):
563    """Get first value from iterable if iterable.
564
565    Returns:
566        First value or value itself if not iterable.
567    """
568    if isSequence(value):
569        return value[0]
570    else:
571        return value

Get first value from iterable if iterable.

Returns:

First value or value itself if not iterable.

def pickLast(value):
574def pickLast(value):
575    """Get last value from iterable if iterable.
576
577    Returns:
578        Last value or value itself if not iterable.
579    """
580    if isSequence(value):
581        return value[-1]
582    else:
583        return value

Get last value from iterable if iterable.

Returns:

Last value or value itself if not iterable.

def pickRandom(items: list) -> tuple:
586def pickRandom(items: list) -> tuple:
587    """Pick 1 item at random and return remaining items.
588
589    Returns:
590        Tuple of (item, remainingItems).
591    """
592    item = random.choice(items)
593    itemIndex = items.index(item)
594    items.pop(itemIndex)
595
596    return item, items

Pick 1 item at random and return remaining items.

Returns:

Tuple of (item, remainingItems).

def pickExtremes(values: list):
599def pickExtremes(values: list):
600    """Get first and last from list.
601
602    Example:
603        `[1, 2, 3, 4]` => `(1, 4)`
604
605    Returns:
606        Tuple of (first, last) or values itself if length <= 1.
607    """
608    if len(values) > 1:
609        return values[0], values[-1]
610    else:
611        return values

Get first and last from list.

Example:

[1, 2, 3, 4] => (1, 4)

Returns:

Tuple of (first, last) or values itself if length <= 1.

def pushToEnd(items: list[~T], index=0):
614def pushToEnd(items: list[T], index=0):
615    """Move item at index to the end and return modified list.
616
617    Example:
618        `ABCD` `index=0` => `BCDA`
619
620    Returns:
621        Modified list.
622    """
623    result = items.copy()  # Avoid mutating original
624    result.append(result.pop(index))
625    return result

Move item at index to the end and return modified list.

Example:

ABCD index=0 => BCDA

Returns:

Modified list.

def halven(input: ~T) -> list[~T]:
643def halven(input: T) -> list[T]:
644    """Split input into two halves. Works for lists and strings.
645
646    - `even`: two equal halves
647    - `odd`: larger/smaller half
648
649    Returns:
650        List of two halves (even or odd sized).
651    """
652    return list(_splitToGroups(input, 2))

Split input into two halves. Works for lists and strings.

  • even: two equal halves
  • odd: larger/smaller half
Returns:

List of two halves (even or odd sized).

def splitEvenly(input: ~T, groups: int = 3) -> list[~T]:
655def splitEvenly(input: T, groups: int = 3) -> list[T]:
656    """Split input into evenly sized groups. Works for lists and strings.
657
658    Args:
659        input: List or string to split.
660        groups: Number of groups.
661
662    Example:
663        `groups=2`, `ABCD` => `AB CD`
664
665    Returns:
666        List of groups.
667    """
668    return list(_splitToGroups(input, groups))

Split input into evenly sized groups. Works for lists and strings.

Arguments:
  • input: List or string to split.
  • groups: Number of groups.
Example:

groups=2, ABCD => AB CD

Returns:

List of groups.

def toChunks(items: list[~T], groups=2) -> list[list[~T]]:
671def toChunks(items: list[T], groups=2) -> list[list[T]]:
672    """Combine groups in alternating manner.
673
674    Example:
675        `groups=2`, `ABCD` => `AC BD`
676
677    Returns:
678        List of chunked groups.
679    """
680    return [items[start::groups] for start in range(groups)]

Combine groups in alternating manner.

Example:

groups=2, ABCD => AC BD

Returns:

List of chunked groups.

def shuffleAtRandomSegment(items: list[~T]) -> list[~T]:
683def shuffleAtRandomSegment(items: list[T]) -> list[T]:
684    """Shuffle list at a random segment to maintain order.
685
686    Returns:
687        List shuffled at a random index.
688    """
689    randomItem = random.choice(items)
690    randomIndex = items.index(randomItem)
691    return flatten([items[randomIndex:], items[:randomIndex]])

Shuffle list at a random segment to maintain order.

Returns:

List shuffled at a random index.

def shuffleAtInterval(items: list[~T], n: int = 2) -> list[~T]:
694def shuffleAtInterval(items: list[T], n: int = 2) -> list[T]:
695    """
696    Rearrange a list by grouping every nth item together, then flattening the result.
697
698    Example:
699    - For items `[A, B, C, D, E, F]` and n `3`:
700    - The list is split into 3 groups: `[A, D], [B, E], [C, F]`
701    - The result is: `[A, D, B, E, C, F]`
702
703    Returns:
704        A new list with items reordered by interval.
705    """
706    return sum(toChunks(items, n), [])

Rearrange a list by grouping every nth item together, then flattening the result.

Example:

  • For items [A, B, C, D, E, F] and n 3:
  • The list is split into 3 groups: [A, D], [B, E], [C, F]
  • The result is: [A, D, B, E, C, F]
Returns:

A new list with items reordered by interval.

def spaceEvenly(items: list, size=2):
709def spaceEvenly(items: list, size=2):
710    """Get n items from list, evenly spaced.
711
712    Args:
713        items: List of items.
714        size: Number of items to get.
715
716    Example:
717    - 1 => get midpoint
718    - 2 => get first and last
719
720    Returns:
721        List of evenly spaced items.
722    """
723
724    def _getSpacedIndices(start, stop, length):
725        return [round(start + x * (stop - start) / (length - 1)) for x in range(length)]
726
727    itemCount = len(items)
728    if size == 1:
729        # Return midpoint
730        indices = [len(items) >> 1]
731    else:
732        # Never return size larger than itemCount
733        indices = _getSpacedIndices(0, itemCount - 1, min(itemCount, size))
734
735    return [items[i] for i in indices]

Get n items from list, evenly spaced.

Arguments:
  • items: List of items.
  • size: Number of items to get.

Example:

  • 1 => get midpoint
  • 2 => get first and last
Returns:

List of evenly spaced items.

def pick(object: dict, keys: list[str]) -> dict:
743def pick(object: dict, keys: list[str]) -> dict:
744    """Pick properties by `key`.
745
746    Args:
747        object: Dictionary to pick from.
748        keys: Keys to pick.
749
750    Returns:
751        Dictionary with only picked keys.
752    """
753    return {k: v for k, v in object.items() if k in coerceList(keys)}

Pick properties by key.

Arguments:
  • object: Dictionary to pick from.
  • keys: Keys to pick.
Returns:

Dictionary with only picked keys.

def omit(object: dict, keys: list[str]) -> dict:
756def omit(object: dict, keys: list[str]) -> dict:
757    """Remove properties by `key`.
758
759    Args:
760        object: Dictionary to omit from.
761        keys: Keys to omit.
762
763    Returns:
764        Dictionary with omitted keys.
765    """
766    return {k: v for k, v in object.items() if k not in coerceList(keys)}

Remove properties by key.

Arguments:
  • object: Dictionary to omit from.
  • keys: Keys to omit.
Returns:

Dictionary with omitted keys.

def omitBy(object: dict, omit=[0, None, []]) -> dict:
769def omitBy(object: dict, omit=[0, None, []]) -> dict:
770    """Remove properties by `value`.
771
772    Remove falsy by default: `0`, `None`, `[]`.
773
774    Args:
775        object: Dictionary to filter.
776        omit: Values to omit.
777
778    Returns:
779        Dictionary with omitted values.
780    """
781    return {k: v for k, v in object.items() if v not in coerceList(omit)}

Remove properties by value.

Remove falsy by default: 0, None, [].

Arguments:
  • object: Dictionary to filter.
  • omit: Values to omit.
Returns:

Dictionary with omitted values.

def merge(dicts: list[dict]):
789def merge(dicts: list[dict]):
790    """Merge multiple instances of `dict`.
791
792    What happens to values:
793    - `str` gets overwritten by last occurrence
794    - `list` gets extended (`flatten` + `dedupe`)
795
796    Args:
797        dicts: List of dictionaries to merge.
798
799    Returns:
800        Merged dictionary.
801    """
802    result = dict()
803
804    for d in dicts:
805        for [key, value] in d.items():
806            hasKey = result.get(key)
807            if hasKey:
808                if isinstance(value, list):
809                    result[key] = dedupe(flatten([result[key], value]))
810                else:
811                    result[key] = value
812            else:
813                result[key] = value
814
815    return result

Merge multiple instances of dict.

What happens to values:

  • str gets overwritten by last occurrence
  • list gets extended (flatten + dedupe)
Arguments:
  • dicts: List of dictionaries to merge.
Returns:

Merged dictionary.

def findByKey(items: list[dict], key: str, value, defaultValue=None) -> dict | None:
818def findByKey(items: list[dict], key: str, value, defaultValue=None) -> dict | None:
819    """Find item in list of dictionaries/objects by key.
820
821    Args:
822        items: List of dictionaries or objects.
823        key: Key to search for.
824        value: Value to match.
825        defaultValue: Value to return if not found.
826
827    Returns:
828        The found item or defaultValue.
829    """
830
831    def getValue(item, key):
832        # Use built-in method for dictionaries
833        if isinstance(item, dict):
834            return item.get(key)
835        # Otherwise assume it’s an object
836        else:
837            return getattr(item, key)
838
839    return next((item for item in items if getValue(item, key) == value), defaultValue)

Find item in list of dictionaries/objects by key.

Arguments:
  • items: List of dictionaries or objects.
  • key: Key to search for.
  • value: Value to match.
  • defaultValue: Value to return if not found.
Returns:

The found item or defaultValue.

def sortByKey(items: list[dict], key: str, defaultValue: int | str = None):
842def sortByKey(items: list[dict], key: str, defaultValue: int | str = None):
843    """Sort list of dictionaries by key.
844
845    Args:
846        items: List of dictionaries.
847        key: Key to sort by.
848        defaultValue: Value to use if key is missing.
849
850    Returns:
851        Sorted list or original if sorting fails.
852    """
853    # May be []
854    try:
855        return sorted(items, key=lambda item: item.get(key, defaultValue))
856    except TypeError:
857        return items

Sort list of dictionaries by key.

Arguments:
  • items: List of dictionaries.
  • key: Key to sort by.
  • defaultValue: Value to use if key is missing.
Returns:

Sorted list or original if sorting fails.

def groupByKey(items: list[dict], key: str) -> list[list[dict]]:
860def groupByKey(items: list[dict], key: str) -> list[list[dict]]:
861    """Group list of dictionaries by `key`.
862
863    Args:
864        items: List of dictionaries.
865        key: Key to group by.
866
867    Returns:
868        Grouped items as an iterator.
869    """
870    return groupby(items, key=lambda item: item.get(key))

Group list of dictionaries by key.

Arguments:
  • items: List of dictionaries.
  • key: Key to group by.
Returns:

Grouped items as an iterator.

def reject(items: list[dict], key: str, value):
873def reject(items: list[dict], key: str, value):
874    """Remove items matching `key`:`value`.
875
876    Args:
877        items: List of dictionaries.
878        key: Key to check.
879        value: Value to reject.
880
881    Returns:
882        Filtered list.
883    """
884    return [item for item in items if item.get(key) != value]

Remove items matching key:value.

Arguments:
  • items: List of dictionaries.
  • key: Key to check.
  • value: Value to reject.
Returns:

Filtered list.

def mergeCollections( main: list[dict], additions: list[dict], key: str = 'id', caseSensitive=True, mode: Literal['merge', 'replace'] = 'merge') -> list[dict]:
887def mergeCollections(
888    main: list[dict],
889    additions: list[dict],
890    key: str = "id",
891    caseSensitive=True,
892    mode: Literal["merge", "replace"] = "merge",
893) -> list[dict]:
894    """Merge two instances of `list[dict]` and dedupe by key.
895
896    Args:
897        main: Main list of dictionaries.
898        additions: List of dictionaries to merge in.
899        key: Key to match for merging.
900        caseSensitive: If False, dedupe case-insensitively.
901        mode: When both values are lists:
902            - 'merge': Concatenate and dedupe (default)
903            - 'replace': Use addition's value, discard main's value
904
905    Returns:
906        Merged list of dictionaries.
907    """
908    # Avoid modifying original collection
909    merged = deepcopy(main)
910    for addition in additions:
911        foundMain = findByKey(merged, key, addition.get(key))
912
913        # Update existing dictionary
914        if foundMain:
915            for property in addition:
916                valueMain = foundMain.get(property)
917                valueAdd = addition.get(property)
918                # Update value if new
919                if not valueMain == valueAdd:
920                    # Concat if both are lists
921                    if all([isinstance(v, list) for v in [valueMain, valueAdd]]):
922                        if mode == "replace":
923                            valueNew = valueAdd
924                        else:
925                            valueNew = dedupe(
926                                flatten([*valueMain, *valueAdd]),
927                                caseSensitive=caseSensitive,
928                            )
929                    # Otherwise overwrite
930                    else:
931                        valueNew = valueAdd
932                    foundMain.update({property: valueNew})
933        # Otherwise insert as last
934        else:
935            merged.append(addition)
936    return merged

Merge two instances of list[dict] and dedupe by key.

Arguments:
  • main: Main list of dictionaries.
  • additions: List of dictionaries to merge in.
  • key: Key to match for merging.
  • caseSensitive: If False, dedupe case-insensitively.
  • mode: When both values are lists:
    • 'merge': Concatenate and dedupe (default)
    • 'replace': Use addition's value, discard main's value
Returns:

Merged list of dictionaries.

def flatMap(collection: list[dict], key: str) -> list:
939def flatMap(collection: list[dict], key: str) -> list:
940    """Map all `key` values into a single list.
941
942    Args:
943        collection: List of dictionaries.
944        key: Key to extract.
945
946    Returns:
947        Flattened list of all key values.
948
949    Example:
950        >>> data = [
951        ...     {"id": "a", "items": [1, 2, 3]},
952        ...     {"id": "b", "items": [4, 5]},
953        ... ]
954        >>> flatMap(data, "items")
955        [1, 2, 3, 4, 5]
956    """
957    assert isinstance(collection, list), "Collection must be a list"
958    assert isFlat(collection), "Collection must be flat list of dicts"
959
960    result = []
961    for item in collection:
962        value = item.get(key, [])
963        # Handle strings (convert to list of characters)
964        if isinstance(value, str):
965            result.extend(list(value))
966        # Handle iterables
967        elif isinstance(value, (list, tuple)):
968            result.extend(value)
969        # Handle single non-iterable values
970        else:
971            result.append(value)
972    return result

Map all key values into a single list.

Arguments:
  • collection: List of dictionaries.
  • key: Key to extract.
Returns:

Flattened list of all key values.

Example:
>>> data = [
...     {"id": "a", "items": [1, 2, 3]},
...     {"id": "b", "items": [4, 5]},
... ]
>>> flatMap(data, "items")
[1, 2, 3, 4, 5]