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

1"""Group model for KDBX database folders.""" 

2 

3from __future__ import annotations 

4 

5import re 

6import uuid as uuid_module 

7from collections.abc import Iterator 

8from dataclasses import dataclass, field 

9from typing import TYPE_CHECKING 

10 

11from .entry import Entry 

12from .times import Times 

13 

14if TYPE_CHECKING: 

15 from ..database import Database 

16 from ..templates import EntryTemplate 

17 

18 

19@dataclass 

20class Group: 

21 """A group (folder) in a KDBX database. 

22 

23 Groups organize entries into a hierarchical structure. Each group can 

24 contain entries and subgroups. 

25 

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 """ 

41 

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) 

55 

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) 

62 

63 @property 

64 def parent(self) -> Group | None: 

65 """Get parent group, or None if this is the root.""" 

66 return self._parent 

67 

68 @property 

69 def database(self) -> Database | None: 

70 """Get the database this group belongs to.""" 

71 return self._database 

72 

73 @property 

74 def is_root_group(self) -> bool: 

75 """Check if this is the database root group.""" 

76 return self._is_root 

77 

78 @property 

79 def index(self) -> int: 

80 """Get the index of this group within its parent group. 

81 

82 Returns: 

83 Zero-based index of this group in the parent's subgroups list. 

84 

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) 

91 

92 @property 

93 def path(self) -> list[str]: 

94 """Get path from root to this group. 

95 

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 

109 

110 @property 

111 def expired(self) -> bool: 

112 """Check if group has expired.""" 

113 return self.times.expired 

114 

115 @property 

116 def custom_icon(self) -> uuid_module.UUID | None: 

117 """Get or set custom icon by UUID or name. 

118 

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. 

123 

124 Returns: 

125 UUID of the custom icon, or None if not set 

126 """ 

127 return self.custom_icon_uuid 

128 

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__}") 

147 

148 def touch(self, modify: bool = False) -> None: 

149 """Update access time, optionally modification time.""" 

150 self.times.touch(modify=modify) 

151 

152 def reindex(self, new_index: int) -> None: 

153 """Move this group to a new position within its parent group. 

154 

155 Args: 

156 new_index: Target position (zero-based). Negative indices are 

157 supported (e.g., -1 for last position). 

158 

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)") 

165 

166 subgroups = self._parent.subgroups 

167 current_index = subgroups.index(self) 

168 

169 # Handle negative indices 

170 if new_index < 0: 

171 new_index = len(subgroups) + new_index 

172 

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") 

176 

177 # No-op if already at target position 

178 if current_index == new_index: 

179 return 

180 

181 # Remove from current position and insert at new position 

182 subgroups.pop(current_index) 

183 subgroups.insert(new_index, self) 

184 

185 # --- Entry management --- 

186 

187 def add_entry(self, entry: Entry) -> Entry: 

188 """Add an entry to this group. 

189 

190 Args: 

191 entry: Entry to add 

192 

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 

201 

202 def remove_entry(self, entry: Entry) -> None: 

203 """Remove an entry from this group. 

204 

205 Args: 

206 entry: Entry to remove 

207 

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) 

216 

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. 

229 

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 

238 

239 Returns: 

240 Newly created entry 

241 

242 Example: 

243 >>> # Standard entry (no template) 

244 >>> entry = group.create_entry(title="Site", username="user", password="pass") 

245 

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 ... ) 

256 

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 

269 

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 

276 

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 ) 

287 

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 ) 

297 

298 return self.add_entry(entry) 

299 

300 # --- Subgroup management --- 

301 

302 def add_subgroup(self, group: Group) -> Group: 

303 """Add a subgroup to this group. 

304 

305 Args: 

306 group: Group to add 

307 

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 

317 

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) 

325 

326 def remove_subgroup(self, group: Group) -> None: 

327 """Remove a subgroup from this group. 

328 

329 Args: 

330 group: Group to remove 

331 

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) 

340 

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. 

348 

349 Args: 

350 name: Group name 

351 notes: Optional notes 

352 icon_id: Icon ID 

353 

354 Returns: 

355 Newly created group 

356 """ 

357 group = Group(name=name, notes=notes, icon_id=icon_id) 

358 return self.add_subgroup(group) 

359 

360 def move_to(self, destination: Group) -> None: 

361 """Move this group to a different parent group. 

362 

363 Removes the group from its current parent and adds it to the 

364 destination group. Updates the location_changed timestamp. 

365 

366 Args: 

367 destination: Target parent group to move this group to 

368 

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") 

384 

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)") 

388 

389 # Remove from current parent 

390 self._parent.subgroups.remove(self) 

391 old_parent = self._parent 

392 self._parent = None 

393 

394 # Add to new parent 

395 destination.subgroups.append(self) 

396 self._parent = destination 

397 

398 # Update timestamps 

399 self.times.update_location() 

400 old_parent.touch(modify=True) 

401 destination.touch(modify=True) 

402 

403 def _is_descendant(self, group: Group) -> bool: 

404 """Check if the given group is a descendant of this group. 

405 

406 Args: 

407 group: Group to check 

408 

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 

418 

419 # --- Iteration and search --- 

420 

421 def iter_entries(self, recursive: bool = True, history: bool = False) -> Iterator[Entry]: 

422 """Iterate over entries in this group. 

423 

424 Args: 

425 recursive: If True, include entries from all subgroups 

426 history: If True, include history entries 

427 

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) 

438 

439 def iter_groups(self, recursive: bool = True) -> Iterator[Group]: 

440 """Iterate over subgroups. 

441 

442 Args: 

443 recursive: If True, include nested subgroups 

444 

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) 

452 

453 def find_entry_by_uuid(self, uuid: uuid_module.UUID, recursive: bool = True) -> Entry | None: 

454 """Find an entry by UUID. 

455 

456 Args: 

457 uuid: Entry UUID to find 

458 recursive: Search in subgroups 

459 

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 

467 

468 def find_group_by_uuid(self, uuid: uuid_module.UUID, recursive: bool = True) -> Group | None: 

469 """Find a group by UUID. 

470 

471 Args: 

472 uuid: Group UUID to find 

473 recursive: Search in nested subgroups 

474 

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 

484 

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. 

502 

503 All criteria are combined with AND logic. None means "any value". 

504 

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 

519 

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 

551 

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. 

565 

566 All criteria are combined with AND logic. None means "any value". 

567 

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 

578 

579 Returns: 

580 List of matching entries 

581 """ 

582 

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() 

589 

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 

606 

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. 

620 

621 All criteria are combined with AND logic. None means "any value". 

622 

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 

633 

634 Returns: 

635 List of matching entries 

636 

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) 

655 

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 

660 

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 

677 

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. 

685 

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 

690 

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 

704 

705 def dump(self, recursive: bool = False) -> str: 

706 """Return a human-readable summary of the group for debugging. 

707 

708 Args: 

709 recursive: If True, include subgroups and entries recursively 

710 

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}") 

724 

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) 

732 

733 return "\n".join(lines) 

734 

735 def __str__(self) -> str: 

736 path_str = "/".join(self.path) if self.path else "(root)" 

737 return f'Group: "{path_str}"' 

738 

739 def __hash__(self) -> int: 

740 return hash(self.uuid) 

741 

742 def __eq__(self, other: object) -> bool: 

743 if isinstance(other, Group): 

744 return self.uuid == other.uuid 

745 return NotImplemented 

746 

747 @classmethod 

748 def create_root(cls, name: str = "Root") -> Group: 

749 """Create a root group for a new database. 

750 

751 Args: 

752 name: Name for the root group 

753 

754 Returns: 

755 New root Group instance 

756 """ 

757 group = cls(name=name) 

758 group._is_root = True 

759 return group