Coverage for src / kdbxtool / models / group.py: 93%
312 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-19 21:22 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-19 21:22 +0000
1"""Group model for KDBX database folders."""
3from __future__ import annotations
5import re
6import uuid as uuid_module
7from collections.abc import Iterator
8from dataclasses import dataclass, field
9from typing import TYPE_CHECKING
11from .entry import Entry
12from .times import Times
14if TYPE_CHECKING:
15 from ..database import Database
16 from ..templates import EntryTemplate
19@dataclass
20class Group:
21 """A group (folder) in a KDBX database.
23 Groups organize entries into a hierarchical structure. Each group can
24 contain entries and subgroups.
26 Attributes:
27 uuid: Unique identifier for the group
28 name: Display name of the group
29 notes: Optional notes/description
30 times: Timestamps (creation, modification, access, expiry)
31 icon_id: Icon ID for display (standard icon)
32 custom_icon_uuid: UUID of custom icon (overrides icon_id if set)
33 is_expanded: Whether group is expanded in UI
34 default_autotype_sequence: Default AutoType sequence for entries
35 enable_autotype: Whether AutoType is enabled for this group
36 enable_searching: Whether entries in this group are searchable
37 last_top_visible_entry: UUID of last visible entry (UI state)
38 entries: List of entries in this group
39 subgroups: List of subgroups
40 """
42 uuid: uuid_module.UUID = field(default_factory=uuid_module.uuid4)
43 name: str | None = None
44 notes: str | None = None
45 times: Times = field(default_factory=Times.create_new)
46 icon_id: str = "48" # Default folder icon
47 custom_icon_uuid: uuid_module.UUID | None = None
48 is_expanded: bool = True
49 default_autotype_sequence: str | None = None
50 enable_autotype: bool | None = None # None = inherit from parent
51 enable_searching: bool | None = None # None = inherit from parent
52 last_top_visible_entry: uuid_module.UUID | None = None
53 entries: list[Entry] = field(default_factory=list)
54 subgroups: list[Group] = field(default_factory=list)
56 # Runtime reference to parent group (not serialized)
57 _parent: Group | None = field(default=None, repr=False, compare=False)
58 # Flag for root group
59 _is_root: bool = field(default=False, repr=False)
60 # Runtime reference to database (not serialized) - used for icon name resolution
61 _database: Database | None = field(default=None, repr=False, compare=False)
63 @property
64 def parent(self) -> Group | None:
65 """Get parent group, or None if this is the root."""
66 return self._parent
68 @property
69 def database(self) -> Database | None:
70 """Get the database this group belongs to."""
71 return self._database
73 @property
74 def is_root_group(self) -> bool:
75 """Check if this is the database root group."""
76 return self._is_root
78 @property
79 def index(self) -> int:
80 """Get the index of this group within its parent group.
82 Returns:
83 Zero-based index of this group in the parent's subgroups list.
85 Raises:
86 ValueError: If group has no parent (is root group).
87 """
88 if self._parent is None:
89 raise ValueError("Group has no parent (is root group)")
90 return self._parent.subgroups.index(self)
92 @property
93 def path(self) -> list[str]:
94 """Get path from root to this group.
96 Returns:
97 List of group names from root (exclusive) to this group (inclusive).
98 Empty list for the root group.
99 """
100 if self.is_root_group or self._parent is None:
101 return []
102 parts: list[str] = []
103 current: Group | None = self
104 while current is not None and not current.is_root_group:
105 if current.name is not None:
106 parts.insert(0, current.name)
107 current = current._parent
108 return parts
110 @property
111 def expired(self) -> bool:
112 """Check if group has expired."""
113 return self.times.expired
115 @property
116 def custom_icon(self) -> uuid_module.UUID | None:
117 """Get or set custom icon by UUID or name.
119 When setting, accepts either a UUID or an icon name (string).
120 If a string is provided, it must match exactly one icon name in the
121 database. Requires the group to be associated with a database for
122 name-based lookup.
124 Returns:
125 UUID of the custom icon, or None if not set
126 """
127 return self.custom_icon_uuid
129 @custom_icon.setter
130 def custom_icon(self, value: uuid_module.UUID | str | None) -> None:
131 if value is None:
132 self.custom_icon_uuid = None
133 elif isinstance(value, uuid_module.UUID):
134 self.custom_icon_uuid = value
135 elif isinstance(value, str):
136 # Look up icon by name
137 if self._database is None:
138 raise ValueError(
139 "Cannot set custom icon by name: group is not associated with a database"
140 )
141 icon_uuid = self._database.find_custom_icon_by_name(value)
142 if icon_uuid is None:
143 raise ValueError(f"No custom icon found with name: {value}")
144 self.custom_icon_uuid = icon_uuid
145 else:
146 raise TypeError(f"custom_icon must be UUID, str, or None, not {type(value).__name__}")
148 def touch(self, modify: bool = False) -> None:
149 """Update access time, optionally modification time."""
150 self.times.touch(modify=modify)
152 def reindex(self, new_index: int) -> None:
153 """Move this group to a new position within its parent group.
155 Args:
156 new_index: Target position (zero-based). Negative indices are
157 supported (e.g., -1 for last position).
159 Raises:
160 ValueError: If group has no parent (is root group).
161 IndexError: If new_index is out of range.
162 """
163 if self._parent is None:
164 raise ValueError("Group has no parent (is root group)")
166 subgroups = self._parent.subgroups
167 current_index = subgroups.index(self)
169 # Handle negative indices
170 if new_index < 0:
171 new_index = len(subgroups) + new_index
173 # Validate bounds
174 if new_index < 0 or new_index >= len(subgroups):
175 raise IndexError(f"Index {new_index} out of range for {len(subgroups)} groups")
177 # No-op if already at target position
178 if current_index == new_index:
179 return
181 # Remove from current position and insert at new position
182 subgroups.pop(current_index)
183 subgroups.insert(new_index, self)
185 # --- Entry management ---
187 def add_entry(self, entry: Entry) -> Entry:
188 """Add an entry to this group.
190 Args:
191 entry: Entry to add
193 Returns:
194 The added entry
195 """
196 entry._parent = self
197 entry._database = self._database
198 self.entries.append(entry)
199 self.touch(modify=True)
200 return entry
202 def remove_entry(self, entry: Entry) -> None:
203 """Remove an entry from this group.
205 Args:
206 entry: Entry to remove
208 Raises:
209 ValueError: If entry is not in this group
210 """
211 if entry not in self.entries:
212 raise ValueError("Entry not in this group")
213 self.entries.remove(entry)
214 entry._parent = None
215 self.touch(modify=True)
217 def create_entry(
218 self,
219 title: str | None = None,
220 username: str | None = None,
221 password: str | None = None,
222 url: str | None = None,
223 notes: str | None = None,
224 tags: list[str] | None = None,
225 *,
226 template: EntryTemplate | None = None,
227 ) -> Entry:
228 """Create and add a new entry to this group.
230 Args:
231 title: Entry title
232 username: Username
233 password: Password
234 url: URL
235 notes: Notes
236 tags: Tags
237 template: Optional entry template instance with field values
239 Returns:
240 Newly created entry
242 Example:
243 >>> # Standard entry (no template)
244 >>> entry = group.create_entry(title="Site", username="user", password="pass")
246 >>> # Using a template with typed fields
247 >>> from kdbxtool import Templates
248 >>> entry = group.create_entry(
249 ... title="My Visa",
250 ... template=Templates.CreditCard(
251 ... card_number="4111111111111111",
252 ... expiry_date="12/25",
253 ... cvv="123",
254 ... ),
255 ... )
257 >>> # Server template with standard fields
258 >>> entry = group.create_entry(
259 ... title="prod-server",
260 ... username="admin",
261 ... password="secret",
262 ... template=Templates.Server(
263 ... hostname="192.168.1.1",
264 ... port="22",
265 ... ),
266 ... )
267 """
268 from .entry import StringField
270 # Determine icon and whether to include standard fields
271 icon_id = "0"
272 include_standard = True
273 if template is not None:
274 icon_id = str(template._icon_id)
275 include_standard = template._include_standard
277 # Create base entry
278 entry = Entry.create(
279 title=title,
280 username=username if include_standard else None,
281 password=password if include_standard else None,
282 url=url if include_standard else None,
283 notes=notes,
284 tags=tags,
285 icon_id=icon_id,
286 )
288 # Add template fields from the template instance
289 if template is not None:
290 for display_name, (value, is_protected) in template._get_fields().items():
291 if value is not None:
292 entry.strings[display_name] = StringField(
293 key=display_name,
294 value=value,
295 protected=is_protected,
296 )
298 return self.add_entry(entry)
300 # --- Subgroup management ---
302 def add_subgroup(self, group: Group) -> Group:
303 """Add a subgroup to this group.
305 Args:
306 group: Group to add
308 Returns:
309 The added group
310 """
311 group._parent = self
312 # Propagate database reference to the subgroup and all its contents
313 self._propagate_database(group)
314 self.subgroups.append(group)
315 self.touch(modify=True)
316 return group
318 def _propagate_database(self, group: Group) -> None:
319 """Recursively propagate database reference to a group and its contents."""
320 group._database = self._database
321 for entry in group.entries:
322 entry._database = self._database
323 for subgroup in group.subgroups:
324 self._propagate_database(subgroup)
326 def remove_subgroup(self, group: Group) -> None:
327 """Remove a subgroup from this group.
329 Args:
330 group: Group to remove
332 Raises:
333 ValueError: If group is not a subgroup of this group
334 """
335 if group not in self.subgroups:
336 raise ValueError("Group is not a subgroup")
337 self.subgroups.remove(group)
338 group._parent = None
339 self.touch(modify=True)
341 def create_subgroup(
342 self,
343 name: str,
344 notes: str | None = None,
345 icon_id: str = "48",
346 ) -> Group:
347 """Create and add a new subgroup.
349 Args:
350 name: Group name
351 notes: Optional notes
352 icon_id: Icon ID
354 Returns:
355 Newly created group
356 """
357 group = Group(name=name, notes=notes, icon_id=icon_id)
358 return self.add_subgroup(group)
360 def move_to(self, destination: Group) -> None:
361 """Move this group to a different parent group.
363 Removes the group from its current parent and adds it to the
364 destination group. Updates the location_changed timestamp.
366 Args:
367 destination: Target parent group to move this group to
369 Raises:
370 ValueError: If this is the root group (cannot be moved)
371 ValueError: If group has no parent (not yet added to a database)
372 ValueError: If destination is the current parent
373 ValueError: If destination is this group (cannot move into self)
374 ValueError: If destination is a descendant of this group (would create cycle)
375 """
376 if self._is_root:
377 raise ValueError("Cannot move the root group")
378 if self._parent is None:
379 raise ValueError("Cannot move group that has no parent")
380 if self._parent is destination:
381 raise ValueError("Group is already in the destination group")
382 if destination is self:
383 raise ValueError("Cannot move group into itself")
385 # Check for cycle: destination cannot be a descendant of this group
386 if self._is_descendant(destination):
387 raise ValueError("Cannot move group into its own descendant (would create cycle)")
389 # Remove from current parent
390 self._parent.subgroups.remove(self)
391 old_parent = self._parent
392 self._parent = None
394 # Add to new parent
395 destination.subgroups.append(self)
396 self._parent = destination
398 # Update timestamps
399 self.times.update_location()
400 old_parent.touch(modify=True)
401 destination.touch(modify=True)
403 def _is_descendant(self, group: Group) -> bool:
404 """Check if the given group is a descendant of this group.
406 Args:
407 group: Group to check
409 Returns:
410 True if group is a descendant of this group
411 """
412 for subgroup in self.subgroups:
413 if subgroup is group:
414 return True
415 if subgroup._is_descendant(group):
416 return True
417 return False
419 # --- Iteration and search ---
421 def iter_entries(self, recursive: bool = True, history: bool = False) -> Iterator[Entry]:
422 """Iterate over entries in this group.
424 Args:
425 recursive: If True, include entries from all subgroups
426 history: If True, include history entries
428 Yields:
429 Entry objects
430 """
431 for entry in self.entries:
432 yield entry
433 if history:
434 yield from entry.history
435 if recursive:
436 for subgroup in self.subgroups:
437 yield from subgroup.iter_entries(recursive=True, history=history)
439 def iter_groups(self, recursive: bool = True) -> Iterator[Group]:
440 """Iterate over subgroups.
442 Args:
443 recursive: If True, include nested subgroups
445 Yields:
446 Group objects
447 """
448 for subgroup in self.subgroups:
449 yield subgroup
450 if recursive:
451 yield from subgroup.iter_groups(recursive=True)
453 def find_entry_by_uuid(self, uuid: uuid_module.UUID, recursive: bool = True) -> Entry | None:
454 """Find an entry by UUID.
456 Args:
457 uuid: Entry UUID to find
458 recursive: Search in subgroups
460 Returns:
461 Entry if found, None otherwise
462 """
463 for entry in self.iter_entries(recursive=recursive):
464 if entry.uuid == uuid:
465 return entry
466 return None
468 def find_group_by_uuid(self, uuid: uuid_module.UUID, recursive: bool = True) -> Group | None:
469 """Find a group by UUID.
471 Args:
472 uuid: Group UUID to find
473 recursive: Search in nested subgroups
475 Returns:
476 Group if found, None otherwise
477 """
478 if self.uuid == uuid:
479 return self
480 for group in self.iter_groups(recursive=recursive):
481 if group.uuid == uuid:
482 return group
483 return None
485 def find_entries(
486 self,
487 title: str | None = None,
488 username: str | None = None,
489 password: str | None = None,
490 url: str | None = None,
491 notes: str | None = None,
492 otp: str | None = None,
493 tags: list[str] | None = None,
494 string: dict[str, str] | None = None,
495 autotype_enabled: bool | None = None,
496 autotype_sequence: str | None = None,
497 autotype_window: str | None = None,
498 recursive: bool = True,
499 history: bool = False,
500 ) -> list[Entry]:
501 """Find entries matching criteria.
503 All criteria are combined with AND logic. None means "any value".
505 Args:
506 title: Match entries with this title (exact)
507 username: Match entries with this username (exact)
508 password: Match entries with this password (exact)
509 url: Match entries with this URL (exact)
510 notes: Match entries with these notes (exact)
511 otp: Match entries with this OTP (exact)
512 tags: Match entries containing all these tags
513 string: Match entries with custom properties (dict of key:value)
514 autotype_enabled: Filter by AutoType enabled state
515 autotype_sequence: Match entries with this AutoType sequence (exact)
516 autotype_window: Match entries with this AutoType window (exact)
517 recursive: Search in subgroups
518 history: Include history entries in search
520 Returns:
521 List of matching entries
522 """
523 results: list[Entry] = []
524 for entry in self.iter_entries(recursive=recursive, history=history):
525 if title is not None and entry.title != title:
526 continue
527 if username is not None and entry.username != username:
528 continue
529 if password is not None and entry.password != password:
530 continue
531 if url is not None and entry.url != url:
532 continue
533 if notes is not None and entry.notes != notes:
534 continue
535 if otp is not None and entry.otp != otp:
536 continue
537 if tags is not None and not all(t in entry.tags for t in tags):
538 continue
539 if string is not None and not all(
540 entry.get_custom_property(k) == v for k, v in string.items()
541 ):
542 continue
543 if autotype_enabled is not None and entry.autotype.enabled != autotype_enabled:
544 continue
545 if autotype_sequence is not None and entry.autotype.sequence != autotype_sequence:
546 continue
547 if autotype_window is not None and entry.autotype.window != autotype_window:
548 continue
549 results.append(entry)
550 return results
552 def find_entries_contains(
553 self,
554 title: str | None = None,
555 username: str | None = None,
556 password: str | None = None,
557 url: str | None = None,
558 notes: str | None = None,
559 otp: str | None = None,
560 recursive: bool = True,
561 case_sensitive: bool = False,
562 history: bool = False,
563 ) -> list[Entry]:
564 """Find entries where fields contain the given substrings.
566 All criteria are combined with AND logic. None means "any value".
568 Args:
569 title: Match entries whose title contains this substring
570 username: Match entries whose username contains this substring
571 password: Match entries whose password contains this substring
572 url: Match entries whose URL contains this substring
573 notes: Match entries whose notes contain this substring
574 otp: Match entries whose OTP contains this substring
575 recursive: Search in subgroups
576 case_sensitive: If False (default), matching is case-insensitive
577 history: Include history entries in search
579 Returns:
580 List of matching entries
581 """
583 def contains(field_value: str | None, search: str) -> bool:
584 if field_value is None:
585 return False
586 if case_sensitive:
587 return search in field_value
588 return search.lower() in field_value.lower()
590 results: list[Entry] = []
591 for entry in self.iter_entries(recursive=recursive, history=history):
592 if title is not None and not contains(entry.title, title):
593 continue
594 if username is not None and not contains(entry.username, username):
595 continue
596 if password is not None and not contains(entry.password, password):
597 continue
598 if url is not None and not contains(entry.url, url):
599 continue
600 if notes is not None and not contains(entry.notes, notes):
601 continue
602 if otp is not None and not contains(entry.otp, otp):
603 continue
604 results.append(entry)
605 return results
607 def find_entries_regex(
608 self,
609 title: str | None = None,
610 username: str | None = None,
611 password: str | None = None,
612 url: str | None = None,
613 notes: str | None = None,
614 otp: str | None = None,
615 recursive: bool = True,
616 case_sensitive: bool = False,
617 history: bool = False,
618 ) -> list[Entry]:
619 """Find entries where fields match the given regex patterns.
621 All criteria are combined with AND logic. None means "any value".
623 Args:
624 title: Regex pattern to match against title
625 username: Regex pattern to match against username
626 password: Regex pattern to match against password
627 url: Regex pattern to match against URL
628 notes: Regex pattern to match against notes
629 otp: Regex pattern to match against OTP
630 recursive: Search in subgroups
631 case_sensitive: If False (default), matching is case-insensitive
632 history: Include history entries in search
634 Returns:
635 List of matching entries
637 Raises:
638 re.error: If any pattern is not a valid regex
639 """
640 # Pre-compile patterns for efficiency
641 flags = 0 if case_sensitive else re.IGNORECASE
642 patterns: dict[str, re.Pattern[str]] = {}
643 if title is not None:
644 patterns["title"] = re.compile(title, flags)
645 if username is not None:
646 patterns["username"] = re.compile(username, flags)
647 if password is not None:
648 patterns["password"] = re.compile(password, flags)
649 if url is not None:
650 patterns["url"] = re.compile(url, flags)
651 if notes is not None:
652 patterns["notes"] = re.compile(notes, flags)
653 if otp is not None:
654 patterns["otp"] = re.compile(otp, flags)
656 def matches(field_value: str | None, pattern: re.Pattern[str]) -> bool:
657 if field_value is None:
658 return False
659 return pattern.search(field_value) is not None
661 results: list[Entry] = []
662 for entry in self.iter_entries(recursive=recursive, history=history):
663 if "title" in patterns and not matches(entry.title, patterns["title"]):
664 continue
665 if "username" in patterns and not matches(entry.username, patterns["username"]):
666 continue
667 if "password" in patterns and not matches(entry.password, patterns["password"]):
668 continue
669 if "url" in patterns and not matches(entry.url, patterns["url"]):
670 continue
671 if "notes" in patterns and not matches(entry.notes, patterns["notes"]):
672 continue
673 if "otp" in patterns and not matches(entry.otp, patterns["otp"]):
674 continue
675 results.append(entry)
676 return results
678 def find_groups(
679 self,
680 name: str | None = None,
681 recursive: bool = True,
682 first: bool = False,
683 ) -> list[Group] | Group | None:
684 """Find groups matching criteria.
686 Args:
687 name: Match groups with this name (exact)
688 recursive: Search in nested subgroups
689 first: If True, return first matching group or None instead of list
691 Returns:
692 List of matching groups, or single Group/None if first=True
693 """
694 results: list[Group] = []
695 for group in self.iter_groups(recursive=recursive):
696 if name is not None and group.name != name:
697 continue
698 if first:
699 return group
700 results.append(group)
701 if first:
702 return None
703 return results
705 def dump(self, recursive: bool = False) -> str:
706 """Return a human-readable summary of the group for debugging.
708 Args:
709 recursive: If True, include subgroups and entries recursively
711 Returns:
712 Multi-line string with group details.
713 """
714 path_str = "/".join(self.path) if self.path else "(root)"
715 lines = [f'Group: "{path_str}"']
716 lines.append(f" UUID: {self.uuid}")
717 if self.notes:
718 notes_display = f"{self.notes[:50]}..." if len(self.notes) > 50 else self.notes
719 lines.append(f" Notes: {notes_display}")
720 lines.append(f" Entries: {len(self.entries)}")
721 lines.append(f" Subgroups: {len(self.subgroups)}")
722 lines.append(f" Created: {self.times.creation_time}")
723 lines.append(f" Modified: {self.times.last_modification_time}")
725 if recursive:
726 for entry in self.entries:
727 for line in entry.dump().split("\n"):
728 lines.append(" " + line)
729 for subgroup in self.subgroups:
730 for line in subgroup.dump(recursive=True).split("\n"):
731 lines.append(" " + line)
733 return "\n".join(lines)
735 def __str__(self) -> str:
736 path_str = "/".join(self.path) if self.path else "(root)"
737 return f'Group: "{path_str}"'
739 def __hash__(self) -> int:
740 return hash(self.uuid)
742 def __eq__(self, other: object) -> bool:
743 if isinstance(other, Group):
744 return self.uuid == other.uuid
745 return NotImplemented
747 @classmethod
748 def create_root(cls, name: str = "Root") -> Group:
749 """Create a root group for a new database.
751 Args:
752 name: Name for the root group
754 Returns:
755 New root Group instance
756 """
757 group = cls(name=name)
758 group._is_root = True
759 return group