pbnightingale 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. pbnightingale-0.2.1/.claude/scheduled_tasks.lock +1 -0
  2. pbnightingale-0.2.1/.github/workflows/ci.yml +172 -0
  3. pbnightingale-0.2.1/.gitignore +53 -0
  4. pbnightingale-0.2.1/.markdownlint-cli2.yaml +12 -0
  5. pbnightingale-0.2.1/.pre-commit-config.yaml +30 -0
  6. pbnightingale-0.2.1/.readthedocs.yaml +20 -0
  7. pbnightingale-0.2.1/CHANGELOG.md +85 -0
  8. pbnightingale-0.2.1/CLAUDE.md +218 -0
  9. pbnightingale-0.2.1/CODING.md +2029 -0
  10. pbnightingale-0.2.1/LICENSE +674 -0
  11. pbnightingale-0.2.1/LICENSE-DOCS +437 -0
  12. pbnightingale-0.2.1/LICENSE-ICONS +437 -0
  13. pbnightingale-0.2.1/Makefile +292 -0
  14. pbnightingale-0.2.1/PKG-INFO +125 -0
  15. pbnightingale-0.2.1/README.md +86 -0
  16. pbnightingale-0.2.1/babel.cfg +2 -0
  17. pbnightingale-0.2.1/docs/_static/.gitkeep +0 -0
  18. pbnightingale-0.2.1/docs/_static/custom.css +26 -0
  19. pbnightingale-0.2.1/docs/api.rst +274 -0
  20. pbnightingale-0.2.1/docs/conf.py +258 -0
  21. pbnightingale-0.2.1/docs/index.rst +37 -0
  22. pbnightingale-0.2.1/docs/locale/fr/LC_MESSAGES/index.po +64 -0
  23. pbnightingale-0.2.1/docs/locale/fr/LC_MESSAGES/manual/concepts.po +408 -0
  24. pbnightingale-0.2.1/docs/locale/fr/LC_MESSAGES/manual/email_clients.po +219 -0
  25. pbnightingale-0.2.1/docs/locale/fr/LC_MESSAGES/manual/first_key.po +125 -0
  26. pbnightingale-0.2.1/docs/locale/fr/LC_MESSAGES/manual/importing_keys.po +200 -0
  27. pbnightingale-0.2.1/docs/locale/fr/LC_MESSAGES/manual/index.po +43 -0
  28. pbnightingale-0.2.1/docs/locale/fr/LC_MESSAGES/manual/managing_keys.po +432 -0
  29. pbnightingale-0.2.1/docs/locale/fr/LC_MESSAGES/manual/preferences.po +63 -0
  30. pbnightingale-0.2.1/docs/locale/fr/LC_MESSAGES/manual/trust_and_signing.po +229 -0
  31. pbnightingale-0.2.1/docs/manual/concepts.rst +239 -0
  32. pbnightingale-0.2.1/docs/manual/email_clients.rst +87 -0
  33. pbnightingale-0.2.1/docs/manual/first_key.rst +67 -0
  34. pbnightingale-0.2.1/docs/manual/importing_keys.rst +77 -0
  35. pbnightingale-0.2.1/docs/manual/index.rst +20 -0
  36. pbnightingale-0.2.1/docs/manual/managing_keys.rst +169 -0
  37. pbnightingale-0.2.1/docs/manual/preferences.rst +22 -0
  38. pbnightingale-0.2.1/docs/manual/trust_and_signing.rst +88 -0
  39. pbnightingale-0.2.1/docs/requirements.txt +3 -0
  40. pbnightingale-0.2.1/hatch_build.py +35 -0
  41. pbnightingale-0.2.1/hooks/pyi_rth_fonts.py +49 -0
  42. pbnightingale-0.2.1/pbnightingale.spec +182 -0
  43. pbnightingale-0.2.1/pixi.lock +7818 -0
  44. pbnightingale-0.2.1/pyproject.toml +159 -0
  45. pbnightingale-0.2.1/src/pbnightingale/__init__.py +17 -0
  46. pbnightingale-0.2.1/src/pbnightingale/__main__.py +192 -0
  47. pbnightingale-0.2.1/src/pbnightingale/core/__init__.py +5 -0
  48. pbnightingale-0.2.1/src/pbnightingale/core/gpg_backend.py +2853 -0
  49. pbnightingale-0.2.1/src/pbnightingale/core/passphrase_cache.py +89 -0
  50. pbnightingale-0.2.1/src/pbnightingale/core/password_strength.py +111 -0
  51. pbnightingale-0.2.1/src/pbnightingale/core/secret.py +52 -0
  52. pbnightingale-0.2.1/src/pbnightingale/i18n.py +185 -0
  53. pbnightingale-0.2.1/src/pbnightingale/locale/en/LC_MESSAGES/pbnightingale.po +1459 -0
  54. pbnightingale-0.2.1/src/pbnightingale/locale/fr/LC_MESSAGES/pbnightingale.po +1494 -0
  55. pbnightingale-0.2.1/src/pbnightingale/platform/__init__.py +14 -0
  56. pbnightingale-0.2.1/src/pbnightingale/platform/auto_update.py +406 -0
  57. pbnightingale-0.2.1/src/pbnightingale/platform/dirs.py +203 -0
  58. pbnightingale-0.2.1/src/pbnightingale/platform/locale.py +34 -0
  59. pbnightingale-0.2.1/src/pbnightingale/preferences.py +110 -0
  60. pbnightingale-0.2.1/src/pbnightingale/resources/__init__.py +21 -0
  61. pbnightingale-0.2.1/src/pbnightingale/resources/edit-copy.svg +1 -0
  62. pbnightingale-0.2.1/src/pbnightingale/resources/help-about.svg +1 -0
  63. pbnightingale-0.2.1/src/pbnightingale/resources/help-contextual.svg +1 -0
  64. pbnightingale-0.2.1/src/pbnightingale/resources/help-user-manual.svg +1 -0
  65. pbnightingale-0.2.1/src/pbnightingale/resources/key-backup.svg +1 -0
  66. pbnightingale-0.2.1/src/pbnightingale/resources/key-delete.svg +1 -0
  67. pbnightingale-0.2.1/src/pbnightingale/resources/key-expire.svg +1 -0
  68. pbnightingale-0.2.1/src/pbnightingale/resources/key-export.svg +1 -0
  69. pbnightingale-0.2.1/src/pbnightingale/resources/key-import.svg +1 -0
  70. pbnightingale-0.2.1/src/pbnightingale/resources/key-new.svg +1 -0
  71. pbnightingale-0.2.1/src/pbnightingale/resources/key-revoke.svg +1 -0
  72. pbnightingale-0.2.1/src/pbnightingale/resources/key-sign.svg +1 -0
  73. pbnightingale-0.2.1/src/pbnightingale/resources/locked-black.svg +1 -0
  74. pbnightingale-0.2.1/src/pbnightingale/resources/password-change.svg +1 -0
  75. pbnightingale-0.2.1/src/pbnightingale/resources/password-mask.svg +1 -0
  76. pbnightingale-0.2.1/src/pbnightingale/resources/password-view.svg +1 -0
  77. pbnightingale-0.2.1/src/pbnightingale/resources/pbnightingale.icns +0 -0
  78. pbnightingale-0.2.1/src/pbnightingale/resources/pbnightingale.ico +0 -0
  79. pbnightingale-0.2.1/src/pbnightingale/resources/pbnightingale.png +0 -0
  80. pbnightingale-0.2.1/src/pbnightingale/resources/photo-add.svg +1 -0
  81. pbnightingale-0.2.1/src/pbnightingale/resources/photo-delete.svg +1 -0
  82. pbnightingale-0.2.1/src/pbnightingale/resources/preferences-system.svg +1 -0
  83. pbnightingale-0.2.1/src/pbnightingale/resources/previous.svg +1 -0
  84. pbnightingale-0.2.1/src/pbnightingale/resources/quit.svg +1 -0
  85. pbnightingale-0.2.1/src/pbnightingale/resources/server-publish.svg +1 -0
  86. pbnightingale-0.2.1/src/pbnightingale/resources/server-refresh.svg +1 -0
  87. pbnightingale-0.2.1/src/pbnightingale/resources/server-search.svg +1 -0
  88. pbnightingale-0.2.1/src/pbnightingale/resources/subkey-add.svg +1 -0
  89. pbnightingale-0.2.1/src/pbnightingale/resources/subkey-expire.svg +1 -0
  90. pbnightingale-0.2.1/src/pbnightingale/resources/subkey-revoke.svg +1 -0
  91. pbnightingale-0.2.1/src/pbnightingale/resources/trust-refresh.svg +1 -0
  92. pbnightingale-0.2.1/src/pbnightingale/resources/trust-set.svg +1 -0
  93. pbnightingale-0.2.1/src/pbnightingale/resources/unlocked-black.svg +1 -0
  94. pbnightingale-0.2.1/src/pbnightingale/resources/user-add.svg +1 -0
  95. pbnightingale-0.2.1/src/pbnightingale/resources/user-default.svg +1 -0
  96. pbnightingale-0.2.1/src/pbnightingale/resources/user-delete.svg +1 -0
  97. pbnightingale-0.2.1/src/pbnightingale/resources/view-refresh.svg +1 -0
  98. pbnightingale-0.2.1/src/pbnightingale/resources/view.svg +1 -0
  99. pbnightingale-0.2.1/src/pbnightingale/settings.py +39 -0
  100. pbnightingale-0.2.1/src/pbnightingale/ui/__init__.py +1 -0
  101. pbnightingale-0.2.1/src/pbnightingale/ui/about_dialog.py +23 -0
  102. pbnightingale-0.2.1/src/pbnightingale/ui/about_dialog_ui.py +66 -0
  103. pbnightingale-0.2.1/src/pbnightingale/ui/add_photo_dialog.py +153 -0
  104. pbnightingale-0.2.1/src/pbnightingale/ui/add_photo_dialog_ui.py +58 -0
  105. pbnightingale-0.2.1/src/pbnightingale/ui/add_subkey_dialog.py +82 -0
  106. pbnightingale-0.2.1/src/pbnightingale/ui/add_subkey_dialog_ui.py +41 -0
  107. pbnightingale-0.2.1/src/pbnightingale/ui/add_uid_dialog.py +96 -0
  108. pbnightingale-0.2.1/src/pbnightingale/ui/add_uid_dialog_ui.py +29 -0
  109. pbnightingale-0.2.1/src/pbnightingale/ui/backup_private_key_dialog.py +110 -0
  110. pbnightingale-0.2.1/src/pbnightingale/ui/backup_private_key_dialog_ui.py +23 -0
  111. pbnightingale-0.2.1/src/pbnightingale/ui/change_passphrase_dialog.py +116 -0
  112. pbnightingale-0.2.1/src/pbnightingale/ui/change_passphrase_dialog_ui.py +38 -0
  113. pbnightingale-0.2.1/src/pbnightingale/ui/delete_key_dialog.py +82 -0
  114. pbnightingale-0.2.1/src/pbnightingale/ui/delete_key_dialog_ui.py +23 -0
  115. pbnightingale-0.2.1/src/pbnightingale/ui/download_signatures_report_dialog.py +52 -0
  116. pbnightingale-0.2.1/src/pbnightingale/ui/download_signatures_report_dialog_ui.py +40 -0
  117. pbnightingale-0.2.1/src/pbnightingale/ui/geometry_mixin.py +109 -0
  118. pbnightingale-0.2.1/src/pbnightingale/ui/gpg_worker.py +113 -0
  119. pbnightingale-0.2.1/src/pbnightingale/ui/import_key_dialog.py +218 -0
  120. pbnightingale-0.2.1/src/pbnightingale/ui/import_key_dialog_ui.py +95 -0
  121. pbnightingale-0.2.1/src/pbnightingale/ui/key_list_view.py +977 -0
  122. pbnightingale-0.2.1/src/pbnightingale/ui/key_list_view_ui.py +257 -0
  123. pbnightingale-0.2.1/src/pbnightingale/ui/key_operation_dialog.py +194 -0
  124. pbnightingale-0.2.1/src/pbnightingale/ui/key_operation_dialog_ui.py +85 -0
  125. pbnightingale-0.2.1/src/pbnightingale/ui/main_window.py +824 -0
  126. pbnightingale-0.2.1/src/pbnightingale/ui/main_window_ui.py +598 -0
  127. pbnightingale-0.2.1/src/pbnightingale/ui/new_key_wizard.py +319 -0
  128. pbnightingale-0.2.1/src/pbnightingale/ui/new_key_wizard_ui.py +124 -0
  129. pbnightingale-0.2.1/src/pbnightingale/ui/password_line_edit.py +49 -0
  130. pbnightingale-0.2.1/src/pbnightingale/ui/password_strength_meter.py +65 -0
  131. pbnightingale-0.2.1/src/pbnightingale/ui/password_strength_meter_ui.py +21 -0
  132. pbnightingale-0.2.1/src/pbnightingale/ui/photo_viewer_dialog.py +41 -0
  133. pbnightingale-0.2.1/src/pbnightingale/ui/photo_viewer_dialog_ui.py +31 -0
  134. pbnightingale-0.2.1/src/pbnightingale/ui/refresh_keys_dialog.py +60 -0
  135. pbnightingale-0.2.1/src/pbnightingale/ui/refresh_keys_dialog_ui.py +26 -0
  136. pbnightingale-0.2.1/src/pbnightingale/ui/refresh_keys_report_dialog.py +49 -0
  137. pbnightingale-0.2.1/src/pbnightingale/ui/refresh_keys_report_dialog_ui.py +30 -0
  138. pbnightingale-0.2.1/src/pbnightingale/ui/revoke_key_dialog.py +77 -0
  139. pbnightingale-0.2.1/src/pbnightingale/ui/revoke_key_dialog_ui.py +24 -0
  140. pbnightingale-0.2.1/src/pbnightingale/ui/revoke_photo_dialog.py +86 -0
  141. pbnightingale-0.2.1/src/pbnightingale/ui/revoke_photo_dialog_ui.py +33 -0
  142. pbnightingale-0.2.1/src/pbnightingale/ui/revoke_subkey_dialog.py +77 -0
  143. pbnightingale-0.2.1/src/pbnightingale/ui/revoke_subkey_dialog_ui.py +24 -0
  144. pbnightingale-0.2.1/src/pbnightingale/ui/revoke_uid_dialog.py +77 -0
  145. pbnightingale-0.2.1/src/pbnightingale/ui/revoke_uid_dialog_ui.py +24 -0
  146. pbnightingale-0.2.1/src/pbnightingale/ui/search_key_dialog.py +185 -0
  147. pbnightingale-0.2.1/src/pbnightingale/ui/search_key_dialog_ui.py +67 -0
  148. pbnightingale-0.2.1/src/pbnightingale/ui/set_expiration_dialog.py +132 -0
  149. pbnightingale-0.2.1/src/pbnightingale/ui/set_expiration_dialog_ui.py +43 -0
  150. pbnightingale-0.2.1/src/pbnightingale/ui/set_owner_trust_dialog.py +121 -0
  151. pbnightingale-0.2.1/src/pbnightingale/ui/set_owner_trust_dialog_ui.py +55 -0
  152. pbnightingale-0.2.1/src/pbnightingale/ui/set_primary_uid_dialog.py +72 -0
  153. pbnightingale-0.2.1/src/pbnightingale/ui/set_primary_uid_dialog_ui.py +23 -0
  154. pbnightingale-0.2.1/src/pbnightingale/ui/settings_dialog.py +58 -0
  155. pbnightingale-0.2.1/src/pbnightingale/ui/settings_dialog_ui.py +62 -0
  156. pbnightingale-0.2.1/src/pbnightingale/ui/sign_key_dialog.py +130 -0
  157. pbnightingale-0.2.1/src/pbnightingale/ui/sign_key_dialog_ui.py +56 -0
  158. pbnightingale-0.2.1/src/pbnightingale/ui/window_state.py +146 -0
  159. pbnightingale-0.2.1/tests/__init__.py +0 -0
  160. pbnightingale-0.2.1/tests/conftest.py +135 -0
  161. pbnightingale-0.2.1/tests/gpg_test_helpers.py +86 -0
  162. pbnightingale-0.2.1/tests/test_about_dialog.py +43 -0
  163. pbnightingale-0.2.1/tests/test_add_photo_dialog.py +258 -0
  164. pbnightingale-0.2.1/tests/test_add_subkey_dialog.py +138 -0
  165. pbnightingale-0.2.1/tests/test_add_uid_dialog.py +145 -0
  166. pbnightingale-0.2.1/tests/test_backup_private_key_dialog.py +186 -0
  167. pbnightingale-0.2.1/tests/test_change_passphrase_dialog.py +156 -0
  168. pbnightingale-0.2.1/tests/test_delete_key_dialog.py +137 -0
  169. pbnightingale-0.2.1/tests/test_download_signatures_report_dialog.py +62 -0
  170. pbnightingale-0.2.1/tests/test_geometry_mixin.py +152 -0
  171. pbnightingale-0.2.1/tests/test_gpg_backend.py +2905 -0
  172. pbnightingale-0.2.1/tests/test_gpg_worker.py +48 -0
  173. pbnightingale-0.2.1/tests/test_i18n.py +158 -0
  174. pbnightingale-0.2.1/tests/test_import_key_dialog.py +323 -0
  175. pbnightingale-0.2.1/tests/test_key_list_view.py +1346 -0
  176. pbnightingale-0.2.1/tests/test_key_operation_dialog.py +239 -0
  177. pbnightingale-0.2.1/tests/test_main.py +164 -0
  178. pbnightingale-0.2.1/tests/test_main_window.py +1948 -0
  179. pbnightingale-0.2.1/tests/test_new_key_wizard.py +289 -0
  180. pbnightingale-0.2.1/tests/test_passphrase_cache.py +78 -0
  181. pbnightingale-0.2.1/tests/test_password_line_edit.py +36 -0
  182. pbnightingale-0.2.1/tests/test_password_strength.py +82 -0
  183. pbnightingale-0.2.1/tests/test_password_strength_meter.py +82 -0
  184. pbnightingale-0.2.1/tests/test_photo_viewer_dialog.py +52 -0
  185. pbnightingale-0.2.1/tests/test_platform_auto_update.py +452 -0
  186. pbnightingale-0.2.1/tests/test_platform_dirs.py +150 -0
  187. pbnightingale-0.2.1/tests/test_platform_locale.py +69 -0
  188. pbnightingale-0.2.1/tests/test_preferences.py +65 -0
  189. pbnightingale-0.2.1/tests/test_refresh_keys_dialog.py +50 -0
  190. pbnightingale-0.2.1/tests/test_refresh_keys_report_dialog.py +81 -0
  191. pbnightingale-0.2.1/tests/test_resources.py +22 -0
  192. pbnightingale-0.2.1/tests/test_revoke_key_dialog.py +128 -0
  193. pbnightingale-0.2.1/tests/test_revoke_photo_dialog.py +154 -0
  194. pbnightingale-0.2.1/tests/test_revoke_subkey_dialog.py +150 -0
  195. pbnightingale-0.2.1/tests/test_revoke_uid_dialog.py +137 -0
  196. pbnightingale-0.2.1/tests/test_search_key_dialog.py +211 -0
  197. pbnightingale-0.2.1/tests/test_secret.py +64 -0
  198. pbnightingale-0.2.1/tests/test_set_expiration_dialog.py +209 -0
  199. pbnightingale-0.2.1/tests/test_set_owner_trust_dialog.py +96 -0
  200. pbnightingale-0.2.1/tests/test_set_primary_uid_dialog.py +114 -0
  201. pbnightingale-0.2.1/tests/test_settings.py +22 -0
  202. pbnightingale-0.2.1/tests/test_settings_dialog.py +149 -0
  203. pbnightingale-0.2.1/tests/test_sign_key_dialog.py +213 -0
  204. pbnightingale-0.2.1/tests/test_window_state.py +71 -0
  205. pbnightingale-0.2.1/tools/bump_version.py +92 -0
  206. pbnightingale-0.2.1/tools/extract_changelog.py +65 -0
  207. pbnightingale-0.2.1/tools/fix_po_files.py +45 -0
  208. pbnightingale-0.2.1/tools/git_version.sh +10 -0
  209. pbnightingale-0.2.1/tools/po_check.py +197 -0
  210. pbnightingale-0.2.1/tools/update_icons.py +252 -0
  211. pbnightingale-0.2.1/xx.log +1512 -0
@@ -0,0 +1 @@
1
+ {"sessionId":"8adf1241-358a-437f-87d1-2fcef27cfd94","pid":382052,"procStart":"3651907","acquiredAt":1789588198490}
@@ -0,0 +1,172 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: ["**"]
6
+ # Semver tags only (glob syntax — + quantifier not supported by GitHub)
7
+ tags: ["v[0-9]*.[0-9]*.[0-9]*"]
8
+ pull_request:
9
+
10
+ # Cancel in-flight runs for the same ref when a newer push arrives.
11
+ concurrency:
12
+ group: ${{ github.workflow }}-${{ github.ref }}
13
+ cancel-in-progress: true
14
+
15
+ jobs:
16
+ # ── Tests ────────────────────────────────────────────────────────────────────
17
+ test:
18
+ name: Tests
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v6
22
+
23
+ - name: Set up pixi environment
24
+ uses: prefix-dev/setup-pixi@v0.10.0
25
+ with:
26
+ pixi-version: latest
27
+ cache: true
28
+
29
+ - name: Compile translation catalogues
30
+ run: pixi run -- pybabel compile -d src/pbnightingale/locale -D pbnightingale
31
+
32
+ - name: Run test suite
33
+ env:
34
+ QT_QPA_PLATFORM: offscreen
35
+ run: make test
36
+
37
+ - name: Upload coverage to Codecov
38
+ # v6+ is required to pick up codecov-action's own Node 24 runtime
39
+ # (it was pinned to actions/github-script's Node 20 build through
40
+ # v5.5.5); no input/behavior change otherwise.
41
+ uses: codecov/codecov-action@v7
42
+ with:
43
+ token: ${{ secrets.CODECOV_TOKEN }}
44
+ files: coverage.xml
45
+ fail_ci_if_error: true
46
+
47
+ # ── Pre-commit hooks ─────────────────────────────────────────────────────────
48
+ hooks:
49
+ name: Pre-commit hooks
50
+ runs-on: ubuntu-latest
51
+ steps:
52
+ - uses: actions/checkout@v6
53
+
54
+ - name: Set up pixi environment
55
+ uses: prefix-dev/setup-pixi@v0.10.0
56
+ with:
57
+ pixi-version: latest
58
+ cache: true
59
+
60
+ - name: Cache pre-commit hook environments
61
+ uses: actions/cache@v5
62
+ with:
63
+ path: ~/.cache/pre-commit
64
+ key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
65
+
66
+ - name: Run hooks on all files
67
+ run: pixi run -- pre-commit run --all-files
68
+
69
+ # ── Documentation ───────────────────────────────────────────────────────────
70
+ docs:
71
+ name: Build docs
72
+ needs: [test]
73
+ runs-on: ubuntu-latest
74
+ steps:
75
+ - uses: actions/checkout@v6
76
+
77
+ - name: Set up pixi environment
78
+ uses: prefix-dev/setup-pixi@v0.10.0
79
+ with:
80
+ pixi-version: latest
81
+ cache: true
82
+
83
+ - name: Build HTML documentation
84
+ run: make docs
85
+
86
+ - name: Upload documentation artifact
87
+ uses: actions/upload-artifact@v7
88
+ with:
89
+ name: docs-html
90
+ path: docs/_build/html/
91
+ if-no-files-found: error
92
+
93
+ # ── Build executables ────────────────────────────────────────────────────────
94
+ build:
95
+ name: Build / ${{ matrix.os }}
96
+ needs: [test, hooks]
97
+ strategy:
98
+ fail-fast: false
99
+ matrix:
100
+ os: [ubuntu-latest, windows-latest, macos-latest]
101
+ runs-on: ${{ matrix.os }}
102
+
103
+ steps:
104
+ - uses: actions/checkout@v6
105
+
106
+ - name: Set up pixi environment
107
+ uses: prefix-dev/setup-pixi@v0.10.0
108
+ with:
109
+ pixi-version: latest
110
+ cache: true
111
+
112
+ - name: Compile translation catalogues
113
+ # .mo files are gitignored generated artifacts; rebuild from .po sources.
114
+ run: pixi run -- pybabel compile -d src/pbnightingale/locale -D pbnightingale
115
+
116
+ - name: Build with PyInstaller
117
+ # Call PyInstaller directly to avoid GNU Make portability issues
118
+ # on Windows runners. Single-line command: the `\` line-continuation
119
+ # used by bash is not valid PowerShell, the default shell on
120
+ # windows-latest runners.
121
+ run: pixi run -- pyinstaller --clean --noconfirm --distpath dist --workpath build/pyinstaller pbnightingale.spec
122
+
123
+ - name: Zip .app bundle (macOS)
124
+ # A .app bundle is a directory; zip it for upload and release attachment.
125
+ if: runner.os == 'macOS'
126
+ shell: bash
127
+ run: |
128
+ cd dist
129
+ for app in *.app; do
130
+ zip -r "${app%.app}.zip" "$app"
131
+ rm -rf "$app"
132
+ done
133
+
134
+ - name: Upload build artifact
135
+ uses: actions/upload-artifact@v7
136
+ with:
137
+ # One artifact per runner; merged in the release job.
138
+ name: build-${{ runner.os }}
139
+ path: dist/pbnightingale-*
140
+ if-no-files-found: error
141
+
142
+ # ── GitHub release ───────────────────────────────────────────────────────────
143
+ release:
144
+ name: Release ${{ github.ref_name }}
145
+ needs: [build]
146
+ # Only create a release on semver tags; the tag filter on push already
147
+ # restricts the trigger, but this guard makes the intent explicit.
148
+ if: github.ref_type == 'tag'
149
+ runs-on: ubuntu-latest
150
+ permissions:
151
+ contents: write # required to create a release and upload assets
152
+
153
+ steps:
154
+ - uses: actions/checkout@v6
155
+
156
+ - name: Download all build artifacts
157
+ uses: actions/download-artifact@v8
158
+ with:
159
+ # Merge all per-OS artifacts into a single flat directory.
160
+ path: dist
161
+ merge-multiple: true
162
+
163
+ - name: Extract changelog entry for ${{ github.ref_name }}
164
+ id: changelog
165
+ run: python3 tools/extract_changelog.py
166
+
167
+ - name: Create GitHub release
168
+ uses: softprops/action-gh-release@v3
169
+ with:
170
+ body: ${{ steps.changelog.outputs.body }}
171
+ files: dist/pbnightingale-*
172
+ fail_on_unmatched_files: true
@@ -0,0 +1,53 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ dist/
7
+ build/
8
+ .eggs/
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+ env/
14
+
15
+ # Testing
16
+ .pytest_cache/
17
+ .ruff_cache/
18
+ .coverage
19
+ htmlcov/
20
+ coverage.xml
21
+
22
+ # Sphinx
23
+ docs/_build/
24
+ docs/changelog.rst
25
+ docs/_static/pbnightingale.png
26
+ docs/_static/icons/
27
+
28
+ # IDE
29
+ .idea/
30
+ .vscode/
31
+ *.swp
32
+ *~
33
+
34
+ # OS
35
+ .DS_Store
36
+ Thumbs.db
37
+
38
+ # Make stamp files
39
+ .translate.stamp
40
+
41
+ # i18n — generated artifacts, rebuilt on demand by make translate
42
+ *.pot
43
+ *.mo
44
+ tools/_lang_name_stub.py
45
+
46
+ # Claude Code
47
+ .claude/settings.local.json
48
+
49
+ # pixi (defensive: environments should live in ~/PIXI/ via the global
50
+ # `pixi config set --global detached-environments`, but this covers
51
+ # machines/accounts where that global config isn't set, so .pixi/
52
+ # never ends up committed)
53
+ .pixi/
@@ -0,0 +1,12 @@
1
+ config:
2
+ # Line length not enforced — CHANGELOG bullets and docs are naturally long
3
+ MD013: false
4
+ # Duplicate headings allowed when they live under different parent sections
5
+ # (standard Keep-a-Changelog pattern: ### Added, ### Fixed repeat per release)
6
+ MD024:
7
+ siblings_only: true
8
+
9
+ # CLAUDE.md is intentionally a compact tool-specific file
10
+ ignores:
11
+ - CLAUDE.md
12
+ - .pytest_cache/**
@@ -0,0 +1,30 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v6.0.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: end-of-file-fixer
7
+ - id: mixed-line-ending
8
+ args: [--fix=lf]
9
+ - id: check-merge-conflict
10
+ - id: check-added-large-files
11
+ # Default 500 KB is too low for pbnightingale.icns: a macOS .icns
12
+ # bundles several raster sizes and is a few MB even for a simple
13
+ # source image — legitimate packaging content, not accidental bloat.
14
+ args: [--maxkb=3072]
15
+ - id: check-yaml
16
+ - id: check-toml
17
+ - id: check-json
18
+ - id: debug-statements # Python only
19
+
20
+ - repo: https://github.com/astral-sh/ruff-pre-commit
21
+ rev: v0.16.7
22
+ hooks:
23
+ - id: ruff
24
+ args: [--fix]
25
+ - id: ruff-format
26
+
27
+ - repo: https://github.com/DavidAnson/markdownlint-cli2
28
+ rev: v0.22.1
29
+ hooks:
30
+ - id: markdownlint-cli2
@@ -0,0 +1,20 @@
1
+ # Read the Docs configuration
2
+ # https://docs.readthedocs.io/en/stable/config-file/v2.html
3
+ version: 2
4
+
5
+ build:
6
+ os: ubuntu-22.04
7
+ tools:
8
+ python: "3.14"
9
+
10
+ python:
11
+ install:
12
+ - method: pip
13
+ path: .
14
+ extra_requirements:
15
+ - dev
16
+
17
+ sphinx:
18
+ configuration: docs/conf.py
19
+ builder: html
20
+ fail_on_warning: false
@@ -0,0 +1,85 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.2.1] - 2026-09-20
8
+
9
+ ### Added
10
+
11
+ - `-d`/`--debug` and `-q`/`--quiet` CLI flags: `--debug` enables debug-level
12
+ logging, including a trace of every operation launched through an
13
+ external program (`gpg`, `gpg-connect-agent`, …); `--quiet` restricts
14
+ logging to warnings and errors
15
+ - `--print-completion {bash,zsh,tcsh,fish,powershell}` (via `shtab`), in
16
+ the "tools" option group, to generate a shell completion script
17
+
18
+ ### Changed
19
+
20
+ - A short program history note added to the README and the user manual
21
+
22
+ ### Fixed
23
+
24
+ - gpg's purely informational `[GNUPG:] KEYEXPIRED <timestamp>` status
25
+ lines no longer appear in diagnostic/error messages
26
+
27
+ ### Security
28
+
29
+ - Passphrases are now carried through the application as a dedicated
30
+ `Passphrase` wrapper (`core/secret.py`) instead of a bare string:
31
+ `str()`/`repr()` on it always show a fixed placeholder, so an enhanced
32
+ traceback tool or debugger dumping a stack frame's local variables can
33
+ no longer expose a passphrase in the clear
34
+ - Debug-mode tracing of external `gpg` invocations redacts every
35
+ passphrase from the logged command input; the real value sent to gpg is
36
+ unaffected
37
+
38
+ ## [0.2.0] - 2026-09-17
39
+
40
+ ### Added
41
+
42
+ - Sortable main key list: clicking a column header (Name, Email, Key ID,
43
+ Expires) sorts the keys inside each group, clicking it again reverses the
44
+ order. The "My keys" and "Other keys" groups themselves keep their fixed
45
+ order, and keys that never expire always sort last in the Expires column.
46
+ The list is sorted by name ascending on first launch, and the chosen sort
47
+ column and direction are remembered across sessions
48
+ - Numpydoc docstring convention adopted project-wide (`core/`, `platform/`,
49
+ top-level modules, and every `ui/*.py` except `*_ui.py` layout files),
50
+ documented in CODING.md
51
+ - `pydocstyle` (ruff `D`, `numpy` convention) enforced in lint, scoped to
52
+ match the documented docstring convention
53
+
54
+ ### Fixed
55
+
56
+ - Sphinx docs: `napoleon_numpy_docstring` was disabled, so numpydoc
57
+ sections in docstrings were never actually parsed or rendered
58
+ - CI: bumped `codecov-action` to v7 to pick up its own Node 24 runtime
59
+ (it was pinned to a deprecated Node 20 build via `actions/github-script`)
60
+
61
+ ## [0.1.0] - 2026-09-16
62
+
63
+ This program is an evolution (heavily improved) of an older Python
64
+ program, itself an evolution of an ancient (~2014) Perl program.
65
+
66
+ ### Added
67
+
68
+ - Personal key generation (RSA or ED25519) with a passphrase strength meter
69
+ - Subkey management: add, revoke, export, back up, delete
70
+ - Editable identities: text and photo user IDs
71
+ - Expiration date management for keys and subkeys
72
+ - Passphrase changes, with in-memory caching and show/hide fields
73
+ - Key signing, owner trust, and trust refresh, including a signatures view
74
+ - Key import from a file or a keyserver, with a review step before merging
75
+ - Keyserver search, publish, and refresh
76
+ - French and English interface, with a preferences dialog
77
+ - Packaging (PyInstaller) and Sphinx user documentation
78
+ - GitHub Actions CI: tests, pre-commit hooks, documentation build, per-OS
79
+ PyInstaller builds, and GitHub Releases cut from this changelog on
80
+ semver tags
81
+ - Help menu entry opening the online user manual in the interface's
82
+ current language
83
+ - Keyboard shortcuts for standard actions (new, import, export, back up,
84
+ delete, settings, help, quit) and a refresh family (F5 for keys,
85
+ Shift+F5 for trust, Ctrl+F5 for keyservers)
@@ -0,0 +1,218 @@
1
+ # Mode
2
+ `full-en` — code and all communication in English.
3
+
4
+ # Project: PBNightingale
5
+ GUI GPG key manager (Linux/Windows/macOS). Python 3.14+, PySide6 (Qt6), src layout.
6
+ Publisher: PBMou | Author: Marcel Spock <mrspock@cardolan.net>
7
+ License: GPLv3 (code); icons under `resources/` (sourced from PBIcons) are
8
+ CC BY-NC-SA 4.0 instead — see LICENSE-ICONS
9
+
10
+ ## Current state
11
+
12
+ Published to GitHub (`origin` → `git@github.com:ppoilbarbe/PBNightingale.git`,
13
+ `main` tracks `origin/main`). `.github/workflows/ci.yml` runs tests, hooks,
14
+ docs build and per-OS PyInstaller builds on every push/PR, and cuts a
15
+ GitHub release from `CHANGELOG.md` on semver tags. **Still never push without an explicit request from the user**
16
+ (see global CLAUDE.md). `make ci` (lint → hooks → test) remains the local
17
+ gate to run before pushing.
18
+
19
+ Implementation proceeds milestone by milestone; each milestone is validated
20
+ (`make ci` green + manual check) before the next one starts.
21
+
22
+ ## Stack
23
+
24
+ - GUI: PySide6; UI layouts hand-written (`*_ui.py`), no Qt Designer
25
+ - GPG backend: `python-gnupg` wrapping the system `gpg` binary (portable;
26
+ avoids compiling GPGME bindings on Windows/macOS)
27
+ - Password strength: `zxcvbn` (conda-forge), thresholds/tiers ported from
28
+ KeePassXC's `PasswordHealth` — see CODING.md, "Password strength"
29
+ - Tests: pytest + pytest-qt (`qtbot`; never instantiate `QApplication`
30
+ manually) — GPG tests run against an isolated temporary `GNUPGHOME`, never
31
+ the user's real keyring
32
+ - Lint: ruff (line-length 88, target py314), default rule set + `I`/`PGH`/
33
+ `UP`/`BLE`/`S`/`D` (no explicit `select` — see pyproject.toml comment);
34
+ `D` uses the `numpy` pydocstyle convention, matching this project's
35
+ numpydoc docstrings (see CODING.md, "Coding conventions")
36
+ - Env: pixi (conda-forge channel only) — `make venv`
37
+ - CI/CD: GitHub Actions (`.github/workflows/ci.yml`) + `make ci` locally
38
+ before pushing
39
+ - i18n: French + English only, pybabel/gettext (see `i18n.py`)
40
+ - Auto-update: GitHub-releases self-update, `--auto-update` CLI flag —
41
+ only meaningful (and only registered) in a PyInstaller-frozen executable
42
+ (milestone 15); inert everywhere else
43
+
44
+ ## Layout
45
+
46
+ ```text
47
+ src/pbnightingale/
48
+ ├── __init__.py version, author metadata; builtins._ no-op fallback
49
+ ├── __main__.py entry point; --auto-update dispatch (frozen only)
50
+ ├── i18n.py gettext bootstrap (setup(app), language override)
51
+ ├── settings.py config-dir root (configure() override for tests)
52
+ ├── preferences.py general app preferences (preferred key algorithm, …)
53
+ ├── locale/ en/fr catalogues (.po committed, .mo gitignored)
54
+ ├── core/
55
+ │ ├── gpg_backend.py GPGBackend (python-gnupg wrapper) + Key/Subkey model;
56
+ │ │ configure()/default_backend() override for tests
57
+ │ ├── secret.py Passphrase — frozen wrapper hiding a passphrase from
58
+ │ │ str()/repr() (e.g. in a traceback's local-var dump)
59
+ │ ├── passphrase_cache.py in-memory {fingerprint: Passphrase} cache, TTL-based
60
+ │ └── password_strength.py zxcvbn-based entropy/quality for a new passphrase
61
+ ├── platform/
62
+ │ ├── dirs.py AppDirs factory: XdgDirs / _MacDirs / _WindowsDirs
63
+ │ ├── locale.py system_language()
64
+ │ └── auto_update.py perform_auto_update() — GitHub release self-update
65
+ ├── resources/ bundled SVG/PNG icons (resources.path(name))
66
+ └── ui/
67
+ ├── main_window.py main window: loads keys, signals
68
+ ├── main_window_ui.py main window layout: actions, toolbars (one per theme), menus
69
+ ├── key_list_view(_ui).py central widget: key list + detail panel
70
+ ├── new_key_wizard(_ui).py personal key creation wizard
71
+ ├── password_strength_meter(_ui).py colored entropy bar for a new passphrase
72
+ ├── add_subkey_dialog(_ui).py Add Subkey dialog
73
+ ├── revoke_subkey_dialog(_ui).py Revoke Subkey dialog (strong confirmation)
74
+ ├── change_passphrase_dialog(_ui).py Change Passphrase dialog
75
+ ├── add_uid_dialog(_ui).py Add User ID dialog
76
+ ├── set_primary_uid_dialog(_ui).py Set Primary User ID dialog
77
+ ├── revoke_uid_dialog(_ui).py Revoke User ID dialog (strong confirmation)
78
+ ├── add_photo_dialog(_ui).py Add Photo dialog
79
+ ├── revoke_photo_dialog(_ui).py Revoke Photo dialog (strong confirmation)
80
+ ├── set_expiration_dialog(_ui).py Set Expiration dialog (key or subkey)
81
+ ├── sign_key_dialog(_ui).py Sign Key dialog
82
+ ├── set_owner_trust_dialog(_ui).py Set Owner Trust dialog
83
+ ├── about_dialog(_ui).py
84
+ ├── settings_dialog(_ui).py language selector
85
+ ├── gpg_worker.py run_async() — QThreadPool bridge for GPGBackend calls
86
+ ├── window_state.py persisted window geometry / splitter sizes (QSettings)
87
+ └── geometry_mixin.py GeometryMixin — wires window_state into a window
88
+ ```
89
+
90
+ `tools/update_icons.py` syncs `resources/` icons from the shared PBIcons
91
+ asset repo (local checkout sibling, else GitHub) — see CODING.md.
92
+
93
+ ## Key conventions
94
+
95
+ - All OS-specific code goes in `src/pbnightingale/platform/`.
96
+ - Never test against the real user's GPG keyring — always an isolated
97
+ `GNUPGHOME`. Enforced automatically:
98
+ `gpg_backend.configure()`/`default_backend()` mirror
99
+ `settings.configure()` exactly, and `tests/conftest.py`'s autouse
100
+ `_isolated_gnupghome`/`_isolated_config` fixtures redirect both to a tmp
101
+ dir for every test — never the real user's keyring or config directory.
102
+ - After any user-visible string change: `make translate`, fill every new
103
+ `msgstr ""` in both `en` and `fr` `.po` files, `make translate` again to
104
+ compile. Verify with `python tools/po_check.py --empty` — never grep/msgfmt
105
+ directly on `.po` files (they can have multi-line entries). Run
106
+ `po_check.py --empty` even when a milestone feels string-free — milestones
107
+ 6 and 7 both merged without it and left 65 strings untranslated in both
108
+ languages until milestone 8 caught it.
109
+ - Every top-level window/dialog persists its geometry (and splitter sizes)
110
+ via `ui/geometry_mixin.py::GeometryMixin` (`self._init_geometry(key,
111
+ splitters={...})` after `setupUi()`) — apply it to new windows too, except
112
+ small fixed-purpose dialogs like `AboutDialog`.
113
+ - ED25519 key generation cannot fold `encrypt` usage into the primary key
114
+ (gpg rejects it outright — EdDSA is signature-only); `NewKeyRequest`/
115
+ `add_subkey()` always add a dedicated Curve25519 subkey for encryption
116
+ under ED25519, and the wizard/Add Subkey dialog force-check-and-disable
117
+ that checkbox accordingly. Don't "simplify" this away — RSA's "fold into
118
+ primary" behavior genuinely doesn't generalize to ED25519.
119
+ - Subkey revocation has no python-gnupg wrapper — `GPGBackend.
120
+ revoke_subkey()` drives `gpg --edit-key` directly via `--command-fd`/
121
+ `--status-fd` scripting. The passphrase line in that script must be
122
+ positioned exactly where gpg's `GET_HIDDEN passphrase.enter` status
123
+ appears (right after confirming the revocation reason) — anywhere else in
124
+ the sequence gets silently consumed as a bogus `keyedit.prompt` command
125
+ instead. See CODING.md — "Subkey management".
126
+ - `self._gpg.gnupghome` is `None` in production (no `configure()`
127
+ override) — never assume it's a usable path. Anything that shells out
128
+ directly (`_restart_agent()`, `revoke_subkey()`) must go through
129
+ `GPGBackend._homedir_args()` rather than building a `--homedir` arg by
130
+ hand; a bare `self._gpg.gnupghome` in a `subprocess.run()` arg list
131
+ crashes with `TypeError: ... not NoneType` in production, since every
132
+ test's `GPGBackend` has a real temp-dir `gnupghome` and never exercises
133
+ the `None` case — this class of bug won't show up in `make ci`.
134
+ - `GPGBackend.change_passphrase()` drives gpg's dedicated
135
+ `--change-passphrase` command (never `--edit-key`'s `passwd`
136
+ sub-command): unlike `passwd`, a wrong old passphrase there gets a
137
+ proper machine-readable `[GNUPG:] ERROR keyedit.passwd <code>` status
138
+ line — but only when a wrong passphrase gets exactly the right number of
139
+ retries: gpg retries the old-passphrase prompt once per secret-key part
140
+ (primary + each subkey), and a wrong one can't be told apart from a
141
+ correct one until each retry has played out, so the script sent is
142
+ `old`, `new`, then `old` again `_MAX_SECRET_KEY_PARTS` (64) more times as
143
+ filler. A real 4-subkey-part key with too few retries queued ran the
144
+ script dry mid-sequence, downgrading the failure to gpg's generic
145
+ "operation canceled" code (99, not 11) and dumping its whole raw
146
+ multi-attempt diagnostic text into the dialog instead of a clean
147
+ message — reported against real usage; see CODING.md. A *correct*
148
+ passphrase only ever needs the first two lines (`old`, `new`) — no
149
+ repeat-to-confirm step in the protocol itself, that's the UI's own job
150
+ (`ChangePassphraseDialog`'s `txtNewPassphrase`/`txtNewPassphraseConfirm`).
151
+ Always clears gpg-agent's passphrase cache first via `gpg-connect-agent
152
+ RELOADAGENT` (never `_restart_agent()`'s `gpgconf --kill`, which is async
153
+ and races the next call) — otherwise an already-cached key silently
154
+ skips the old-passphrase check and misapplies its value as the new
155
+ passphrase instead. Only works on a key that already has a real
156
+ passphrase — an
157
+ unprotected key given `old_passphrase=""` reports success but stays
158
+ unprotected (`--edit-key passwd` handles that transition correctly, but
159
+ loses the machine-readable error code). See CODING.md — "Changing a
160
+ key's passphrase".
161
+ - Detect a wrong GPG passphrase by the numeric code on gpg's `[GNUPG:]
162
+ ERROR <location> <code>` status line (`& 0xFFFF == 11`, libgpg-error's
163
+ `GPG_ERR_BAD_PASSPHRASE`) — never by matching gpg's own human-readable
164
+ text ("Mauvaise phrase secrète"/"Bad Passphrase"/…), which is localized
165
+ to the system's own language and has nothing to do with this app's own
166
+ `en`/`fr` catalogues. See `GPGBackendError`/`BadPassphraseError` in
167
+ `core/gpg_backend.py`.
168
+ - There is no reliable "primary UID" signal in gpg's plain `--list-keys`
169
+ output — verified empirically, don't re-derive it: UID listing order
170
+ looks tempting but isn't a stable stand-in (a clean test with UIDs added
171
+ in order A, B, C came back as `C, A, B`, not creation or timestamp
172
+ order). The real primary flag only shows up in `gpg --edit-key`'s own
173
+ `--with-colons` listing, at the cost of one extra `gpg` subprocess per
174
+ key — not worth paying for a cosmetic label, so `Uid` has no `primary`
175
+ field. See CODING.md — "Editable user IDs".
176
+ - python-gnupg's `list_keys()` silently drops photo user IDs ("uat" colon
177
+ records) — its own record-type allowlist doesn't include them (verified
178
+ against its source, not just observed). `GPGBackend._load_photos()`
179
+ works around this with a separate raw `--attribute-file` listing.
180
+ `addphoto` (scripted via `--edit-key`, like subkey revocation) needs
181
+ `--no-tty` or it fails outright in any headless/GUI-launched process,
182
+ and its passphrase line goes right after the file path, *before*
183
+ `save` — unlike every other scripted `--edit-key` flow in this app,
184
+ where the passphrase goes last. See CODING.md — "Photo user IDs".
185
+ - `Key.trust` (validity — is the UID/key binding genuine) and
186
+ `Key.owner_trust` (how much you personally trust this owner to certify
187
+ *others*) are different gpg concepts that happen to share colon-format
188
+ letter codes — never conflate them. gpg auto-assigns "ultimate"
189
+ ownertrust to any key you hold the secret part of unless explicitly
190
+ overridden, which masks signing's effect on validity in tests unless you
191
+ override it first. `--min-cert-level` (default 2) disregards level-1
192
+ signatures for validity, but level 0 is always accepted — a signing
193
+ dialog defaulting to cert level 0 is a real default, not a placebo. See
194
+ CODING.md — "Key signing & web of trust".
195
+ - Every passphrase in this codebase is a `core/secret.py::Passphrase`
196
+ (frozen dataclass), never a bare `str` — `GPGBackend` method parameters,
197
+ `NewKeyRequest.passphrase`, `passphrase_cache`'s stored values, what a
198
+ dialog reads off `txtPassphrase`. `str()`/`repr()` on it always print
199
+ `"**********"`, closing off a leak a bare `str` local doesn't: an
200
+ enhanced traceback tool or debugger dumping a frame's locals on an
201
+ unhandled exception would otherwise print the real value at every stack
202
+ frame it passed through. `bool(passphrase)`/`==` work on the wrapper
203
+ directly — only unwrap via `.passphrase` at the one point gpg's own
204
+ protocol needs the raw text (a `--command-fd` script, an `input=`
205
+ payload, a python-gnupg kwarg, or the widget echoing it back). Don't
206
+ interpolate a `Passphrase` into an f-string unwrapped — with no
207
+ `__format__` override it falls back to `str()` and sends the literal
208
+ `"**********"` to gpg instead of the real passphrase, a real protocol
209
+ bug, not just a leak. See CODING.md — "GPG backend".
210
+ - New external-program calls in `core/gpg_backend.py` must go through
211
+ `_traced_run()`/`_traced_popen()`, never a bare `subprocess.run()`/
212
+ `Popen()` — that's what makes `-d`/`--debug` trace every gpg invocation,
213
+ and it's also where `_clean_stderr()` strips gpg's purely-informational
214
+ `[GNUPG:] KEYEXPIRED …` status lines from `result.stderr` before
215
+ anything (an exception message, a retry check) ever sees them. A
216
+ passphrase fed via `input=`/a script must be listed in `secrets=` so the
217
+ debug trace redacts it — the real value sent to gpg is never affected,
218
+ only what gets logged.