diff --git a/.buildinfo b/.buildinfo index e9f95df..17657a0 100644 --- a/.buildinfo +++ b/.buildinfo @@ -1,4 +1,4 @@ # Sphinx build info version 1 # This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. -config: bd55037cf58072dab2b4928f34e6396f +config: 53eec1611f49388a3456e46df7aa6f21 tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/_modules/index.html b/_modules/index.html index db8ddcd..2d40347 100644 --- a/_modules/index.html +++ b/_modules/index.html @@ -6,7 +6,7 @@ Overview: module code — lkj 0.1.10 documentation - + diff --git a/_modules/lkj.html b/_modules/lkj.html index 6aea3bb..3d0d012 100644 --- a/_modules/lkj.html +++ b/_modules/lkj.html @@ -6,7 +6,7 @@ lkj — lkj 0.1.10 documentation - + @@ -87,26 +87,33 @@

Source code for lkj

 Lightweight Kit Jumpstart
 """
 
-from lkj.iterables import (
+from lkj.iterables import (
     compare_sets,  # Compare two iterables and return common, left-only, and right-only elements
     index_of,  # Get the index of an element in an iterable
     get_by_value,  # Get a dictionary from a list of dictionaries by a field value
 )
-from lkj.funcs import mk_factory
-from lkj.dicts import truncate_dict_values, inclusive_subdict, exclusive_subdict
-from lkj.filesys import get_app_data_dir, get_watermarked_dir, enable_sourcing_from_file
-from lkj.strings import (
+from lkj.funcs import mk_factory
+from lkj.dicts import (
+    truncate_dict_values,  # Truncate list and string values in a dictionary
+    inclusive_subdict,  # new dictionary with only the keys in `include`
+    exclusive_subdict,  # new dictionary with only the keys not in `exclude`.
+    merge_dicts,  # Merge multiple dictionaries recursively
+)
+from lkj.filesys import get_app_data_dir, get_watermarked_dir, enable_sourcing_from_file
+from lkj.strings import (
     indent_lines,  # Indent all lines of a string
     most_common_indent,  # Get the most common indent of a multiline string
     regex_based_substitution,
-    truncate_string_with_marker,  # Truncate a string to a maximum length, inserting a marker in the middle.
+    truncate_string,  # Truncate a string to a maximum length, inserting a marker in the middle.
+    truncate_lines,  # Truncate a multiline string to a maximum number of lines
     unique_affixes,  # Get unique prefixes or suffixes of a list of strings
     camel_to_snake,  # Convert CamelCase to snake_case
     snake_to_camel,  # Convert snake_case to CamelCase
     fields_of_string_format,  # Extract field names from a string format
-    fields_of_string_formats,  # Extract field names from an iterable of string formats
+    fields_of_string_formats,  # Extract field names from an iterable of string formats,
+    truncate_string_with_marker,  # Deprecated: Backcompatibility alias
 )
-from lkj.loggers import (
+from lkj.loggers import (
     print_with_timestamp,
     print_progress,
     log_calls,
@@ -115,16 +122,16 @@ 

Source code for lkj

     return_error_info_on_error,
     wrapped_print,
 )
-from lkj.importing import import_object, register_namespace_forwarding
-from lkj.chunking import chunk_iterable, chunker
-from lkj.misc import identity, value_in_interval
+from lkj.importing import import_object, register_namespace_forwarding
+from lkj.chunking import chunk_iterable, chunker
+from lkj.misc import identity, value_in_interval
 
 ddir = lambda obj: list(filter(lambda x: not x.startswith('_'), dir(obj)))
 
 
 
[docs] -def user_machine_id(): +def user_machine_id(): """Get an ID for the current computer/user that calls this function.""" return __import__('platform').node()
@@ -132,7 +139,7 @@

Source code for lkj

 
 
[docs] -def add_attr(attr_name: str, attr_val: str = None, obj=None): +def add_attr(attr_name: str, attr_val: str = None, obj=None): """Add an attribute to an object. If no object is provided, return a partial function that takes an object as its @@ -176,7 +183,7 @@

Source code for lkj

 
     """
     if obj is None:
-        from functools import partial
+        from functools import partial
 
         if attr_val is None:
             return partial(add_attr, attr_name)
@@ -189,7 +196,7 @@ 

Source code for lkj

 
 
[docs] -def add_as_attribute_of(obj, name=None): +def add_as_attribute_of(obj, name=None): """Decorator that adds a function as an attribute of a container object ``obj``. If no ``name`` is given, the ``__name__`` of the function will be used, with a @@ -244,7 +251,7 @@

Source code for lkj

 
     """
 
-    def _decorator(f):
+    def _decorator(f):
         attrname = name or f.__name__
         if not name and attrname.startswith('_'):
             attrname = attrname[1:]  # remove leading underscore
@@ -257,12 +264,12 @@ 

Source code for lkj

 
 
[docs] -def get_caller_package_name(default=None): +def get_caller_package_name(default=None): """Return package name of caller See: https://github.com/i2mint/i2mint/issues/1#issuecomment-1479416085 """ - import inspect + import inspect try: stack = inspect.stack() diff --git a/_modules/lkj/chunking.html b/_modules/lkj/chunking.html index 64b7d17..e58d81e 100644 --- a/_modules/lkj/chunking.html +++ b/_modules/lkj/chunking.html @@ -6,7 +6,7 @@ lkj.chunking — lkj 0.1.10 documentation - + @@ -86,9 +86,9 @@

Source code for lkj.chunking

 """Tools for chunking (segumentation, batching, slicing, etc.)"""
 
-from itertools import zip_longest, chain, islice
+from itertools import zip_longest, chain, islice
 
-from typing import (
+from typing import (
     Iterable,
     Union,
     Dict,
@@ -108,7 +108,7 @@ 

Source code for lkj.chunking

 
 
[docs] -def chunk_iterable( +def chunk_iterable( iterable: Union[Iterable[T], Mapping[KT, VT]], chk_size: int, *, @@ -167,7 +167,7 @@

Source code for lkj.chunking

 
 
[docs] -def chunker( +def chunker( a: Iterable[T], chk_size: int, *, diff --git a/_modules/lkj/dicts.html b/_modules/lkj/dicts.html index 157fc2c..f4b0b68 100644 --- a/_modules/lkj/dicts.html +++ b/_modules/lkj/dicts.html @@ -6,7 +6,7 @@ lkj.dicts — lkj 0.1.10 documentation - + @@ -92,12 +92,12 @@

Source code for lkj.dicts

 
 """
 
-from typing import Optional
+from typing import Optional
 
 
 
[docs] -def inclusive_subdict(d, include): +def inclusive_subdict(d, include): """ Returns a new dictionary with only the keys in `include`. @@ -116,7 +116,7 @@

Source code for lkj.dicts

 
 
[docs] -def exclusive_subdict(d, exclude): +def exclusive_subdict(d, exclude): """ Returns a new dictionary with only the keys not in `exclude`. @@ -127,7 +127,7 @@

Source code for lkj.dicts

     Example:
     >>> exclusive_subdict({'a': 1, 'b': 2, 'c': 3}, {'a', 'c'})
     {'b': 2}
-    
+
     """
     return {k: d[k] for k in d.keys() - exclude}
@@ -136,12 +136,12 @@

Source code for lkj.dicts

 # Note: There is a copy of truncate_dict_values in the ju package.
 
[docs] -def truncate_dict_values( +def truncate_dict_values( d: dict, *, max_list_size: Optional[int] = 2, max_string_size: Optional[int] = 66, - middle_marker: str = "..." + middle_marker: str = "...", ) -> dict: """ Returns a new dictionary with the same nested keys structure, where: @@ -175,7 +175,7 @@

Source code for lkj.dicts

 
     """
 
-    def truncate_string(value, max_len, marker):
+    def truncate_string(value, max_len, marker):
         if max_len is None or len(value) <= max_len:
             return value
         half_len = (max_len - len(marker)) // 2
@@ -199,6 +199,126 @@ 

Source code for lkj.dicts

     else:
         return d
+ + +from typing import Mapping, Callable, TypeVar, Iterable, Tuple + +KT = TypeVar("KT") # Key type +VT = TypeVar("VT") # Value type + +# Note: Could have all function parameters (recursive_condition, etc.) also take the +# enumerated index of the mapping as an argument. That would give us even more +# flexibility, but it might be overkill and make the interface more complex. +from typing import Mapping, Callable, TypeVar, Iterable, Tuple +from collections import defaultdict + +KT = TypeVar("KT") # Key type +VT = TypeVar("VT") # Value type + + +
+[docs] +def merge_dicts( + *mappings: Mapping[KT, VT], + recursive_condition: Callable[[VT], bool] = lambda v: isinstance(v, Mapping), + conflict_resolver: Callable[[VT, VT], VT] = lambda x, y: y, + mapping_constructor: Callable[[Iterable[Tuple[KT, VT]]], Mapping[KT, VT]] = dict, +) -> Mapping[KT, VT]: + """ + Merge multiple mappings into a single mapping, recursively if needed, + with customizable conflict resolution for non-mapping values. + + This function generalizes the normal `dict.update()` method, which takes the union + of the keys and resolves conflicting values by overriding them with the last value. + While `dict.update()` performs a single-level merge, `merge_dicts` provides additional + flexibility to handle nested mappings. With `merge_dicts`, you can: + - Control when to recurse (e.g., based on whether a value is a `Mapping`). + - Specify how to resolve value conflicts (e.g., override, add, or accumulate in a list). + - Choose the type of mapping (e.g., `dict`, `defaultdict`) to use as the container. + + Args: + mappings: The mappings to merge. + recursive_condition: A callable to determine if values should be merged recursively. + By default, checks if the value is a `Mapping`. + conflict_resolver: A callable that resolves conflicts between two values. + By default, overrides with the last seen value (`lambda x, y: y`). + mapping_constructor: A callable to construct the resulting mapping. + Defaults to the standard `dict` constructor. + + Returns: + A merged mapping that combines all the input mappings. + + Examples: + Basic usage with single-level merge (override behavior): + >>> dict1 = {"a": 1} + >>> dict2 = {"a": 2, "b": 3} + >>> merge_dicts(dict1, dict2) + {'a': 2, 'b': 3} + + Handling nested mappings with default behavior (override conflicts): + >>> dict1 = {"a": 1, "b": {"x": 10, "y": 20}} + >>> dict2 = {"b": {"y": 30, "z": 40}, "c": 3} + >>> dict3 = {"b": {"x": 50}, "d": 4} + >>> merge_dicts(dict1, dict2, dict3) + {'a': 1, 'b': {'x': 50, 'y': 30, 'z': 40}, 'c': 3, 'd': 4} + + Resolving conflicts by summing values: + >>> dict1 = {"a": 1} + >>> dict2 = {"a": 2} + >>> merge_dicts(dict1, dict2, conflict_resolver=lambda x, y: x + y) + {'a': 3} + + Accumulating conflicting values into a list: + >>> dict1 = {"a": 1, "b": [1, 2]} + >>> dict2 = {"b": [3, 4]} + >>> merge_dicts(dict1, dict2, conflict_resolver=lambda x, y: x + y if isinstance(x, list) else [x, y]) + {'a': 1, 'b': [1, 2, 3, 4]} + + Recursing only on specific conditions: + >>> dict1 = {"a": {"nested": 1}} + >>> dict2 = {"a": {"nested": 2, "new": 3}} + >>> merge_dicts(dict1, dict2) + {'a': {'nested': 2, 'new': 3}} + + >>> dict1 = {"a": {"nested": [1, 2]}} + >>> dict2 = {"a": {"nested": [3, 4]}} + >>> merge_dicts(dict1, dict2, recursive_condition=lambda v: isinstance(v, dict)) + {'a': {'nested': [3, 4]}} + + Using a custom mapping type (`defaultdict`): + >>> from collections import defaultdict + >>> merge_dicts( + ... dict1, dict2, mapping_constructor=lambda items: defaultdict(int, items) + ... ) + defaultdict(<class 'int'>, {'a': defaultdict(<class 'int'>, {'nested': [3, 4]})}) + """ + # Initialize merged mapping with an empty iterable for constructors requiring input + merged = mapping_constructor([]) + + for mapping in mappings: + for key, value in mapping.items(): + if ( + key in merged + and recursive_condition(value) + and recursive_condition(merged[key]) + ): + # Recursively merge nested mappings + merged[key] = merge_dicts( + merged[key], + value, + recursive_condition=recursive_condition, + conflict_resolver=conflict_resolver, + mapping_constructor=mapping_constructor, + ) + elif key in merged: + # Resolve conflict using the provided resolver + merged[key] = conflict_resolver(merged[key], value) + else: + # Otherwise, add the value + merged[key] = value + + return merged
+
diff --git a/_modules/lkj/filesys.html b/_modules/lkj/filesys.html index ed0ebaa..6d0ffb9 100644 --- a/_modules/lkj/filesys.html +++ b/_modules/lkj/filesys.html @@ -6,7 +6,7 @@ lkj.filesys — lkj 0.1.10 documentation - + @@ -86,16 +86,16 @@

Source code for lkj.filesys

 """File system utils"""
 
-import os
-from typing import Callable, Any
-from pathlib import Path
-from functools import wraps, partial
+import os
+from typing import Callable, Any
+from pathlib import Path
+from functools import wraps, partial
 
 
 # TODO: General pattern Consider generalizing to different conditions and actions
 
[docs] -def enable_sourcing_from_file(func=None, *, write_output=False): +def enable_sourcing_from_file(func=None, *, write_output=False): """ Decorator for functions enables the decorated function to source from a file. @@ -115,7 +115,7 @@

Source code for lkj.filesys

         return partial(enable_sourcing_from_file, write_output=write_output)
     
     @wraps(func)
-    def wrapper(*args, write_output=write_output, **kwargs):
+    def wrapper(*args, write_output=write_output, **kwargs):
         # Check if the first argument is a string and a valid file path
         if args and isinstance(args[0], str) and os.path.isfile(args[0]):
             file_path = args[0]
@@ -145,13 +145,13 @@ 

Source code for lkj.filesys

 
 
[docs] -def do_nothing(*args, **kwargs) -> None: +def do_nothing(*args, **kwargs) -> None: """Function that does nothing.""" pass
-def _app_data_rootdir(): +def _app_data_rootdir(): """ Returns the full path of a directory suitable for storing application-specific data. @@ -185,7 +185,7 @@

Source code for lkj.filesys

 
 
[docs] -def get_app_data_dir( +def get_app_data_dir( dirname='', *, if_exists: Callable[[str], Any] = do_nothing, @@ -261,7 +261,7 @@

Source code for lkj.filesys

 
 
[docs] -def watermark_dir(dirpath: str, watermark: str = DFLT_WATERMARK): +def watermark_dir(dirpath: str, watermark: str = DFLT_WATERMARK): """Watermark.""" (Path(dirpath) / watermark).touch()
@@ -269,13 +269,13 @@

Source code for lkj.filesys

 
 
[docs] -def has_watermark(dirpath: str, watermark: str = DFLT_WATERMARK): +def has_watermark(dirpath: str, watermark: str = DFLT_WATERMARK): """Check if a directory has a watermark.""" return (Path(dirpath) / watermark).exists()
-def _raise_watermark_error(dirpath, watermark): +def _raise_watermark_error(dirpath, watermark): raise ValueError( f'Directory {dirpath} is not watermarked with {watermark}. ' f'Perhaps you deleted the watermark file? If so, create the file and all will ' @@ -286,7 +286,7 @@

Source code for lkj.filesys

 
 
[docs] -def get_watermarked_dir( +def get_watermarked_dir( dirname: str, watermark: str = DFLT_WATERMARK, *, @@ -314,11 +314,11 @@

Source code for lkj.filesys

 
     """
 
-    def create_and_watermark(dirpath):
+    def create_and_watermark(dirpath):
         make_dir(dirpath)
         watermark_dir(dirpath, watermark=watermark)
 
-    def validate_watermark(dirpath):
+    def validate_watermark(dirpath):
         if not has_watermark(dirpath, watermark):
             return if_watermark_validation_fails(dirpath, watermark)
 
@@ -336,7 +336,7 @@ 

Source code for lkj.filesys

 
 
[docs] -def rename_file( +def rename_file( file: Filepath, renamer_function: Callable[[str], str], *, diff --git a/_modules/lkj/funcs.html b/_modules/lkj/funcs.html index 57ddcc2..9f99f62 100644 --- a/_modules/lkj/funcs.html +++ b/_modules/lkj/funcs.html @@ -6,7 +6,7 @@ lkj.funcs — lkj 0.1.10 documentation - + @@ -86,7 +86,7 @@

Source code for lkj.funcs

 """Tools for working with functions."""
 
-from functools import partial
+from functools import partial
 
 mk_factory = partial(partial, partial)
 mk_factory.__doc__ = """
@@ -111,7 +111,7 @@ 

Source code for lkj.funcs

 # when, for example, you want to avoid using a `lambda` function in some situation
 # (say, because [lambda functions are not pickle-serializable](https://realpython.com/python-pickle-module/)).
 
-import operator
+import operator
 
 greater_than = mk_factory(operator.lt)
 greater_than.__doc__ = """
@@ -174,7 +174,7 @@ 

Source code for lkj.funcs

 # Imagine working with a large project where you frequently need to generate file paths.
 # With mk_factory, creating a path generator becomes effortless.
 
-import os
+import os
 
 mk_abs_path_maker = mk_factory(os.path.join)
 mk_abs_path_maker.__doc__ = """
@@ -250,12 +250,12 @@ 

Source code for lkj.funcs

 #
 # Use zipper with an infinite iterator to provide a continuous stream of unique keys to pair with values dynamically.
 
-from itertools import count
+from itertools import count
 
 
 
[docs] -def zip_with_filenames(start_idx=1): +def zip_with_filenames(start_idx=1): """ Create a function that zips an iterable with file names like 'file_001', 'file_002', etc. @@ -279,7 +279,7 @@

Source code for lkj.funcs

 #
 # For tasks involving regular expressions, mk_factory can simplify creating matchers and extractors.
 
-import re
+import re
 
 matcher = mk_factory(re.match)
 matcher.__doc__ = """
@@ -314,8 +314,8 @@ 

Source code for lkj.funcs

 #
 # Reduction functions allow aggregation of elements, useful for calculating products, sums, or even concatenating strings.
 
-from functools import reduce
-from operator import mul, add
+from functools import reduce
+from operator import mul, add
 
 reducer = mk_factory(reduce)
 reducer.__doc__ = """
@@ -359,7 +359,7 @@ 

Source code for lkj.funcs

 # Example using map with os.path.join to create full file paths
 
[docs] -def full_path_maker(base_directory): +def full_path_maker(base_directory): """ Create a function that generates full file paths given filenames. @@ -377,7 +377,7 @@

Source code for lkj.funcs

 # Example of using greater_than in a filter
 
[docs] -def filter_greater_than(threshold, data): +def filter_greater_than(threshold, data): """ Filter elements in data that are greater than the threshold. diff --git a/_modules/lkj/importing.html b/_modules/lkj/importing.html index f220767..16b826e 100644 --- a/_modules/lkj/importing.html +++ b/_modules/lkj/importing.html @@ -6,7 +6,7 @@ lkj.importing — lkj 0.1.10 documentation - + @@ -89,7 +89,7 @@

Source code for lkj.importing

 
 
[docs] -def import_object(dot_path: str): +def import_object(dot_path: str): """Imports and returns an object from a dot string path. >>> f = import_object('os.path.join') @@ -98,7 +98,7 @@

Source code for lkj.importing

     True
 
     """
-    from importlib import import_module
+    from importlib import import_module
 
     module_path, _, object_name = dot_path.rpartition('.')
     if not module_path:
@@ -111,14 +111,14 @@ 

Source code for lkj.importing

 
 
 
-import sys
-import importlib.util
-import importlib.abc
+import sys
+import importlib.util
+import importlib.abc
 
 
 
[docs] -class NamespaceForwardingLoader(importlib.abc.Loader): +class NamespaceForwardingLoader(importlib.abc.Loader): """ A custom loader that forwards the import from a source namespace to a target namespace. @@ -132,14 +132,14 @@

Source code for lkj.importing

             Loads and returns the target module corresponding to the source module name.
     """
 
-    def __init__(self, fullname, source_base, target_base):
+    def __init__(self, fullname, source_base, target_base):
         self.fullname = fullname
         self.source_base = source_base
         self.target_base = target_base
 
 
[docs] - def load_module(self, fullname): + def load_module(self, fullname): target_name = fullname.replace(self.source_base, self.target_base) # If the target module is already loaded, return it @@ -162,7 +162,7 @@

Source code for lkj.importing

 
 
[docs] -class NamespaceForwardingFinder(importlib.abc.MetaPathFinder): +class NamespaceForwardingFinder(importlib.abc.MetaPathFinder): """ A custom finder that detects when a module in the source namespace is being imported and forwards it to the target namespace. @@ -176,13 +176,13 @@

Source code for lkj.importing

             Finds and returns the spec for the target module corresponding to the source module name.
     """
 
-    def __init__(self, source_base, target_base):
+    def __init__(self, source_base, target_base):
         self.source_base = source_base
         self.target_base = target_base
 
 
[docs] - def find_spec(self, fullname, path, target=None): + def find_spec(self, fullname, path, target=None): if fullname.startswith(self.source_base): return importlib.util.spec_from_loader( fullname, @@ -195,7 +195,7 @@

Source code for lkj.importing

 
 
[docs] -def register_namespace_forwarding(source_base, target_base): +def register_namespace_forwarding(source_base, target_base): """ Register the namespace forwarding from source_base to target_base. diff --git a/_modules/lkj/iterables.html b/_modules/lkj/iterables.html index 2838b44..90dfad0 100644 --- a/_modules/lkj/iterables.html +++ b/_modules/lkj/iterables.html @@ -6,7 +6,7 @@ lkj.iterables — lkj 0.1.10 documentation - + @@ -86,12 +86,12 @@

Source code for lkj.iterables

 """Tools with iterables (dicts, lists, tuples, sets, etc.)."""
 
-from typing import Sequence, Mapping, KT, VT, Iterable, Iterable, NamedTuple
+from typing import Sequence, Mapping, KT, VT, Iterable, Iterable, NamedTuple
 
 
 
[docs] -class SetsComparisonResult(NamedTuple): +class SetsComparisonResult(NamedTuple): common: set left_only: set right_only: set
@@ -100,7 +100,7 @@

Source code for lkj.iterables

 
 
[docs] -def compare_sets(left: Iterable, right: Iterable) -> SetsComparisonResult: +def compare_sets(left: Iterable, right: Iterable) -> SetsComparisonResult: """ Compares two iterables and returns a named tuple with: - Elements in both iterables. @@ -141,7 +141,7 @@

Source code for lkj.iterables

 
 
[docs] -def index_of(iterable: Iterable[VT], value: VT) -> int: +def index_of(iterable: Iterable[VT], value: VT) -> int: """ List list.index but for any iterable. @@ -165,7 +165,7 @@

Source code for lkj.iterables

 
 
[docs] -def get_by_value( +def get_by_value( list_of_dicts: Sequence[Mapping[KT, VT]], value: VT, field: KT ) -> Mapping[KT, VT]: """ diff --git a/_modules/lkj/loggers.html b/_modules/lkj/loggers.html index 7265d8b..5bfc559 100644 --- a/_modules/lkj/loggers.html +++ b/_modules/lkj/loggers.html @@ -6,7 +6,7 @@ lkj.loggers — lkj 0.1.10 documentation - + @@ -86,16 +86,16 @@

Source code for lkj.loggers

 """Utils for logging."""
 
-from typing import Callable, Tuple, Any, Optional, Union, Iterable
-from functools import partial, wraps
-from operator import attrgetter
+from typing import Callable, Tuple, Any, Optional, Union, Iterable
+from functools import partial, wraps
+from operator import attrgetter
 
 # TODO: Verify and add test for line_prefix
 # TODO: Merge with wrap_text_with_exact_spacing
 # TODO: Add doctests for string
 
[docs] -def wrapped_print( +def wrapped_print( items: Union[str, Iterable], sep=', ', max_width=80, @@ -146,7 +146,7 @@

Source code for lkj.loggers

             items, max_width=max_width, print_func=print_func, line_prefix=line_prefix
         )
     else:
-        import textwrap
+        import textwrap
 
         return print_func(
             line_prefix
@@ -160,7 +160,7 @@ 

Source code for lkj.loggers

 # TODO: Merge with wrapped_print
 
[docs] -def wrap_text_with_exact_spacing( +def wrap_text_with_exact_spacing( text, *, max_width=80, print_func=print, line_prefix: str = "" ): """ @@ -171,7 +171,7 @@

Source code for lkj.loggers

     - text (str): The text to wrap and print.
     - max_width (int): The maximum width of each line (default is 88).
     """
-    import textwrap
+    import textwrap
 
     # Split the text into lines, preserving the existing newlines
     lines = text.splitlines(keepends=True)
@@ -197,7 +197,7 @@ 

Source code for lkj.loggers