lib.helpers

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

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 readFile(filePath: str, mode: Literal['txt', 'json'] = 'txt'):
39def readFile(filePath: str, mode: Literal["txt", "json"] = "txt"):
40    """Read a file and return its contents.
41
42    Args:
43        filePath: Path to the file.
44        mode: `txt` to return raw str, `json` to return parsed JSON.
45
46    Returns:
47        File contents as str or parsed JSON.
48    """
49    file = open(filePath, encoding="utf-8").read()
50    # Parse JSON, otherwise return raw str
51    return json.loads(file) if mode == "json" else file

Read a file and return its contents.

Arguments:
  • filePath: Path to the file.
  • mode: txt to return raw str, json to return parsed JSON.
Returns:

File contents as str or parsed JSON.

def createFolder(path: str) -> str:
54def createFolder(path: str) -> str:
55    """Create folder if non-existent, return its path.
56
57    Args:
58        path: Path to the folder.
59
60    Returns:
61        The path if created or exists, otherwise an empty string.
62    """
63    try:
64        # ? Create parent directories recursively, ignore error if exists
65        os.makedirs(path, exist_ok=True)
66        return path
67    except Exception as e:
68        if isDirectory(path):
69            return path
70        else:
71            loguru.logger.warning(
72                "Error creating folder for path '{}' with exception {}", path, e
73            )
74            # Return empty string if failed => write to root
75            return ""

Create folder if non-existent, return its path.

Arguments:
  • path: Path to the folder.
Returns:

The path if created or exists, otherwise an empty string.

def isFile(string: str) -> bool:
78def isFile(string: str) -> bool:
79    """Returns True if the string is a path to a non-empty file."""
80    return (
81        isinstance(string, str)
82        and os.path.isfile(string)
83        and os.path.getsize(string) > 0
84    )

Returns True if the string is a path to a non-empty file.

def isDirectory(string: str) -> bool:
87def isDirectory(string: str) -> bool:
88    """Returns True if the string is a directory path."""
89    return os.path.isdir(string)

Returns True if the string is a directory path.

def coerceList(value, separate=False, strict=False) -> list:
 92def coerceList(value, separate=False, strict=False) -> list:
 93    """Coerce a value to a list.
 94
 95    Args:
 96        value: The value to coerce.
 97        separate: If True, split str to characters.
 98            - (False) `"abc"` => `["abc"]`
 99            - (True) `"abc"` => `["a", "b", "c"]`
100        strict: If True, force tuple to list.
101            - (False) `(a, b)` => `(a, b)`
102            - (True) `(a, b)` => `[(a, b)]`
103
104    Returns:
105        The coerced list.
106
107    Example:
108    - `1` or `[1]` => `[1]`
109    - `1, 2` => `[1, 2]`
110    """
111    passingTypes = list if strict else (list, tuple)
112    # Check if not None, can be []
113    if not value is None:
114        if isinstance(value, passingTypes):
115            return value
116        else:
117            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]:
120def coerceNumericList(value, separator=" ") -> list[int]:
121    """Coerce a value to a list of integers.
122
123    Args:
124        value: The value to coerce.
125        separator: Separator for splitting strings.
126
127    Returns:
128        List of integers.
129
130    Example:
131    - (int) `5` => `[5]`
132    - (str) `"5 5"` => `[5, 5]`
133    """
134    if isinstance(value, (int, float)):
135        value = [value]
136    elif isinstance(value, str):
137        value = [int(side) for side in value.split(separator)]
138    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) "5 5" => [5, 5]
def coerceTuple(value) -> tuple:
141def coerceTuple(value) -> tuple:
142    """Coerce a value to a tuple.
143
144    Args:
145        value: The value to coerce.
146
147    Returns:
148        The coerced tuple.
149
150    Example:
151    - `1` or `(1,)` => `(1,)`
152    - `1, 2` => `(1, 2)`
153    """
154    if isinstance(value, tuple):
155        return value
156    else:
157        return (value,) if not isinstance(value, (list, tuple)) else tuple(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:
160def expand(
161    input, n=2, defaultValue=None, format: Literal["auto", "list", "tuple"] = "auto"
162) -> list | tuple:
163    """Expand to iterable with length of `n`.
164
165    Args:
166        input: The input value(s) to expand.
167        n: Desired length.
168        defaultValue: Fallback value for cycling.
169        format: Output type.
170
171    Returns:
172        Iterable of length n.
173
174    Example:
175    - `n=3`: `a` => `[a, a, a]`
176    - `n=4`: `[1, 2]` => `[1, 2, 1, 2]`
177    """
178
179    def _getValueAtIndex(i: int):
180        try:
181            return input[i]
182        except:
183            return defaultValue or input[i % len(input)]
184
185    input = coerceList(input)
186    if format == "auto":
187        outputFormat = tuple if isinstance(input, tuple) else list
188    else:
189        outputFormat = tuple if format == "tuple" else list
190    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 isInRange(start: int, end: int, value: int):
194def isInRange(start: int, end: int, value: int):
195    """Returns True if value is in the inclusive range [start, end]."""
196    return start <= value <= end

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

def truncateString(string: str, length=10, suffix='...') -> str:
200def truncateString(string: str, length=10, suffix="...") -> str:
201    """
202    Truncate a string to a given length and append a suffix.
203
204    Example:
205        `(5)` `Hello there!`  => `Hello...`
206    """
207    return string[:length] + suffix

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

Example:

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

def reverseList(input: list[~T]) -> list[~T]:
211def reverseList(input: list[T]) -> list[T]:
212    """Inverse values in iterable.
213
214    Returns:
215        The reversed list.
216    """
217    return input[::-1]

Inverse values in iterable.

Returns:

The reversed list.

def truncateList(input: list, length=3) -> str:
220def truncateList(input: list, length=3) -> str:
221    """
222    Truncate a list and describe remaining items.
223
224    Example:
225        (2) `[A, B, C, D]`  => `[A, B] and 2 more items`
226    """
227    remainder = len(input) - length
228    noun = "items" if remainder > 1 else "item"
229
230    output = f"{input[:length]}"
231
232    if remainder > 0:
233        output += f" and {remainder} more {noun}"
234
235    return output

Truncate a list and describe remaining items.

Example:

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

def inverseValues(values: list[int]) -> list:
238def inverseValues(values: list[int]) -> list:
239    """Negate all values in a list.
240
241    Example:
242       `[1, 2]` => `[-1, -2]`
243    """
244    return [-value for value in coerceList(values)]

Negate all values in a list.

Example:

[1, 2] => [-1, -2]

def flatten(items: list):
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

Flatten a list of lists or tuples.

Returns:

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

def flattenTuples(input):
267def flattenTuples(input):
268    """Flatten nested tuples and lists into a single list of tuples.
269
270    Example:
271        `[(1, 2), [(3, 4), (5, 6)]]` => `[(1, 2), (3, 4), (5, 6)]`
272    """
273    output = []
274
275    if isinstance(input, tuple):
276        input = [input]
277
278    for item in input:
279        if isinstance(item, list):
280            for subitem in item:
281                output.append(subitem)
282        else:
283            output.append(item)
284    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:
287def intersect(lists: list[list], retainOrder=True) -> list:
288    """Return intersection of multiple lists.
289
290    Args:
291        lists: List of lists to intersect.
292        retainOrder: If True, order based on first list.
293
294    Returns:
295        List of intersected items.
296
297    Example:
298        `[a, b, c]` + `[b]` => `[b]`
299    """
300    result = set.intersection(*map(set, lists))
301    listA = lists[0]
302
303    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]:
306def zipWithMode(
307    groups: list[list], mode: Literal["drop", "fill"] = "fill"
308) -> list[list]:
309    """Zip lists with different modes.
310
311    Args:
312        groups: List of lists to zip.
313        mode: 'drop' to zip to shortest, 'fill' to zip to longest.
314
315    Returns:
316        Zipped list of lists.
317    """
318
319    def _fillGroup(group):
320        isShorter = len(group) < longest
321        return [group[i % len(group)] for i in range(longest)] if isShorter else group
322
323    if mode == "fill":
324        groups = [group if group else [group] for group in groups]  # None => [None]
325        longest = max([len(group) for group in groups])
326        groups = [_fillGroup(group) for group in groups]
327
328    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:
331def removeFalsy(
332    input: list, mode: Literal[None, False, "empty", "either"] = "either"
333) -> list:
334    """Remove falsy values from a list.
335
336    Args:
337        input: List to filter.
338        mode: 'None', 'False', 'empty', or 'either'.
339
340    Returns:
341        Filtered list.
342    """
343
344    def _checkItem(item):
345        isNotNone = item is not None
346        isNotFalse = item is not False
347        isNotEmpty = isinstance(item, list) and len(item)
348        isNotEither = isNotNone and isNotFalse and isNotEmpty
349        if mode is None:
350            return isNotNone
351        elif mode is False:
352            return isNotFalse
353        else:
354            return isNotEither
355
356    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:
359def removeNone(input: list) -> list:
360    """Remove `None` only from a list.
361
362    Returns:
363        List with None values removed.
364    """
365    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:
368def removeFromList(input: list, remove: list) -> list:
369    """Remove specified items from a list.
370
371    Example:
372        `[a, b, c]` + `[b]` => `[a, c]`
373
374    Returns:
375        List with specified items removed.
376    """
377    output = []
378    remove = flatten(coerceList(remove))
379
380    for element in input:
381        if element not in remove:
382            output.append(element)
383
384    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, sortAlphabetically=False, caseSensitive=False, debug=False):
387def dedupe(input: list, sortAlphabetically=False, caseSensitive=False, debug=False):
388    """Remove duplicates in list (= uniq).
389
390    Args:
391        input: List to deduplicate.
392        sortAlphabetically: If True, sort output.
393        caseSensitive: If False, dedupe case-insensitively.
394        debug: If True, log duplicates.
395
396    Returns:
397        Deduplicated list.
398    """
399    input = [item for item in input if item]  # Remove None
400    output = list()
401    uniq = set()
402
403    for item in input:
404        itemCase = (
405            item if caseSensitive or isinstance(item, (int, float)) else item.casefold()
406        )
407        if itemCase not in uniq:
408            uniq.add(itemCase)
409            output.append(item)
410        elif debug:
411            loguru.logger.trace("[Dedupe] {}", item)
412
413    if sortAlphabetically:
414        output = sorted(output)
415
416    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:
419def findClosestValue(values: list, value: int, sort=True, discardLarger=False) -> int:
420    """Return closest value in a sorted list.
421
422    If two values are equally close, return the smallest value.
423
424    Args:
425        values: List of values.
426        value: Value to compare.
427        sort: If True, sort the list.
428        discardLarger: If True, discard values larger than input.
429
430    Returns:
431        Closest value or None if list is empty.
432    """
433    from bisect import bisect_left
434
435    # Discard larger values
436    if discardLarger:
437        values = [v for v in values if v <= value]
438
439    if sort:
440        values = sorted(values)
441
442    pos = bisect_left(values, value)
443    if pos == 0:
444        return values[0] if len(values) else None
445    if pos == len(values):
446        return values[-1]
447    before = values[pos - 1]
448    after = values[pos]
449
450    if after - value < value - before:
451        return after
452    else:
453        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:
456def findClosestIndex(values: list, value: str) -> int:
457    """Find index of closest matching string in a list.
458
459    Useful for finding `sort()` key.
460
461    Example:
462        `["B", "F"], "Foo"` => returns `1`
463
464    Returns:
465        Index of closest match or None.
466    """
467    if not value:
468        return None
469
470    indexFound = None
471
472    while len(value) and not isinstance(indexFound, int):
473        try:
474            indexFound = values.index(value)
475        except:
476            pass
477
478        value = value[:-1]
479
480    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 isListOfOne(items: list) -> bool:
483def isListOfOne(items: list) -> bool:
484    """Returns True if the list contains exactly one item.
485
486    Example:
487    - `[1, 2]` => `false`
488    - `1`      => `false`
489    - `[1]`    => `true`
490    """
491    return isinstance(items, list) and len(items) == 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'):
494def isFirst(
495    parent: list | int,
496    child: list | int,
497    mode: Literal["indices", "lists"] = "indices",
498):
499    """Returns True if child is the first element in parent.
500
501    Args:
502        parent: Iterable or count.
503        child: Item or index.
504        mode: 'indices' or 'lists'.
505    """
506    childIndex = (
507        child if isinstance(child, int) and mode == "indices" else parent.index(child)
508    )
509    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'):
512def isLast(
513    parent: list | int,
514    child: list | int,
515    mode: Literal["indices", "lists"] = "indices",
516):
517    """Returns True if child is the last 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    parentCount = len(parent) if not isinstance(parent, int) else parent
528    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):
531def isNth(index: int, nth: int = 3):
532    """Returns True if index is every nth element.
533
534    Args:
535        index: Index to check.
536        nth: Nth interval.
537
538    Example:
539        `nth=3` => Every third
540    """
541    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):
544def pickFirst(value):
545    """Get first value from iterable if iterable.
546
547    Returns:
548        First value or value itself if not iterable.
549    """
550    if isinstance(value, (list, tuple)):
551        return value[0]
552    else:
553        return value

Get first value from iterable if iterable.

Returns:

First value or value itself if not iterable.

def pickLast(value):
556def pickLast(value):
557    """Get last value from iterable if iterable.
558
559    Returns:
560        Last value or value itself if not iterable.
561    """
562    if isinstance(value, (list, tuple)):
563        return value[-1]
564    else:
565        return value

Get last value from iterable if iterable.

Returns:

Last value or value itself if not iterable.

def pickRandom(items: list) -> tuple:
568def pickRandom(items: list) -> tuple:
569    """Pick 1 item at random and return remaining items.
570
571    Returns:
572        Tuple of (item, remainingItems).
573    """
574    item = random.choice(items)
575    itemIndex = items.index(item)
576    items.pop(itemIndex)
577
578    return item, items

Pick 1 item at random and return remaining items.

Returns:

Tuple of (item, remainingItems).

def pickExtremes(values: list):
581def pickExtremes(values: list):
582    """Get first and last from list.
583
584    Example:
585        `[1, 2, 3, 4]` => `(1, 4)`
586
587    Returns:
588        Tuple of (first, last) or values itself if length <= 1.
589    """
590    if len(values) > 1:
591        return values[0], values[-1]
592    else:
593        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):
596def pushToEnd(items: list[T], index=0):
597    """Move item at index to the end and return modified list.
598
599    Example:
600        `ABCD` `index=0` => `BCDA`
601
602    Returns:
603        Modified list.
604    """
605    result = items.copy()  # Avoid mutating original
606    result.append(result.pop(index))
607    return result

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

Example:

ABCD index=0 => BCDA

Returns:

Modified list.

def halven(input: list | str):
625def halven(input: list | str):
626    """Split input into two halves.
627    - `even`: two equal halves
628    - `odd`: larger/smaller half
629
630    Returns:
631        List of two halves (even or odd sized).
632    """
633    return list(_splitToGroups(input, 2))

Split input into two halves.

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

List of two halves (even or odd sized).

def splitEvenly(input: list | str, groups=3):
636def splitEvenly(input: list | str, groups=3):
637    """Split input into evenly sized groups.
638
639    Args:
640        input: List or string to split.
641        groups: Number of groups.
642
643    Example:
644        `groups=2`, `ABCD` => `AB CD`
645
646    Returns:
647        List of groups.
648    """
649    return list(_splitToGroups(input, groups))

Split input into evenly sized groups.

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]]:
652def toChunks(items: list[T], groups=2) -> list[list[T]]:
653    """Combine groups in alternating manner.
654
655    Example:
656        `groups=2`, `ABCD` => `AC BD`
657
658    Returns:
659        List of chunked groups.
660    """
661    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]:
664def shuffleAtRandomSegment(items: list[T]) -> list[T]:
665    """Shuffle list at a random segment to maintain order.
666
667    Returns:
668        List shuffled at a random index.
669    """
670    randomItem = random.choice(items)
671    randomIndex = items.index(randomItem)
672    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]:
675def shuffleAtInterval(items: list[T], n: int = 2) -> list[T]:
676    """
677    Rearrange a list by grouping every nth item together, then flattening the result.
678
679    Example:
680    - For items `[A, B, C, D, E, F]` and n `3`:
681    - The list is split into 3 groups: `[A, D], [B, E], [C, F]`
682    - The result is: `[A, D, B, E, C, F]`
683
684    Returns:
685        A new list with items reordered by interval.
686    """
687    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):
690def spaceEvenly(items: list, size=2):
691    """Get n items from list, evenly spaced.
692
693    Args:
694        items: List of items.
695        size: Number of items to get.
696
697    Example:
698    - 1 => get midpoint
699    - 2 => get first and last
700
701    Returns:
702        List of evenly spaced items.
703    """
704
705    def _getSpacedIndices(start, stop, length):
706        return [round(start + x * (stop - start) / (length - 1)) for x in range(length)]
707
708    itemCount = len(items)
709    if size == 1:
710        # Return midpoint
711        indices = [len(items) >> 1]
712    else:
713        # Never return size larger than itemCount
714        indices = _getSpacedIndices(0, itemCount - 1, min(itemCount, size))
715
716    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:
720def pick(object: dict, keys: list[str]) -> dict:
721    """Pick properties by `key`.
722
723    Args:
724        object: Dictionary to pick from.
725        keys: Keys to pick.
726
727    Returns:
728        Dictionary with only picked keys.
729    """
730    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:
733def omit(object: dict, keys: list[str]) -> dict:
734    """Remove properties by `key`.
735
736    Args:
737        object: Dictionary to omit from.
738        keys: Keys to omit.
739
740    Returns:
741        Dictionary with omitted keys.
742    """
743    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:
746def omitBy(object: dict, omit=[0, None, []]) -> dict:
747    """Remove properties by `value`.
748
749    Remove falsy by default: `0`, `None`, `[]`.
750
751    Args:
752        object: Dictionary to filter.
753        omit: Values to omit.
754
755    Returns:
756        Dictionary with omitted values.
757    """
758    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]):
762def merge(dicts: list[dict]):
763    """Merge multiple instances of `dict`.
764
765    What happens to values:
766    - `str` gets overwritten by last occurrence
767    - `list` gets extended (`flatten` + `dedupe`)
768
769    Args:
770        dicts: List of dictionaries to merge.
771
772    Returns:
773        Merged dictionary.
774    """
775    result = dict()
776
777    for d in dicts:
778        for [key, value] in d.items():
779            hasKey = result.get(key)
780            if hasKey:
781                if isinstance(value, list):
782                    result[key] = dedupe(flatten([result[key], value]))
783                else:
784                    result[key] = value
785            else:
786                result[key] = value
787
788    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:
791def findByKey(items: list[dict], key: str, value, defaultValue=None) -> dict | None:
792    """Find item in list of dictionaries/objects by key.
793
794    Args:
795        items: List of dictionaries or objects.
796        key: Key to search for.
797        value: Value to match.
798        defaultValue: Value to return if not found.
799
800    Returns:
801        The found item or defaultValue.
802    """
803
804    def getValue(item, key):
805        # Use built-in method for dictionaries
806        if isinstance(item, dict):
807            return item.get(key)
808        # Otherwise assume it’s an object
809        else:
810            return getattr(item, key)
811
812    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):
815def sortByKey(items: list[dict], key: str, defaultValue: int | str = None):
816    """Sort list of dictionaries by key.
817
818    Args:
819        items: List of dictionaries.
820        key: Key to sort by.
821        defaultValue: Value to use if key is missing.
822
823    Returns:
824        Sorted list or original if sorting fails.
825    """
826    # May be []
827    try:
828        return sorted(items, key=lambda item: item.get(key, defaultValue))
829    except:
830        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]]:
833def groupByKey(items: list[dict], key: str) -> list[list[dict]]:
834    """Group list of dictionaries by `key`.
835
836    Args:
837        items: List of dictionaries.
838        key: Key to group by.
839
840    Returns:
841        Grouped items as an iterator.
842    """
843    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):
846def reject(items: list[dict], key: str, value):
847    """Remove items matching `key`:`value`.
848
849    Args:
850        items: List of dictionaries.
851        key: Key to check.
852        value: Value to reject.
853
854    Returns:
855        Filtered list.
856    """
857    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) -> list[dict]:
860def mergeCollections(
861    main: list[dict], additions: list[dict], key: str = "id", caseSensitive=True
862) -> list[dict]:
863    """Merge two instances of `list[dict]` by key.
864
865    Args:
866        main: Main list of dictionaries.
867        additions: List of dictionaries to merge in.
868        key: Key to match for merging.
869        caseSensitive: If False, dedupe case-insensitively.
870
871    Returns:
872        Merged list of dictionaries.
873    """
874    # Avoid modifying original collection
875    merged = deepcopy(main)
876    for addition in additions:
877        foundMain = findByKey(merged, key, addition.get(key))
878
879        # Update existing dictionary
880        if foundMain:
881            for property in addition:
882                valueMain = foundMain.get(property)
883                valueAdd = addition.get(property)
884                # Update value if new
885                if not valueMain == valueAdd:
886                    # Concat if both are lists
887                    if all([isinstance(v, list) for v in [valueMain, valueAdd]]):
888                        valueNew = dedupe(
889                            flatten([*valueMain, *valueAdd]),
890                            caseSensitive=caseSensitive,
891                        )
892                    # Otherwise overwrite
893                    else:
894                        valueNew = valueAdd
895                    foundMain.update({property: valueNew})
896        # Otherwise insert as last
897        else:
898            merged.append(addition)
899    return merged

Merge two instances of list[dict] 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.
Returns:

Merged list of dictionaries.

def flatMap(collection: list[dict], key: str) -> list:
902def flatMap(collection: list[dict], key: str) -> list:
903    """Map all `key` values into a single list.
904
905    Args:
906        collection: List of dictionaries.
907        key: Key to extract.
908
909    Returns:
910        Flattened list of all key values.
911    """
912    # Return empty [] if key not found to facilitate flatten
913    return flatten([item.get(key, []) for item in collection])

Map all key values into a single list.

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

Flattened list of all key values.

def getTimestamp(format: Literal['machine', 'human']) -> str:
917def getTimestamp(format: Literal["machine", "human"]) -> str:
918    """Get current timestamp in specified format.
919
920    Example:
921        - `machine` => `2024-06-14`
922        - `human`   => `Jun 14, 2024`
923    """
924    now = datetime.now()
925    if format == "machine":
926        return now.strftime("%Y-%m-%d")
927    else:
928        return now.strftime("%b %d, %Y")

Get current timestamp in specified format.

Example:
  • machine => 2024-06-14
  • human => Jun 14, 2024