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

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:
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        os.mkdir(path)
64        return path
65    except:
66        if isDirectory(path):
67            return path
68        else:
69            loguru.logger.warning("Error creating folder {}", path)
70            # Return empty string if failed => write to root
71            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:
74def isFile(string: str) -> bool:
75    """Returns True if the string is a path to a non-empty file."""
76    return (
77        isinstance(string, str)
78        and os.path.isfile(string)
79        and os.path.getsize(string) > 0
80    )

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

def isDirectory(string: str) -> bool:
83def isDirectory(string: str) -> bool:
84    """Returns True if the string is a directory path."""
85    return os.path.isdir(string)

Returns True if the string is a directory path.

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

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

def truncateString(string: str, length=10, suffix='...') -> str:
177def truncateString(string: str, length=10, suffix="...") -> str:
178    """
179    Truncate a string to a given length and append a suffix.
180
181    Example:
182        `(5)` `Hello there!`  => `Hello...`
183    """
184    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]:
188def reverseList(input: list[T]) -> list[T]:
189    """Inverse values in iterable.
190
191    Returns:
192        The reversed list.
193    """
194    return input[::-1]

Inverse values in iterable.

Returns:

The reversed list.

def truncateList(input: list, length=3) -> str:
197def truncateList(input: list, length=3) -> str:
198    """
199    Truncate a list and describe remaining items.
200
201    Example:
202        (2) `[A, B, C, D]`  => `[A, B] and 2 more items`
203    """
204    remainder = len(input) - length
205    noun = "items" if remainder > 1 else "item"
206
207    output = f"{input[:length]}"
208
209    if remainder > 0:
210        output += f" and {remainder} more {noun}"
211
212    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:
215def inverseValues(values: list[int]) -> list:
216    """Negate all values in a list.
217
218    Example:
219       `[1, 2]` => `[-1, -2]`
220    """
221    return [-value for value in coerceList(values)]

Negate all values in a list.

Example:

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

def flatten(items: list):
224def flatten(items: list):
225    """Flatten a list of lists or tuples.
226
227    Returns:
228        Flattened list or original input if not all items are lists/tuples.
229    """
230    areItemsLists = all([isinstance(item, list) for item in items])
231    areItemsTuples = all([isinstance(item, tuple) for item in items])
232
233    if areItemsLists or areItemsTuples:
234        start = [] if areItemsLists else ()
235        return list(sum(items, start))
236    # Otherwise pass through unchanged
237    else:
238        isIterable = isinstance(items, (list, tuple))
239        if not isIterable:
240            loguru.logger.debug("Unable to flatten: {}", items)
241        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):
244def flattenTuples(input):
245    """Flatten nested tuples and lists into a single list of tuples.
246
247    Example:
248        `[(1, 2), [(3, 4), (5, 6)]]` => `[(1, 2), (3, 4), (5, 6)]`
249    """
250    output = []
251
252    if isinstance(input, tuple):
253        input = [input]
254
255    for item in input:
256        if isinstance(item, list):
257            for subitem in item:
258                output.append(subitem)
259        else:
260            output.append(item)
261    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:
264def intersect(lists: list[list], retainOrder=True) -> list:
265    """Return intersection of multiple lists.
266
267    Args:
268        lists: List of lists to intersect.
269        retainOrder: If True, order based on first list.
270
271    Returns:
272        List of intersected items.
273
274    Example:
275        `[a, b, c]` + `[b]` => `[b]`
276    """
277    result = set.intersection(*map(set, lists))
278    listA = lists[0]
279
280    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]:
283def zipWithMode(
284    groups: list[list], mode: Literal["drop", "fill"] = "fill"
285) -> list[list]:
286    """Zip lists with different modes.
287
288    Args:
289        groups: List of lists to zip.
290        mode: 'drop' to zip to shortest, 'fill' to zip to longest.
291
292    Returns:
293        Zipped list of lists.
294    """
295
296    def _fillGroup(group):
297        isShorter = len(group) < longest
298        return [group[i % len(group)] for i in range(longest)] if isShorter else group
299
300    if mode == "fill":
301        groups = [group if group else [group] for group in groups]  # None => [None]
302        longest = max([len(group) for group in groups])
303        groups = [_fillGroup(group) for group in groups]
304
305    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:
308def removeFalsy(
309    input: list, mode: Literal[None, False, "empty", "either"] = "either"
310) -> list:
311    """Remove falsy values from a list.
312
313    Args:
314        input: List to filter.
315        mode: 'None', 'False', 'empty', or 'either'.
316
317    Returns:
318        Filtered list.
319    """
320
321    def _checkItem(item):
322        isNotNone = item is not None
323        isNotFalse = item is not False
324        isNotEmpty = isinstance(item, list) and len(item)
325        isNotEither = isNotNone and isNotFalse and isNotEmpty
326        if mode is None:
327            return isNotNone
328        elif mode is False:
329            return isNotFalse
330        else:
331            return isNotEither
332
333    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:
336def removeNone(input: list) -> list:
337    """Remove `None` only from a list.
338
339    Returns:
340        List with None values removed.
341    """
342    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:
345def removeFromList(input: list, remove: list) -> list:
346    """Remove specified items from a list.
347
348    Example:
349        `[a, b, c]` + `[b]` => `[a, c]`
350
351    Returns:
352        List with specified items removed.
353    """
354    output = []
355    remove = flatten(coerceList(remove))
356
357    for element in input:
358        if element not in remove:
359            output.append(element)
360
361    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):
364def dedupe(input: list, sortAlphabetically=False, caseSensitive=False, debug=False):
365    """Remove duplicates in list (= uniq).
366
367    Args:
368        input: List to deduplicate.
369        sortAlphabetically: If True, sort output.
370        caseSensitive: If False, dedupe case-insensitively.
371        debug: If True, log duplicates.
372
373    Returns:
374        Deduplicated list.
375    """
376    input = [item for item in input if item]  # Remove None
377    output = list()
378    uniq = set()
379
380    for item in input:
381        itemCase = (
382            item if caseSensitive or isinstance(item, (int, float)) else item.casefold()
383        )
384        if itemCase not in uniq:
385            uniq.add(itemCase)
386            output.append(item)
387        elif debug:
388            loguru.logger.trace("[Dedupe] {}", item)
389
390    if sortAlphabetically:
391        output = sorted(output)
392
393    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:
396def findClosestValue(values: list, value: int, sort=True, discardLarger=False) -> int:
397    """Return closest value in a sorted list.
398
399    If two values are equally close, return the smallest value.
400
401    Args:
402        values: List of values.
403        value: Value to compare.
404        sort: If True, sort the list.
405        discardLarger: If True, discard values larger than input.
406
407    Returns:
408        Closest value or None if list is empty.
409    """
410    from bisect import bisect_left
411
412    # Discard larger values
413    if discardLarger:
414        values = [v for v in values if v <= value]
415
416    if sort:
417        values = sorted(values)
418
419    pos = bisect_left(values, value)
420    if pos == 0:
421        return values[0] if len(values) else None
422    if pos == len(values):
423        return values[-1]
424    before = values[pos - 1]
425    after = values[pos]
426
427    if after - value < value - before:
428        return after
429    else:
430        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:
433def findClosestIndex(values: list, value: str) -> int:
434    """Find index of closest matching string in a list.
435
436    Useful for finding `sort()` key.
437
438    Example:
439        `["B", "F"], "Foo"` => returns `1`
440
441    Returns:
442        Index of closest match or None.
443    """
444    if not value:
445        return None
446
447    indexFound = None
448
449    while len(value) and not isinstance(indexFound, int):
450        try:
451            indexFound = values.index(value)
452        except:
453            pass
454
455        value = value[:-1]
456
457    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:
460def isListOfOne(items: list) -> bool:
461    """Returns True if the list contains exactly one item.
462
463    Example:
464    - `[1, 2]` => `false`
465    - `1`      => `false`
466    - `[1]`    => `true`
467    """
468    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'):
471def isFirst(
472    parent: list | int,
473    child: list | int,
474    mode: Literal["indices", "lists"] = "indices",
475):
476    """Returns True if child is the first element in parent.
477
478    Args:
479        parent: Iterable or count.
480        child: Item or index.
481        mode: 'indices' or 'lists'.
482    """
483    childIndex = (
484        child if isinstance(child, int) and mode == "indices" else parent.index(child)
485    )
486    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'):
489def isLast(
490    parent: list | int,
491    child: list | int,
492    mode: Literal["indices", "lists"] = "indices",
493):
494    """Returns True if child is the last element in parent.
495
496    Args:
497        parent: Iterable or count.
498        child: Item or index.
499        mode: 'indices' or 'lists'.
500    """
501    childIndex = (
502        child if isinstance(child, int) and mode == "indices" else parent.index(child)
503    )
504    parentCount = len(parent) if not isinstance(parent, int) else parent
505    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):
508def isNth(index: int, nth: int = 3):
509    """Returns True if index is every nth element.
510
511    Args:
512        index: Index to check.
513        nth: Nth interval.
514
515    Example:
516        `nth=3` => Every third
517    """
518    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):
521def pickFirst(value):
522    """Get first value from iterable if iterable.
523
524    Returns:
525        First value or value itself if not iterable.
526    """
527    if isinstance(value, (list, tuple)):
528        return value[0]
529    else:
530        return value

Get first value from iterable if iterable.

Returns:

First value or value itself if not iterable.

def pickLast(value):
533def pickLast(value):
534    """Get last value from iterable if iterable.
535
536    Returns:
537        Last value or value itself if not iterable.
538    """
539    if isinstance(value, (list, tuple)):
540        return value[-1]
541    else:
542        return value

Get last value from iterable if iterable.

Returns:

Last value or value itself if not iterable.

def pickRandom(items: list) -> tuple:
545def pickRandom(items: list) -> tuple:
546    """Pick 1 item at random and return remaining items.
547
548    Returns:
549        Tuple of (item, remainingItems).
550    """
551    item = random.choice(items)
552    itemIndex = items.index(item)
553    items.pop(itemIndex)
554
555    return item, items

Pick 1 item at random and return remaining items.

Returns:

Tuple of (item, remainingItems).

def pickExtremes(values: list):
558def pickExtremes(values: list):
559    """Get first and last from list.
560
561    Example:
562        `[1, 2, 3, 4]` => `(1, 4)`
563
564    Returns:
565        Tuple of (first, last) or values itself if length <= 1.
566    """
567    if len(values) > 1:
568        return values[0], values[-1]
569    else:
570        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):
573def pushToEnd(items: list[T], index=0):
574    """Move item at index to the end and return modified list.
575
576    Example:
577        `ABCD` `index=0` => `BCDA`
578
579    Returns:
580        Modified list.
581    """
582    result = items.copy()  # Avoid mutating original
583    result.append(result.pop(index))
584    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):
602def halven(input: list | str):
603    """Split input into two halves.
604    - `even`: two equal halves
605    - `odd`: larger/smaller half
606
607    Returns:
608        List of two halves (even or odd sized).
609    """
610    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):
613def splitEvenly(input: list | str, groups=3):
614    """Split input into evenly sized groups.
615
616    Args:
617        input: List or string to split.
618        groups: Number of groups.
619
620    Example:
621        `groups=2`, `ABCD` => `AB CD`
622
623    Returns:
624        List of groups.
625    """
626    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]]:
629def toChunks(items: list[T], groups=2) -> list[list[T]]:
630    """Combine groups in alternating manner.
631
632    Example:
633        `groups=2`, `ABCD` => `AC BD`
634
635    Returns:
636        List of chunked groups.
637    """
638    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]:
641def shuffleAtRandomSegment(items: list[T]) -> list[T]:
642    """Shuffle list at a random segment to maintain order.
643
644    Returns:
645        List shuffled at a random index.
646    """
647    randomItem = random.choice(items)
648    randomIndex = items.index(randomItem)
649    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]:
652def shuffleAtInterval(items: list[T], n: int = 2) -> list[T]:
653    """
654    Rearrange a list by grouping every nth item together, then flattening the result.
655
656    Example:
657    - For items `[A, B, C, D, E, F]` and n `3`:
658    - The list is split into 3 groups: `[A, D], [B, E], [C, F]`
659    - The result is: `[A, D, B, E, C, F]`
660
661    Returns:
662        A new list with items reordered by interval.
663    """
664    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):
667def spaceEvenly(items: list, size=2):
668    """Get n items from list, evenly spaced.
669
670    Args:
671        items: List of items.
672        size: Number of items to get.
673
674    Example:
675    - 1 => get midpoint
676    - 2 => get first and last
677
678    Returns:
679        List of evenly spaced items.
680    """
681
682    def _getSpacedIndices(start, stop, length):
683        return [round(start + x * (stop - start) / (length - 1)) for x in range(length)]
684
685    itemCount = len(items)
686    if size == 1:
687        # Return midpoint
688        indices = [len(items) >> 1]
689    else:
690        # Never return size larger than itemCount
691        indices = _getSpacedIndices(0, itemCount - 1, min(itemCount, size))
692
693    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:
697def pick(object: dict, keys: list[str]) -> dict:
698    """Pick properties by `key`.
699
700    Args:
701        object: Dictionary to pick from.
702        keys: Keys to pick.
703
704    Returns:
705        Dictionary with only picked keys.
706    """
707    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:
710def omit(object: dict, keys: list[str]) -> dict:
711    """Remove properties by `key`.
712
713    Args:
714        object: Dictionary to omit from.
715        keys: Keys to omit.
716
717    Returns:
718        Dictionary with omitted keys.
719    """
720    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:
723def omitBy(object: dict, omit=[0, None, []]) -> dict:
724    """Remove properties by `value`.
725
726    Remove falsy by default: `0`, `None`, `[]`.
727
728    Args:
729        object: Dictionary to filter.
730        omit: Values to omit.
731
732    Returns:
733        Dictionary with omitted values.
734    """
735    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]):
739def merge(dicts: list[dict]):
740    """Merge multiple instances of `dict`.
741
742    What happens to values:
743    - `str` gets overwritten by last occurrence
744    - `list` gets extended (`flatten` + `dedupe`)
745
746    Args:
747        dicts: List of dictionaries to merge.
748
749    Returns:
750        Merged dictionary.
751    """
752    result = dict()
753
754    for d in dicts:
755        for [key, value] in d.items():
756            hasKey = result.get(key)
757            if hasKey:
758                if isinstance(value, list):
759                    result[key] = dedupe(flatten([result[key], value]))
760                else:
761                    result[key] = value
762            else:
763                result[key] = value
764
765    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:
768def findByKey(items: list[dict], key: str, value, defaultValue=None) -> dict | None:
769    """Find item in list of dictionaries/objects by key.
770
771    Args:
772        items: List of dictionaries or objects.
773        key: Key to search for.
774        value: Value to match.
775        defaultValue: Value to return if not found.
776
777    Returns:
778        The found item or defaultValue.
779    """
780
781    def getValue(item, key):
782        # Use built-in method for dictionaries
783        if isinstance(item, dict):
784            return item.get(key)
785        # Otherwise assume it’s an object
786        else:
787            return getattr(item, key)
788
789    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):
792def sortByKey(items: list[dict], key: str, defaultValue: int | str = None):
793    """Sort list of dictionaries by key.
794
795    Args:
796        items: List of dictionaries.
797        key: Key to sort by.
798        defaultValue: Value to use if key is missing.
799
800    Returns:
801        Sorted list or original if sorting fails.
802    """
803    # May be []
804    try:
805        return sorted(items, key=lambda item: item.get(key, defaultValue))
806    except:
807        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]]:
810def groupByKey(items: list[dict], key: str) -> list[list[dict]]:
811    """Group list of dictionaries by `key`.
812
813    Args:
814        items: List of dictionaries.
815        key: Key to group by.
816
817    Returns:
818        Grouped items as an iterator.
819    """
820    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):
823def reject(items: list[dict], key: str, value):
824    """Remove items matching `key`:`value`.
825
826    Args:
827        items: List of dictionaries.
828        key: Key to check.
829        value: Value to reject.
830
831    Returns:
832        Filtered list.
833    """
834    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]:
837def mergeCollections(
838    main: list[dict], additions: list[dict], key: str = "id", caseSensitive=True
839) -> list[dict]:
840    """Merge two instances of `list[dict]` by key.
841
842    Args:
843        main: Main list of dictionaries.
844        additions: List of dictionaries to merge in.
845        key: Key to match for merging.
846        caseSensitive: If False, dedupe case-insensitively.
847
848    Returns:
849        Merged list of dictionaries.
850    """
851    # Avoid modifying original collection
852    merged = deepcopy(main)
853    for addition in additions:
854        foundMain = findByKey(merged, key, addition.get(key))
855
856        # Update existing dictionary
857        if foundMain:
858            for property in addition:
859                valueMain = foundMain.get(property)
860                valueAdd = addition.get(property)
861                # Update value if new
862                if not valueMain == valueAdd:
863                    # Concat if both are lists
864                    if all([isinstance(v, list) for v in [valueMain, valueAdd]]):
865                        valueNew = dedupe(
866                            flatten([*valueMain, *valueAdd]),
867                            caseSensitive=caseSensitive,
868                        )
869                    # Otherwise overwrite
870                    else:
871                        valueNew = valueAdd
872                    foundMain.update({property: valueNew})
873        # Otherwise insert as last
874        else:
875            merged.append(addition)
876    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:
879def flatMap(collection: list[dict], key: str) -> list:
880    """Map all `key` values into a single list.
881
882    Args:
883        collection: List of dictionaries.
884        key: Key to extract.
885
886    Returns:
887        Flattened list of all key values.
888    """
889    # Return empty [] if key not found to facilitate flatten
890    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.