lkj 0.1.33__tar.gz → 0.1.35__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lkj
3
- Version: 0.1.33
3
+ Version: 0.1.35
4
4
  Summary: A dump of homeless useful utils
5
5
  Home-page: https://github.com/thorwhalen/lkj
6
6
  Author: Thor Whalen
@@ -8,18 +8,25 @@ from lkj.iterables import (
8
8
  get_by_value, # Get a dictionary from a list of dictionaries by a field value
9
9
  )
10
10
  from lkj.funcs import mk_factory
11
- from lkj.dicts import truncate_dict_values, inclusive_subdict, exclusive_subdict
11
+ from lkj.dicts import (
12
+ truncate_dict_values, # Truncate list and string values in a dictionary
13
+ inclusive_subdict, # new dictionary with only the keys in `include`
14
+ exclusive_subdict, # new dictionary with only the keys not in `exclude`.
15
+ merge_dicts, # Merge multiple dictionaries recursively
16
+ )
12
17
  from lkj.filesys import get_app_data_dir, get_watermarked_dir, enable_sourcing_from_file
13
18
  from lkj.strings import (
14
19
  indent_lines, # Indent all lines of a string
15
20
  most_common_indent, # Get the most common indent of a multiline string
16
21
  regex_based_substitution,
17
- truncate_string_with_marker, # Truncate a string to a maximum length, inserting a marker in the middle.
22
+ truncate_string, # Truncate a string to a maximum length, inserting a marker in the middle.
23
+ truncate_lines, # Truncate a multiline string to a maximum number of lines
18
24
  unique_affixes, # Get unique prefixes or suffixes of a list of strings
19
25
  camel_to_snake, # Convert CamelCase to snake_case
20
26
  snake_to_camel, # Convert snake_case to CamelCase
21
27
  fields_of_string_format, # Extract field names from a string format
22
- fields_of_string_formats, # Extract field names from an iterable of string formats
28
+ fields_of_string_formats, # Extract field names from an iterable of string formats,
29
+ truncate_string_with_marker, # Deprecated: Backcompatibility alias
23
30
  )
24
31
  from lkj.loggers import (
25
32
  print_with_timestamp,
@@ -0,0 +1,223 @@
1
+ """
2
+ Tools for working with dictionaries (and other Mappings).
3
+
4
+ If you are looking for more, check out the `lkj.iterables` module too
5
+ (after all, dicts are iterables).
6
+
7
+ """
8
+
9
+ from typing import Optional
10
+
11
+
12
+ def inclusive_subdict(d, include):
13
+ """
14
+ Returns a new dictionary with only the keys in `include`.
15
+
16
+ Parameters:
17
+ d (dict): The input dictionary.
18
+ include (set): The set of keys to include in the new dictionary.
19
+
20
+ Example:
21
+ >>> inclusive_subdict({'a': 1, 'b': 2, 'c': 3}, {'a', 'c'})
22
+ {'a': 1, 'c': 3}
23
+
24
+ """
25
+ return {k: d[k] for k in d.keys() & include}
26
+
27
+
28
+ def exclusive_subdict(d, exclude):
29
+ """
30
+ Returns a new dictionary with only the keys not in `exclude`.
31
+
32
+ Parameters:
33
+ d (dict): The input dictionary.
34
+ exclude (set): The set of keys to exclude from the new dictionary.
35
+
36
+ Example:
37
+ >>> exclusive_subdict({'a': 1, 'b': 2, 'c': 3}, {'a', 'c'})
38
+ {'b': 2}
39
+
40
+ """
41
+ return {k: d[k] for k in d.keys() - exclude}
42
+
43
+
44
+ # Note: There is a copy of truncate_dict_values in the ju package.
45
+ def truncate_dict_values(
46
+ d: dict,
47
+ *,
48
+ max_list_size: Optional[int] = 2,
49
+ max_string_size: Optional[int] = 66,
50
+ middle_marker: str = "...",
51
+ ) -> dict:
52
+ """
53
+ Returns a new dictionary with the same nested keys structure, where:
54
+ - List values are reduced to a maximum size of max_list_size.
55
+ - String values longer than max_string_size are truncated in the middle.
56
+
57
+ Parameters:
58
+ d (dict): The input dictionary.
59
+ max_list_size (int, optional): Maximum size for lists. Defaults to 2.
60
+ max_string_size (int, optional): Maximum length for strings. Defaults to None (no truncation).
61
+ middle_marker (str, optional): String to insert in the middle of truncated strings. Defaults to '...'.
62
+
63
+ Returns:
64
+ dict: A new dictionary with truncated lists and strings.
65
+
66
+ This can be useful when you have a large dictionary that you want to investigate,
67
+ but printing/logging it takes too much space.
68
+
69
+ Example:
70
+
71
+ >>> large_dict = {'a': [1, 2, 3, 4, 5], 'b': {'c': [6, 7, 8, 9], 'd': 'A string like this that is too long'}, 'e': [10, 11]}
72
+ >>> truncate_dict_values(large_dict, max_list_size=3, max_string_size=20)
73
+ {'a': [1, 2, 3], 'b': {'c': [6, 7, 8], 'd': 'A string...too long'}, 'e': [10, 11]}
74
+
75
+ You can use `None` to indicate "no max":
76
+
77
+ >>> assert (
78
+ ... truncate_dict_values(large_dict, max_list_size=None, max_string_size=None)
79
+ ... == large_dict
80
+ ... )
81
+
82
+ """
83
+
84
+ def truncate_string(value, max_len, marker):
85
+ if max_len is None or len(value) <= max_len:
86
+ return value
87
+ half_len = (max_len - len(marker)) // 2
88
+ return value[:half_len] + marker + value[-half_len:]
89
+
90
+ kwargs = dict(
91
+ max_list_size=max_list_size,
92
+ max_string_size=max_string_size,
93
+ middle_marker=middle_marker,
94
+ )
95
+ if isinstance(d, dict):
96
+ return {k: truncate_dict_values(v, **kwargs) for k, v in d.items()}
97
+ elif isinstance(d, list):
98
+ return (
99
+ [truncate_dict_values(v, **kwargs) for v in d[:max_list_size]]
100
+ if max_list_size is not None
101
+ else d
102
+ )
103
+ elif isinstance(d, str):
104
+ return truncate_string(d, max_string_size, middle_marker)
105
+ else:
106
+ return d
107
+
108
+
109
+ from typing import Mapping, Callable, TypeVar, Iterable, Tuple
110
+
111
+ KT = TypeVar("KT") # Key type
112
+ VT = TypeVar("VT") # Value type
113
+
114
+ # Note: Could have all function parameters (recursive_condition, etc.) also take the
115
+ # enumerated index of the mapping as an argument. That would give us even more
116
+ # flexibility, but it might be overkill and make the interface more complex.
117
+ from typing import Mapping, Callable, TypeVar, Iterable, Tuple
118
+ from collections import defaultdict
119
+
120
+ KT = TypeVar("KT") # Key type
121
+ VT = TypeVar("VT") # Value type
122
+
123
+
124
+ def merge_dicts(
125
+ *mappings: Mapping[KT, VT],
126
+ recursive_condition: Callable[[VT], bool] = lambda v: isinstance(v, Mapping),
127
+ conflict_resolver: Callable[[VT, VT], VT] = lambda x, y: y,
128
+ mapping_constructor: Callable[[Iterable[Tuple[KT, VT]]], Mapping[KT, VT]] = dict,
129
+ ) -> Mapping[KT, VT]:
130
+ """
131
+ Merge multiple mappings into a single mapping, recursively if needed,
132
+ with customizable conflict resolution for non-mapping values.
133
+
134
+ This function generalizes the normal `dict.update()` method, which takes the union
135
+ of the keys and resolves conflicting values by overriding them with the last value.
136
+ While `dict.update()` performs a single-level merge, `merge_dicts` provides additional
137
+ flexibility to handle nested mappings. With `merge_dicts`, you can:
138
+ - Control when to recurse (e.g., based on whether a value is a `Mapping`).
139
+ - Specify how to resolve value conflicts (e.g., override, add, or accumulate in a list).
140
+ - Choose the type of mapping (e.g., `dict`, `defaultdict`) to use as the container.
141
+
142
+ Args:
143
+ mappings: The mappings to merge.
144
+ recursive_condition: A callable to determine if values should be merged recursively.
145
+ By default, checks if the value is a `Mapping`.
146
+ conflict_resolver: A callable that resolves conflicts between two values.
147
+ By default, overrides with the last seen value (`lambda x, y: y`).
148
+ mapping_constructor: A callable to construct the resulting mapping.
149
+ Defaults to the standard `dict` constructor.
150
+
151
+ Returns:
152
+ A merged mapping that combines all the input mappings.
153
+
154
+ Examples:
155
+ Basic usage with single-level merge (override behavior):
156
+ >>> dict1 = {"a": 1}
157
+ >>> dict2 = {"a": 2, "b": 3}
158
+ >>> merge_dicts(dict1, dict2)
159
+ {'a': 2, 'b': 3}
160
+
161
+ Handling nested mappings with default behavior (override conflicts):
162
+ >>> dict1 = {"a": 1, "b": {"x": 10, "y": 20}}
163
+ >>> dict2 = {"b": {"y": 30, "z": 40}, "c": 3}
164
+ >>> dict3 = {"b": {"x": 50}, "d": 4}
165
+ >>> merge_dicts(dict1, dict2, dict3)
166
+ {'a': 1, 'b': {'x': 50, 'y': 30, 'z': 40}, 'c': 3, 'd': 4}
167
+
168
+ Resolving conflicts by summing values:
169
+ >>> dict1 = {"a": 1}
170
+ >>> dict2 = {"a": 2}
171
+ >>> merge_dicts(dict1, dict2, conflict_resolver=lambda x, y: x + y)
172
+ {'a': 3}
173
+
174
+ Accumulating conflicting values into a list:
175
+ >>> dict1 = {"a": 1, "b": [1, 2]}
176
+ >>> dict2 = {"b": [3, 4]}
177
+ >>> merge_dicts(dict1, dict2, conflict_resolver=lambda x, y: x + y if isinstance(x, list) else [x, y])
178
+ {'a': 1, 'b': [1, 2, 3, 4]}
179
+
180
+ Recursing only on specific conditions:
181
+ >>> dict1 = {"a": {"nested": 1}}
182
+ >>> dict2 = {"a": {"nested": 2, "new": 3}}
183
+ >>> merge_dicts(dict1, dict2)
184
+ {'a': {'nested': 2, 'new': 3}}
185
+
186
+ >>> dict1 = {"a": {"nested": [1, 2]}}
187
+ >>> dict2 = {"a": {"nested": [3, 4]}}
188
+ >>> merge_dicts(dict1, dict2, recursive_condition=lambda v: isinstance(v, dict))
189
+ {'a': {'nested': [3, 4]}}
190
+
191
+ Using a custom mapping type (`defaultdict`):
192
+ >>> from collections import defaultdict
193
+ >>> merge_dicts(
194
+ ... dict1, dict2, mapping_constructor=lambda items: defaultdict(int, items)
195
+ ... )
196
+ defaultdict(<class 'int'>, {'a': defaultdict(<class 'int'>, {'nested': [3, 4]})})
197
+ """
198
+ # Initialize merged mapping with an empty iterable for constructors requiring input
199
+ merged = mapping_constructor([])
200
+
201
+ for mapping in mappings:
202
+ for key, value in mapping.items():
203
+ if (
204
+ key in merged
205
+ and recursive_condition(value)
206
+ and recursive_condition(merged[key])
207
+ ):
208
+ # Recursively merge nested mappings
209
+ merged[key] = merge_dicts(
210
+ merged[key],
211
+ value,
212
+ recursive_condition=recursive_condition,
213
+ conflict_resolver=conflict_resolver,
214
+ mapping_constructor=mapping_constructor,
215
+ )
216
+ elif key in merged:
217
+ # Resolve conflict using the provided resolver
218
+ merged[key] = conflict_resolver(merged[key], value)
219
+ else:
220
+ # Otherwise, add the value
221
+ merged[key] = value
222
+
223
+ return merged
@@ -136,9 +136,7 @@ def snake_to_camel(snake_string):
136
136
 
137
137
 
138
138
  # Note: Vendored in i2.multi_objects and dol.util
139
- def truncate_string_with_marker(
140
- s, *, left_limit=15, right_limit=15, middle_marker="..."
141
- ):
139
+ def truncate_string(s: str, *, left_limit=15, right_limit=15, middle_marker="..."):
142
140
  """
143
141
  Truncate a string to a maximum length, inserting a marker in the middle.
144
142
 
@@ -148,23 +146,23 @@ def truncate_string_with_marker(
148
146
  If the string is shorter than the sum of the left_limit and right_limit,
149
147
  the string is returned as is.
150
148
 
151
- >>> truncate_string_with_marker('1234567890')
149
+ >>> truncate_string('1234567890')
152
150
  '1234567890'
153
151
 
154
152
  But if the string is longer than the sum of the limits, it is truncated:
155
153
 
156
- >>> truncate_string_with_marker('1234567890', left_limit=3, right_limit=3)
154
+ >>> truncate_string('1234567890', left_limit=3, right_limit=3)
157
155
  '123...890'
158
- >>> truncate_string_with_marker('1234567890', left_limit=3, right_limit=0)
156
+ >>> truncate_string('1234567890', left_limit=3, right_limit=0)
159
157
  '123...'
160
- >>> truncate_string_with_marker('1234567890', left_limit=0, right_limit=3)
158
+ >>> truncate_string('1234567890', left_limit=0, right_limit=3)
161
159
  '...890'
162
160
 
163
161
  If you're using a specific parametrization of the function often, you can
164
162
  create a partial function with the desired parameters:
165
163
 
166
164
  >>> from functools import partial
167
- >>> truncate_string = partial(truncate_string_with_marker, left_limit=2, right_limit=2, middle_marker='---')
165
+ >>> truncate_string = partial(truncate_string, left_limit=2, right_limit=2, middle_marker='---')
168
166
  >>> truncate_string('1234567890')
169
167
  '12---90'
170
168
  >>> truncate_string('supercalifragilisticexpialidocious')
@@ -181,6 +179,54 @@ def truncate_string_with_marker(
181
179
  return s[:left_limit] + middle_marker + s[-right_limit:]
182
180
 
183
181
 
182
+ truncate_string_with_marker = truncate_string # backwards compatibility alias
183
+
184
+
185
+ def truncate_lines(
186
+ s: str, top_limit: int = None, bottom_limit: int = None, middle_marker: str = "..."
187
+ ) -> str:
188
+ """
189
+ Truncates a string by limiting the number of lines from the top and bottom.
190
+ If the total number of lines is greater than top_limit + bottom_limit,
191
+ it keeps the first `top_limit` lines, keeps the last `bottom_limit` lines,
192
+ and replaces the omitted middle portion with a single line containing
193
+ `middle_marker`.
194
+
195
+ If top_limit or bottom_limit is None, it is treated as 0.
196
+
197
+ Example:
198
+ >>> text = '''Line1
199
+ ... Line2
200
+ ... Line3
201
+ ... Line4
202
+ ... Line5
203
+ ... Line6'''
204
+
205
+ >>> print(truncate_lines(text, top_limit=2, bottom_limit=2))
206
+ Line1
207
+ Line2
208
+ ...
209
+ Line5
210
+ Line6
211
+ """
212
+ # Interpret None as zero for convenience
213
+ top = top_limit if top_limit is not None else 0
214
+ bottom = bottom_limit if bottom_limit is not None else 0
215
+
216
+ # Split on line boundaries (retaining any trailing newlines in each piece)
217
+ lines = s.splitlines(True)
218
+ total_lines = len(lines)
219
+
220
+ # If no need to truncate, return as is
221
+ if total_lines <= top + bottom:
222
+ return s
223
+
224
+ # Otherwise, keep the top lines, keep the bottom lines,
225
+ # and insert a single marker line in the middle
226
+ truncated = lines[:top] + [middle_marker + "\n"] + lines[-bottom:]
227
+ return "".join(truncated)
228
+
229
+
184
230
  # TODO: Generalize so that it can be used with regex keys (not escaped)
185
231
  def regex_based_substitution(replacements: dict, regex=None, s: str = None):
186
232
  """
@@ -201,29 +247,41 @@ def regex_based_substitution(replacements: dict, regex=None, s: str = None):
201
247
  'I like orange and grapes.'
202
248
 
203
249
  You have access to the ``replacements`` and ``regex`` attributes of the
204
- ``substitute`` function:
250
+ ``substitute`` function. See how the replacements dict has been ordered by
251
+ descending length of keys. This is to ensure that longer keys are replaced
252
+ before shorter keys, avoiding partial replacements.
205
253
 
206
254
  >>> substitute.replacements
207
- {'apple': 'orange', 'banana': 'grape'}
255
+ {'banana': 'grape', 'apple': 'orange'}
208
256
 
209
257
  """
210
258
  import re
211
259
  from functools import partial
212
260
 
213
261
  if regex is None and s is None:
214
- replacements = dict(replacements)
215
-
216
- if not replacements: # if replacements iterable is empty.
217
- return lambda s: s # return identity function
218
-
219
- regex = re.compile("|".join(re.escape(key) for key in replacements.keys()))
220
-
221
- substitute = partial(regex_based_substitution, replacements, regex)
222
- substitute.replacements = replacements
262
+ # Sort keys by length while maintaining value alignment
263
+ sorted_replacements = sorted(
264
+ replacements.items(), key=lambda x: len(x[0]), reverse=True
265
+ )
266
+
267
+ # Create regex pattern from sorted keys (without escaping to allow regex)
268
+ sorted_keys = [pair[0] for pair in sorted_replacements]
269
+ sorted_values = [pair[1] for pair in sorted_replacements]
270
+ regex = re.compile("|".join(sorted_keys))
271
+
272
+ # Prepare the substitution function with aligned replacements
273
+ aligned_replacements = dict(zip(sorted_keys, sorted_values))
274
+ substitute = partial(regex_based_substitution, aligned_replacements, regex)
275
+ substitute.replacements = aligned_replacements
223
276
  substitute.regex = regex
224
277
  return substitute
225
- else:
278
+ elif s is not None:
279
+ # Perform substitution using the compiled regex and aligned replacements
226
280
  return regex.sub(lambda m: replacements[m.group(0)], s)
281
+ else:
282
+ raise ValueError(
283
+ "Invalid usage: provide either `s` or let the function construct itself."
284
+ )
227
285
 
228
286
 
229
287
  from typing import Callable, Iterable, Sequence
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lkj
3
- Version: 0.1.33
3
+ Version: 0.1.35
4
4
  Summary: A dump of homeless useful utils
5
5
  Home-page: https://github.com/thorwhalen/lkj
6
6
  Author: Thor Whalen
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = lkj
3
- version = 0.1.33
3
+ version = 0.1.35
4
4
  url = https://github.com/thorwhalen/lkj
5
5
  platforms = any
6
6
  description_file = README.md
lkj-0.1.33/lkj/dicts.py DELETED
@@ -1,106 +0,0 @@
1
- """
2
- Tools for working with dictionaries (and other Mappings).
3
-
4
- If you are looking for more, check out the `lkj.iterables` module too
5
- (after all, dicts are iterables).
6
-
7
- """
8
-
9
- from typing import Optional
10
-
11
-
12
- def inclusive_subdict(d, include):
13
- """
14
- Returns a new dictionary with only the keys in `include`.
15
-
16
- Parameters:
17
- d (dict): The input dictionary.
18
- include (set): The set of keys to include in the new dictionary.
19
-
20
- Example:
21
- >>> inclusive_subdict({'a': 1, 'b': 2, 'c': 3}, {'a', 'c'})
22
- {'a': 1, 'c': 3}
23
-
24
- """
25
- return {k: d[k] for k in d.keys() & include}
26
-
27
-
28
- def exclusive_subdict(d, exclude):
29
- """
30
- Returns a new dictionary with only the keys not in `exclude`.
31
-
32
- Parameters:
33
- d (dict): The input dictionary.
34
- exclude (set): The set of keys to exclude from the new dictionary.
35
-
36
- Example:
37
- >>> exclusive_subdict({'a': 1, 'b': 2, 'c': 3}, {'a', 'c'})
38
- {'b': 2}
39
-
40
- """
41
- return {k: d[k] for k in d.keys() - exclude}
42
-
43
-
44
- # Note: There is a copy of truncate_dict_values in the ju package.
45
- def truncate_dict_values(
46
- d: dict,
47
- *,
48
- max_list_size: Optional[int] = 2,
49
- max_string_size: Optional[int] = 66,
50
- middle_marker: str = "..."
51
- ) -> dict:
52
- """
53
- Returns a new dictionary with the same nested keys structure, where:
54
- - List values are reduced to a maximum size of max_list_size.
55
- - String values longer than max_string_size are truncated in the middle.
56
-
57
- Parameters:
58
- d (dict): The input dictionary.
59
- max_list_size (int, optional): Maximum size for lists. Defaults to 2.
60
- max_string_size (int, optional): Maximum length for strings. Defaults to None (no truncation).
61
- middle_marker (str, optional): String to insert in the middle of truncated strings. Defaults to '...'.
62
-
63
- Returns:
64
- dict: A new dictionary with truncated lists and strings.
65
-
66
- This can be useful when you have a large dictionary that you want to investigate,
67
- but printing/logging it takes too much space.
68
-
69
- Example:
70
-
71
- >>> large_dict = {'a': [1, 2, 3, 4, 5], 'b': {'c': [6, 7, 8, 9], 'd': 'A string like this that is too long'}, 'e': [10, 11]}
72
- >>> truncate_dict_values(large_dict, max_list_size=3, max_string_size=20)
73
- {'a': [1, 2, 3], 'b': {'c': [6, 7, 8], 'd': 'A string...too long'}, 'e': [10, 11]}
74
-
75
- You can use `None` to indicate "no max":
76
-
77
- >>> assert (
78
- ... truncate_dict_values(large_dict, max_list_size=None, max_string_size=None)
79
- ... == large_dict
80
- ... )
81
-
82
- """
83
-
84
- def truncate_string(value, max_len, marker):
85
- if max_len is None or len(value) <= max_len:
86
- return value
87
- half_len = (max_len - len(marker)) // 2
88
- return value[:half_len] + marker + value[-half_len:]
89
-
90
- kwargs = dict(
91
- max_list_size=max_list_size,
92
- max_string_size=max_string_size,
93
- middle_marker=middle_marker,
94
- )
95
- if isinstance(d, dict):
96
- return {k: truncate_dict_values(v, **kwargs) for k, v in d.items()}
97
- elif isinstance(d, list):
98
- return (
99
- [truncate_dict_values(v, **kwargs) for v in d[:max_list_size]]
100
- if max_list_size is not None
101
- else d
102
- )
103
- elif isinstance(d, str):
104
- return truncate_string(d, max_string_size, middle_marker)
105
- else:
106
- return d
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes