Skip to content

Commit af7c098

Browse files
authored
New "exclude subtree" filter (gramps-project#933)
* first working version of excludesubtree person filter * Add boolean option to include the matched persons themselves * fix d63958f exception upon filter building > File "/usr/lib/python3/dist-packages/gramps/gui/editors/filtereditor.py", line 594, in init > t = v1 > TypeError: init() missing 2 required positional arguments: 'uistate' and 'track' * fix #1 proper implementation of boolean filter parameter * add debug logging, slightly optimize runtime * gramps 6.0 compat: rename function apply -> apply_to_one * chore: rename filter result to self.selected_handles as requested in PR * fix issues noted by review in PR gramps-project#933 2. Missing type imports 3. Mutable class-level attribute 4. reset() never tears down the sub-filter 5. Potential AttributeError when Gramps ID is not found 6. Progress counter can exceed its declared total 7. Name/description mismatch between gpr.py and the class 8. Both .py files are missing the required GPL-2.0-or-later license header block. 9. Import sections need the standard comment headers 10. Each class needs a navigation header comment: 11. The ExcludeSubtree class has no docstring. 12. set([]) → set(). (14) get_relatives yields None handles (missing father/mother); filtering inside the generator rather than at the call site would be cleaner. * feat: pass "include matched" param as enum to avoid GUI imports Instead of adding a checkbox-widget for the boolean parameter, avoid GUI code in this non-GUI addon by receiving a generic string and using empty/false as "include" and any other content (e.g. "exclude") as true * chore: run `black` formatter * fix: copy gettext(translation) boilerplate from howto; bump plugin version * fix type annotations & possible AttributeError on None * fix: add `from __future__ import annotations` for backward compatibility * fix: guard log.debug statements to only evaluate when logging enabled * feat: remove "include_stopfilter_matches:bool", default to "exclude" stringly-typed enums / magic values are bad UX. Include can simply be realized by adding the MatchesFilter to the containing Filter, too * remove initialization of class variable selected_handles "confusing" according to review. I still think initializing the empty set is correct: should `apply_to_one` ever be called before `prepare` this will raise an AttributeError.
1 parent 3818af6 commit af7c098

3 files changed

Lines changed: 201 additions & 0 deletions

File tree

ExcludeSubtreeFilter/__init__.py

Whitespace-only changes.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#
2+
# Gramps - a GTK+/GNOME based genealogy program
3+
#
4+
# Copyright (C) 2026 Jonathan Biegert
5+
#
6+
# This program is free software; you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation; either version 2 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License along
17+
# with this program; if not, see <https://www.gnu.org/licenses/>.
18+
#
19+
20+
# https://github.com/gramps-project/gramps/blob/master/gramps/gen/plug/_pluginreg.py
21+
register(
22+
RULE,
23+
id="ExcludeSubtree",
24+
name=_("People reachable from <Person>, stopping at <Filter> matches"),
25+
description=_(
26+
"Matches people who are reachable starting from <Person> "
27+
"(walking all parents and children of attached families, "
28+
"recursively) stopping at persons in <Filter>."
29+
),
30+
version="0.6",
31+
authors=["Jonathan Biegert"],
32+
authors_email=["azrdev@gmail.com"],
33+
gramps_target_version="6.0",
34+
status=BETA,
35+
fname="excludesubtree.py",
36+
ruleclass="ExcludeSubtree",
37+
namespace="Person",
38+
)
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
#
2+
# Gramps - a GTK+/GNOME based genealogy program
3+
#
4+
# Copyright (C) 2026 Jonathan Biegert
5+
#
6+
# This program is free software; you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation; either version 2 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License along
17+
# with this program; if not, see <https://www.gnu.org/licenses/>.
18+
#
19+
20+
# -------------------------------------------------------------------------
21+
#
22+
# Standard Python modules
23+
#
24+
# -------------------------------------------------------------------------
25+
from __future__ import annotations
26+
import itertools
27+
import logging
28+
29+
# -------------------------------------------------------------------------
30+
#
31+
# Gramps modules
32+
#
33+
# -------------------------------------------------------------------------
34+
from gramps.gen.const import GRAMPS_LOCALE as glocale
35+
from gramps.gen.filters.rules.person import MatchesFilter
36+
from gramps.gen.filters.rules import Rule
37+
38+
try:
39+
_trans = glocale.get_addon_translator(__file__)
40+
except ValueError:
41+
_trans = glocale.translation
42+
_ = _trans.gettext
43+
44+
# -------------------------------------------------------------------------
45+
#
46+
# Typing modules
47+
#
48+
# -------------------------------------------------------------------------
49+
from typing import List, Set
50+
from gramps.gen.lib import Person
51+
from gramps.gen.db import Database
52+
from gramps.gen.types import PersonHandle
53+
54+
LOG = logging.getLogger(__name__)
55+
56+
57+
def get_relatives(db, person):
58+
"""
59+
Given a person handle, iterate all existing person handles of its
60+
relatives.
61+
62+
adapted from IsRelatedWith.add_relative()
63+
"""
64+
if person:
65+
for h in itertools.chain(
66+
# person as child
67+
person.get_parent_family_handle_list(),
68+
# person as parent
69+
person.get_family_handle_list(),
70+
):
71+
family = db.get_family_from_handle(h)
72+
if family:
73+
# parents / spouse
74+
for parent in (family.get_father_handle(), family.get_mother_handle()):
75+
if parent:
76+
yield parent
77+
# siblings / children
78+
for child_ref in family.get_child_ref_list():
79+
yield child_ref.ref
80+
81+
82+
# -------------------------------------------------------------------------
83+
#
84+
# ExcludeSubtree
85+
#
86+
# -------------------------------------------------------------------------
87+
class ExcludeSubtree(Rule):
88+
"""
89+
Person filter rule including all persons reachable (parents/children in the same families) from the active/selected person, except those in the given filter.
90+
91+
This allows to cut partial trees, e.g. exclude everything learned about my spouse but have all non-relatives in my half of the tree.
92+
"""
93+
94+
# filter rule operation
95+
selected_handles: Set[PersonHandle]
96+
filt = None # "stop" person filter
97+
98+
# external interface
99+
labels = [
100+
# rule parameters, see
101+
# gramps.gui.editors.filtereditor.EditRule.__init__
102+
# must be (label, widget class) or special string as label
103+
_("ID:"), # starting person
104+
_("Person filter name:"), # TODO: also allow family filter
105+
]
106+
name = _("People reachable from <Person>, stopping at <Filter> matches")
107+
category = _("Relationship filters")
108+
description = _(
109+
"Matches people who are reachable starting from <Person> "
110+
"(walking all parents and children of attached families, "
111+
"recursively) stopping at persons in <Filter>."
112+
)
113+
114+
def prepare(self, db: Database, user):
115+
self.reset()
116+
self.db = db
117+
118+
if user:
119+
user.begin_progress(
120+
self.category,
121+
_("Retrieving all sub-filter matches"),
122+
db.get_number_of_people(),
123+
)
124+
try:
125+
# initialize search from filter parameters (passed as self.list)
126+
start_person = db.get_person_from_gramps_id(self.list[0])
127+
if start_person is None:
128+
return
129+
self.filt = MatchesFilter(self.list[1:])
130+
self.filt.requestprepare(db, user)
131+
132+
# walk the db using a queue
133+
search_list: List[PersonHandle] = [start_person.handle]
134+
while search_list:
135+
current_h = search_list.pop()
136+
if current_h in self.selected_handles:
137+
continue # already got them
138+
if user:
139+
user.step_progress()
140+
current = db.get_person_from_handle(current_h)
141+
if LOG.isEnabledFor(logging.DEBUG):
142+
LOG.debug("tree walk arrived at id %s", current.gramps_id)
143+
# check stop filter
144+
if self.filt.apply_to_one(db, current):
145+
if LOG.isEnabledFor(logging.DEBUG):
146+
LOG.debug("Stopping at filter match %s", current.gramps_id)
147+
continue # stop at filter matches
148+
# whitelist person and add their relatives to the queue
149+
self.selected_handles.add(current_h)
150+
search_list.extend((h for h in get_relatives(db, current) if h))
151+
LOG.debug("Found %d filter matches", len(self.selected_handles))
152+
153+
finally:
154+
if user:
155+
user.end_progress()
156+
157+
def reset(self):
158+
self.selected_handles = set()
159+
if self.filt:
160+
self.filt.requestreset()
161+
162+
def apply_to_one(self, db: Database, person: Person) -> bool:
163+
return person.handle in self.selected_handles

0 commit comments

Comments
 (0)