Skip to content

feat: refactor typing and add better diff#17

Open
FriendlyUser1 wants to merge 6 commits into
tomlin7:mainfrom
FriendlyUser1:conflict
Open

feat: refactor typing and add better diff#17
FriendlyUser1 wants to merge 6 commits into
tomlin7:mainfrom
FriendlyUser1:conflict

Conversation

@FriendlyUser1
Copy link
Copy Markdown
Contributor

@FriendlyUser1 FriendlyUser1 commented Jun 1, 2026

These changes include the typing enhancements from #16 plus new common / conflicting ui with key highlighting for better user experience

Resolves #13 and addresses #9

Summary by Sourcery

Improve KeePass database diff UI with separate common/conflicting item views and richer typed entry metadata.

New Features:

  • Add a typed EntryDetails structure and entry comparison helper to support field-level diffing between database entries.
  • Introduce separate UI tabs for exclusive, common, and conflicting items with conflict-aware entry detail views and key highlighting.

Bug Fixes:

  • Avoid treating the root group as a regular group when building paths and merging, preventing incorrect group handling.
  • Safely handle cases where databases have no entries or groups to avoid runtime errors.

Enhancements:

  • Refine merge logic to search groups by name and validate root_group type for more robust group navigation.
  • Update entry details modal to use typed data and visually highlight conflicting fields in the diff view.
  • Tidy tests and dummy objects to align with the new typing and group behavior.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Jun 1, 2026

Reviewer's Guide

Refactors typing across the database, comparison, and UI layers, introduces a structured EntryDetails type, and enhances the Streamlit UI to distinguish exclusive, common, and conflicting KeePass items with field‑level conflict highlighting.

Sequence diagram for viewing conflicting KeePass entries with field-level highlighting

sequenceDiagram
    actor User
    participant StreamlitApp as app.toggle_entry
    participant DatabaseUtils as get_entry_details
    participant Comparison as compare_entries
    participant EntryModal as show_entry_details

    User->>StreamlitApp: Click Conflicting Items tab
    loop For each entry in differences["common_entries"]
        StreamlitApp->>DatabaseUtils: get_entry_details(kp1, entry)
        StreamlitApp->>DatabaseUtils: get_entry_details(kp2, entry)
        StreamlitApp->>Comparison: compare_entries(entry1_details, entry2_details)
        Comparison-->>StreamlitApp: conflicts (List[str])
        alt conflicts not empty
            User->>StreamlitApp: Click view buttons for DB1/DB2
            StreamlitApp->>EntryModal: show_entry_details(entry_details, key, conflicts)
            EntryModal-->>User: Render details with conflicting fields highlighted
        end
    end
Loading

File-Level Changes

Change Details Files
Introduce structured entry typing and safer KeePass object handling in the database utilities.
  • Define an EntryDetails TypedDict capturing all relevant entry fields including timestamps and path.
  • Update get_entry_details to return EntryDetails
None and guard iteration over kp.entries.
  • Adjust get_entries_set and merge_entry to handle missing entries/groups safely and use Group APIs (is_root_group, name) for correctness.
  • Add a Group type import and validate target_kp.root_group is a Group before subgroup traversal.
  • Enhance comparison utilities with typed results and per-field entry diffing.
    • Tighten compare_databases return type to Dict[str, List[str]] while preserving behavior.
    • Add compare_entries to compute a list of differing EntryDetails keys between two entries, handling None safely.
    KeePassDiff/utils/comparison.py
    Refactor the Streamlit UI to separate exclusive/common/conflicting items and surface field‑level conflicts in entry views.
    • Expand the main results tabs to four: Exclusive Items, Common Items, Conflicting Items, and Merge & Export.
    • In the Common tab, prefetch entry details, filter out non-conflicting common entries via compare_entries, and reuse fetched data when rendering details.
    • Add a dedicated Conflicting tab that iterates common entries, computes conflicts, and passes the list of differing fields into show_entry_details for both DBs; hide the tab content when no conflicts exist.
    • Adjust merge buttons and messaging, and keep existing merge/export behavior under the new tab indices.
    KeePassDiff/app.py
    Upgrade entry details modal to use strong typing and highlight conflicting fields.
    • Type show_entry_details to accept EntryDetails and an optional list of conflicting field names.
    • Introduce a helper markdown wrapper that substitutes field values and applies :red-background styling to conflicted fields.
    • Rework the notes text area to use a fixed label with collapsed visibility and typed key handling.
    KeePassDiff/components/entry_modal.py
    Align tests with new group behavior and formatting.
    • Extend DummyGroup with an is_root_group property to satisfy new database utility checks.
    • Simplify assertion formatting in test_diff_and_merge_on_programmatic_db to match updated style while preserving expectations.
    tests/test_keepassdiff.py

    Tips and commands

    Interacting with Sourcery

    • Trigger a new review: Comment @sourcery-ai review on the pull request.
    • Continue discussions: Reply directly to Sourcery's review comments.
    • Generate a GitHub issue from a review comment: Ask Sourcery to create an
      issue from a review comment by replying to it. You can also reply to a
      review comment with @sourcery-ai issue to create an issue from it.
    • Generate a pull request title: Write @sourcery-ai anywhere in the pull
      request title to generate a title at any time. You can also comment
      @sourcery-ai title on the pull request to (re-)generate the title at any time.
    • Generate a pull request summary: Write @sourcery-ai summary anywhere in
      the pull request body to generate a PR summary at any time exactly where you
      want it. You can also comment @sourcery-ai summary on the pull request to
      (re-)generate the summary at any time.
    • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
      request to (re-)generate the reviewer's guide at any time.
    • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
      pull request to resolve all Sourcery comments. Useful if you've already
      addressed all the comments and don't want to see them anymore.
    • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
      request to dismiss all existing Sourcery reviews. Especially useful if you
      want to start fresh with a new review - don't forget to comment
      @sourcery-ai review to trigger a new review!

    Customizing Your Experience

    Access your dashboard to:

    • Enable or disable review features such as the Sourcery-generated pull request
      summary, the reviewer's guide, and others.
    • Change the review language.
    • Add, remove or edit custom review instructions.
    • Adjust other review settings.

    Getting Help

    Copy link
    Copy Markdown

    @sourcery-ai sourcery-ai Bot left a comment

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    Hey - I've found 5 issues, and left some high level feedback:

    • In the 'Common Entries' tab logic, compare_entries(entry1_details, entry2_details) == 1 will never be true because compare_entries returns a list of differing keys; you probably want to check for an empty/non-empty list (e.g. if compare_entries(...): or if len(...) > 0:) to distinguish conflicting from identical entries.
    • In the 'Conflicting Entries' tab, conflicts is overwritten on each loop iteration and then only the last entry's conflicts are used to decide whether to show 'No conflicting entries found'; consider tracking a separate boolean or accumulating conflicts so the final message reflects all entries, not just the last one.
    • For compare_entries, consider adding an explicit return type (e.g. List[str]) and using is None instead of == None to match the rest of the typing-focused refactor and avoid subtle type/identity pitfalls.
    Prompt for AI Agents
    Please address the comments from this code review:
    
    ## Overall Comments
    - In the 'Common Entries' tab logic, `compare_entries(entry1_details, entry2_details) == 1` will never be true because `compare_entries` returns a list of differing keys; you probably want to check for an empty/non-empty list (e.g. `if compare_entries(...):` or `if len(...) > 0:`) to distinguish conflicting from identical entries.
    - In the 'Conflicting Entries' tab, `conflicts` is overwritten on each loop iteration and then only the last entry's conflicts are used to decide whether to show 'No conflicting entries found'; consider tracking a separate boolean or accumulating conflicts so the final message reflects all entries, not just the last one.
    - For `compare_entries`, consider adding an explicit return type (e.g. `List[str]`) and using `is None` instead of `== None` to match the rest of the typing-focused refactor and avoid subtle type/identity pitfalls.
    
    ## Individual Comments
    
    ### Comment 1
    <location path="KeePassDiff/utils/comparison.py" line_range="6-11" />
    <code_context>
    +from KeePassDiff.utils.database import EntryDetails
    
    
     def compare_databases(
         db1_data: Dict[str, Set[str]], db2_data: Dict[str, Set[str]]
    -) -> Dict:
    </code_context>
    <issue_to_address>
    **issue (bug_risk):** "groups_only_in_db2" uses db2 - db2, which will always be empty.
    
    `groups_only_in_db2` is calculated as `db2_data["groups"] - db2_data["groups"]`, which is always an empty set and makes this part of the comparison ineffective. This likely needs to be `db2_data["groups"] - db1_data["groups"]` to match the logic used elsewhere.
    </issue_to_address>
    
    ### Comment 2
    <location path="KeePassDiff/app.py" line_range="148-155" />
    <code_context>
    -
                 with tabs[1]:
    -                st.subheader("Conflicting Entries")
    +                st.subheader("Common Entries")
                     if differences["common_entries"]:
    +                    conflicts = False
                         for entry in differences["common_entries"]:
    +
    +                        entry1_details = get_entry_details(kp1, entry)
    +                        entry2_details = get_entry_details(kp2, entry)
    +                        if compare_entries(entry1_details, entry2_details) == 1:
    +                            continue
    +                        conflicts = True
    </code_context>
    <issue_to_address>
    **issue (bug_risk):** compare_entries is treated as returning an int, but it actually returns a list.
    
    Here `compare_entries(entry1_details, entry2_details) == 1` is always `False` because `compare_entries` returns a list of conflicting field names (or `[]`), not an int. As a result, `continue` never runs and all common entries are shown. If you want to skip entries with conflicts and only show identical ones, this should instead check for an empty list (e.g. `if compare_entries(...) != []:` or `if len(compare_entries(...)) > 0:`), depending on the exact semantics you want.
    </issue_to_address>
    
    ### Comment 3
    <location path="KeePassDiff/app.py" line_range="144-153" />
    <code_context>
                             st.write("None")
    -
                 with tabs[1]:
    -                st.subheader("Conflicting Entries")
    +                st.subheader("Common Entries")
                     if differences["common_entries"]:
    </code_context>
    <issue_to_address>
    **issue (bug_risk):** The `conflicts` variable is reused per-entry but also used as a global flag, which can misreport whether any conflicts exist.
    
    Within the conflicting entries tab, `conflicts` is overwritten on each loop iteration with `compare_entries`’ result, then `if len(conflicts) == 0:` is used afterward to decide whether to show "No conflicting entries found". This only reflects the last entry processed—earlier conflicts are ignored if the final entry has none. Track a separate flag (e.g. `any_conflicts = any_conflicts or bool(conflicts)`) to accurately determine whether any conflicts were found overall.
    </issue_to_address>
    
    ### Comment 4
    <location path="tests/test_keepassdiff.py" line_range="100-101" />
    <code_context>
             def __init__(self):
                 self.path = [None, "group"]
    
    +        @property
    +        def is_root_group(self):
    +            return False
    +
    </code_context>
    <issue_to_address>
    **suggestion (testing):** Add a test case that covers the `is_root_group` behaviour for root vs non-root groups in `get_entries_set`.
    
    Since the double now exposes `is_root_group`, please add a test KeePass fixture containing both a root group (`is_root_group == True`) and a non-root subgroup. Assert that the root group path is excluded from `groups` and the non-root subgroup is included, so changes to the root-group detection logic are caught by tests.
    
    Suggested implementation:
    
    ```python
            def __init__(self):
                # default non-root group used in most tests
                self.path = [None, "group"]
    
            @property
            def is_root_group(self):
                return False
    
    
        class DummyRootGroup(DummyGroup):
            def __init__(self):
                # path corresponding to the KeePass root group
                self.path = [None]
    
            @property
            def is_root_group(self):
                return True
    
    
        class DummyKP:
            entries = [DummyEntry()]
            # include both a root group and a non-root subgroup to exercise
            # root-group filtering logic in get_entries_set
            groups = [DummyRootGroup(), DummyGroup()]
    
        expected_db2_entries = db2_data["entries"]
        assert expected_db1_entries == {"a", "b"}, f"db1 entries: {expected_db1_entries}"
        assert expected_db2_entries == {"b", "c"}, f"db2 entries: {expected_db2_entries}"
    
    
    def test_get_entries_set_filters_root_group_from_groups():
        """
        Ensure that get_entries_set excludes the root group from the groups set,
        while still including non-root subgroups.
        """
        kp = DummyKP()
    
        db_data = get_entries_set(kp)
        groups = db_data["groups"]
    
        # root group (with only [None] as its path) should not appear in groups
        assert "" not in groups
    
        # non-root subgroup from DummyGroup (path [None, "group"]) should be present
        assert "group" in groups
    
    ```
    
    1. Ensure `get_entries_set` is imported into `tests/test_keepassdiff.py` (for example: `from keepassdiff import get_entries_set`) if it is not already.
    2. If `get_entries_set` represents groups differently (e.g. including full paths like `"group/subgroup"` or using tuples), adjust the assertions in `test_get_entries_set_filters_root_group_from_groups` accordingly:
       - Replace `assert "" not in groups` with a check that the representation of the root group is absent.
       - Replace `assert "group" in groups` with the correct expected representation of the non-root subgroup.
    </issue_to_address>
    
    ### Comment 5
    <location path="tests/test_keepassdiff.py" line_range="163" />
    <code_context>
    @@ -148,15 +152,15 @@ def test_diff_and_merge_on_programmatic_db():
    </code_context>
    <issue_to_address>
    **suggestion (testing):** Extend database comparison tests to validate all fields in the `differences` dict, not just the entries-related ones.
    
    Since `compare_databases` now returns a full `Dict[str, List[str]]`, including group-related keys, add assertions for `groups_only_in_db1`, `groups_only_in_db2`, and `common_groups` as well. In `test_diff_and_merge_on_programmatic_db`, explicitly check the expected values (or emptiness) of these keys so changes to group comparison logic are detected by the tests.
    </issue_to_address>

    Sourcery is free for open source - if you like our reviews please consider sharing them ✨
    Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

    Comment thread KeePassDiff/utils/comparison.py
    Comment thread KeePassDiff/app.py Outdated
    Comment thread KeePassDiff/app.py
    Comment thread tests/test_keepassdiff.py
    Comment thread tests/test_keepassdiff.py
    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

    Labels

    None yet

    Projects

    None yet

    Development

    Successfully merging this pull request may close these issues.

    all entries shown as conflicting?

    1 participant