Merge pull request #710 from PR0M3TH3AN/codex/add-entry-type-to-search-results

Include entry type in search results
This commit is contained in:
thePR0M3TH3AN
2025-08-02 16:34:01 -04:00
committed by GitHub
21 changed files with 106 additions and 63 deletions

View File

@@ -59,6 +59,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- **Quick Unlock:** Optionally skip the password prompt after verifying once.
- **Secret Mode:** When enabled, newly generated and retrieved passwords are copied to your clipboard and automatically cleared after a delay.
- **Tagging Support:** Organize entries with optional tags and find them quickly via search.
- **Typed Search Results:** Results now display each entry's type for quicker identification.
- **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API.
- **Parent Seed Backup:** Securely save an encrypted copy of the master seed.
- **Manual Vault Locking:** Instantly clear keys from memory when needed.
@@ -235,6 +236,7 @@ seedpass import --file "~/seedpass_backup.json"
seedpass search "github"
seedpass search --tags "work,personal"
seedpass get "github"
# Search results show the entry type, e.g. "1: Password - GitHub"
# Retrieve a TOTP entry
seedpass entry get "email"
# The code is printed and copied to your clipboard

View File

@@ -136,7 +136,7 @@ Run or stop the local HTTP API.
### `entry` Commands
- **`seedpass entry list`** List entries in the vault, optionally sorted or filtered.
- **`seedpass entry search <query>`** Search across labels, usernames, URLs and notes.
- **`seedpass entry search <query>`** Search across labels, usernames, URLs and notes. Results show the entry type before each label.
- **`seedpass entry get <query>`** Retrieve the password or TOTP code for one matching entry, depending on the entry's type.
- **`seedpass entry add <label>`** Create a new password entry. Use `--length` and flags like `--no-special`, `--special-mode safe`, or `--exclude-ambiguous` to override the global policy.
- **`seedpass entry add-totp <label>`** Create a TOTP entry. Use `--secret` to import an existing secret or `--index` to derive from the seed.

View File

@@ -70,6 +70,7 @@ maintainable while enabling a consistent experience on multiple platforms.
- **Quick Unlock:** Optionally skip the password prompt after verifying once. Startup delay is unaffected.
- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay.
- **Tagging Support:** Organize entries with optional tags and find them quickly via search.
- **Typed Search Results:** Searches display each entry's type for easier scanning.
- **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API.
- **Parent Seed Backup:** Securely save an encrypted copy of the master seed.
- **Manual Vault Locking:** Instantly clear keys from memory when needed.
@@ -219,6 +220,7 @@ seedpass vault import --file "~/seedpass_backup.json"
seedpass search "github"
seedpass search --tags "work,personal"
seedpass get "github"
# Search results show the entry type, e.g. "1: Password - GitHub"
# Retrieve a TOTP entry
seedpass entry get "email"
# The code is printed and copied to your clipboard

View File

@@ -342,31 +342,28 @@ def handle_display_stats(password_manager: PasswordManager) -> None:
def print_matches(
password_manager: PasswordManager,
matches: list[tuple[int, str, str | None, str | None, bool]],
matches: list[tuple[int, str, str | None, str | None, bool, EntryType]],
) -> None:
"""Print a list of search matches."""
print(colored("\n[+] Matches:\n", "green"))
for entry in matches:
idx, website, username, url, blacklisted = entry
idx, website, username, url, blacklisted, etype = entry
data = password_manager.entry_manager.retrieve_entry(idx)
etype = (
data.get("type", data.get("kind", EntryType.PASSWORD.value))
if data
else EntryType.PASSWORD.value
)
print(color_text(f"Index: {idx}", "index"))
if etype == EntryType.TOTP.value:
print(color_text(f" Label: {data.get('label', website)}", "index"))
print(color_text(f" Derivation Index: {data.get('index', idx)}", "index"))
elif etype == EntryType.SEED.value:
if etype == EntryType.TOTP:
label = data.get("label", website) if data else website
deriv = data.get("index", idx) if data else idx
print(color_text(f" Label: {label}", "index"))
print(color_text(f" Derivation Index: {deriv}", "index"))
elif etype == EntryType.SEED:
print(color_text(" Type: Seed Phrase", "index"))
elif etype == EntryType.SSH.value:
elif etype == EntryType.SSH:
print(color_text(" Type: SSH Key", "index"))
elif etype == EntryType.PGP.value:
elif etype == EntryType.PGP:
print(color_text(" Type: PGP Key", "index"))
elif etype == EntryType.NOSTR.value:
elif etype == EntryType.NOSTR:
print(color_text(" Type: Nostr Key", "index"))
elif etype == EntryType.KEY_VALUE.value:
elif etype == EntryType.KEY_VALUE:
print(color_text(" Type: Key/Value", "index"))
else:
if website:

View File

@@ -86,8 +86,9 @@ def search_entry(query: str, authorization: str | None = Header(None)) -> List[A
"username": username,
"url": url,
"archived": archived,
"type": etype.value,
}
for idx, label, username, url, archived in results
for idx, label, username, url, archived, etype in results
]

View File

@@ -161,8 +161,8 @@ def entry_search(
if not results:
typer.echo("No matching entries found")
return
for idx, label, username, url, _arch in results:
line = f"{idx}: {label}"
for idx, label, username, url, _arch, etype in results:
line = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}"
if username:
line += f" ({username})"
if url:
@@ -180,8 +180,8 @@ def entry_get(ctx: typer.Context, query: str) -> None:
raise typer.Exit(code=1)
if len(matches) > 1:
typer.echo("Matches:")
for idx, label, username, _url, _arch in matches:
name = f"{idx}: {label}"
for idx, label, username, _url, _arch, etype in matches:
name = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}"
if username:
name += f" ({username})"
typer.echo(name)

View File

@@ -17,6 +17,7 @@ from pydantic import BaseModel
from .manager import PasswordManager
from .pubsub import bus
from .entry_types import EntryType
class VaultExportRequest(BaseModel):
@@ -274,7 +275,7 @@ class EntryService:
def search_entries(
self, query: str, kinds: list[str] | None = None
) -> list[tuple[int, str, str | None, str | None, bool]]:
) -> list[tuple[int, str, str | None, str | None, bool, EntryType]]:
"""Search entries optionally filtering by ``kinds``.
Parameters

View File

@@ -1210,8 +1210,12 @@ class EntryManager:
def search_entries(
self, query: str, kinds: List[str] | None = None
) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
"""Return entries matching ``query`` across whitelisted metadata fields."""
) -> List[Tuple[int, str, Optional[str], Optional[str], bool, EntryType]]:
"""Return entries matching ``query`` across whitelisted metadata fields.
Each match is represented as ``(index, label, username, url, archived, etype)``
where ``etype`` is the :class:`EntryType` of the entry.
"""
data = self._load_index()
entries_data = data.get("entries", {})
@@ -1220,19 +1224,23 @@ class EntryManager:
return []
query_lower = query.lower()
results: List[Tuple[int, str, Optional[str], Optional[str], bool]] = []
results: List[
Tuple[int, str, Optional[str], Optional[str], bool, EntryType]
] = []
for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])):
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
etype = EntryType(
entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
)
if kinds is not None and etype not in kinds:
if kinds is not None and etype.value not in kinds:
continue
label = entry.get("label", entry.get("website", ""))
username = (
entry.get("username", "") if etype == EntryType.PASSWORD.value else None
entry.get("username", "") if etype == EntryType.PASSWORD else None
)
url = entry.get("url", "") if etype == EntryType.PASSWORD.value else None
url = entry.get("url", "") if etype == EntryType.PASSWORD else None
tags = entry.get("tags", [])
archived = entry.get("archived", entry.get("blacklisted", False))
@@ -1249,6 +1257,7 @@ class EntryManager:
username if username is not None else None,
url if url is not None else None,
archived,
etype,
)
)

View File

@@ -3379,11 +3379,12 @@ class PasswordManager:
child_fingerprint=child_fp,
)
print(colored("\n[+] Search Results:\n", "green"))
for idx, label, username, _url, _b in results:
for idx, label, username, _url, _b, etype in results:
display_label = label
if username:
display_label += f" ({username})"
print(colored(f"{idx}. {display_label}", "cyan"))
type_name = etype.value.replace("_", " ").title()
print(colored(f"{idx}. {type_name} - {display_label}", "cyan"))
idx_input = input(
"Enter index to view details or press Enter to go back: "

View File

@@ -351,7 +351,7 @@ class SearchDialog(toga.Window):
query = self.query_input.value or ""
results = self.main.entries.search_entries(query)
self.main.entry_source.clear()
for idx, label, username, url, _arch in results:
for idx, label, username, url, _arch, _etype in results:
self.main.entry_source.append(
{
"id": idx,

View File

@@ -8,13 +8,16 @@ from fastapi.testclient import TestClient
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass import api
from seedpass.core.entry_types import EntryType
@pytest.fixture
def client(monkeypatch):
dummy = SimpleNamespace(
entry_manager=SimpleNamespace(
search_entries=lambda q: [(1, "Site", "user", "url", False)],
search_entries=lambda q: [
(1, "Site", "user", "url", False, EntryType.PASSWORD)
],
retrieve_entry=lambda i: {"label": "Site"},
add_entry=lambda *a, **k: 1,
modify_entry=lambda *a, **k: None,

View File

@@ -9,6 +9,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.entry_types import EntryType
def setup_entry_mgr(tmp_path: Path) -> EntryManager:
@@ -26,7 +27,9 @@ def test_archive_nonpassword_list_search():
idx = em.search_entries("Example")[0][0]
assert em.list_entries() == [(idx, "Example", None, None, False)]
assert em.search_entries("Example") == [(idx, "Example", None, None, False)]
assert em.search_entries("Example") == [
(idx, "Example", None, None, False, EntryType.TOTP)
]
em.archive_entry(idx)
assert em.retrieve_entry(idx)["archived"] is True
@@ -34,9 +37,13 @@ def test_archive_nonpassword_list_search():
assert em.list_entries(include_archived=True) == [
(idx, "Example", None, None, True)
]
assert em.search_entries("Example") == [(idx, "Example", None, None, True)]
assert em.search_entries("Example") == [
(idx, "Example", None, None, True, EntryType.TOTP)
]
em.restore_entry(idx)
assert em.retrieve_entry(idx)["archived"] is False
assert em.list_entries() == [(idx, "Example", None, None, False)]
assert em.search_entries("Example") == [(idx, "Example", None, None, False)]
assert em.search_entries("Example") == [
(idx, "Example", None, None, False, EntryType.TOTP)
]

View File

@@ -14,6 +14,7 @@ from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.manager import PasswordManager, EncryptionMode
from seedpass.core.entry_types import EntryType
def setup_entry_mgr(tmp_path: Path) -> EntryManager:
@@ -31,7 +32,7 @@ def test_archive_restore_affects_listing_and_search():
assert em.list_entries() == [(idx, "example.com", "alice", "", False)]
assert em.search_entries("example") == [
(idx, "example.com", "alice", "", False)
(idx, "example.com", "alice", "", False, EntryType.PASSWORD)
]
em.archive_entry(idx)
@@ -40,13 +41,15 @@ def test_archive_restore_affects_listing_and_search():
assert em.list_entries(include_archived=True) == [
(idx, "example.com", "alice", "", True)
]
assert em.search_entries("example") == [(idx, "example.com", "alice", "", True)]
assert em.search_entries("example") == [
(idx, "example.com", "alice", "", True, EntryType.PASSWORD)
]
em.restore_entry(idx)
assert em.retrieve_entry(idx)["archived"] is False
assert em.list_entries() == [(idx, "example.com", "alice", "", False)]
assert em.search_entries("example") == [
(idx, "example.com", "alice", "", False)
(idx, "example.com", "alice", "", False, EntryType.PASSWORD)
]

View File

@@ -5,6 +5,7 @@ from typer.testing import CliRunner
from seedpass import cli
from seedpass.cli import app
from seedpass.core.entry_types import EntryType
runner = CliRunner()
@@ -34,7 +35,7 @@ def test_cli_entry_add_search_sync(monkeypatch):
def search_entries(q, kinds=None):
calls["search"] = (q, kinds)
return [(1, "Label", None, None, False)]
return [(1, "Label", None, None, False, EntryType.PASSWORD)]
def start_background_vault_sync():
calls["sync"] = True

View File

@@ -17,7 +17,9 @@ class DummyPM:
list_entries=lambda sort_by="index", filter_kind=None, include_archived=False: [
(1, "Label", "user", "url", False)
],
search_entries=lambda q, kinds=None: [(1, "GitHub", "user", "", False)],
search_entries=lambda q, kinds=None: [
(1, "GitHub", "user", "", False, EntryType.PASSWORD)
],
retrieve_entry=lambda idx: {"type": EntryType.PASSWORD.value, "length": 8},
get_totp_code=lambda idx, seed: "123456",
add_entry=lambda label, length, username, url, **kwargs: 1,

View File

@@ -27,7 +27,7 @@ def make_pm(search_results, entry=None, totp_code="123456"):
def test_search_command(monkeypatch, capsys):
pm = make_pm([(0, "Example", "user", "", False)])
pm = make_pm([(0, "Example", "user", "", False, EntryType.PASSWORD)])
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
monkeypatch.setattr(main, "initialize_app", lambda: None)
@@ -40,7 +40,7 @@ def test_search_command(monkeypatch, capsys):
def test_get_command(monkeypatch, capsys):
entry = {"type": EntryType.PASSWORD.value, "length": 8}
pm = make_pm([(0, "Example", "user", "", False)], entry=entry)
pm = make_pm([(0, "Example", "user", "", False, EntryType.PASSWORD)], entry=entry)
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
monkeypatch.setattr(main, "initialize_app", lambda: None)
@@ -53,7 +53,7 @@ def test_get_command(monkeypatch, capsys):
def test_totp_command(monkeypatch, capsys):
entry = {"type": EntryType.TOTP.value, "period": 30, "index": 0}
pm = make_pm([(0, "Example", None, None, False)], entry=entry)
pm = make_pm([(0, "Example", None, None, False, EntryType.TOTP)], entry=entry)
called = {}
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
@@ -83,7 +83,10 @@ def test_search_command_no_results(monkeypatch, capsys):
def test_get_command_multiple_matches(monkeypatch, capsys):
matches = [(0, "Example", "user", "", False), (1, "Ex2", "bob", "", False)]
matches = [
(0, "Example", "user", "", False, EntryType.PASSWORD),
(1, "Ex2", "bob", "", False, EntryType.PASSWORD),
]
pm = make_pm(matches)
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
@@ -97,7 +100,7 @@ def test_get_command_multiple_matches(monkeypatch, capsys):
def test_get_command_wrong_type(monkeypatch, capsys):
entry = {"type": EntryType.TOTP.value}
pm = make_pm([(0, "Example", "user", "", False)], entry=entry)
pm = make_pm([(0, "Example", None, None, False, EntryType.TOTP)], entry=entry)
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
monkeypatch.setattr(main, "initialize_app", lambda: None)
@@ -109,7 +112,10 @@ def test_get_command_wrong_type(monkeypatch, capsys):
def test_totp_command_multiple_matches(monkeypatch, capsys):
matches = [(0, "GH", None, None, False), (1, "Git", None, None, False)]
matches = [
(0, "GH", None, None, False, EntryType.TOTP),
(1, "Git", None, None, False, EntryType.TOTP),
]
pm = make_pm(matches)
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
@@ -123,7 +129,7 @@ def test_totp_command_multiple_matches(monkeypatch, capsys):
def test_totp_command_wrong_type(monkeypatch, capsys):
entry = {"type": EntryType.PASSWORD.value, "length": 8}
pm = make_pm([(0, "Example", "user", "", False)], entry=entry)
pm = make_pm([(0, "Example", "user", "", False, EntryType.PASSWORD)], entry=entry)
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
monkeypatch.setattr(main, "initialize_app", lambda: None)

View File

@@ -2,6 +2,7 @@ import types
from types import SimpleNamespace
from seedpass.core.api import VaultService, EntryService, SyncService, UnlockRequest
from seedpass.core.entry_types import EntryType
def test_vault_service_unlock():
@@ -27,7 +28,7 @@ def test_entry_service_add_entry_and_search():
def search_entries(q, kinds=None):
called["search"] = (q, kinds)
return [(5, "Example", username, url, False)]
return [(5, "Example", username, url, False, EntryType.PASSWORD)]
def start_background_vault_sync():
called["sync"] = True
@@ -47,7 +48,7 @@ def test_entry_service_add_entry_and_search():
assert called.get("sync") is True
results = service.search_entries("ex", kinds=["password"])
assert results == [(5, "Example", username, url, False)]
assert results == [(5, "Example", username, url, False, EntryType.PASSWORD)]
assert called["search"] == ("ex", ["password"])

View File

@@ -46,6 +46,6 @@ def test_search_entries_prompt_for_details(monkeypatch, capsys):
pm.handle_search_entries()
out = capsys.readouterr().out
assert "0. Example" in out
assert "0. Totp - Example" in out
assert "Label: Example" in out
assert "Period: 30s" in out

View File

@@ -28,7 +28,7 @@ def test_search_by_website():
entry_mgr.add_entry("Other.com", 8, "bob")
result = entry_mgr.search_entries("example")
assert result == [(idx0, "Example.com", "alice", "", False)]
assert result == [(idx0, "Example.com", "alice", "", False, EntryType.PASSWORD)]
def test_search_by_username():
@@ -40,7 +40,7 @@ def test_search_by_username():
idx1 = entry_mgr.add_entry("Test.com", 8, "Bob")
result = entry_mgr.search_entries("bob")
assert result == [(idx1, "Test.com", "Bob", "", False)]
assert result == [(idx1, "Test.com", "Bob", "", False, EntryType.PASSWORD)]
def test_search_by_url():
@@ -52,7 +52,9 @@ def test_search_by_url():
entry_mgr.add_entry("Other", 8)
result = entry_mgr.search_entries("login")
assert result == [(idx, "Example", "", "https://ex.com/login", False)]
assert result == [
(idx, "Example", "", "https://ex.com/login", False, EntryType.PASSWORD)
]
def test_search_by_notes_and_totp():
@@ -117,7 +119,7 @@ def test_search_by_tag_password():
idx = entry_mgr.add_entry("TaggedSite", 8, tags=["work"])
result = entry_mgr.search_entries("work")
assert result == [(idx, "TaggedSite", "", "", False)]
assert result == [(idx, "TaggedSite", "", "", False, EntryType.PASSWORD)]
def test_search_by_tag_totp():
@@ -129,7 +131,7 @@ def test_search_by_tag_totp():
idx = entry_mgr.search_entries("OTPAccount")[0][0]
result = entry_mgr.search_entries("mfa")
assert result == [(idx, "OTPAccount", None, None, False)]
assert result == [(idx, "OTPAccount", None, None, False, EntryType.TOTP)]
def test_search_with_kind_filter():
@@ -147,4 +149,4 @@ def test_search_with_kind_filter():
assert {r[0] for r in all_results} == {idx_pw, idx_totp}
only_pw = entry_mgr.search_entries("", kinds=[EntryType.PASSWORD.value])
assert only_pw == [(idx_pw, "Site", "", "", False)]
assert only_pw == [(idx_pw, "Site", "", "", False, EntryType.PASSWORD)]

View File

@@ -9,6 +9,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.entry_types import EntryType
def setup_entry_manager(tmp_path: Path) -> EntryManager:
@@ -29,7 +30,7 @@ def test_tags_persist_on_new_entry():
entry_mgr = setup_entry_manager(tmp_path)
result = entry_mgr.search_entries("work")
assert result == [(idx, "Site", "", "", False)]
assert result == [(idx, "Site", "", "", False, EntryType.PASSWORD)]
def test_tags_persist_after_modify():
@@ -41,9 +42,11 @@ def test_tags_persist_after_modify():
entry_mgr.modify_entry(idx, tags=["personal"])
# Ensure tag searchable before reload
assert entry_mgr.search_entries("personal") == [(idx, "Site", "", "", False)]
assert entry_mgr.search_entries("personal") == [
(idx, "Site", "", "", False, EntryType.PASSWORD)
]
# Reinitialize to simulate application restart
entry_mgr = setup_entry_manager(tmp_path)
result = entry_mgr.search_entries("personal")
assert result == [(idx, "Site", "", "", False)]
assert result == [(idx, "Site", "", "", False, EntryType.PASSWORD)]

View File

@@ -34,19 +34,21 @@ def test_entry_list(monkeypatch):
def test_entry_search(monkeypatch):
pm = SimpleNamespace(
entry_manager=SimpleNamespace(
search_entries=lambda q, kinds=None: [(1, "L", None, None, False)]
search_entries=lambda q, kinds=None: [
(1, "L", None, None, False, EntryType.PASSWORD)
]
),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["entry", "search", "l"])
assert result.exit_code == 0
assert "1: L" in result.stdout
assert "Password - L" in result.stdout
def test_entry_get_password(monkeypatch):
def search(q, kinds=None):
return [(2, "Example", "", "", False)]
return [(2, "Example", "", "", False, EntryType.PASSWORD)]
entry = {"type": EntryType.PASSWORD.value, "length": 8}
pm = SimpleNamespace(