Skip to content

Commit 9aeef09

Browse files
committed
get_entries(important: TristateFilter), also search_entries() and counts.
For #254.
1 parent 2a383bb commit 9aeef09

File tree

9 files changed

+162
-29
lines changed

9 files changed

+162
-29
lines changed

src/reader/_storage.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1417,6 +1417,16 @@ def entry_factory(t: tuple[Any, ...]) -> Entry:
14171417
return Entry._make(entry)
14181418

14191419

1420+
TRISTATE_FILTER_TO_SQL = dict(
1421+
istrue="({expr} IS NOT NULL AND {expr})",
1422+
isfalse="({expr} IS NOT NULL AND NOT {expr})",
1423+
notset="{expr} IS NULL",
1424+
nottrue="({expr} IS NULL OR NOT {expr})",
1425+
notfalse="({expr} IS NULL OR {expr})",
1426+
isset="{expr} IS NOT NULL",
1427+
)
1428+
1429+
14201430
def apply_entry_filter_options(
14211431
query: Query, filter_options: EntryFilterOptions, keyword: str = 'WHERE'
14221432
) -> dict[str, Any]:
@@ -1435,10 +1445,8 @@ def apply_entry_filter_options(
14351445
if read is not None:
14361446
add(f"{'' if read else 'NOT'} entries.read")
14371447

1438-
if important is not None:
1439-
add(
1440-
f"{'' if important else 'NOT'} (entries.important IS NOT NULL AND entries.important)"
1441-
)
1448+
if important != 'any':
1449+
add(TRISTATE_FILTER_TO_SQL[important].format(expr='entries.important'))
14421450

14431451
if has_enclosures is not None:
14441452
add(

src/reader/_types.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from types import MappingProxyType
1111
from types import SimpleNamespace
1212
from typing import Any
13+
from typing import get_args
14+
from typing import Literal
1315
from typing import NamedTuple
1416
from typing import TypeVar
1517
from typing import Union
@@ -27,6 +29,7 @@
2729
from .types import Feed
2830
from .types import FeedInput
2931
from .types import TagFilterInput
32+
from .types import TristateFilterInput
3033

3134
# Private API
3235
# https://github.com/lemon24/reader/issues/111
@@ -388,6 +391,31 @@ def normalize_tag(tag: str | bool) -> bool | tuple[bool, str]:
388391
return rv
389392

390393

394+
TristateFilter = Literal[
395+
'istrue',
396+
'isfalse',
397+
'notset',
398+
'nottrue',
399+
'notfalse',
400+
'isset',
401+
'any',
402+
]
403+
404+
405+
def tristate_filter_argument(value: TristateFilterInput, name: str) -> TristateFilter:
406+
# https://github.com/lemon24/reader/issues/254#issuecomment-1435648359
407+
if value is None:
408+
return 'any'
409+
if value == True: # noqa: E712
410+
return 'istrue'
411+
if value == False: # noqa: E712
412+
return 'nottrue'
413+
args = get_args(TristateFilter)
414+
if value in args:
415+
return value
416+
raise ValueError(f"{name} must be none, bool, or one of {args}")
417+
418+
391419
_EFO = TypeVar('_EFO', bound='EntryFilterOptions')
392420

393421

@@ -398,7 +426,7 @@ class EntryFilterOptions(NamedTuple):
398426
feed_url: str | None = None
399427
entry_id: str | None = None
400428
read: bool | None = None
401-
important: bool | None = None
429+
important: TristateFilter = 'any'
402430
has_enclosures: bool | None = None
403431
feed_tags: TagFilter = ()
404432

@@ -408,7 +436,7 @@ def from_args(
408436
feed: FeedInput | None = None,
409437
entry: EntryInput | None = None,
410438
read: bool | None = None,
411-
important: bool | None = None,
439+
important: TristateFilterInput = None,
412440
has_enclosures: bool | None = None,
413441
feed_tags: TagFilterInput = None,
414442
) -> _EFO:
@@ -422,14 +450,16 @@ def from_args(
422450

423451
if read not in (None, False, True):
424452
raise ValueError("read should be one of (None, False, True)")
425-
if important not in (None, False, True):
426-
raise ValueError("important should be one of (None, False, True)")
453+
454+
important_filter = tristate_filter_argument(important, 'important')
427455
if has_enclosures not in (None, False, True):
428456
raise ValueError("has_enclosures should be one of (None, False, True)")
429457

430458
feed_tag_filter = tag_filter_argument(feed_tags, 'feed_tags')
431459

432-
return cls(feed_url, entry_id, read, important, has_enclosures, feed_tag_filter)
460+
return cls(
461+
feed_url, entry_id, read, important_filter, has_enclosures, feed_tag_filter
462+
)
433463

434464

435465
_FFO = TypeVar('_FFO', bound='FeedFilterOptions')

src/reader/core.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
from .types import ResourceInput
6767
from .types import SearchSortOrder
6868
from .types import TagFilterInput
69+
from .types import TristateFilterInput
6970
from .types import UpdatedFeed
7071
from .types import UpdateResult
7172

@@ -1096,7 +1097,7 @@ def get_entries(
10961097
feed: FeedInput | None = None,
10971098
entry: EntryInput | None = None,
10981099
read: bool | None = None,
1099-
important: bool | None = None,
1100+
important: TristateFilterInput = None,
11001101
has_enclosures: bool | None = None,
11011102
feed_tags: TagFilterInput = None,
11021103
sort: EntrySortOrder = 'recent',
@@ -1264,7 +1265,7 @@ def get_entry_counts(
12641265
feed: FeedInput | None = None,
12651266
entry: EntryInput | None = None,
12661267
read: bool | None = None,
1267-
important: bool | None = None,
1268+
important: TristateFilterInput = None,
12681269
has_enclosures: bool | None = None,
12691270
feed_tags: TagFilterInput = None,
12701271
) -> EntryCounts:
@@ -1642,7 +1643,7 @@ def search_entries(
16421643
feed: FeedInput | None = None,
16431644
entry: EntryInput | None = None,
16441645
read: bool | None = None,
1645-
important: bool | None = None,
1646+
important: TristateFilterInput = None,
16461647
has_enclosures: bool | None = None,
16471648
feed_tags: TagFilterInput = None,
16481649
sort: SearchSortOrder = 'relevant',
@@ -1778,7 +1779,7 @@ def search_entry_counts(
17781779
feed: FeedInput | None = None,
17791780
entry: EntryInput | None = None,
17801781
read: bool | None = None,
1781-
important: bool | None = None,
1782+
important: TristateFilterInput = None,
17821783
has_enclosures: bool | None = None,
17831784
feed_tags: TagFilterInput = None,
17841785
) -> EntrySearchCounts:

src/reader/types.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,20 @@ def _resource_argument(resource: ResourceInput) -> ResourceId:
770770
]
771771

772772

773+
TristateFilterInput = Literal[
774+
None,
775+
True,
776+
False,
777+
'istrue',
778+
'isfalse',
779+
'notset',
780+
'nottrue',
781+
'notfalse',
782+
'isset',
783+
'any',
784+
]
785+
786+
773787
@dataclass(frozen=True)
774788
class FeedCounts(_namedtuple_compat):
775789

tests/conftest.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -183,16 +183,7 @@ def chunk_size(request):
183183
return request.param
184184

185185

186-
@pytest.fixture(
187-
params=[
188-
# defaults not included
189-
reader_methods.get_entries_recent,
190-
reader_methods.get_entries_random,
191-
reader_methods.search_entries_relevant,
192-
reader_methods.search_entries_recent,
193-
reader_methods.search_entries_random,
194-
],
195-
)
186+
@pytest.fixture(params=reader_methods.get_entries_methods)
196187
def get_entries(request):
197188
yield request.param
198189

tests/reader_methods.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,30 @@ def get_feeds(reader, **kwargs):
7070
for name, obj in dict(globals()).items():
7171
if name.startswith('get_'):
7272
obj.after_update = do_nothing
73+
if name.startswith('search_entries'):
74+
obj.after_update = enable_and_update_search
75+
76+
77+
def get_entry_counts(reader, **kwargs):
78+
return reader.get_entry_counts(**kwargs)
79+
80+
81+
def search_entry_counts(reader, **kwargs):
82+
return reader.search_entry_counts('entry', **kwargs)
83+
7384

7485
for name, obj in dict(globals()).items():
86+
if name.startswith('get_entries'):
87+
obj.counts = get_entry_counts
7588
if name.startswith('search_entries'):
76-
obj.after_update = enable_and_update_search
89+
obj.counts = search_entry_counts
90+
91+
92+
get_entries_methods = [
93+
# defaults not included
94+
get_entries_recent,
95+
get_entries_random,
96+
search_entries_relevant,
97+
search_entries_recent,
98+
search_entries_random,
99+
]

tests/test__types.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from reader._types import FeedData
88
from reader._types import fix_datetime_tzinfo
99
from reader._types import tag_filter_argument
10+
from reader._types import tristate_filter_argument
1011

1112

1213
TAG_DATA = [
@@ -51,6 +52,30 @@ def test_tag_filter_argument_error(input, error):
5152
assert error in str(excinfo.value)
5253

5354

55+
TRISTATE_FILTER_DATA = [
56+
(v, v)
57+
for v in ['istrue', 'isfalse', 'notset', 'nottrue', 'notfalse', 'isset', 'any']
58+
] + [
59+
(None, 'any'),
60+
(True, 'istrue'),
61+
(False, 'nottrue'),
62+
(1, 'istrue'),
63+
(0, 'nottrue'),
64+
]
65+
66+
67+
@pytest.mark.parametrize('input, expected', TRISTATE_FILTER_DATA)
68+
def test_tristate_filter_argument(input, expected):
69+
tristate_filter_argument(input, 'name')
70+
71+
72+
@pytest.mark.parametrize('input', ['all', 2, -1, ()])
73+
def test_tristate_filter_argument_error(input):
74+
with pytest.raises(ValueError) as excinfo:
75+
tristate_filter_argument(input, 'name')
76+
assert 'name' in str(excinfo.value)
77+
78+
5479
@pytest.mark.parametrize('data_file', ['full', 'empty'])
5580
def test_entry_data_from_obj(data_dir, data_file):
5681
expected = {'url_base': '', 'rel_base': ''}

tests/test_reader.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1713,6 +1713,47 @@ def test_entries_filtering_error(reader, get_entries, kwargs):
17131713
list(get_entries(reader, **kwargs))
17141714

17151715

1716+
@pytest.mark.parametrize('modified', [None, datetime(2010, 1, 1)])
1717+
def test_entries_filtering_important(reader, subtests, get_entries, modified):
1718+
reader._parser = parser = Parser()
1719+
1720+
reader.add_feed(parser.feed(1))
1721+
one = parser.entry(1, 1)
1722+
two = parser.entry(1, 2)
1723+
reader.add_feed(parser.feed(2))
1724+
three = parser.entry(2, 3)
1725+
1726+
reader.update_feeds()
1727+
reader.update_search()
1728+
1729+
reader.set_entry_important(one, None, modified)
1730+
reader.set_entry_important(two, True, modified)
1731+
reader.set_entry_important(three, False, modified)
1732+
1733+
data = {
1734+
'istrue': {'1, 2'},
1735+
True: {'1, 2'},
1736+
'isfalse': {'2, 3'},
1737+
'notset': {'1, 1'},
1738+
'nottrue': {'1, 1', '2, 3'},
1739+
False: {'1, 1', '2, 3'},
1740+
'notfalse': {'1, 1', '1, 2'},
1741+
'isset': {'1, 2', '2, 3'},
1742+
'any': {'1, 1', '1, 2', '2, 3'},
1743+
None: {'1, 1', '1, 2', '2, 3'},
1744+
} # fmt: skip
1745+
1746+
for important, expected in data.items():
1747+
with subtests.test(important=important):
1748+
actual = {e.id for e in get_entries(reader, important=important)}
1749+
assert actual == expected
1750+
assert get_entries.counts(reader, important=important).total == len(
1751+
expected
1752+
)
1753+
1754+
1755+
# TODO: ideally, systematize all filtering tests?
1756+
17161757
# END entry filtering tests
17171758

17181759

tests/test_storage.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ def test_important_unimportant_by_default(storage):
535535
assert {
536536
e.id
537537
for e in storage.get_entries(
538-
datetime(2010, 1, 1), EntryFilterOptions(important=False)
538+
datetime(2010, 1, 1), EntryFilterOptions(important='nottrue')
539539
)
540540
} == {'one', 'two'}
541541

@@ -551,19 +551,19 @@ def test_important_get_entries(storage):
551551
assert {
552552
e.id
553553
for e in storage.get_entries(
554-
datetime(2010, 1, 1), EntryFilterOptions(important=None)
554+
datetime(2010, 1, 1), EntryFilterOptions(important='any')
555555
)
556556
} == {'one', 'two'}
557557
assert {
558558
e.id
559559
for e in storage.get_entries(
560-
datetime(2010, 1, 1), EntryFilterOptions(important=True)
560+
datetime(2010, 1, 1), EntryFilterOptions(important='istrue')
561561
)
562562
} == {'one'}
563563
assert {
564564
e.id
565565
for e in storage.get_entries(
566-
datetime(2010, 1, 1), EntryFilterOptions(important=False)
566+
datetime(2010, 1, 1), EntryFilterOptions(important='nottrue')
567567
)
568568
} == {'two'}
569569

@@ -586,7 +586,7 @@ def test_important_mark_as_unimportant(storage):
586586
assert {
587587
e.id
588588
for e in storage.get_entries(
589-
datetime(2010, 1, 1), EntryFilterOptions(important=True)
589+
datetime(2010, 1, 1), EntryFilterOptions(important='istrue')
590590
)
591591
} == set()
592592

0 commit comments

Comments
 (0)