Skip to content

Commit 1b1d378

Browse files
gtkaczmahmoud
authored andcommitted
feat(strutils): add human_readable_list function to format lists of strings
1 parent 6b9a2bf commit 1b1d378

2 files changed

Lines changed: 139 additions & 5 deletions

File tree

boltons/strutils.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,20 @@
3636

3737

3838
import builtins
39+
import collections
3940
import re
41+
import string
4042
import sys
43+
import typing
44+
import unicodedata
4145
import uuid
4246
import zlib
43-
import string
44-
import unicodedata
45-
import collections
4647
from collections.abc import Mapping
4748
from gzip import GzipFile
48-
from html.parser import HTMLParser
4949
from html import entities as htmlentitydefs
50+
from html.parser import HTMLParser
5051
from io import BytesIO as StringIO
5152

52-
5353
__all__ = ['camel2under', 'under2camel', 'slugify', 'split_punct_ws',
5454
'unit_len', 'ordinalize', 'cardinalize', 'pluralize', 'singularize',
5555
'asciify', 'is_ascii', 'is_uuid', 'html2text', 'strip_ansi',
@@ -1285,3 +1285,32 @@ def removeprefix(text: str, prefix: str) -> str:
12851285
if text.startswith(prefix):
12861286
return text[len(prefix):]
12871287
return text
1288+
1289+
def human_readable_list(items: typing.Sequence[str], delimiter: str = ',', conjunction: str = 'and', *, oxford: bool = True) -> str:
1290+
"""
1291+
Given a list of strings, return a human readable string with
1292+
appropriate delimiters and the conjunction word.
1293+
1294+
Args:
1295+
items: The list of strings to join.
1296+
delimiter (optional): The delimiter to use between items.
1297+
conjunction (optional): The word to use before the last item.
1298+
oxford (optional): Whether to use the Oxford comma/delimiter before
1299+
the conjunction in lists of 3+ items.
1300+
1301+
Returns:
1302+
str: The human readable string.
1303+
"""
1304+
if not items:
1305+
return ''
1306+
1307+
delimiter = f'{delimiter.strip()} '
1308+
conjunction = conjunction.strip()
1309+
1310+
if len(items) == 1:
1311+
return items[0]
1312+
1313+
if len(items) == 2:
1314+
return f'{items[0]} {conjunction} {items[1]}'
1315+
1316+
return f'{delimiter.join(items[:-1])}{delimiter if oxford else ' '}{conjunction} {items[-1]}'

tests/test_strutils.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,111 @@ def test_substitutions_with_regex_chars(self):
142142
self.assertEqual(m.sub('The cat.+ is purple'), 'The kedi is mor')
143143

144144

145+
def test_human_readable_list():
146+
"""Test the human_readable_list function with various inputs."""
147+
148+
# Test empty list
149+
assert strutils.human_readable_list([]) == ''
150+
151+
# Test single item
152+
assert strutils.human_readable_list(['apple']) == 'apple'
153+
154+
# Test two items (no Oxford comma applies)
155+
assert strutils.human_readable_list(['apple', 'banana']) == 'apple and banana'
156+
157+
# Test three items with Oxford comma (default)
158+
assert strutils.human_readable_list(['apple', 'banana', 'cherry']) == 'apple, banana, and cherry'
159+
160+
# Test three items without Oxford comma
161+
assert strutils.human_readable_list(['apple', 'banana', 'cherry'], oxford=False) == 'apple, banana and cherry'
162+
163+
# Test four items with Oxford comma
164+
assert strutils.human_readable_list(['apple', 'banana', 'cherry', 'date']) == 'apple, banana, cherry, and date'
165+
166+
# Test four items without Oxford comma
167+
assert strutils.human_readable_list(['apple', 'banana', 'cherry', 'date'], oxford=False) == 'apple, banana, cherry and date'
168+
169+
# Test custom delimiter
170+
assert strutils.human_readable_list(['apple', 'banana', 'cherry'], delimiter=';') == 'apple; banana; and cherry'
171+
172+
# Test custom conjunction
173+
assert strutils.human_readable_list(['apple', 'banana', 'cherry'], conjunction='or') == 'apple, banana, or cherry'
174+
175+
# Test custom delimiter and conjunction
176+
assert strutils.human_readable_list(['apple', 'banana', 'cherry'], delimiter='|', conjunction='plus') == 'apple| banana| plus cherry'
177+
178+
# Test custom conjunction without Oxford comma
179+
assert strutils.human_readable_list(['apple', 'banana', 'cherry'], conjunction='or', oxford=False) == 'apple, banana or cherry'
180+
181+
# Test delimiter with extra spaces (should be stripped)
182+
assert strutils.human_readable_list(['apple', 'banana', 'cherry'], delimiter=' , ') == 'apple, banana, and cherry'
183+
184+
# Test conjunction with extra spaces (should be stripped)
185+
assert strutils.human_readable_list(['apple', 'banana', 'cherry'], conjunction=' or ') == 'apple, banana, or cherry'
186+
187+
# Test with empty strings in the list
188+
assert strutils.human_readable_list(['apple', '', 'cherry']) == 'apple, , and cherry'
189+
190+
# Test with whitespace strings
191+
assert strutils.human_readable_list(['apple', ' ', 'cherry']) == 'apple, , and cherry'
192+
193+
# Test with special characters
194+
assert strutils.human_readable_list(['apple & pear', 'banana/plantain', 'cherry-bomb']) == 'apple & pear, banana/plantain, and cherry-bomb'
195+
196+
# Test with unicode characters
197+
assert strutils.human_readable_list(['🍎', '🍌', '🍒']) == '🍎, 🍌, and 🍒'
198+
199+
# Test with numbers as strings
200+
assert strutils.human_readable_list(['1', '2', '3']) == '1, 2, and 3'
201+
202+
# Test edge case with only delimiter character as items
203+
assert strutils.human_readable_list([',', ',', ',']) == ',, ,, and ,'
204+
205+
# Test long list to ensure pattern consistency
206+
long_list = ['a', 'b', 'c', 'd', 'e', 'f']
207+
assert strutils.human_readable_list(long_list) == 'a, b, c, d, e, and f'
208+
assert strutils.human_readable_list(long_list, oxford=False) == 'a, b, c, d, e and f'
209+
210+
# Edge cases
211+
# Test with empty delimiter
212+
assert strutils.human_readable_list(['apple', 'banana', 'cherry'], delimiter='') == 'applebananaand cherry'
213+
214+
# Test with empty conjunction
215+
assert strutils.human_readable_list(['apple', 'banana', 'cherry'], conjunction='') == 'apple, banana, cherry'
216+
217+
# Test two items with custom delimiter and conjunction
218+
assert strutils.human_readable_list(['apple', 'banana'], delimiter=';', conjunction='or') == 'apple or banana'
219+
220+
# Test single item with custom parameters (should ignore them)
221+
assert strutils.human_readable_list(['apple'], delimiter='|', conjunction='plus', oxford=False) == 'apple'
222+
223+
# Test very long strings
224+
long_item1 = 'a' * 100
225+
long_item2 = 'b' * 100
226+
result = strutils.human_readable_list([long_item1, long_item2])
227+
assert result == f'{long_item1} and {long_item2}'
228+
229+
230+
def test_human_readable_list_type_annotations():
231+
"""Test that the function works with different sequence types."""
232+
233+
# Test with tuple
234+
assert strutils.human_readable_list(('apple', 'banana', 'cherry')) == 'apple, banana, and cherry'
235+
236+
# Test with list (already tested above, but for completeness)
237+
assert strutils.human_readable_list(['apple', 'banana', 'cherry']) == 'apple, banana, and cherry'
238+
239+
# Test with generator (converted to list for the test)
240+
def fruit_generator():
241+
yield 'apple'
242+
yield 'banana'
243+
yield 'cherry'
244+
245+
# Convert generator to list since the function expects a Sequence
246+
fruits = list(fruit_generator())
247+
assert strutils.human_readable_list(fruits) == 'apple, banana, and cherry'
248+
249+
145250
def test_roundzip():
146251
aaa = b'a' * 10000
147252
assert strutils.gunzip_bytes(strutils.gzip_bytes(aaa)) == aaa

0 commit comments

Comments
 (0)