├── .coveragerc ├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── NEWS.rst ├── README.rst ├── SECURITY.md ├── conftest.py ├── docs ├── conf.py ├── history.rst └── index.rst ├── hook-keyring.backend.py ├── keyring ├── __init__.py ├── __main__.py ├── backend.py ├── backend_complete.bash ├── backend_complete.zsh ├── backends │ ├── SecretService.py │ ├── Windows.py │ ├── __init__.py │ ├── chainer.py │ ├── fail.py │ ├── kwallet.py │ ├── libsecret.py │ ├── macOS │ │ ├── __init__.py │ │ └── api.py │ └── null.py ├── cli.py ├── compat │ ├── __init__.py │ ├── properties.py │ ├── py312.py │ └── py38.py ├── completion.py ├── core.py ├── credentials.py ├── devpi_client.py ├── errors.py ├── http.py ├── py.typed ├── testing │ ├── __init__.py │ ├── backend.py │ └── util.py └── util │ ├── __init__.py │ └── platform_.py ├── mypy.ini ├── pyproject.toml ├── pytest.ini ├── ruff.toml ├── tea.yaml ├── tests ├── backends │ ├── test_SecretService.py │ ├── test_Windows.py │ ├── test_chainer.py │ ├── test_kwallet.py │ ├── test_libsecret.py │ └── test_macOS.py ├── test_cli.py ├── test_core.py ├── test_errors.py ├── test_integration.py ├── test_multiprocess.py ├── test_packaging.py └── util │ └── test_platform.py ├── towncrier.toml └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # leading `*/` for pytest-dev/pytest-cov#456 4 | */.tox/* 5 | disable_warnings = 6 | couldnt-parse 7 | 8 | [report] 9 | show_missing = True 10 | exclude_also = 11 | # Exclude common false positives per 12 | # https://coverage.readthedocs.io/en/latest/excluding.html#advanced-exclusion 13 | # Ref jaraco/skeleton#97 and jaraco/skeleton#135 14 | class .*\bProtocol\): 15 | if TYPE_CHECKING: 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 4 7 | insert_final_newline = true 8 | end_of_line = lf 9 | 10 | [*.py] 11 | indent_style = space 12 | max_line_length = 88 13 | 14 | [*.{yml,yaml}] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [*.rst] 19 | indent_style = space 20 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: jaraco 2 | tidelift: pypi/keyring 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Environment** 24 | 25 | - OS: [e.g. macOS] 26 | 27 | ``` 28 | $ pip list | grep keyring 29 | ... 30 | $ keyring --list-backends 31 | ... 32 | ``` 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | merge_group: 5 | push: 6 | branches-ignore: 7 | # temporary GH branches relating to merge queues (jaraco/skeleton#93) 8 | - gh-readonly-queue/** 9 | tags: 10 | # required if branches-ignore is supplied (jaraco/skeleton#103) 11 | - '**' 12 | pull_request: 13 | workflow_dispatch: 14 | 15 | permissions: 16 | contents: read 17 | 18 | env: 19 | # Environment variable to support color support (jaraco/skeleton#66) 20 | FORCE_COLOR: 1 21 | 22 | # Suppress noisy pip warnings 23 | PIP_DISABLE_PIP_VERSION_CHECK: 'true' 24 | PIP_NO_WARN_SCRIPT_LOCATION: 'true' 25 | 26 | # Ensure tests can sense settings about the environment 27 | TOX_OVERRIDE: >- 28 | testenv.pass_env+=GITHUB_*,FORCE_COLOR 29 | 30 | 31 | jobs: 32 | test: 33 | strategy: 34 | # https://blog.jaraco.com/efficient-use-of-ci-resources/ 35 | matrix: 36 | python: 37 | - "3.9" 38 | - "3.13" 39 | platform: 40 | - ubuntu-latest 41 | - macos-latest 42 | - windows-latest 43 | include: 44 | - python: "3.10" 45 | platform: ubuntu-latest 46 | - python: "3.11" 47 | platform: ubuntu-latest 48 | - python: "3.12" 49 | platform: ubuntu-latest 50 | - python: "3.14" 51 | platform: ubuntu-latest 52 | - python: pypy3.10 53 | platform: ubuntu-latest 54 | runs-on: ${{ matrix.platform }} 55 | continue-on-error: ${{ matrix.python == '3.14' }} 56 | steps: 57 | - uses: actions/checkout@v4 58 | - name: Install build dependencies 59 | # Install dependencies for building packages on pre-release Pythons 60 | # jaraco/skeleton#161 61 | if: matrix.python == '3.14' && matrix.platform == 'ubuntu-latest' 62 | run: | 63 | sudo apt update 64 | sudo apt install -y libxml2-dev libxslt-dev 65 | - name: Setup Python 66 | uses: actions/setup-python@v5 67 | with: 68 | python-version: ${{ matrix.python }} 69 | allow-prereleases: true 70 | - name: Install tox 71 | run: python -m pip install tox 72 | - name: Run 73 | run: tox 74 | 75 | collateral: 76 | strategy: 77 | fail-fast: false 78 | matrix: 79 | job: 80 | - diffcov 81 | - docs 82 | runs-on: ubuntu-latest 83 | steps: 84 | - uses: actions/checkout@v4 85 | with: 86 | fetch-depth: 0 87 | - name: Setup Python 88 | uses: actions/setup-python@v5 89 | with: 90 | python-version: 3.x 91 | - name: Install tox 92 | run: python -m pip install tox 93 | - name: Eval ${{ matrix.job }} 94 | run: tox -e ${{ matrix.job }} 95 | 96 | check: # This job does nothing and is only used for the branch protection 97 | if: always() 98 | 99 | needs: 100 | - test 101 | - collateral 102 | 103 | runs-on: ubuntu-latest 104 | 105 | steps: 106 | - name: Decide whether the needed jobs succeeded or failed 107 | uses: re-actors/alls-green@release/v1 108 | with: 109 | jobs: ${{ toJSON(needs) }} 110 | 111 | release: 112 | permissions: 113 | contents: write 114 | needs: 115 | - check 116 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 117 | runs-on: ubuntu-latest 118 | 119 | steps: 120 | - uses: actions/checkout@v4 121 | - name: Setup Python 122 | uses: actions/setup-python@v5 123 | with: 124 | python-version: 3.x 125 | - name: Install tox 126 | run: python -m pip install tox 127 | - name: Run 128 | run: tox -e release 129 | env: 130 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 131 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 132 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io 2 | 3 | ### Python ### 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.9.9 4 | hooks: 5 | - id: ruff 6 | args: [--fix, --unsafe-fixes] 7 | - id: ruff-format 8 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | python: 3 | install: 4 | - path: . 5 | extra_requirements: 6 | - doc 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | # required boilerplate readthedocs/readthedocs.org#10401 12 | build: 13 | os: ubuntu-lts-latest 14 | tools: 15 | python: latest 16 | # post-checkout job to ensure the clone isn't shallow jaraco/skeleton#114 17 | jobs: 18 | post_checkout: 19 | - git fetch --unshallow || true 20 | -------------------------------------------------------------------------------- /NEWS.rst: -------------------------------------------------------------------------------- 1 | v25.6.0 2 | ======= 3 | 4 | Features 5 | -------- 6 | 7 | - Avoid logging a warning when config does not specify a backend. (#682) 8 | 9 | 10 | v25.5.0 11 | ======= 12 | 13 | Features 14 | -------- 15 | 16 | - When parsing ``keyring_path`` from the config, the home directory is now expanded from ``~``. (#696) 17 | 18 | 19 | Bugfixes 20 | -------- 21 | 22 | - In get_credential, now returns None when the indicated username is not found. (#698) 23 | 24 | 25 | v25.4.1 26 | ======= 27 | 28 | Bugfixes 29 | -------- 30 | 31 | - Fixed ValueError for AnonymousCredentials in CLI. (#694) 32 | 33 | 34 | v25.4.0 35 | ======= 36 | 37 | Features 38 | -------- 39 | 40 | - Refined type spec and interfaces on credential objects. Introduced AnonymousCredential to model a secret without a username. (#689) 41 | 42 | 43 | v25.3.0 44 | ======= 45 | 46 | Features 47 | -------- 48 | 49 | - Deprecated support for empty usernames. Now all backends will reject an empty string as input for the 'username' field when setting a password. Later this deprecation will become a more visible user warning and even later an error. If this warning is triggered in your environment, please consider using a static value (even 'username') or comment in the issue and describe the use-case that demands support for empty usernames. (#668) 50 | 51 | 52 | v25.2.1 53 | ======= 54 | 55 | Bugfixes 56 | -------- 57 | 58 | - Fix typo in CLI creds mode. (#681) 59 | 60 | 61 | v25.2.0 62 | ======= 63 | 64 | Features 65 | -------- 66 | 67 | - Added options for 'keyring get' command to support credential retrieval and emit as JSON. (#678) 68 | 69 | 70 | v25.1.0 71 | ======= 72 | 73 | Features 74 | -------- 75 | 76 | - Replace ExceptionRaisedContext with ExceptionTrap. 77 | 78 | 79 | v25.0.1 80 | ======= 81 | 82 | Bugfixes 83 | -------- 84 | 85 | - When completion is unavailable, exit with non-zero status and emit message to stderr. (#671) 86 | 87 | 88 | v25.0.0 89 | ======= 90 | 91 | Deprecations and Removals 92 | ------------------------- 93 | 94 | - Removed check for config in XDG_DATA_HOME on Linux systems. (#99) 95 | - In platform config support, remove support for Windows XP, now 10 years sunset. 96 | 97 | 98 | v24.3.1 99 | ======= 100 | 101 | Bugfixes 102 | -------- 103 | 104 | - Minor fixes to account for emergent typing and linter concerns. 105 | 106 | 107 | v24.3.0 108 | ======= 109 | 110 | Features 111 | -------- 112 | 113 | - Added bash completion support. (#643) 114 | 115 | 116 | v24.2.0 117 | ======= 118 | 119 | Features 120 | -------- 121 | 122 | - Require Python 3.8 or later. 123 | 124 | 125 | v24.1.1 126 | ======= 127 | 128 | Bugfixes 129 | -------- 130 | 131 | - Restore support for reading from a config file (with regression test). (#638) 132 | 133 | 134 | v24.1.0 135 | ======= 136 | 137 | Bugfixes 138 | -------- 139 | 140 | - Avoid logging warning when no config file is present. (#635) 141 | - Include all operations in the error message if no operation was supplied. (#636) 142 | 143 | 144 | Improved Documentation 145 | ---------------------- 146 | 147 | - Correct name of macOS backend in README. (#637) 148 | 149 | 150 | v24.0.1 151 | ======= 152 | 153 | Misc 154 | ---- 155 | 156 | - 157 | 158 | 159 | v24.0.0 160 | ======= 161 | 162 | * #633: Added ``diagnose`` command with basic support. 163 | * #487: Removed ``keyring.backends.OS_X`` module. 164 | * #593: Removed ``keyring.util.properties`` module. 165 | 166 | v23.14.0 167 | ======== 168 | 169 | * #623: On macOS, warn the user when ``keychain`` property is 170 | indicated, as that value can't be honored (macOS API no longer 171 | supports indicating the target keychain). 172 | 173 | v23.13.1 174 | ======== 175 | 176 | * #573: Fixed failure in macOS backend when attempting to set a 177 | password after previously setting a blank password, including a 178 | test applying to all backends. 179 | 180 | v23.13.0 181 | ======== 182 | 183 | * #608: Added support for tab completion on the ``keyring`` command 184 | if the ``completion`` extra is installed (``keyring[completion]``). 185 | 186 | v23.12.1 187 | ======== 188 | 189 | * #612: Prevent installation of ``pywin32-ctypes 0.1.2`` with broken 190 | ``use2to3`` directive. 191 | 192 | v23.12.0 193 | ======== 194 | 195 | * #607: Removed PSF license as it was unused and confusing. Project 196 | remains MIT licensed as always. 197 | 198 | v23.11.0 199 | ======== 200 | 201 | * #603: In ``libsecret``, check that the service is available before 202 | declaring viability. 203 | 204 | v23.10.0 205 | ======== 206 | 207 | * #526: Bump requirement on ``importlib_metadata`` to pull in fix for 208 | improperly-normalized names on egg-info. 209 | 210 | v23.9.3 211 | ======= 212 | 213 | * #596: Add workaround for devpi_client hook with wrapped implementation. 214 | 215 | v23.9.2 216 | ======= 217 | 218 | * #597: Fixed wrong name in compatibility logic for ``properties`` 219 | (masked by the compatibility fallback). 220 | 221 | v23.9.1 222 | ======= 223 | 224 | * #593: Restore ``keyring.util.properties`` with deprecation warning for 225 | backward compatibility. 226 | 227 | v23.9.0 228 | ======= 229 | 230 | * #588: Project now depends on ``jaraco.classes`` for class property support. 231 | 232 | v23.8.2 233 | ======= 234 | 235 | * #581: Corrected regression in ``libsecret`` tests (``.collection`` property). 236 | 237 | v23.8.1 238 | ======= 239 | 240 | * #587: Fix regression in ``libsecret``. 241 | 242 | v23.8.0 243 | ======= 244 | 245 | * #448: ``SecretService`` and ``libsecret`` backends now support a 246 | new ``SelectableScheme``, allowing the keys for "username" and 247 | "service" to be overridden for compatibility with other schemes 248 | such as KeePassXC. 249 | 250 | * Introduced a new ``.with_properties`` method on backends to 251 | produce a new keyring with different properties. Use for example 252 | to get a keyring with a different ``keychain`` (macOS) or 253 | ``scheme`` (SecretService/libsecret). e.g.:: 254 | 255 | keypass = keyring.get_keyring().with_properties(scheme='KeePassXC') 256 | 257 | * ``.with_keychain`` method on macOS is superseded by ``.with_properties`` 258 | and so is now deprecated. 259 | 260 | v23.7.0 261 | ======= 262 | 263 | * #582: Suppress KeyringErrors for devpi client. 264 | 265 | v23.6.0 266 | ======= 267 | 268 | * #575: Only require ``importlib_metadata`` on older Pythons. 269 | * #579: Add ``.with_keychain`` method on macOS for easy reference 270 | to alternate keychains. 271 | 272 | v23.5.1 273 | ======= 274 | 275 | * The correct config root is now used on Windows. 276 | 277 | v23.5.0 278 | ======= 279 | 280 | * Require Python 3.7 or later. 281 | 282 | v23.4.1 283 | ======= 284 | 285 | * #551: Fixed docs warnings. 286 | 287 | v23.4.0 288 | ======= 289 | 290 | * #549: EnvironCredential now allows for equality 291 | comparison. 292 | 293 | v23.3.0 294 | ======= 295 | 296 | * #529: macOS backend is no longer viable if the API module 297 | cannot be loaded. Prevents "symbol not found" errors on 298 | macOS 11 (Big Sur) and later when a "universal2" binary 299 | is not used (available for Python 3.8.7 and later). 300 | 301 | * #547: Tests no longer attempt to run macOS backends even 302 | on macOS when the backend is non-viable. 303 | 304 | * #542: Change get_credential to return generic Credential. 305 | 306 | v23.2.1 307 | ======= 308 | 309 | * #530: In libsecret tests, use a session collection to 310 | allow tests to pass on Debian. 311 | 312 | v23.2.0 313 | ======= 314 | 315 | * #521: Add libsecret backend. 316 | 317 | v23.1.0 318 | ======= 319 | 320 | * #519: macOS backend APIs updated to newer, non-legacy 321 | APIs. 322 | 323 | v23.0.1 324 | ======= 325 | 326 | * #504: Better error with invalid parameter to init_keyring. 327 | * #505: Nicer documentation for headless Docker. 328 | 329 | v23.0.0 330 | ======= 331 | 332 | * Backends now all invoke ``set_properties_from_env`` on 333 | self in the initializer. Derived backends should be sure 334 | to invoke ``super().__init__()``. 335 | 336 | v22.4.0 337 | ======= 338 | 339 | * Use new entry points API from importlib_metadata 3.6. 340 | 341 | v22.3.0 342 | ======= 343 | 344 | * Added redundant type declarations for accessor functions 345 | in ``keyring.core``. 346 | 347 | v22.2.0 348 | ======= 349 | 350 | * #487: Restored ``Keyring`` in ``OS_X`` module with 351 | deprecation warning for users specifying the backend by 352 | name. 353 | 354 | v22.1.0 355 | ======= 356 | 357 | * Added type declaration for ``keyring.core.get_keyring()``. 358 | 359 | v22.0.1 360 | ======= 361 | 362 | * #486: Restored ``keyring.backends.OS_X`` module (with no 363 | functionality) to mask errors when older keyring versions 364 | are present until underlying issue is addressed and available 365 | in importlib_metadata. 366 | 367 | v22.0.0 368 | ======= 369 | 370 | * Renamed macOS backend from ``OS_X`` to ``macOS``. 371 | Any users specifying the backend by name will need to 372 | use the new name ``keyring.backends.macOS``. 373 | 374 | v21.8.0 375 | ======= 376 | 377 | * #438: For better interoperability with other 378 | applications, ``Windows`` backend now attempts to 379 | decode passwords using UTF-8 if UTF-16 decoding fails. 380 | Passwords are still stored as UTF-16. 381 | 382 | v21.7.0 383 | ======= 384 | 385 | * #437: Package now declares typing support. 386 | 387 | v21.6.0 388 | ======= 389 | 390 | * #403: Keyring no longer eagerly initializes the backend 391 | on import, but instead defers the backend initialization 392 | until a keyring is accessed. Any callers reliant on this 393 | early initialization behavior may need to call 394 | ``keyring.core.init_backend()`` to explicitly initialize 395 | the detected backend. 396 | 397 | v21.5.0 398 | ======= 399 | 400 | * #474: SecretService and KWallet backends are now 401 | disabled if the relevant names are not available on 402 | D-Bus. Keyring should now be much more responsive 403 | in these environments. 404 | 405 | * #463: Fixed regression in KWallet ``get_credential`` 406 | where a simple string was returned instead of a 407 | SimpleCredential. 408 | 409 | v21.4.0 410 | ======= 411 | 412 | * #431: KWallet backend now supports ``get_credential``. 413 | 414 | v21.3.1 415 | ======= 416 | 417 | * #445: Suppress errors when ``sys.argv`` is not 418 | a list of at least one element. 419 | 420 | v21.3.0 421 | ======= 422 | 423 | * #440: Keyring now honors XDG_CONFIG_HOME as 424 | ``~/.config``. 425 | * #452: SecretService ``get_credential`` now returns 426 | ``None`` for unmatched query. 427 | 428 | v21.2.1 429 | ======= 430 | 431 | * #426: Restored lenience on startup when entry point 432 | metadata is missing. 433 | * #423: Avoid RecursionError when initializing backends 434 | when a limit is supplied. 435 | 436 | v21.2.0 437 | ======= 438 | 439 | * #372: Chainer now deterministically resolves at a lower 440 | priority than the Fail keyring (when there are no backends 441 | to chain). 442 | * #372: Fail keyring now raises a ``NoKeyringError`` for 443 | easier selectability. 444 | * #405: Keyring now logs at DEBUG rather than INFO during 445 | backend startup. 446 | 447 | v21.1.1 448 | ======= 449 | 450 | * Refreshed package metadata. 451 | 452 | v21.1.0 453 | ======= 454 | 455 | * #380: In SecretService backend, close connections after 456 | using them. 457 | 458 | v21.0.0 459 | ======= 460 | 461 | * Require Python 3.6 or later. 462 | 463 | v20.0.1 464 | ======= 465 | 466 | * #417: Fix TypeError when backend fails to initialize. 467 | 468 | v20.0.0 469 | ======= 470 | 471 | * Extracted ``keyring.testing`` package to contain supporting 472 | functionality for plugin backends. ``keyring.tests`` has been 473 | removed from the package. 474 | 475 | v19.3.0 476 | ======= 477 | 478 | * Switch to `importlib.metadata 479 | `_ 480 | for loading entry points. Removes one dependency on Python 3.8. 481 | 482 | * Added new ``KeyringBackend.set_properties_from_env``. 483 | 484 | * #382: Add support for alternate persistence scopes for Windows 485 | backend. Set ``.persist`` to "local machine" or "session" 486 | to enable the alternate scopes or "enterprise" to use the 487 | default scope. 488 | 489 | * #404: Improve import times when a backend is specifically 490 | configured by lazily calling ``get_all_keyring``. 491 | 492 | 19.2.0 493 | ====== 494 | 495 | * Add support for get_credential() with the SecretService backend. 496 | 497 | 19.1.0 498 | ====== 499 | 500 | * #369: macOS Keyring now honors a ``KEYCHAIN_PATH`` 501 | environment variable. If set, Keyring will use that 502 | keychain instead of the default. 503 | 504 | 19.0.2 505 | ====== 506 | 507 | * Refresh package skeleton. 508 | * Adopt `black `_ code style. 509 | 510 | 19.0.1 511 | ====== 512 | 513 | * Merge with 18.0.1. 514 | 515 | 18.0.1 516 | ====== 517 | 518 | * #386: ExceptionInfo no longer retains a reference to the 519 | traceback. 520 | 521 | 19.0.0 522 | ====== 523 | 524 | * #383: Drop support for EOL Python 2.7 - 3.4. 525 | 526 | 18.0.0 527 | ====== 528 | 529 | * #375: On macOS, the backend now raises a ``KeyringLocked`` 530 | when access to the keyring is denied (on get or set) instead 531 | of ``PasswordSetError`` or ``KeyringError``. Any API users 532 | may need to account for this change, probably by catching 533 | the parent ``KeyringError``. 534 | Additionally, the error message from the underlying error is 535 | now included in any errors that occur. 536 | 537 | 17.1.1 538 | ====== 539 | 540 | * #368: Update packaging technique to avoid 0.0.0 releases. 541 | 542 | 17.1.0 543 | ====== 544 | 545 | * #366: When calling ``keyring.core.init_backend``, if any 546 | limit function is supplied, it is saved and later honored by 547 | the ``ChainerBackend`` as well. 548 | 549 | 17.0.0 550 | ====== 551 | 552 | * #345: Remove application attribute from stored passwords 553 | using SecretService, addressing regression introduced in 554 | 10.5.0 (#292). Impacted Linux keyrings will once again 555 | prompt for a password for "Python program". 556 | 557 | 16.1.1 558 | ====== 559 | 560 | * #362: Fix error on import due to circular imports 561 | on Python 3.4. 562 | 563 | 16.1.0 564 | ====== 565 | 566 | * Refactor ChainerBackend, introduced in 16.0 to function 567 | as any other backend, activating when relevant. 568 | 569 | 16.0.2 570 | ====== 571 | 572 | * #319: In Windows backend, trap all exceptions when 573 | attempting to import pywin32. 574 | 575 | 16.0.1 576 | ====== 577 | 578 | * #357: Once again allow all positive, non-zero priority 579 | keyrings to participate. 580 | 581 | 16.0.0 582 | ====== 583 | 584 | * #323: Fix race condition in delete_password on Windows. 585 | * #352: All suitable backends (priority 1 and greater) are 586 | allowed to participate. 587 | 588 | 15.2.0 589 | ====== 590 | 591 | * #350: Added new API for ``get_credentials``, for backends 592 | that can resolve both a username and password for a service. 593 | 594 | 15.1.0 595 | ====== 596 | 597 | * #340: Add the Null keyring, disabled by default. 598 | * #340: Added ``--disable`` option to command-line 599 | interface. 600 | * #340: Now honor a ``PYTHON_KEYRING_BACKEND`` 601 | environment variable to select a backend. Environments 602 | may set to ``keyring.backends.null.Keyring`` to disable 603 | keyring. 604 | 605 | 15.0.0 606 | ====== 607 | 608 | Removed deprecated ``keyring.util.escape`` module. 609 | 610 | Fixed warning about using deprecated Abstract Base Classes 611 | from collections module. 612 | 613 | 14.0.0 614 | ====== 615 | 616 | Removed ``getpassbackend`` module and alias in 617 | ``keyring.get_pass_get_password``. Instead, just use:: 618 | 619 | keyring.get_password(getpass.getuser(), 'Python') 620 | 621 | 13.2.1 622 | ====== 623 | 624 | * #335: Fix regression in command line client. 625 | 626 | 13.2.0 627 | ====== 628 | 629 | * Keyring command-line interface now reads the password 630 | directly from stdin if stdin is connected to a pipe. 631 | 632 | 13.1.0 633 | ====== 634 | 635 | * #329: Improve output of ``keyring --list-backends``. 636 | 637 | 13.0.0 638 | ====== 639 | 640 | * #327: In kwallet backend, if the collection or item is 641 | locked, a ``KeyringLocked`` exception is raised. Clients 642 | expecting a None response from ``get_password`` under 643 | this condition will need to catch this exception. 644 | Additionally, an ``InitError`` is now raised if the 645 | connection cannot be established to the DBus. 646 | 647 | * #298: In kwallet backend, when checking an existing 648 | handle, verify that it is still valid or create a new 649 | connection. 650 | 651 | 12.2.1 652 | ====== 653 | 654 | * Fixed issue in SecretService. Ref #226. 655 | 656 | 12.2.0 657 | ====== 658 | 659 | * #322: Fix AttributeError when ``escape.__builtins__`` 660 | is a dict. 661 | 662 | * Deprecated ``keyring.util.escape`` module. If you use 663 | this module or encounter the warning (on the latest 664 | release of your packages), please `file a ticket 665 | `_. 666 | 667 | 12.1.0 668 | ====== 669 | 670 | * Unpin SecretStorage on Python 3.5+. Requires that 671 | Setuptools 17.1 be used. Note that the special 672 | handling will be unnecessary once Pip 9 can be 673 | assumed (as it will exclude SecretStorage 3 in 674 | non-viable environments). 675 | 676 | 12.0.2 677 | ====== 678 | 679 | * Pin SecretStorage to 2.x. 680 | 681 | 12.0.1 682 | ====== 683 | 684 | * #314: No changes except to rebuild. 685 | 686 | 12.0.0 687 | ====== 688 | 689 | * #310: Keyring now loads all backends through entry 690 | points. 691 | 692 | For most users, this release will be fully compatible. Some 693 | users may experience compatibility issues if entrypoints is 694 | not installed (as declared) or the metadata on which entrypoints 695 | relies is unavailable. For that reason, the package is released 696 | with a major version bump. 697 | 698 | 11.1.0 699 | ====== 700 | 701 | * #312: Use ``entrypoints`` instead of pkg_resources to 702 | avoid performance hit loading pkg_resources. Adds 703 | a dependency on ``entrypoints``. 704 | 705 | 11.0.0 706 | ====== 707 | 708 | * #294: No longer expose ``keyring.__version__`` (added 709 | in 8.1) to avoid performance hit loading pkg_resources. 710 | 711 | 10.6.0 712 | ====== 713 | 714 | * #299: Keyring exceptions are now derived from a base 715 | ``keyring.errors.KeyringError``. 716 | 717 | 10.5.1 718 | ====== 719 | 720 | * #296: Prevent AttributeError on import when Debian has 721 | created broken dbus installs. 722 | 723 | 10.5.0 724 | ====== 725 | 726 | * #287: Added ``--list-backends`` option to 727 | command-line interface. 728 | 729 | * Removed ``logger`` from ``keyring``. See #291 for related 730 | request. 731 | 732 | * #292: Set the appid for SecretService & KWallet to 733 | something meaningful. 734 | 735 | 10.4.0 736 | ====== 737 | 738 | * #279: In Kwallet, pass mainloop to SessionBus. 739 | 740 | * #278: Unpin pywin32-ctypes, but blacklist known 741 | incompatible versions. 742 | 743 | 10.3.3 744 | ====== 745 | 746 | * #278: Pin to pywin32-ctypes 0.0.1 to avoid apparent 747 | breakage introduced in 0.1.0. 748 | 749 | 10.3.2 750 | ====== 751 | 752 | * #267: More leniently unescape lowercased characters as 753 | they get re-cased by ConfigParser. 754 | 755 | 10.3.1 756 | ====== 757 | 758 | * #266: Use private compatibility model rather than six to 759 | avoid the dependency. 760 | 761 | 10.3 762 | ==== 763 | 764 | * #264: Implement devpi hook for supplying a password when 765 | logging in with `devpi `_ 766 | client. 767 | 768 | * #260: For macOS, added initial API support for internet 769 | passwords. 770 | 771 | 10.2 772 | ==== 773 | 774 | * #259: Allow to set a custom application attribute for 775 | SecretService backend. 776 | 777 | 10.1 778 | ==== 779 | 780 | * #253: Backends now expose a '.name' attribute suitable 781 | for identifying each backend to users. 782 | 783 | 10.0.2 784 | ====== 785 | 786 | * #247: Restored console script. 787 | 788 | 10.0.1 789 | ====== 790 | 791 | * Update readme to reflect test recommendations. 792 | 793 | 10.0 794 | ==== 795 | 796 | * Drop support for Python 3.2. 797 | * Test suite now uses tox instead of pytest-runner. 798 | Test requirements are now defined in tests/requirements.txt. 799 | 800 | 9.3.1 801 | ===== 802 | 803 | * Link to the new Gitter chat room is now in the 804 | readme. 805 | * Issue #235: ``kwallet`` backend now returns 806 | string objects instead of ``dbus.String`` objects, 807 | for less surprising reprs. 808 | * Minor doc fixes. 809 | 810 | 9.3 811 | === 812 | 813 | * Issue #161: In SecretService backend, unlock 814 | individual entries. 815 | 816 | 9.2.1 817 | ===== 818 | 819 | * Issue #230: Don't rely on dbus-python and instead 820 | defer to SecretStorage to describe the installation 821 | requirements. 822 | 823 | 9.2 824 | === 825 | 826 | * Issue #231 via #233: On Linux, ``secretstorage`` 827 | is now a declared dependency, allowing recommended 828 | keyring to work simply after installation. 829 | 830 | 9.1 831 | === 832 | 833 | * Issue #83 via #229: ``kwallet`` backend now stores 834 | the service name as a folder name in the backend rather 835 | than storing all passwords in a Python folder. 836 | 837 | 9.0 838 | === 839 | 840 | * Issue #217: Once again, the OS X backend uses the 841 | Framework API for invoking the Keychain service. 842 | As a result, applications utilizing this API will be 843 | authorized per application, rather than relying on the 844 | authorization of the 'security' application. Consequently, 845 | users will be prompted to authorize the system Python 846 | executable and also new Python executables, such as 847 | those created by virtualenv. 848 | #260: No longer does the keyring honor the ``store`` 849 | attribute on the keyring. Only application passwords 850 | are accessible. 851 | 852 | 8.7 853 | === 854 | 855 | * Changelog now links to issues and provides dates of 856 | releases. 857 | 858 | 8.6 859 | === 860 | 861 | * Issue #217: Add warning in OS Keyring when 'store' 862 | is set to 'internet' to determine if this feature is 863 | used in the wild. 864 | 865 | 8.5.1 866 | ===== 867 | 868 | * Pull Request #216: Kwallet backend now has lower 869 | priority than the preferred SecretService backend, 870 | now that the desktop check is no longer in place. 871 | 872 | 8.5 873 | === 874 | 875 | * Issue #168: Now prefer KF5 Kwallet to KF4. Users relying 876 | on KF4 must use prior releases. 877 | 878 | 8.4 879 | === 880 | 881 | * Pull Request #209: Better error message when no backend is 882 | available (indicating keyrings.alt as a quick workaround). 883 | * Pull Request #208: Fix pywin32-ctypes package name in 884 | requirements. 885 | 886 | 8.3 887 | === 888 | 889 | * Issue #207: Library now requires win32ctypes on Windows 890 | systems, which will be installed automatically by 891 | Setuptools 0.7 or Pip 6 (or later). 892 | * Actually removed QtKwallet, which was meant to be dropped in 893 | 8.0 but somehow remained. 894 | 895 | 8.2 896 | === 897 | 898 | * Update readme to include how-to use with Linux 899 | non-graphical environments. 900 | 901 | 8.1 902 | === 903 | 904 | * Issue #197: Add ``__version__`` attribute to keyring module. 905 | 906 | 8.0 907 | === 908 | 909 | * Issue #117: Removed all but the preferred keyring backends 910 | for each of the major desktop platforms: 911 | 912 | - keyring.backends.kwallet.DBusKeyring 913 | - keyring.backends.OS_X.Keyring 914 | - keyring.backends.SecretService.Keyring 915 | - keyring.backends.Windows.WinVaultKeyring 916 | 917 | All other keyrings 918 | have been moved to a new package, `keyrings.alt 919 | `_ and 920 | backward-compatibility aliases removed. 921 | To retain 922 | availability of these less preferred keyrings, include 923 | that package in your installation (install both keyring 924 | and keyrings.alt). 925 | 926 | As these keyrings have moved, any keyrings indicated 927 | explicitly in configuration will need to be updated to 928 | replace "keyring.backends." with "keyrings.alt.". For 929 | example, "keyring.backends.file.PlaintextKeyring" 930 | becomes "keyrings.alt.file.PlaintextKeyring". 931 | 932 | 7.3.1 933 | ===== 934 | 935 | * Issue #194: Redirect away from docs until they have something 936 | more than the changelog. Users seeking the changelog will 937 | want to follow the `direct link 938 | `_. 939 | 940 | 7.3 941 | === 942 | 943 | * Issue #117: Added support for filtering which 944 | backends are acceptable. To limit to only loading recommended 945 | keyrings (those with priority >= 1), call:: 946 | 947 | keyring.core.init_backend(limit=keyring.core.recommended) 948 | 949 | 7.2 950 | === 951 | 952 | * Pull Request #190: OS X backend now exposes a ``keychain`` 953 | attribute, which if set will be used by ``get_password`` when 954 | retrieving passwords. Useful in environments such as when 955 | running under cron where the default keychain is not the same 956 | as the default keychain in a login session. Example usage:: 957 | 958 | keyring.get_keyring().keychain = '/path/to/login.keychain' 959 | pw = keyring.get_password(...) 960 | 961 | 7.1 962 | === 963 | 964 | * Issue #186: Removed preference for keyrings based on 965 | ``XDG_CURRENT_DESKTOP`` as these values are to varied 966 | to be a reliable indicator of which keyring implementation 967 | might be preferable. 968 | 969 | 7.0.2 970 | ===== 971 | 972 | * Issue #187: Restore ``Keyring`` name in ``kwallet`` backend. 973 | Users of keyring 6.1 or later should prefer an explicit reference 974 | to DBusKeyring or QtKeyring instead. 975 | 976 | 7.0.1 977 | ===== 978 | 979 | * Issue #183 and Issue #185: Gnome keyring no longer relies 980 | on environment variables, but instead relies on the GnomeKeyring 981 | library to determine viability. 982 | 983 | 7.0 984 | === 985 | 986 | * Issue #99: Keyring now expects the config file to be located 987 | in the XDG_CONFIG_HOME rather than XDG_DATA_HOME and will 988 | fail to start if the config is found in the old location but not 989 | the new. On systems where the two locations are distinct, 990 | simply copy or symlink the config to remain compatible with 991 | older versions or move the file to work only with 7.0 and later. 992 | 993 | * Replaced Pull Request #182 with a conditional SessionBus 994 | construction, based on subsequent discussion. 995 | 996 | 6.1.1 997 | ===== 998 | 999 | * Pull Request #182: Prevent DBus from indicating as a viable 1000 | backend when no viable X DISPLAY variable is present. 1001 | 1002 | 6.1 1003 | === 1004 | 1005 | * Pull Request #174: Add DBus backend for KWallet, preferred to Qt 1006 | backend. Theoretically, it should be auto-detected based on 1007 | available libraries and interchangeable with the Qt backend. 1008 | 1009 | 6.0 1010 | === 1011 | 1012 | * Drop support for Python 2.6. 1013 | 1014 | 5.7.1 1015 | ===== 1016 | 1017 | * Updated project metadata to match Github hosting and 1018 | generally refreshed the metadata structure to match 1019 | practices with other projects. 1020 | 1021 | 5.7 1022 | === 1023 | 1024 | * Issue #177: Resolve default keyring name on Gnome using the API. 1025 | * Issue #145: Add workaround for password exposure through 1026 | process status for most passwords containing simple 1027 | characters. 1028 | 1029 | 5.6 1030 | === 1031 | 1032 | * Allow keyring to be invoked from command-line with 1033 | ``python -m keyring``. 1034 | 1035 | 5.5.1 1036 | ===== 1037 | 1038 | * Issue #156: Fixed test failures in ``pyfs`` keyring related to 1039 | 0.5 release. 1040 | 1041 | 5.5 1042 | === 1043 | 1044 | * Pull Request #176: Use recommended mechanism for checking 1045 | GnomeKeyring version. 1046 | 1047 | 5.4 1048 | === 1049 | 1050 | * Prefer setuptools_scm to hgtools. 1051 | 1052 | 5.3 1053 | === 1054 | 1055 | * Prefer hgtools to setuptools_scm due to `setuptools_scm #21 1056 | `_. 1057 | 1058 | 5.2 1059 | === 1060 | 1061 | * Prefer setuptools_scm to hgtools. 1062 | 1063 | 5.1 1064 | === 1065 | 1066 | * Host project at Github (`repo `_). 1067 | 1068 | 5.0 1069 | === 1070 | 1071 | * Version numbering is now derived from the code repository tags via `hgtools 1072 | `_. 1073 | * Build and install now requires setuptools. 1074 | 1075 | 4.1.1 1076 | ===== 1077 | 1078 | * The entry point group must look like a module name, so the group is now 1079 | "keyring.backends". 1080 | 1081 | 4.1 1082 | === 1083 | 1084 | * Added preliminary support for loading keyring backends through ``setuptools 1085 | entry points``, specifically "keyring backends". 1086 | 1087 | 4.0 1088 | === 1089 | 1090 | * Removed ``keyring_path`` parameter from ``load_keyring``. See release notes 1091 | for 3.0.3 for more details. 1092 | * Issue #22: Removed support for loading the config from the current 1093 | directory. The config file must now be located in the platform-specific 1094 | config location. 1095 | 1096 | 3.8 1097 | === 1098 | 1099 | * Issue #22: Deprecated loading of config from current directory. Support for 1100 | loading the config in this manner will be removed in a future version. 1101 | * Issue #131: Keyring now will prefer `pywin32-ctypes 1102 | `_ to pywin32 if available. 1103 | 1104 | 3.7 1105 | === 1106 | 1107 | * Gnome keyring no longer relies on the GNOME_KEYRING_CONTROL environment 1108 | variable. 1109 | * Issue #140: Restore compatibility for older versions of PyWin32. 1110 | 1111 | 3.6 1112 | === 1113 | 1114 | * `Pull Request #1 (github) `_: 1115 | Add support for packages that wish to bundle keyring by using relative 1116 | imports throughout. 1117 | 1118 | 3.5 1119 | === 1120 | 1121 | * Issue #49: Give the backend priorities a 1.5 multiplier bump when an 1122 | XDG_CURRENT_DESKTOP environment variable matches the keyring's target 1123 | environment. 1124 | * Issue #99: Clarified documentation on location of config and data files. 1125 | Prepared the code base to treat the two differently on Unix-based systems. 1126 | For now, the behavior is unchanged. 1127 | 1128 | 3.4 1129 | === 1130 | 1131 | * Extracted FileBacked and Encrypted base classes. 1132 | * Add a pyinstaller hook to expose backend modules. Ref #124 1133 | * Pull request #41: Use errno module instead of hardcoding error codes. 1134 | * SecretService backend: correctly handle cases when user dismissed 1135 | the collection creation or unlock prompt. 1136 | 1137 | 3.3 1138 | === 1139 | 1140 | * Pull request #40: KWallet backend will now honor the ``KDE_FULL_SESSION`` 1141 | environment variable as found on openSUSE. 1142 | 1143 | 3.2.1 1144 | ===== 1145 | 1146 | * SecretService backend: use a different function to check that the 1147 | backend is functional. The default collection may not exist, but 1148 | the collection will remain usable in that case. 1149 | 1150 | Also, make the error message more verbose. 1151 | 1152 | Resolves https://bugs.launchpad.net/bugs/1242412. 1153 | 1154 | 3.2 1155 | === 1156 | 1157 | * Issue #120: Invoke KeyringBackend.priority during load_keyring to ensure 1158 | that any keyring loaded is actually viable (or raises an informative 1159 | exception). 1160 | 1161 | * File keyring: 1162 | 1163 | - Issue #123: fix removing items. 1164 | - Correctly escape item name when removing. 1165 | - Use with statement when working with files. 1166 | 1167 | * Add a test for removing one item in group. 1168 | 1169 | * Issue #81: Added experimental support for third-party backends. See 1170 | `keyring.core._load_library_extensions` for information on supplying 1171 | a third-party backend. 1172 | 1173 | 3.1 1174 | === 1175 | 1176 | * All code now runs natively on both Python 2 and Python 3, no 2to3 conversion 1177 | is required. 1178 | * Testsuite: clean up, and make more use of unittest2 methods. 1179 | 1180 | 3.0.5 1181 | ===== 1182 | 1183 | * Issue #114: Fix logic in pyfs detection. 1184 | 1185 | 3.0.4 1186 | ===== 1187 | 1188 | * Issue #114: Fix detection of pyfs under Mercurial Demand Import. 1189 | 1190 | 3.0.3 1191 | ===== 1192 | 1193 | * Simplified the implementation of ``keyring.core.load_keyring``. It now uses 1194 | ``__import__`` instead of loading modules explicitly. The ``keyring_path`` 1195 | parameter to ``load_keyring`` is now deprecated. Callers should instead 1196 | ensure their module is available on ``sys.path`` before calling 1197 | ``load_keyring``. Keyring still honors ``keyring-path``. This change fixes 1198 | Issue #113 in which the explicit module loading of keyring modules was 1199 | breaking package-relative imports. 1200 | 1201 | 3.0.2 1202 | ===== 1203 | 1204 | * Renamed ``keyring.util.platform`` to ``keyring.util.platform_``. As reported 1205 | in Issue #112 and `mercurial_keyring #31 1206 | `_ and in `Mercurial 1207 | itself `_, Mercurial's Demand 1208 | Import does not honor ``absolute_import`` directives, so it's not possible 1209 | to have a module with the same name as another top-level module. A patch is 1210 | in place to fix this issue upstream, but to support older Mercurial 1211 | versions, this patch will remain for some time. 1212 | 1213 | 3.0.1 1214 | ===== 1215 | 1216 | * Ensure that modules are actually imported even in Mercurial's Demand Import 1217 | environment. 1218 | 1219 | 3.0 1220 | === 1221 | 1222 | * Removed support for Python 2.5. 1223 | * Removed names in ``keyring.backend`` moved in 1.1 and previously retained 1224 | for compatibility. 1225 | 1226 | 2.1.1 1227 | ===== 1228 | 1229 | * Restored Python 2.5 compatibility (lost in 2.0). 1230 | 1231 | 2.1 1232 | === 1233 | 1234 | * Issue #10: Added a 'store' attribute to the OS X Keyring, enabling custom 1235 | instances of the KeyringBackend to use another store, such as the 1236 | 'internet' store. For example:: 1237 | 1238 | keys = keyring.backends.OS_X.Keyring() 1239 | keys.store = 'internet' 1240 | keys.set_password(system, user, password) 1241 | keys.get_password(system, user) 1242 | 1243 | The default for all instances can be set in the class:: 1244 | 1245 | keyring.backends.OS_X.Keyring.store = 'internet' 1246 | 1247 | * GnomeKeyring: fix availability checks, and make sure the warning 1248 | message from pygobject is not printed. 1249 | 1250 | * Fixes to GnomeKeyring and SecretService tests. 1251 | 1252 | 2.0.3 1253 | ===== 1254 | 1255 | * Issue #112: Backend viability/priority checks now are more aggressive about 1256 | module presence checking, requesting ``__name__`` from imported modules to 1257 | force the demand importer to actually attempt the import. 1258 | 1259 | 2.0.2 1260 | ===== 1261 | 1262 | * Issue #111: Windows backend isn't viable on non-Windows platforms. 1263 | 1264 | 2.0.1 1265 | ===== 1266 | 1267 | * Issue #110: Fix issues with ``Windows.RegistryKeyring``. 1268 | 1269 | 2.0 1270 | === 1271 | 1272 | * Issue #80: Prioritized backend support. The primary interface for Keyring 1273 | backend classes has been refactored to now emit a 'priority' based on the 1274 | current environment (operating system, libraries available, etc). These 1275 | priorities provide an indication of the applicability of that backend for 1276 | the current environment. Users are still welcome to specify a particular 1277 | backend in configuration, but the default behavior should now be to select 1278 | the most appropriate backend by default. 1279 | 1280 | 1.6.1 1281 | ===== 1282 | 1283 | * Only include pytest-runner in 'setup requirements' when ptr invocation is 1284 | indicated in the command-line (Issue #105). 1285 | 1286 | 1.6 1287 | === 1288 | 1289 | * GNOME Keyring backend: 1290 | 1291 | - Use the same attributes (``username`` / ``service``) as the SecretService 1292 | backend uses, allow searching for old ones for compatibility. 1293 | - Also set ``application`` attribute. 1294 | - Correctly handle all types of errors, not only ``CANCELLED`` and ``NO_MATCH``. 1295 | - Avoid printing warnings to stderr when GnomeKeyring is not available. 1296 | 1297 | * Secret Service backend: 1298 | 1299 | - Use a better label for passwords, the same as GNOME Keyring backend uses. 1300 | 1301 | 1.5 1302 | === 1303 | 1304 | * SecretService: allow deleting items created using previous python-keyring 1305 | versions. 1306 | 1307 | Before the switch to secretstorage, python-keyring didn't set "application" 1308 | attribute. Now in addition to supporting searching for items without that 1309 | attribute, python-keyring also supports deleting them. 1310 | 1311 | * Use ``secretstorage.get_default_collection`` if it's available. 1312 | 1313 | On secretstorage 1.0 or later, python-keyring now tries to create the 1314 | default collection if it doesn't exist, instead of just raising the error. 1315 | 1316 | * Improvements for tests, including fix for Issue #102. 1317 | 1318 | 1.4 1319 | === 1320 | 1321 | * Switch GnomeKeyring backend to use native libgnome-keyring via 1322 | GObject Introspection, not the obsolete python-gnomekeyring module. 1323 | 1324 | 1.3 1325 | === 1326 | 1327 | * Use the `SecretStorage library `_ 1328 | to implement the Secret Service backend (instead of using dbus directly). 1329 | Now the keyring supports prompting for and deleting passwords. Fixes #69, 1330 | #77, and #93. 1331 | * Catch `gnomekeyring.IOError` per the issue `reported in Nova client 1332 | `_. 1333 | * Issue #92 Added support for delete_password on Mac OS X Keychain. 1334 | 1335 | 1.2.3 1336 | ===== 1337 | 1338 | * Fix for Encrypted File backend on Python 3. 1339 | * Issue #97 Improved support for PyPy. 1340 | 1341 | 1.2.2 1342 | ===== 1343 | 1344 | * Fixed handling situations when user cancels kwallet dialog or denies access 1345 | for the app. 1346 | 1347 | 1.2.1 1348 | ===== 1349 | 1350 | * Fix for kwallet delete. 1351 | * Fix for OS X backend on Python 3. 1352 | * Issue #84: Fix for Google backend on Python 3 (use of raw_input not caught 1353 | by 2to3). 1354 | 1355 | 1.2 1356 | === 1357 | 1358 | * Implemented delete_password on most keyrings. Keyring 2.0 will require 1359 | delete_password to implement a Keyring. Fixes #79. 1360 | 1361 | 1.1.2 1362 | ===== 1363 | 1364 | * Issue #78: pyfilesystem backend now works on Windows. 1365 | 1366 | 1.1.1 1367 | ===== 1368 | 1369 | * Fixed MANIFEST.in so .rst files are included. 1370 | 1371 | 1.1 1372 | === 1373 | 1374 | This is the last build that will support installation in a pure-distutils 1375 | mode. Subsequent releases will require setuptools/distribute to install. 1376 | Python 3 installs have always had this requirement (for 2to3 install support), 1377 | but starting with the next minor release (1.2+), setuptools will be required. 1378 | 1379 | Additionally, this release has made some substantial refactoring in an 1380 | attempt to modularize the backends. An attempt has been made to maintain 100% 1381 | backward-compatibility, although if your library does anything fancy with 1382 | module structure or clasess, some tweaking may be necessary. The 1383 | backward-compatible references will be removed in 2.0, so the 1.1+ releases 1384 | represent a transitional implementation which should work with both legacy 1385 | and updated module structure. 1386 | 1387 | * Added a console-script 'keyring' invoking the command-line interface. 1388 | * Deprecated _ExtensionKeyring. 1389 | * Moved PasswordSetError and InitError to an `errors` module (references kept 1390 | for backward-compatibility). 1391 | * Moved concrete backend implementations into their own modules (references 1392 | kept for backward compatibility): 1393 | 1394 | - OSXKeychain -> backends.OS_X.Keyring 1395 | - GnomeKeyring -> backends.Gnome.Keyring 1396 | - SecretServiceKeyring -> backends.SecretService.Keyring 1397 | - KDEKWallet -> backends.kwallet.Keyring 1398 | - BasicFileKeyring -> backends.file.BaseKeyring 1399 | - CryptedFileKeyring -> backends.file.EncryptedKeyring 1400 | - UncryptedFileKeyring -> backends.file.PlaintextKeyring 1401 | - Win32CryptoKeyring -> backends.Windows.EncryptedKeyring 1402 | - WinVaultKeyring -> backends.Windows.WinVaultKeyring 1403 | - Win32CryptoRegistry -> backends.Windows.RegistryKeyring 1404 | - select_windows_backend -> backends.Windows.select_windows_backend 1405 | - GoogleDocsKeyring -> backends.Google.DocsKeyring 1406 | - Credential -> keyring.credentials.Credential 1407 | - BaseCredential -> keyring.credentials.SimpleCredential 1408 | - EnvironCredential -> keyring.credentials.EnvironCredential 1409 | - GoogleEnvironCredential -> backends.Google.EnvironCredential 1410 | - BaseKeyczarCrypter -> backends.keyczar.BaseCrypter 1411 | - KeyczarCrypter -> backends.keyczar.Crypter 1412 | - EnvironKeyczarCrypter -> backends.keyczar.EnvironCrypter 1413 | - EnvironGoogleDocsKeyring -> backends.Google.KeyczarDocsKeyring 1414 | - BasicPyfilesystemKeyring -> backends.pyfs.BasicKeyring 1415 | - UnencryptedPyfilesystemKeyring -> backends.pyfs.PlaintextKeyring 1416 | - EncryptedPyfilesystemKeyring -> backends.pyfs.EncryptedKeyring 1417 | - EnvironEncryptedPyfilesystemKeyring -> backends.pyfs.KeyczarKeyring 1418 | - MultipartKeyringWrapper -> backends.multi.MultipartKeyringWrapper 1419 | 1420 | * Officially require Python 2.5 or greater (although unofficially, this 1421 | requirement has been in place since 0.10). 1422 | 1423 | 1.0 1424 | === 1425 | 1426 | This backward-incompatible release attempts to remove some cruft from the 1427 | codebase that's accumulated over the versions. 1428 | 1429 | * Removed legacy file relocation support. `keyring` no longer supports loading 1430 | configuration or file-based backends from ~. If upgrading from 0.8 or later, 1431 | the files should already have been migrated to their new proper locations. 1432 | If upgrading from 0.7.x or earlier, the files will have to be migrated 1433 | manually. 1434 | * Removed CryptedFileKeyring migration support. To maintain an existing 1435 | CryptedFileKeyring, one must first upgrade to 0.9.2 or later and access the 1436 | keyring before upgrading to 1.0 to retain the existing keyring. 1437 | * File System backends now create files without group and world permissions. 1438 | Fixes #67. 1439 | 1440 | 0.10.1 1441 | ====== 1442 | 1443 | * Merged 0.9.3 to include fix for #75. 1444 | 1445 | 0.10 1446 | ==== 1447 | 1448 | * Add support for using `Keyczar `_ to encrypt 1449 | keyrings. Keyczar is "an open source cryptographic toolkit designed to make 1450 | it easier and safer for developers to use cryptography in their 1451 | applications." 1452 | * Added support for storing keyrings on Google Docs or any other filesystem 1453 | supported by pyfilesystem. 1454 | * Fixed issue in Gnome Keyring when unicode is passed as the service name, 1455 | username, or password. 1456 | * Tweaked SecretService code to pass unicode to DBus, as unicode is the 1457 | preferred format. 1458 | * Issue #71 - Fixed logic in CryptedFileKeyring. 1459 | * Unencrypted keyring file will be saved with user read/write (and not group 1460 | or world read/write). 1461 | 1462 | 0.9.3 1463 | ===== 1464 | 1465 | * Ensure migration is run when get_password is called. Fixes #75. Thanks to 1466 | Marc Deslauriers for reporting the bug and supplying the patch. 1467 | 1468 | 0.9.2 1469 | ===== 1470 | 1471 | * Keyring 0.9.1 introduced a whole different storage format for the 1472 | CryptedFileKeyring, but this introduced some potential compatibility issues. 1473 | This release incorporates the security updates but reverts to the INI file 1474 | format for storage, only encrypting the passwords and leaving the service 1475 | and usernames in plaintext. Subsequent releases may incorporate a new 1476 | keyring to implement a whole-file encrypted version. Fixes #64. 1477 | * The CryptedFileKeyring now requires simplejson for Python 2.5 clients. 1478 | 1479 | 0.9.1 1480 | ===== 1481 | 1482 | * Fix for issue where SecretServiceBackend.set_password would raise a 1483 | UnicodeError on Python 3 or when a unicode password was provided on Python 1484 | 2. 1485 | * CryptedFileKeyring now uses PBKDF2 to derive the key from the user's 1486 | password and a random hash. The IV is chosen randomly as well. All the 1487 | stored passwords are encrypted at once. Any keyrings using the old format 1488 | will be automatically converted to the new format (but will no longer be 1489 | compatible with 0.9 and earlier). The user's password is no longer limited 1490 | to 32 characters. PyCrypto 2.5 or greater is now required for this keyring. 1491 | 1492 | 0.9 1493 | === 1494 | 1495 | * Add support for GTK 3 and secret service D-Bus. Fixes #52. 1496 | * Issue #60 - Use correct method for decoding. 1497 | 1498 | 0.8.1 1499 | ===== 1500 | 1501 | * Fix regression in keyring lib on Windows XP where the LOCALAPPDATA 1502 | environment variable is not present. 1503 | 1504 | 0.8 1505 | === 1506 | 1507 | * Mac OS X keyring backend now uses subprocess calls to the `security` 1508 | command instead of calling the API, which with the latest updates, no 1509 | longer allows Python to invoke from a virtualenv. Fixes issue #13. 1510 | * When using file-based storage, the keyring files are no longer stored 1511 | in the user's home directory, but are instead stored in platform-friendly 1512 | locations (`%localappdata%\Python Keyring` on Windows and according to 1513 | the freedesktop.org Base Dir Specification 1514 | (`$XDG_DATA_HOME/python_keyring` or `$HOME/.local/share/python_keyring`) 1515 | on other operating systems). This fixes #21. 1516 | 1517 | *Backward Compatibility Notice* 1518 | 1519 | Due to the new storage location for file-based keyrings, keyring 0.8 1520 | supports backward compatibility by automatically moving the password 1521 | files to the updated location. In general, users can upgrade to 0.8 and 1522 | continue to operate normally. Any applications that customize the storage 1523 | location or make assumptions about the storage location will need to take 1524 | this change into consideration. Additionally, after upgrading to 0.8, 1525 | it is not possible to downgrade to 0.7 without manually moving 1526 | configuration files. In 1.0, the backward compatibility 1527 | will be removed. 1528 | 1529 | 0.7.1 1530 | ===== 1531 | 1532 | * Removed non-ASCII characters from README and CHANGES docs (required by 1533 | distutils if we're to include them in the long_description). Fixes #55. 1534 | 1535 | 0.7 1536 | === 1537 | 1538 | * Python 3 is now supported. All tests now pass under Python 3.2 on 1539 | Windows and Linux (although Linux backend support is limited). Fixes #28. 1540 | * Extension modules on Mac and Windows replaced by pure-Python ctypes 1541 | implementations. Thanks to Jerome Laheurte. 1542 | * WinVaultKeyring now supports multiple passwords for the same service. Fixes 1543 | #47. 1544 | * Most of the tests don't require user interaction anymore. 1545 | * Entries stored in Gnome Keyring appears now with a meaningful name if you try 1546 | to browser your keyring (for ex. with Seahorse) 1547 | * Tests from Gnome Keyring no longer pollute the user own keyring. 1548 | * `keyring.util.escape` now accepts only unicode strings. Don't try to encode 1549 | strings passed to it. 1550 | 1551 | 0.6.2 1552 | ===== 1553 | 1554 | * fix compiling on OSX with XCode 4.0 1555 | 1556 | 0.6.1 1557 | ===== 1558 | 1559 | * Gnome keyring should not be used if there is no DISPLAY or if the dbus is 1560 | not around (https://bugs.launchpad.net/launchpadlib/+bug/752282). 1561 | 1562 | * Added `keyring.http` for facilitating HTTP Auth using keyring. 1563 | 1564 | * Add a utility to access the keyring from the command line. 1565 | 1566 | 0.5.1 1567 | ===== 1568 | 1569 | * Remove a spurious KDE debug message when using KWallet 1570 | 1571 | * Fix a bug that caused an exception if the user canceled the KWallet dialog 1572 | (https://bitbucket.org/kang/python-keyring-lib/issue/37/user-canceling-of-kde-wallet-dialogs). 1573 | 1574 | 0.5 1575 | === 1576 | 1577 | * Now using the existing Gnome and KDE python libs instead of custom C++ 1578 | code. 1579 | 1580 | * Using the getpass module instead of custom code 1581 | 1582 | 0.4 1583 | === 1584 | 1585 | * Fixed the setup script (some subdirs were not included in the release.) 1586 | 1587 | 0.3 1588 | === 1589 | 1590 | * Fixed keyring.core when the user doesn't have a cfg, or is not 1591 | properly configured. 1592 | 1593 | * Fixed escaping issues for usernames with non-ascii characters 1594 | 1595 | 0.2 1596 | === 1597 | 1598 | * Add support for Python 2.4+ 1599 | http://bitbucket.org/kang/python-keyring-lib/issue/2 1600 | 1601 | * Fix the bug in KDE Kwallet extension compiling 1602 | http://bitbucket.org/kang/python-keyring-lib/issue/3 1603 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/pypi/v/keyring.svg 2 | :target: https://pypi.org/project/keyring 3 | 4 | .. image:: https://img.shields.io/pypi/pyversions/keyring.svg 5 | 6 | .. image:: https://github.com/jaraco/keyring/actions/workflows/main.yml/badge.svg 7 | :target: https://github.com/jaraco/keyring/actions?query=workflow%3A%22tests%22 8 | :alt: tests 9 | 10 | .. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json 11 | :target: https://github.com/astral-sh/ruff 12 | :alt: Ruff 13 | 14 | .. image:: https://readthedocs.org/projects/keyring/badge/?version=latest 15 | :target: https://keyring.readthedocs.io/en/latest/?badge=latest 16 | 17 | .. image:: https://img.shields.io/badge/skeleton-2025-informational 18 | :target: https://blog.jaraco.com/skeleton 19 | 20 | .. image:: https://tidelift.com/badges/package/pypi/keyring 21 | :target: https://tidelift.com/subscription/pkg/pypi-keyring?utm_source=pypi-keyring&utm_medium=readme 22 | 23 | .. image:: https://badges.gitter.im/jaraco/keyring.svg 24 | :alt: Join the chat at https://gitter.im/jaraco/keyring 25 | :target: https://gitter.im/jaraco/keyring?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge 26 | 27 | The Python keyring library provides an easy way to access the 28 | system keyring service from python. It can be used in any 29 | application that needs safe password storage. 30 | 31 | These recommended keyring backends are supported: 32 | 33 | * macOS `Keychain 34 | `_ 35 | * Freedesktop `Secret Service 36 | `_ supports many DE including 37 | GNOME (requires `secretstorage `_) 38 | * KDE4 & KDE5 `KWallet `_ 39 | (requires `dbus `_) 40 | * `Windows Credential Locker 41 | `_ 42 | 43 | Other keyring implementations are available through `Third-Party Backends`_. 44 | 45 | Installation - Linux 46 | ==================== 47 | 48 | On Linux, the KWallet backend relies on dbus-python_, which does not always 49 | install correctly when using pip (compilation is needed). For best results, 50 | install dbus-python as a system package. 51 | 52 | .. _dbus-python: https://gitlab.freedesktop.org/dbus/dbus-python 53 | 54 | Compatibility - macOS 55 | ===================== 56 | 57 | macOS keychain supports macOS 11 (Big Sur) and later requires Python 3.8.7 58 | or later with the "universal2" binary. See 59 | `#525 `_ for details. 60 | 61 | Using Keyring 62 | ============= 63 | 64 | The basic usage of keyring is pretty simple: just call 65 | ``keyring.set_password`` and ``keyring.get_password``:: 66 | 67 | >>> import keyring 68 | >>> keyring.set_password("system", "username", "password") 69 | >>> keyring.get_password("system", "username") 70 | 'password' 71 | 72 | Command-line Utility 73 | -------------------- 74 | 75 | Keyring supplies a ``keyring`` command which is installed with the 76 | package. After installing keyring in most environments, the 77 | command should be available for setting, getting, and deleting 78 | passwords. For more usage information, invoke with no arguments 79 | or with ``--help`` as so:: 80 | 81 | $ keyring --help 82 | $ keyring set system username 83 | Password for 'username' in 'system': 84 | $ keyring get system username 85 | password 86 | 87 | The command-line functionality is also exposed as an executable 88 | package, suitable for invoking from Python like so:: 89 | 90 | $ python -m keyring --help 91 | $ python -m keyring set system username 92 | Password for 'username' in 'system': 93 | $ python -m keyring get system username 94 | password 95 | 96 | Tab Completion 97 | -------------- 98 | 99 | If installed via a package manager (apt, pacman, nix, homebrew, etc), 100 | these shell completions may already have been distributed with the package 101 | (no action required). 102 | 103 | Keyring provides tab completion if the ``completion`` extra is installed:: 104 | 105 | $ pip install 'keyring[completion]' 106 | 107 | Then, generate shell completions, something like:: 108 | 109 | $ keyring --print-completion bash | sudo tee /usr/share/bash-completion/completions/keyring 110 | $ keyring --print-completion zsh | sudo tee /usr/share/zsh/site-functions/_keyring 111 | $ keyring --print-completion tcsh | sudo tee /etc/profile.d/keyring.csh 112 | 113 | **Note**: the path of `/usr/share` is mainly for GNU/Linux. For other OSs, 114 | consider: 115 | 116 | - macOS (Homebrew x86): /usr/local/share 117 | - macOS (Homebrew ARM): /opt/homebrew/share 118 | - Android (Termux): /data/data/com.termux/files/usr/share 119 | - Windows (mingw64 of msys2): /mingw64/share 120 | - ... 121 | 122 | After installing the shell completions, enable them following your shell's 123 | recommended instructions. e.g.: 124 | 125 | - bash: install `bash-completion `_, 126 | and ensure ``. /usr/share/bash-completion/bash_completion`` in ``~/.bashrc``. 127 | - zsh: ensure ``autoload -Uz compinit && compinit`` appears in ``~/.zshrc``, 128 | then ``grep -w keyring ~/.zcompdump`` to verify keyring appears, indicating 129 | it was installed correctly. 130 | 131 | Configuring 132 | =========== 133 | 134 | The python keyring lib contains implementations for several backends. The 135 | library will attempt to 136 | automatically choose the most suitable backend for the current 137 | environment. Users may also specify the preferred keyring in a 138 | config file or by calling the ``set_keyring()`` function. 139 | 140 | Config file path 141 | ---------------- 142 | 143 | The configuration is stored in a file named "keyringrc.cfg" 144 | found in a platform-specific location. To determine 145 | where the config file is stored, run ``keyring diagnose``. 146 | 147 | Config file content 148 | ------------------- 149 | 150 | To specify a keyring backend, set the **default-keyring** option to the 151 | full path of the class for that backend, such as 152 | ``keyring.backends.macOS.Keyring``. 153 | 154 | If **keyring-path** is indicated, keyring will add that path to the Python 155 | module search path before loading the backend. 156 | 157 | For example, this config might be used to load the 158 | ``SimpleKeyring`` from the ``simplekeyring`` module in 159 | the ``./demo`` directory (not implemented):: 160 | 161 | [backend] 162 | default-keyring=simplekeyring.SimpleKeyring 163 | keyring-path=demo 164 | 165 | Third-Party Backends 166 | ==================== 167 | 168 | In addition to the backends provided by the core keyring package for 169 | the most common and secure use cases, there 170 | are additional keyring backend implementations available for other 171 | use cases. Simply install them to make them available: 172 | 173 | - `keyrings.cryptfile `_ 174 | - Encrypted text file storage. 175 | - `keyrings.alt `_ - "alternate", 176 | possibly-insecure backends, originally part of the core package, but 177 | available for opt-in. 178 | - `gsheet-keyring `_ 179 | - a backend that stores secrets in a Google Sheet. For use with 180 | `ipython-secrets `_. 181 | - `bitwarden-keyring `_ 182 | - a backend that stores secrets in the `BitWarden `_ 183 | password manager. 184 | - `onepassword-keyring `_ 185 | - a backend that stores secrets in the `1Password `_ password manager. 186 | - `sagecipher `_ - an encryption 187 | backend which uses the ssh agent protocol's signature operation to 188 | derive the cipher key. 189 | - `keyrings.osx_keychain_keys `_ 190 | - OSX keychain key-management, for private, public, and symmetric keys. 191 | - `keyring_pass.PasswordStoreBackend `_ 192 | - Password Store (pass) backend for python's keyring 193 | - `keyring_jeepney `__ - a 194 | pure Python backend using the secret service DBus API for desktop 195 | Linux (requires ``keyring<24``). 196 | 197 | 198 | Write your own keyring backend 199 | ============================== 200 | 201 | The interface for the backend is defined by ``keyring.backend.KeyringBackend``. 202 | Every backend should derive from that base class and define a ``priority`` 203 | attribute and three functions: ``get_password()``, ``set_password()``, and 204 | ``delete_password()``. The ``get_credential()`` function may be defined if 205 | desired. 206 | 207 | See the ``backend`` module for more detail on the interface of this class. 208 | 209 | Keyring employs entry points to allow any third-party package to implement 210 | backends without any modification to the keyring itself. Those interested in 211 | creating new backends are encouraged to create new, third-party packages 212 | in the ``keyrings`` namespace, in a manner modeled by the `keyrings.alt 213 | package `_. See the 214 | ``setup.cfg`` file 215 | in that project for hints on how to create the requisite entry points. 216 | Backends that prove essential may be considered for inclusion in the core 217 | library, although the ease of installing these third-party packages should 218 | mean that extensions may be readily available. 219 | 220 | To create an extension for Keyring, please submit a pull request to 221 | have your extension mentioned as an available extension. 222 | 223 | Runtime Configuration 224 | ===================== 225 | 226 | Keyring additionally allows programmatic configuration of the 227 | backend calling the api ``set_keyring()``. The indicated backend 228 | will subsequently be used to store and retrieve passwords. 229 | 230 | To invoke ``set_keyring``:: 231 | 232 | # define a new keyring class which extends the KeyringBackend 233 | import keyring.backend 234 | 235 | class TestKeyring(keyring.backend.KeyringBackend): 236 | """A test keyring which always outputs the same password 237 | """ 238 | priority = 1 239 | 240 | def set_password(self, servicename, username, password): 241 | pass 242 | 243 | def get_password(self, servicename, username): 244 | return "password from TestKeyring" 245 | 246 | def delete_password(self, servicename, username): 247 | pass 248 | 249 | # set the keyring for keyring lib 250 | keyring.set_keyring(TestKeyring()) 251 | 252 | # invoke the keyring lib 253 | try: 254 | keyring.set_password("demo-service", "tarek", "passexample") 255 | print("password stored successfully") 256 | except keyring.errors.PasswordSetError: 257 | print("failed to store password") 258 | print("password", keyring.get_password("demo-service", "tarek")) 259 | 260 | 261 | Disabling Keyring 262 | ================= 263 | 264 | In many cases, uninstalling keyring will never be necessary. 265 | Especially on Windows and macOS, the behavior of keyring is 266 | usually degenerate, meaning it will return empty values to 267 | the caller, allowing the caller to fall back to some other 268 | behavior. 269 | 270 | In some cases, the default behavior of keyring is undesirable and 271 | it would be preferable to disable the keyring behavior altogether. 272 | There are several mechanisms to disable keyring: 273 | 274 | - Uninstall keyring. Most applications are tolerant to keyring 275 | not being installed. Uninstalling keyring should cause those 276 | applications to fall back to the behavior without keyring. 277 | This approach affects the Python environment where keyring 278 | would otherwise have been installed. 279 | 280 | - Configure the Null keyring in the environment. Set 281 | ``PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring`` 282 | in the environment, and the ``Null`` (degenerate) backend 283 | will be used. This approach affects all uses of Keyring where 284 | that variable is set. 285 | 286 | - Permanently configure the Null keyring for the user by running 287 | ``keyring --disable`` or ``python -m keyring --disable``. 288 | This approach affects all uses of keyring for that user. 289 | 290 | 291 | Altering Keyring Behavior 292 | ========================= 293 | 294 | Keyring provides a mechanism to alter the keyring's behavior through 295 | environment variables. Each backend implements a 296 | ``KeyringBackend.set_properties_from_env``, which 297 | when invoked will find all environment variables beginning with 298 | ``KEYRING_PROPERTY_{NAME}`` and will set a property for each 299 | ``{NAME.lower()}`` on the keyring. This method is invoked during 300 | initialization for the default/configured keyring. 301 | 302 | This mechanism may be used to set some useful values on various 303 | keyrings, including: 304 | 305 | - keychain; macOS, path to an alternate keychain file 306 | - appid; Linux/SecretService, alternate ID for the application 307 | 308 | 309 | Using Keyring on Ubuntu 16.04 310 | ============================= 311 | 312 | The following is a complete transcript for installing keyring in a 313 | virtual environment on Ubuntu 16.04. No config file was used:: 314 | 315 | $ sudo apt install python3-venv libdbus-glib-1-dev 316 | $ cd /tmp 317 | $ pyvenv py3 318 | $ source py3/bin/activate 319 | $ pip install -U pip 320 | $ pip install secretstorage dbus-python 321 | $ pip install keyring 322 | $ python 323 | >>> import keyring 324 | >>> keyring.get_keyring() 325 | 326 | >>> keyring.set_password("system", "username", "password") 327 | >>> keyring.get_password("system", "username") 328 | 'password' 329 | 330 | 331 | Using Keyring on headless Linux systems 332 | ======================================= 333 | 334 | It is possible to use the SecretService backend on Linux systems without 335 | X11 server available (only D-Bus is required). In this case: 336 | 337 | * Install the `GNOME Keyring`_ daemon. 338 | * Start a D-Bus session, e.g. run ``dbus-run-session -- sh`` and run 339 | the following commands inside that shell. 340 | * Run ``gnome-keyring-daemon`` with ``--unlock`` option. The description of 341 | that option says: 342 | 343 | Read a password from stdin, and use it to unlock the login keyring 344 | or create it if the login keyring does not exist. 345 | 346 | When that command is started, enter a password into stdin and 347 | press Ctrl+D (end of data). After that, the daemon will fork into 348 | the background (use ``--foreground`` option to block). 349 | * Now you can use the SecretService backend of Keyring. Remember to 350 | run your application in the same D-Bus session as the daemon. 351 | 352 | .. _GNOME Keyring: https://wiki.gnome.org/Projects/GnomeKeyring 353 | 354 | Using Keyring on headless Linux systems in a Docker container 355 | ============================================================= 356 | 357 | It is possible to use keyring with the SecretService backend in Docker containers as well. 358 | All you need to do is install the necessary dependencies and add the `--privileged` flag 359 | to avoid any `Operation not permitted` errors when attempting to unlock the system's keyring. 360 | 361 | The following is a complete transcript for installing keyring on a Ubuntu 18:04 container:: 362 | 363 | docker run -it -d --privileged ubuntu:18.04 364 | 365 | $ apt-get update 366 | $ apt install -y gnome-keyring python3-venv python3-dev 367 | $ python3 -m venv venv 368 | $ source venv/bin/activate # source a virtual environment to avoid polluting your system 369 | $ pip3 install --upgrade pip 370 | $ pip3 install keyring 371 | $ dbus-run-session -- sh # this will drop you into a new D-bus shell 372 | $ echo 'somecredstorepass' | gnome-keyring-daemon --unlock # unlock the system's keyring 373 | 374 | $ python 375 | >>> import keyring 376 | >>> keyring.get_keyring() 377 | 378 | >>> keyring.set_password("system", "username", "password") 379 | >>> keyring.get_password("system", "username") 380 | 'password' 381 | 382 | Integration 383 | =========== 384 | 385 | API 386 | --- 387 | 388 | The keyring lib has a few functions: 389 | 390 | * ``get_keyring()``: Return the currently-loaded keyring implementation. 391 | * ``get_password(service, username)``: Returns the password stored in the 392 | active keyring. If the password does not exist, it will return None. 393 | * ``get_credential(service, username)``: Return a credential object stored 394 | in the active keyring. This object contains at least ``username`` and 395 | ``password`` attributes for the specified service, where the returned 396 | ``username`` may be different from the argument. 397 | * ``set_password(service, username, password)``: Store the password in the 398 | keyring. 399 | * ``delete_password(service, username)``: Delete the password stored in 400 | keyring. If the password does not exist, it will raise an exception. 401 | 402 | In all cases, the parameters (``service``, ``username``, ``password``) 403 | should be Unicode text. 404 | 405 | 406 | Exceptions 407 | ---------- 408 | 409 | The keyring lib raises the following exceptions: 410 | 411 | * ``keyring.errors.KeyringError``: Base Error class for all exceptions in keyring lib. 412 | * ``keyring.errors.InitError``: Raised when the keyring cannot be initialized. 413 | * ``keyring.errors.PasswordSetError``: Raised when the password cannot be set in the keyring. 414 | * ``keyring.errors.PasswordDeleteError``: Raised when the password cannot be deleted in the keyring. 415 | 416 | Get Involved 417 | ============ 418 | 419 | Python keyring lib is an open community project and eagerly 420 | welcomes contributors. 421 | 422 | * Repository: https://github.com/jaraco/keyring/ 423 | * Bug Tracker: https://github.com/jaraco/keyring/issues/ 424 | * Mailing list: http://groups.google.com/group/python-keyring 425 | 426 | Security Considerations 427 | ======================= 428 | 429 | Each built-in backend may have security considerations to understand 430 | before using this library. Authors of tools or libraries utilizing 431 | ``keyring`` are encouraged to consider these concerns. 432 | 433 | As with any list of known security concerns, this list is not exhaustive. 434 | Additional issues can be added as needed. 435 | 436 | - macOS Keychain 437 | - Any Python script or application can access secrets created by 438 | ``keyring`` from that same Python executable without the operating 439 | system prompting the user for a password. To cause any specific 440 | secret to prompt for a password every time it is accessed, locate 441 | the credential using the ``Keychain Access`` application, and in 442 | the ``Access Control`` settings, remove ``Python`` from the list 443 | of allowed applications. 444 | 445 | - Freedesktop Secret Service 446 | - No analysis has been performed 447 | 448 | - KDE4 & KDE5 KWallet 449 | - No analysis has been performed 450 | 451 | - Windows Credential Locker 452 | - No analysis has been performed 453 | 454 | Making Releases 455 | =============== 456 | 457 | This project makes use of automated releases and continuous 458 | integration. The 459 | simple workflow is to tag a commit and push it to Github. If it 460 | passes tests in CI, it will be automatically deployed to PyPI. 461 | 462 | Other things to consider when making a release: 463 | 464 | - Check that the changelog is current for the intended release. 465 | 466 | Running Tests 467 | ============= 468 | 469 | Tests are continuously run in Github Actions. 470 | 471 | To run the tests locally, install and invoke 472 | `tox `_. 473 | 474 | Background 475 | ========== 476 | 477 | The project was based on Tarek Ziade's idea in `this post`_. Kang Zhang 478 | initially carried it out as a `Google Summer of Code`_ project, and Tarek 479 | mentored Kang on this project. 480 | 481 | .. _this post: http://tarekziade.wordpress.com/2009/03/27/pycon-hallway-session-1-a-keyring-library-for-python/ 482 | .. _Google Summer of Code: http://socghop.appspot.com/ 483 | 484 | For Enterprise 485 | ============== 486 | 487 | Available as part of the Tidelift Subscription. 488 | 489 | This project and the maintainers of thousands of other packages are working with Tidelift to deliver one enterprise subscription that covers all of the open source you use. 490 | 491 | `Learn more `_. 492 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Contact 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | 4 | not_macOS = platform.system() != 'Darwin' 5 | 6 | collect_ignore = ["hook-keyring.backend.py"] + [ 7 | 'keyring/backends/macOS/api.py' 8 | ] * not_macOS 9 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | extensions = [ 4 | 'sphinx.ext.autodoc', 5 | 'jaraco.packaging.sphinx', 6 | ] 7 | 8 | master_doc = "index" 9 | html_theme = "furo" 10 | 11 | # Link dates and other references in the changelog 12 | extensions += ['rst.linker'] 13 | link_files = { 14 | '../NEWS.rst': dict( 15 | using=dict(GH='https://github.com'), 16 | replace=[ 17 | dict( 18 | pattern=r'(Issue #|\B#)(?P\d+)', 19 | url='{package_url}/issues/{issue}', 20 | ), 21 | dict( 22 | pattern=r'(?m:^((?Pv?\d+(\.\d+){1,2}))\n[-=]+\n)', 23 | with_scm='{text}\n{rev[timestamp]:%d %b %Y}\n', 24 | ), 25 | dict( 26 | pattern=r'PEP[- ](?P\d+)', 27 | url='https://peps.python.org/pep-{pep_number:0>4}/', 28 | ), 29 | ], 30 | ) 31 | } 32 | 33 | # Be strict about any broken references 34 | nitpicky = True 35 | nitpick_ignore: list[tuple[str, str]] = [] 36 | 37 | # Include Python intersphinx mapping to prevent failures 38 | # jaraco/skeleton#51 39 | extensions += ['sphinx.ext.intersphinx'] 40 | intersphinx_mapping = { 41 | 'python': ('https://docs.python.org/3', None), 42 | } 43 | 44 | # Preserve authored syntax for defaults 45 | autodoc_preserve_defaults = True 46 | 47 | # Add support for linking usernames, PyPI projects, Wikipedia pages 48 | github_url = 'https://github.com/' 49 | extlinks = { 50 | 'user': (f'{github_url}%s', '@%s'), 51 | 'pypi': ('https://pypi.org/project/%s', '%s'), 52 | 'wiki': ('https://wikipedia.org/wiki/%s', '%s'), 53 | } 54 | extensions += ['sphinx.ext.extlinks'] 55 | 56 | # local 57 | 58 | extensions += ['jaraco.tidelift'] 59 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 2 2 | 3 | .. _changes: 4 | 5 | History 6 | ******* 7 | 8 | .. include:: ../NEWS (links).rst 9 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to |project| documentation! 2 | =================================== 3 | 4 | .. sidebar-links:: 5 | :home: 6 | :pypi: 7 | 8 | .. include:: ../README.rst 9 | 10 | .. toctree:: 11 | :maxdepth: 1 12 | 13 | history 14 | 15 | .. tidelift-referral-banner:: 16 | 17 | .. automodule:: keyring 18 | :members: 19 | :undoc-members: 20 | :show-inheritance: 21 | 22 | .. automodule:: keyring.backend 23 | :members: 24 | :undoc-members: 25 | :show-inheritance: 26 | 27 | .. automodule:: keyring.core 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | 32 | .. automodule:: keyring.credentials 33 | :members: 34 | :undoc-members: 35 | :show-inheritance: 36 | 37 | 38 | Indices and tables 39 | ================== 40 | 41 | * :ref:`genindex` 42 | * :ref:`modindex` 43 | * :ref:`search` 44 | 45 | -------------------------------------------------------------------------------- /hook-keyring.backend.py: -------------------------------------------------------------------------------- 1 | """ 2 | Hook used by pyinstaller to expose hidden imports. 3 | """ 4 | 5 | from keyring.compat.py312 import metadata 6 | 7 | hiddenimports = [ep.value for ep in metadata.entry_points(group='keyring.backends')] 8 | -------------------------------------------------------------------------------- /keyring/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import ( 2 | delete_password, 3 | get_credential, 4 | get_keyring, 5 | get_password, 6 | set_keyring, 7 | set_password, 8 | ) 9 | 10 | __all__ = ( 11 | 'set_keyring', 12 | 'get_keyring', 13 | 'set_password', 14 | 'get_password', 15 | 'delete_password', 16 | 'get_credential', 17 | ) 18 | -------------------------------------------------------------------------------- /keyring/__main__.py: -------------------------------------------------------------------------------- 1 | if __name__ == '__main__': 2 | from keyring import cli 3 | 4 | cli.main() 5 | -------------------------------------------------------------------------------- /keyring/backend.py: -------------------------------------------------------------------------------- 1 | """ 2 | Keyring implementation support 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import abc 8 | import copy 9 | import functools 10 | import logging 11 | import operator 12 | import os 13 | import typing 14 | import warnings 15 | 16 | from jaraco.context import ExceptionTrap 17 | from jaraco.functools import once 18 | 19 | from . import credentials, errors, util 20 | from .compat import properties 21 | from .compat.py312 import metadata 22 | 23 | log = logging.getLogger(__name__) 24 | 25 | 26 | by_priority = operator.attrgetter('priority') 27 | _limit: typing.Callable[[KeyringBackend], bool] | None = None 28 | 29 | 30 | class KeyringBackendMeta(abc.ABCMeta): 31 | """ 32 | Specialized subclass behavior. 33 | 34 | Keeps a registry of all (non-abstract) types. 35 | 36 | Wraps set_password to validate the username. 37 | """ 38 | 39 | def __init__(cls, name, bases, dict): 40 | super().__init__(name, bases, dict) 41 | cls._register() 42 | cls._validate_username_in_set_password() 43 | 44 | def _register(cls): 45 | if not hasattr(cls, '_classes'): 46 | cls._classes = set() 47 | classes = cls._classes 48 | if not cls.__abstractmethods__: 49 | classes.add(cls) 50 | 51 | def _validate_username_in_set_password(cls): 52 | """ 53 | Wrap ``set_password`` such to validate the passed username. 54 | """ 55 | orig = cls.set_password 56 | 57 | @functools.wraps(orig) 58 | def wrapper(self, system, username, *args, **kwargs): 59 | self._validate_username(username) 60 | return orig(self, system, username, *args, **kwargs) 61 | 62 | cls.set_password = wrapper 63 | 64 | 65 | class KeyringBackend(metaclass=KeyringBackendMeta): 66 | """The abstract base class of the keyring, every backend must implement 67 | this interface. 68 | """ 69 | 70 | def __init__(self): 71 | self.set_properties_from_env() 72 | 73 | @properties.classproperty 74 | def priority(self) -> float: 75 | """ 76 | Each backend class must supply a priority, a number (float or integer) 77 | indicating the priority of the backend relative to all other backends. 78 | The priority need not be static -- it may (and should) vary based 79 | attributes of the environment in which is runs (platform, available 80 | packages, etc.). 81 | 82 | A higher number indicates a higher priority. The priority should raise 83 | a RuntimeError with a message indicating the underlying cause if the 84 | backend is not suitable for the current environment. 85 | 86 | As a rule of thumb, a priority between zero but less than one is 87 | suitable, but a priority of one or greater is recommended. 88 | """ 89 | raise NotImplementedError 90 | 91 | # Python 3.8 compatibility 92 | passes = ExceptionTrap().passes 93 | 94 | @properties.classproperty 95 | @passes 96 | def viable(cls): 97 | cls.priority # noqa: B018 98 | 99 | @classmethod 100 | def get_viable_backends( 101 | cls: type[KeyringBackend], 102 | ) -> filter[type[KeyringBackend]]: 103 | """ 104 | Return all subclasses deemed viable. 105 | """ 106 | return filter(operator.attrgetter('viable'), cls._classes) 107 | 108 | @properties.classproperty 109 | def name(cls) -> str: 110 | """ 111 | The keyring name, suitable for display. 112 | 113 | The name is derived from module and class name. 114 | """ 115 | parent, sep, mod_name = cls.__module__.rpartition('.') 116 | mod_name = mod_name.replace('_', ' ') 117 | # mypy doesn't see `cls` is `type[Self]`, might be fixable in jaraco.classes 118 | return ' '.join([mod_name, cls.__name__]) # type: ignore[attr-defined] 119 | 120 | def __str__(self) -> str: 121 | keyring_class = type(self) 122 | return f"{keyring_class.__module__}.{keyring_class.__name__} (priority: {keyring_class.priority:g})" 123 | 124 | @abc.abstractmethod 125 | def get_password(self, service: str, username: str) -> str | None: 126 | """Get password of the username for the service""" 127 | return None 128 | 129 | def _validate_username(self, username: str) -> None: 130 | """ 131 | Ensure the username is not empty. 132 | """ 133 | if not username: 134 | warnings.warn( 135 | "Empty usernames are deprecated. See #668", 136 | DeprecationWarning, 137 | stacklevel=3, 138 | ) 139 | # raise ValueError("Username cannot be empty") 140 | 141 | @abc.abstractmethod 142 | def set_password(self, service: str, username: str, password: str) -> None: 143 | """Set password for the username of the service. 144 | 145 | If the backend cannot store passwords, raise 146 | PasswordSetError. 147 | """ 148 | raise errors.PasswordSetError("reason") 149 | 150 | # for backward-compatibility, don't require a backend to implement 151 | # delete_password 152 | # @abc.abstractmethod 153 | def delete_password(self, service: str, username: str) -> None: 154 | """Delete the password for the username of the service. 155 | 156 | If the backend cannot delete passwords, raise 157 | PasswordDeleteError. 158 | """ 159 | raise errors.PasswordDeleteError("reason") 160 | 161 | # for backward-compatibility, don't require a backend to implement 162 | # get_credential 163 | # @abc.abstractmethod 164 | def get_credential( 165 | self, 166 | service: str, 167 | username: str | None, 168 | ) -> credentials.Credential | None: 169 | """Gets the username and password for the service. 170 | Returns a Credential instance. 171 | 172 | The *username* argument is optional and may be omitted by 173 | the caller or ignored by the backend. Callers must use the 174 | returned username. 175 | """ 176 | # The default implementation requires a username here. 177 | if username is not None: 178 | password = self.get_password(service, username) 179 | if password is not None: 180 | return credentials.SimpleCredential(username, password) 181 | return None 182 | 183 | def set_properties_from_env(self) -> None: 184 | """For all KEYRING_PROPERTY_* env var, set that property.""" 185 | 186 | def parse(item: tuple[str, str]): 187 | key, value = item 188 | pre, sep, name = key.partition('KEYRING_PROPERTY_') 189 | return sep and (name.lower(), value) 190 | 191 | props: filter[tuple[str, str]] = filter(None, map(parse, os.environ.items())) 192 | for name, value in props: 193 | setattr(self, name, value) 194 | 195 | def with_properties(self, **kwargs: typing.Any) -> KeyringBackend: 196 | alt = copy.copy(self) 197 | vars(alt).update(kwargs) 198 | return alt 199 | 200 | 201 | class Crypter: 202 | """Base class providing encryption and decryption""" 203 | 204 | @abc.abstractmethod 205 | def encrypt(self, value): 206 | """Encrypt the value.""" 207 | pass 208 | 209 | @abc.abstractmethod 210 | def decrypt(self, value): 211 | """Decrypt the value.""" 212 | pass 213 | 214 | 215 | class NullCrypter(Crypter): 216 | """A crypter that does nothing""" 217 | 218 | def encrypt(self, value): 219 | return value 220 | 221 | def decrypt(self, value): 222 | return value 223 | 224 | 225 | def _load_plugins() -> None: 226 | """ 227 | Locate all setuptools entry points by the name 'keyring backends' 228 | and initialize them. 229 | Any third-party library may register an entry point by adding the 230 | following to their setup.cfg:: 231 | 232 | [options.entry_points] 233 | keyring.backends = 234 | plugin_name = mylib.mymodule:initialize_func 235 | 236 | `plugin_name` can be anything, and is only used to display the name 237 | of the plugin at initialization time. 238 | 239 | `initialize_func` is optional, but will be invoked if callable. 240 | """ 241 | for ep in metadata.entry_points(group='keyring.backends'): 242 | try: 243 | log.debug('Loading %s', ep.name) 244 | init_func = ep.load() 245 | if callable(init_func): 246 | init_func() 247 | except Exception: 248 | log.exception(f"Error initializing plugin {ep}.") 249 | 250 | 251 | @once 252 | def get_all_keyring() -> list[KeyringBackend]: 253 | """ 254 | Return a list of all implemented keyrings that can be constructed without 255 | parameters. 256 | """ 257 | _load_plugins() 258 | viable_classes = KeyringBackend.get_viable_backends() 259 | rings = util.suppress_exceptions(viable_classes, exceptions=TypeError) 260 | return list(rings) 261 | 262 | 263 | class SchemeSelectable: 264 | """ 265 | Allow a backend to select different "schemes" for the 266 | username and service. 267 | 268 | >>> backend = SchemeSelectable() 269 | >>> backend._query('contoso', 'alice') 270 | {'username': 'alice', 'service': 'contoso'} 271 | >>> backend._query('contoso') 272 | {'service': 'contoso'} 273 | >>> backend.scheme = 'KeePassXC' 274 | >>> backend._query('contoso', 'alice') 275 | {'UserName': 'alice', 'Title': 'contoso'} 276 | >>> backend._query('contoso', 'alice', foo='bar') 277 | {'UserName': 'alice', 'Title': 'contoso', 'foo': 'bar'} 278 | """ 279 | 280 | scheme = 'default' 281 | schemes = dict( 282 | default=dict(username='username', service='service'), 283 | KeePassXC=dict(username='UserName', service='Title'), 284 | ) 285 | 286 | def _query( 287 | self, service: str, username: str | None = None, **base: typing.Any 288 | ) -> dict[str, str]: 289 | scheme = self.schemes[self.scheme] 290 | return dict( 291 | { 292 | scheme['username']: username, 293 | scheme['service']: service, 294 | } 295 | if username is not None 296 | else { 297 | scheme['service']: service, 298 | }, 299 | **base, 300 | ) 301 | -------------------------------------------------------------------------------- /keyring/backend_complete.bash: -------------------------------------------------------------------------------- 1 | # Complete keyring backends for `keyring -b` from `keyring --list-backends` 2 | # # keyring -b 3 | # keyring.backends.chainer.ChainerBackend keyring.backends.fail.Keyring ... 4 | 5 | _keyring_backends() { 6 | local choices 7 | choices=$( 8 | "${COMP_WORDS[0]}" --list-backends 2>/dev/null | 9 | while IFS=$' \t' read -r backend rest; do 10 | printf "%s\n" "$backend" 11 | done 12 | ) 13 | compgen -W "${choices[*]}" -- "$1" 14 | } 15 | -------------------------------------------------------------------------------- /keyring/backend_complete.zsh: -------------------------------------------------------------------------------- 1 | # Complete keyring backends for `keyring -b` from `keyring --list-backends` 2 | # % keyring -b 3 | # keyring priority 4 | # keyring.backends.chainer.ChainerBackend 10 5 | # keyring.backends.fail.Keyring 0 6 | # ... ... 7 | 8 | backend_complete() { 9 | local line 10 | while read -r line; do 11 | choices+=(${${line/ \(priority: /\\\\:}/)/}) 12 | done <<< "$($words[1] --list-backends)" 13 | _arguments "*:keyring priority:(($choices))" 14 | } 15 | -------------------------------------------------------------------------------- /keyring/backends/SecretService.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from contextlib import closing 3 | 4 | from jaraco.context import ExceptionTrap 5 | 6 | from .. import backend 7 | from ..backend import KeyringBackend 8 | from ..compat import properties 9 | from ..credentials import SimpleCredential 10 | from ..errors import ( 11 | InitError, 12 | KeyringLocked, 13 | PasswordDeleteError, 14 | ) 15 | 16 | try: 17 | import secretstorage 18 | import secretstorage.exceptions as exceptions 19 | except ImportError: 20 | pass 21 | except AttributeError: 22 | # See https://github.com/jaraco/keyring/issues/296 23 | pass 24 | 25 | log = logging.getLogger(__name__) 26 | 27 | 28 | class Keyring(backend.SchemeSelectable, KeyringBackend): 29 | """Secret Service Keyring""" 30 | 31 | appid = 'Python keyring library' 32 | 33 | @properties.classproperty 34 | def priority(cls) -> float: 35 | with ExceptionTrap() as exc: 36 | secretstorage.__name__ # noqa: B018 37 | if exc: 38 | raise RuntimeError("SecretStorage required") 39 | if secretstorage.__version_tuple__ < (3, 2): 40 | raise RuntimeError("SecretStorage 3.2 or newer required") 41 | try: 42 | with closing(secretstorage.dbus_init()) as connection: 43 | if not secretstorage.check_service_availability(connection): 44 | raise RuntimeError( 45 | "The Secret Service daemon is neither running nor " 46 | "activatable through D-Bus" 47 | ) 48 | except exceptions.SecretStorageException as e: 49 | raise RuntimeError(f"Unable to initialize SecretService: {e}") from e 50 | return 5 51 | 52 | def get_preferred_collection(self): 53 | """If self.preferred_collection contains a D-Bus path, 54 | the collection at that address is returned. Otherwise, 55 | the default collection is returned. 56 | """ 57 | bus = secretstorage.dbus_init() 58 | try: 59 | if hasattr(self, 'preferred_collection'): 60 | collection = secretstorage.Collection(bus, self.preferred_collection) 61 | else: 62 | collection = secretstorage.get_default_collection(bus) 63 | except exceptions.SecretStorageException as e: 64 | raise InitError(f"Failed to create the collection: {e}.") from e 65 | if collection.is_locked(): 66 | collection.unlock() 67 | if collection.is_locked(): # User dismissed the prompt 68 | raise KeyringLocked("Failed to unlock the collection!") 69 | return collection 70 | 71 | def unlock(self, item): 72 | if hasattr(item, 'unlock'): 73 | item.unlock() 74 | if item.is_locked(): # User dismissed the prompt 75 | raise KeyringLocked('Failed to unlock the item!') 76 | 77 | def get_password(self, service, username): 78 | """Get password of the username for the service""" 79 | collection = self.get_preferred_collection() 80 | with closing(collection.connection): 81 | items = collection.search_items(self._query(service, username)) 82 | for item in items: 83 | self.unlock(item) 84 | return item.get_secret().decode('utf-8') 85 | 86 | def set_password(self, service, username, password): 87 | """Set password for the username of the service""" 88 | collection = self.get_preferred_collection() 89 | attributes = self._query(service, username, application=self.appid) 90 | label = f"Password for '{username}' on '{service}'" 91 | with closing(collection.connection): 92 | collection.create_item(label, attributes, password, replace=True) 93 | 94 | def delete_password(self, service, username): 95 | """Delete the stored password (only the first one)""" 96 | collection = self.get_preferred_collection() 97 | with closing(collection.connection): 98 | items = collection.search_items(self._query(service, username)) 99 | for item in items: 100 | return item.delete() 101 | raise PasswordDeleteError("No such password!") 102 | 103 | def get_credential(self, service, username): 104 | """Gets the first username and password for a service. 105 | Returns a Credential instance 106 | 107 | The username can be omitted, but if there is one, it will use get_password 108 | and return a SimpleCredential containing the username and password 109 | Otherwise, it will return the first username and password combo that it finds. 110 | """ 111 | scheme = self.schemes[self.scheme] 112 | query = self._query(service, username) 113 | collection = self.get_preferred_collection() 114 | 115 | with closing(collection.connection): 116 | items = collection.search_items(query) 117 | for item in items: 118 | self.unlock(item) 119 | username = item.get_attributes().get(scheme['username']) 120 | return SimpleCredential(username, item.get_secret().decode('utf-8')) 121 | -------------------------------------------------------------------------------- /keyring/backends/Windows.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from jaraco.context import ExceptionTrap 6 | 7 | from ..backend import KeyringBackend 8 | from ..compat import properties 9 | from ..credentials import SimpleCredential 10 | from ..errors import PasswordDeleteError 11 | 12 | with ExceptionTrap() as missing_deps: 13 | try: 14 | # prefer pywin32-ctypes 15 | from win32ctypes.pywin32 import pywintypes, win32cred 16 | 17 | # force demand import to raise ImportError 18 | win32cred.__name__ # noqa: B018 19 | except ImportError: 20 | # fallback to pywin32 21 | import pywintypes 22 | import win32cred 23 | 24 | # force demand import to raise ImportError 25 | win32cred.__name__ # noqa: B018 26 | 27 | log = logging.getLogger(__name__) 28 | 29 | 30 | class Persistence: 31 | def __get__(self, keyring, type=None): 32 | return getattr(keyring, '_persist', win32cred.CRED_PERSIST_ENTERPRISE) 33 | 34 | def __set__(self, keyring, value): 35 | """ 36 | Set the persistence value on the Keyring. Value may be 37 | one of the win32cred.CRED_PERSIST_* constants or a 38 | string representing one of those constants. For example, 39 | 'local machine' or 'session'. 40 | """ 41 | if isinstance(value, str): 42 | attr = 'CRED_PERSIST_' + value.replace(' ', '_').upper() 43 | value = getattr(win32cred, attr) 44 | keyring._persist = value 45 | 46 | 47 | class DecodingCredential(dict): 48 | @property 49 | def value(self): 50 | """ 51 | Attempt to decode the credential blob as UTF-16 then UTF-8. 52 | """ 53 | cred = self['CredentialBlob'] 54 | try: 55 | return cred.decode('utf-16') 56 | except UnicodeDecodeError: 57 | decoded_cred_utf8 = cred.decode('utf-8') 58 | log.warning( 59 | "Retrieved a UTF-8 encoded credential. Please be aware that " 60 | "this library only writes credentials in UTF-16." 61 | ) 62 | return decoded_cred_utf8 63 | 64 | 65 | class WinVaultKeyring(KeyringBackend): 66 | """ 67 | WinVaultKeyring stores encrypted passwords using the Windows Credential 68 | Manager. 69 | 70 | Requires pywin32 71 | 72 | This backend does some gymnastics to simulate multi-user support, 73 | which WinVault doesn't support natively. See 74 | https://github.com/jaraco/keyring/issues/47#issuecomment-75763152 75 | for details on the implementation, but here's the gist: 76 | 77 | Passwords are stored under the service name unless there is a collision 78 | (another password with the same service name but different user name), 79 | in which case the previous password is moved into a compound name: 80 | {username}@{service} 81 | """ 82 | 83 | persist = Persistence() 84 | 85 | @properties.classproperty 86 | def priority(cls) -> float: 87 | """ 88 | If available, the preferred backend on Windows. 89 | """ 90 | if missing_deps: 91 | raise RuntimeError("Requires Windows and pywin32") 92 | return 5 93 | 94 | @staticmethod 95 | def _compound_name(username, service): 96 | return f'{username}@{service}' 97 | 98 | def get_password(self, service, username): 99 | res = self._resolve_credential(service, username) 100 | return res and res.value 101 | 102 | def _resolve_credential( 103 | self, service: str, username: str | None 104 | ) -> DecodingCredential | None: 105 | # first attempt to get the password under the service name 106 | res = self._read_credential(service) 107 | if not res or username and res['UserName'] != username: 108 | # It wasn't found so attempt to get it with the compound name 109 | res = self._read_credential(self._compound_name(username, service)) 110 | return res 111 | 112 | def _read_credential(self, target): 113 | try: 114 | res = win32cred.CredRead( 115 | Type=win32cred.CRED_TYPE_GENERIC, TargetName=target 116 | ) 117 | except pywintypes.error as e: 118 | if e.winerror == 1168 and e.funcname == 'CredRead': # not found 119 | return None 120 | raise 121 | return DecodingCredential(res) 122 | 123 | def set_password(self, service, username, password): 124 | existing_pw = self._read_credential(service) 125 | if existing_pw: 126 | # resave the existing password using a compound target 127 | existing_username = existing_pw['UserName'] 128 | target = self._compound_name(existing_username, service) 129 | self._set_password( 130 | target, 131 | existing_username, 132 | existing_pw.value, 133 | ) 134 | self._set_password(service, username, str(password)) 135 | 136 | def _set_password(self, target, username, password): 137 | credential = dict( 138 | Type=win32cred.CRED_TYPE_GENERIC, 139 | TargetName=target, 140 | UserName=username, 141 | CredentialBlob=password, 142 | Comment="Stored using python-keyring", 143 | Persist=self.persist, 144 | ) 145 | win32cred.CredWrite(credential, 0) 146 | 147 | def delete_password(self, service, username): 148 | compound = self._compound_name(username, service) 149 | deleted = False 150 | for target in service, compound: 151 | existing_pw = self._read_credential(target) 152 | if existing_pw and existing_pw['UserName'] == username: 153 | deleted = True 154 | self._delete_password(target) 155 | if not deleted: 156 | raise PasswordDeleteError(service) 157 | 158 | def _delete_password(self, target): 159 | try: 160 | win32cred.CredDelete(Type=win32cred.CRED_TYPE_GENERIC, TargetName=target) 161 | except pywintypes.error as e: 162 | if e.winerror == 1168 and e.funcname == 'CredDelete': # not found 163 | return 164 | raise 165 | 166 | def get_credential(self, service, username): 167 | res = self._resolve_credential(service, username) 168 | return res and SimpleCredential(res['UserName'], res.value) 169 | -------------------------------------------------------------------------------- /keyring/backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaraco/keyring/71dedcbcf21847f358ad0510ea6bff59de1449a2/keyring/backends/__init__.py -------------------------------------------------------------------------------- /keyring/backends/chainer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Keyring Chainer - iterates over other viable backends to 3 | discover passwords in each. 4 | """ 5 | 6 | from .. import backend 7 | from ..compat import properties 8 | from . import fail 9 | 10 | 11 | class ChainerBackend(backend.KeyringBackend): 12 | """ 13 | >>> ChainerBackend() 14 | 15 | """ 16 | 17 | # override viability as 'priority' cannot be determined 18 | # until other backends have been constructed 19 | viable = True 20 | 21 | @properties.classproperty 22 | def priority(cls) -> float: 23 | """ 24 | If there are backends to chain, high priority 25 | Otherwise very low priority since our operation when empty 26 | is the same as null. 27 | """ 28 | return 10 if len(cls.backends) > 1 else (fail.Keyring.priority - 1) 29 | 30 | @properties.classproperty 31 | def backends(cls): 32 | """ 33 | Discover all keyrings for chaining. 34 | """ 35 | 36 | def allow(keyring): 37 | limit = backend._limit or bool 38 | return ( 39 | not isinstance(keyring, ChainerBackend) 40 | and limit(keyring) 41 | and keyring.priority > 0 42 | ) 43 | 44 | allowed = filter(allow, backend.get_all_keyring()) 45 | return sorted(allowed, key=backend.by_priority, reverse=True) 46 | 47 | def get_password(self, service, username): 48 | for keyring in self.backends: 49 | password = keyring.get_password(service, username) 50 | if password is not None: 51 | return password 52 | 53 | def set_password(self, service, username, password): 54 | for keyring in self.backends: 55 | try: 56 | return keyring.set_password(service, username, password) 57 | except NotImplementedError: 58 | pass 59 | 60 | def delete_password(self, service, username): 61 | for keyring in self.backends: 62 | try: 63 | return keyring.delete_password(service, username) 64 | except NotImplementedError: 65 | pass 66 | 67 | def get_credential(self, service, username): 68 | for keyring in self.backends: 69 | credential = keyring.get_credential(service, username) 70 | if credential is not None: 71 | return credential 72 | -------------------------------------------------------------------------------- /keyring/backends/fail.py: -------------------------------------------------------------------------------- 1 | from ..backend import KeyringBackend 2 | from ..compat import properties 3 | from ..errors import NoKeyringError 4 | 5 | 6 | class Keyring(KeyringBackend): 7 | """ 8 | Keyring that raises error on every operation. 9 | 10 | >>> kr = Keyring() 11 | >>> kr.get_password('svc', 'user') 12 | Traceback (most recent call last): 13 | ... 14 | keyring.errors.NoKeyringError: ...No recommended backend... 15 | """ 16 | 17 | @properties.classproperty 18 | def priority(cls) -> float: 19 | return 0 20 | 21 | def get_password(self, service, username, password=None): 22 | msg = ( 23 | "No recommended backend was available. Install a recommended 3rd " 24 | "party backend package; or, install the keyrings.alt package if " 25 | "you want to use the non-recommended backends. See " 26 | "https://pypi.org/project/keyring for details." 27 | ) 28 | raise NoKeyringError(msg) 29 | 30 | set_password = delete_password = get_password 31 | -------------------------------------------------------------------------------- /keyring/backends/kwallet.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os 3 | import sys 4 | 5 | from ..backend import KeyringBackend 6 | from ..compat import properties 7 | from ..credentials import SimpleCredential 8 | from ..errors import InitError, KeyringLocked, PasswordDeleteError, PasswordSetError 9 | 10 | try: 11 | import dbus 12 | from dbus.mainloop.glib import DBusGMainLoop 13 | except ImportError: 14 | pass 15 | except AttributeError: 16 | # See https://github.com/jaraco/keyring/issues/296 17 | pass 18 | 19 | 20 | def _id_from_argv(): 21 | """ 22 | Safely infer an app id from sys.argv. 23 | """ 24 | allowed = AttributeError, IndexError, TypeError 25 | with contextlib.suppress(allowed): 26 | return sys.argv[0] 27 | 28 | 29 | class DBusKeyring(KeyringBackend): 30 | """ 31 | KDE KWallet 5 via D-Bus 32 | """ 33 | 34 | appid = _id_from_argv() or 'Python keyring library' 35 | wallet = None 36 | bus_name = 'org.kde.kwalletd5' 37 | object_path = '/modules/kwalletd5' 38 | 39 | @properties.classproperty 40 | def priority(cls) -> float: 41 | if 'dbus' not in globals(): 42 | raise RuntimeError('python-dbus not installed') 43 | try: 44 | bus = dbus.SessionBus(mainloop=DBusGMainLoop()) 45 | except dbus.DBusException as exc: 46 | raise RuntimeError(exc.get_dbus_message()) from exc 47 | if not ( 48 | bus.name_has_owner(cls.bus_name) 49 | or cls.bus_name in bus.list_activatable_names() 50 | ): 51 | raise RuntimeError( 52 | "The KWallet daemon is neither running nor activatable through D-Bus" 53 | ) 54 | if "KDE" in os.getenv("XDG_CURRENT_DESKTOP", "").split(":"): 55 | return 5.1 56 | return 4.9 57 | 58 | def __init__(self, *arg, **kw): 59 | super().__init__(*arg, **kw) 60 | self.handle = -1 61 | 62 | def _migrate(self, service): 63 | old_folder = 'Python' 64 | entry_list = [] 65 | if self.iface.hasFolder(self.handle, old_folder, self.appid): 66 | entry_list = self.iface.readPasswordList( 67 | self.handle, old_folder, '*@*', self.appid 68 | ) 69 | 70 | for entry in entry_list.items(): 71 | key = entry[0] 72 | password = entry[1] 73 | 74 | username, service = key.rsplit('@', 1) 75 | ret = self.iface.writePassword( 76 | self.handle, service, username, password, self.appid 77 | ) 78 | if ret == 0: 79 | self.iface.removeEntry(self.handle, old_folder, key, self.appid) 80 | 81 | entry_list = self.iface.readPasswordList( 82 | self.handle, old_folder, '*', self.appid 83 | ) 84 | if not entry_list: 85 | self.iface.removeFolder(self.handle, old_folder, self.appid) 86 | 87 | def connected(self, service): 88 | if self.handle >= 0: 89 | if self.iface.isOpen(self.handle): 90 | return True 91 | 92 | bus = dbus.SessionBus(mainloop=DBusGMainLoop()) 93 | wId = 0 94 | try: 95 | remote_obj = bus.get_object(self.bus_name, self.object_path) 96 | self.iface = dbus.Interface(remote_obj, 'org.kde.KWallet') 97 | self.handle = self.iface.open(self.iface.networkWallet(), wId, self.appid) 98 | except dbus.DBusException as e: 99 | raise InitError(f'Failed to open keyring: {e}.') from e 100 | 101 | if self.handle < 0: 102 | return False 103 | self._migrate(service) 104 | return True 105 | 106 | def get_password(self, service, username): 107 | """Get password of the username for the service""" 108 | if not self.connected(service): 109 | # the user pressed "cancel" when prompted to unlock their keyring. 110 | raise KeyringLocked("Failed to unlock the keyring!") 111 | if not self.iface.hasEntry(self.handle, service, username, self.appid): 112 | return None 113 | password = self.iface.readPassword(self.handle, service, username, self.appid) 114 | return str(password) 115 | 116 | def get_credential(self, service, username): 117 | """Gets the first username and password for a service. 118 | Returns a Credential instance 119 | 120 | The username can be omitted, but if there is one, it will forward to 121 | get_password. 122 | Otherwise, it will return the first username and password combo that it finds. 123 | """ 124 | if username is not None: 125 | return super().get_credential(service, username) 126 | 127 | if not self.connected(service): 128 | # the user pressed "cancel" when prompted to unlock their keyring. 129 | raise KeyringLocked("Failed to unlock the keyring!") 130 | 131 | for username in self.iface.entryList(self.handle, service, self.appid): 132 | password = self.iface.readPassword( 133 | self.handle, service, username, self.appid 134 | ) 135 | return SimpleCredential(str(username), str(password)) 136 | 137 | def set_password(self, service, username, password): 138 | """Set password for the username of the service""" 139 | if not self.connected(service): 140 | # the user pressed "cancel" when prompted to unlock their keyring. 141 | raise PasswordSetError("Cancelled by user") 142 | self.iface.writePassword(self.handle, service, username, password, self.appid) 143 | 144 | def delete_password(self, service, username): 145 | """Delete the password for the username of the service.""" 146 | if not self.connected(service): 147 | # the user pressed "cancel" when prompted to unlock their keyring. 148 | raise PasswordDeleteError("Cancelled by user") 149 | if not self.iface.hasEntry(self.handle, service, username, self.appid): 150 | raise PasswordDeleteError("Password not found") 151 | self.iface.removeEntry(self.handle, service, username, self.appid) 152 | 153 | 154 | class DBusKeyringKWallet4(DBusKeyring): 155 | """ 156 | KDE KWallet 4 via D-Bus 157 | """ 158 | 159 | bus_name = 'org.kde.kwalletd' 160 | object_path = '/modules/kwalletd' 161 | 162 | @properties.classproperty 163 | def priority(cls): 164 | return super().priority - 1 165 | -------------------------------------------------------------------------------- /keyring/backends/libsecret.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .. import backend 4 | from ..backend import KeyringBackend 5 | from ..compat import properties 6 | from ..credentials import SimpleCredential 7 | from ..errors import ( 8 | KeyringLocked, 9 | PasswordDeleteError, 10 | PasswordSetError, 11 | ) 12 | 13 | available = False 14 | try: 15 | import gi 16 | from gi.repository import Gio, GLib 17 | 18 | gi.require_version('Secret', '1') 19 | from gi.repository import Secret 20 | 21 | available = True 22 | except (AttributeError, ImportError, ValueError): 23 | pass 24 | 25 | log = logging.getLogger(__name__) 26 | 27 | 28 | class Keyring(backend.SchemeSelectable, KeyringBackend): 29 | """libsecret Keyring""" 30 | 31 | appid = 'Python keyring library' 32 | 33 | @property 34 | def schema(self): 35 | return Secret.Schema.new( 36 | "org.freedesktop.Secret.Generic", 37 | Secret.SchemaFlags.NONE, 38 | self._query( 39 | Secret.SchemaAttributeType.STRING, 40 | Secret.SchemaAttributeType.STRING, 41 | application=Secret.SchemaAttributeType.STRING, 42 | ), 43 | ) 44 | 45 | @properties.NonDataProperty 46 | def collection(self): 47 | return Secret.COLLECTION_DEFAULT 48 | 49 | @properties.classproperty 50 | def priority(cls) -> float: 51 | if not available: 52 | raise RuntimeError("libsecret required") 53 | 54 | # Make sure there is actually a secret service running 55 | try: 56 | Secret.Service.get_sync(Secret.ServiceFlags.OPEN_SESSION, None) 57 | except GLib.Error as error: 58 | raise RuntimeError("Can't open a session to the secret service") from error 59 | 60 | return 4.8 61 | 62 | def get_password(self, service, username): 63 | """Get password of the username for the service""" 64 | attributes = self._query(service, username, application=self.appid) 65 | try: 66 | items = Secret.password_search_sync( 67 | self.schema, attributes, Secret.SearchFlags.UNLOCK, None 68 | ) 69 | except GLib.Error as error: 70 | quark = GLib.quark_try_string('g-io-error-quark') 71 | if error.matches(quark, Gio.IOErrorEnum.FAILED): 72 | raise KeyringLocked('Failed to unlock the item!') from error 73 | raise 74 | for item in items: 75 | try: 76 | return item.retrieve_secret_sync().get_text() 77 | except GLib.Error as error: 78 | quark = GLib.quark_try_string('secret-error') 79 | if error.matches(quark, Secret.Error.IS_LOCKED): 80 | raise KeyringLocked('Failed to unlock the item!') from error 81 | raise 82 | 83 | def set_password(self, service, username, password): 84 | """Set password for the username of the service""" 85 | attributes = self._query(service, username, application=self.appid) 86 | label = f"Password for '{username}' on '{service}'" 87 | try: 88 | stored = Secret.password_store_sync( 89 | self.schema, attributes, self.collection, label, password, None 90 | ) 91 | except GLib.Error as error: 92 | quark = GLib.quark_try_string('secret-error') 93 | if error.matches(quark, Secret.Error.IS_LOCKED): 94 | raise KeyringLocked("Failed to unlock the collection!") from error 95 | quark = GLib.quark_try_string('g-io-error-quark') 96 | if error.matches(quark, Gio.IOErrorEnum.FAILED): 97 | raise KeyringLocked("Failed to unlock the collection!") from error 98 | raise 99 | if not stored: 100 | raise PasswordSetError("Failed to store password!") 101 | 102 | def delete_password(self, service, username): 103 | """Delete the stored password (only the first one)""" 104 | attributes = self._query(service, username, application=self.appid) 105 | try: 106 | items = Secret.password_search_sync( 107 | self.schema, attributes, Secret.SearchFlags.UNLOCK, None 108 | ) 109 | except GLib.Error as error: 110 | quark = GLib.quark_try_string('g-io-error-quark') 111 | if error.matches(quark, Gio.IOErrorEnum.FAILED): 112 | raise KeyringLocked('Failed to unlock the item!') from error 113 | raise 114 | for item in items: 115 | try: 116 | removed = Secret.password_clear_sync( 117 | self.schema, item.get_attributes(), None 118 | ) 119 | except GLib.Error as error: 120 | quark = GLib.quark_try_string('secret-error') 121 | if error.matches(quark, Secret.Error.IS_LOCKED): 122 | raise KeyringLocked('Failed to unlock the item!') from error 123 | raise 124 | return removed 125 | raise PasswordDeleteError("No such password!") 126 | 127 | def get_credential(self, service, username): 128 | """Get the first username and password for a service. 129 | Return a Credential instance 130 | 131 | The username can be omitted, but if there is one, it will use get_password 132 | and return a SimpleCredential containing the username and password 133 | Otherwise, it will return the first username and password combo that it finds. 134 | """ 135 | query = self._query(service, username) 136 | try: 137 | items = Secret.password_search_sync( 138 | self.schema, query, Secret.SearchFlags.UNLOCK, None 139 | ) 140 | except GLib.Error as error: 141 | quark = GLib.quark_try_string('g-io-error-quark') 142 | if error.matches(quark, Gio.IOErrorEnum.FAILED): 143 | raise KeyringLocked('Failed to unlock the item!') from error 144 | raise 145 | for item in items: 146 | username = item.get_attributes().get("username") 147 | try: 148 | return SimpleCredential( 149 | username, item.retrieve_secret_sync().get_text() 150 | ) 151 | except GLib.Error as error: 152 | quark = GLib.quark_try_string('secret-error') 153 | if error.matches(quark, Secret.Error.IS_LOCKED): 154 | raise KeyringLocked('Failed to unlock the item!') from error 155 | raise 156 | -------------------------------------------------------------------------------- /keyring/backends/macOS/__init__.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import os 3 | import platform 4 | import warnings 5 | 6 | from ...backend import KeyringBackend 7 | from ...compat import properties 8 | from ...errors import KeyringError, KeyringLocked, PasswordDeleteError, PasswordSetError 9 | 10 | try: 11 | from . import api 12 | except Exception: 13 | pass 14 | 15 | 16 | def warn_keychain(func): 17 | @functools.wraps(func) 18 | def wrapper(self, *args, **kwargs): 19 | if self.keychain: 20 | warnings.warn("Specified keychain is ignored. See #623", stacklevel=2) 21 | return func(self, *args, **kwargs) 22 | 23 | return wrapper 24 | 25 | 26 | class Keyring(KeyringBackend): 27 | """macOS Keychain""" 28 | 29 | keychain = os.environ.get('KEYCHAIN_PATH') 30 | "Path to keychain file, overriding default" 31 | 32 | @properties.classproperty 33 | def priority(cls): 34 | """ 35 | Preferred for all macOS environments. 36 | """ 37 | if platform.system() != 'Darwin': 38 | raise RuntimeError("macOS required") 39 | if 'api' not in globals(): 40 | raise RuntimeError("Security API unavailable") 41 | return 5 42 | 43 | @warn_keychain 44 | def set_password(self, service, username, password): 45 | if username is None: 46 | username = '' 47 | 48 | try: 49 | api.set_generic_password(self.keychain, service, username, password) 50 | except api.KeychainDenied as e: 51 | raise KeyringLocked(f"Can't store password on keychain: {e}") from e 52 | except api.Error as e: 53 | raise PasswordSetError(f"Can't store password on keychain: {e}") from e 54 | 55 | @warn_keychain 56 | def get_password(self, service, username): 57 | if username is None: 58 | username = '' 59 | 60 | try: 61 | return api.find_generic_password(self.keychain, service, username) 62 | except api.NotFound: 63 | pass 64 | except api.KeychainDenied as e: 65 | raise KeyringLocked(f"Can't get password from keychain: {e}") from e 66 | except api.Error as e: 67 | raise KeyringError(f"Can't get password from keychain: {e}") from e 68 | 69 | @warn_keychain 70 | def delete_password(self, service, username): 71 | if username is None: 72 | username = '' 73 | 74 | try: 75 | return api.delete_generic_password(self.keychain, service, username) 76 | except api.Error as e: 77 | raise PasswordDeleteError(f"Can't delete password in keychain: {e}") from e 78 | 79 | def with_keychain(self, keychain): 80 | warnings.warn( 81 | "macOS.Keyring.with_keychain is deprecated. Use with_properties instead.", 82 | DeprecationWarning, 83 | stacklevel=2, 84 | ) 85 | return self.with_properties(keychain=keychain) 86 | -------------------------------------------------------------------------------- /keyring/backends/macOS/api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import ctypes 5 | import functools 6 | from ctypes import ( 7 | byref, 8 | c_int32, 9 | c_uint32, 10 | c_void_p, 11 | ) 12 | from ctypes.util import find_library 13 | 14 | OS_status = c_int32 15 | 16 | 17 | class error: 18 | item_not_found = -25300 19 | keychain_denied = -128 20 | sec_auth_failed = -25293 21 | plist_missing = -67030 22 | sec_interaction_not_allowed = -25308 23 | 24 | 25 | _sec = ctypes.CDLL(find_library('Security')) 26 | _core = ctypes.CDLL(find_library('CoreServices')) 27 | _found = ctypes.CDLL(find_library('Foundation')) 28 | 29 | CFDictionaryCreate = _found.CFDictionaryCreate 30 | CFDictionaryCreate.restype = c_void_p 31 | CFDictionaryCreate.argtypes = ( 32 | c_void_p, 33 | c_void_p, 34 | c_void_p, 35 | c_int32, 36 | c_void_p, 37 | c_void_p, 38 | ) 39 | 40 | CFStringCreateWithCString = _found.CFStringCreateWithCString 41 | CFStringCreateWithCString.restype = c_void_p 42 | CFStringCreateWithCString.argtypes = [c_void_p, c_void_p, c_uint32] 43 | 44 | CFNumberCreate = _found.CFNumberCreate 45 | CFNumberCreate.restype = c_void_p 46 | CFNumberCreate.argtypes = [c_void_p, c_uint32, ctypes.c_void_p] 47 | 48 | SecItemAdd = _sec.SecItemAdd 49 | SecItemAdd.restype = OS_status 50 | SecItemAdd.argtypes = (c_void_p, c_void_p) 51 | 52 | SecItemCopyMatching = _sec.SecItemCopyMatching 53 | SecItemCopyMatching.restype = OS_status 54 | SecItemCopyMatching.argtypes = (c_void_p, c_void_p) 55 | 56 | SecItemDelete = _sec.SecItemDelete 57 | SecItemDelete.restype = OS_status 58 | SecItemDelete.argtypes = (c_void_p,) 59 | 60 | CFDataGetBytePtr = _found.CFDataGetBytePtr 61 | CFDataGetBytePtr.restype = c_void_p 62 | CFDataGetBytePtr.argtypes = (c_void_p,) 63 | 64 | CFDataGetLength = _found.CFDataGetLength 65 | CFDataGetLength.restype = c_int32 66 | CFDataGetLength.argtypes = (c_void_p,) 67 | 68 | 69 | def k_(s): 70 | return c_void_p.in_dll(_sec, s) 71 | 72 | 73 | @functools.singledispatch 74 | def create_cf(ob): 75 | return ob 76 | 77 | 78 | # explicit bool and int required for Python 3.10 compatibility 79 | @create_cf.register(bool) 80 | @create_cf.register(int) 81 | def _(val: bool | int): 82 | if val.bit_length() > 31: 83 | raise OverflowError(val) 84 | int32 = 0x9 85 | return CFNumberCreate(None, int32, ctypes.byref(c_int32(val))) 86 | 87 | 88 | @create_cf.register 89 | def _(s: str): 90 | kCFStringEncodingUTF8 = 0x08000100 91 | return CFStringCreateWithCString(None, s.encode('utf8'), kCFStringEncodingUTF8) 92 | 93 | 94 | def create_query(**kwargs): 95 | return CFDictionaryCreate( 96 | None, 97 | (c_void_p * len(kwargs))(*map(k_, kwargs.keys())), 98 | (c_void_p * len(kwargs))(*map(create_cf, kwargs.values())), 99 | len(kwargs), 100 | _found.kCFTypeDictionaryKeyCallBacks, 101 | _found.kCFTypeDictionaryValueCallBacks, 102 | ) 103 | 104 | 105 | def cfstr_to_str(data): 106 | return ctypes.string_at(CFDataGetBytePtr(data), CFDataGetLength(data)).decode( 107 | 'utf-8' 108 | ) 109 | 110 | 111 | class Error(Exception): 112 | @classmethod 113 | def raise_for_status(cls, status): 114 | if status == 0: 115 | return 116 | if status == error.item_not_found: 117 | raise NotFound(status, "Item not found") 118 | if status == error.keychain_denied: 119 | raise KeychainDenied(status, "Keychain Access Denied") 120 | if status == error.sec_auth_failed or status == error.plist_missing: 121 | raise SecAuthFailure( 122 | status, 123 | "Security Auth Failure: make sure " 124 | "executable is signed with codesign util", 125 | ) 126 | raise cls(status, "Unknown Error") 127 | 128 | 129 | class NotFound(Error): 130 | pass 131 | 132 | 133 | class KeychainDenied(Error): 134 | pass 135 | 136 | 137 | class SecAuthFailure(Error): 138 | pass 139 | 140 | 141 | def find_generic_password(kc_name, service, username, not_found_ok=False): 142 | q = create_query( 143 | kSecClass=k_('kSecClassGenericPassword'), 144 | kSecMatchLimit=k_('kSecMatchLimitOne'), 145 | kSecAttrService=service, 146 | kSecAttrAccount=username, 147 | kSecReturnData=True, 148 | ) 149 | 150 | data = c_void_p() 151 | status = SecItemCopyMatching(q, byref(data)) 152 | 153 | if status == error.item_not_found and not_found_ok: 154 | return 155 | 156 | Error.raise_for_status(status) 157 | 158 | return cfstr_to_str(data) 159 | 160 | 161 | def set_generic_password(name, service, username, password): 162 | with contextlib.suppress(NotFound): 163 | delete_generic_password(name, service, username) 164 | 165 | q = create_query( 166 | kSecClass=k_('kSecClassGenericPassword'), 167 | kSecAttrService=service, 168 | kSecAttrAccount=username, 169 | kSecValueData=password, 170 | ) 171 | 172 | status = SecItemAdd(q, None) 173 | Error.raise_for_status(status) 174 | 175 | 176 | def delete_generic_password(name, service, username): 177 | q = create_query( 178 | kSecClass=k_('kSecClassGenericPassword'), 179 | kSecAttrService=service, 180 | kSecAttrAccount=username, 181 | ) 182 | 183 | status = SecItemDelete(q) 184 | Error.raise_for_status(status) 185 | -------------------------------------------------------------------------------- /keyring/backends/null.py: -------------------------------------------------------------------------------- 1 | from ..backend import KeyringBackend 2 | from ..compat import properties 3 | 4 | 5 | class Keyring(KeyringBackend): 6 | """ 7 | Keyring that return None on every operation. 8 | 9 | >>> kr = Keyring() 10 | >>> kr.get_password('svc', 'user') 11 | """ 12 | 13 | @properties.classproperty 14 | def priority(cls) -> float: 15 | return -1 16 | 17 | def get_password(self, service, username, password=None): 18 | pass 19 | 20 | set_password = delete_password = get_password 21 | -------------------------------------------------------------------------------- /keyring/cli.py: -------------------------------------------------------------------------------- 1 | """Simple command line interface to get/set password from a keyring""" 2 | 3 | from __future__ import annotations 4 | 5 | import argparse 6 | import getpass 7 | import json 8 | import sys 9 | 10 | from . import ( 11 | backend, 12 | completion, 13 | core, 14 | credentials, 15 | delete_password, 16 | get_credential, 17 | get_password, 18 | set_keyring, 19 | set_password, 20 | ) 21 | from .util import platform_ 22 | 23 | 24 | class CommandLineTool: 25 | # Attributes set dynamically by the ArgumentParser 26 | keyring_path: str | None 27 | keyring_backend: str | None 28 | get_mode: str 29 | output_format: str 30 | operation: str 31 | service: str 32 | username: str 33 | 34 | def __init__(self): 35 | self.parser = argparse.ArgumentParser() 36 | self.parser.add_argument( 37 | "-p", 38 | "--keyring-path", 39 | dest="keyring_path", 40 | default=None, 41 | help="Path to the keyring backend", 42 | ) 43 | self.parser.add_argument( 44 | "-b", 45 | "--keyring-backend", 46 | dest="keyring_backend", 47 | default=None, 48 | help="Name of the keyring backend", 49 | ) 50 | self.parser.add_argument( 51 | "--list-backends", 52 | action="store_true", 53 | help="List keyring backends and exit", 54 | ) 55 | self.parser.add_argument( 56 | "--disable", action="store_true", help="Disable keyring and exit" 57 | ) 58 | self.parser._get_modes = ["password", "creds"] 59 | self.parser.add_argument( 60 | "--mode", 61 | choices=self.parser._get_modes, 62 | dest="get_mode", 63 | default="password", 64 | help=""" 65 | Mode for 'get' operation. 66 | 'password' requires a username and will return only the password. 67 | 'creds' does not require a username and will return both the username and password separated by a newline. 68 | 69 | Default is 'password' 70 | """, 71 | ) 72 | self.parser._output_formats = ["plain", "json"] 73 | self.parser.add_argument( 74 | "--output", 75 | choices=self.parser._output_formats, 76 | dest="output_format", 77 | default="plain", 78 | help=""" 79 | Output format for 'get' operation. 80 | 81 | Default is 'plain' 82 | """, 83 | ) 84 | self.parser._operations = ["get", "set", "del", "diagnose"] 85 | self.parser.add_argument( 86 | 'operation', 87 | choices=self.parser._operations, 88 | nargs="?", 89 | ) 90 | self.parser.add_argument( 91 | 'service', 92 | nargs="?", 93 | ) 94 | self.parser.add_argument( 95 | 'username', 96 | nargs="?", 97 | ) 98 | completion.install(self.parser) 99 | 100 | def run(self, argv): 101 | args = self.parser.parse_args(argv) 102 | vars(self).update(vars(args)) 103 | 104 | if args.list_backends: 105 | for k in backend.get_all_keyring(): 106 | print(k) 107 | return 108 | 109 | if args.disable: 110 | core.disable() 111 | return 112 | 113 | if args.operation == 'diagnose': 114 | self.diagnose() 115 | return 116 | 117 | self._check_args() 118 | self._load_spec_backend() 119 | method = getattr(self, f'do_{self.operation}', self.invalid_op) 120 | return method() 121 | 122 | def _check_args(self): 123 | needs_username = self.operation != 'get' or self.get_mode != 'creds' 124 | required = (['service'] + ['username'] * needs_username) * bool(self.operation) 125 | if any(getattr(self, param) is None for param in required): 126 | self.parser.error(f"{self.operation} requires {' and '.join(required)}") 127 | 128 | def do_get(self): 129 | credential = getattr(self, f'_get_{self.get_mode}')() 130 | if credential is None: 131 | raise SystemExit(1) 132 | getattr(self, f'_emit_{self.output_format}')(credential) 133 | 134 | def _emit_json(self, credential: credentials.Credential): 135 | print(json.dumps(credential._vars())) 136 | 137 | def _emit_plain(self, credential: credentials.Credential): 138 | for val in credential._vars().values(): 139 | print(val) 140 | 141 | def _get_creds(self) -> credentials.Credential | None: 142 | return get_credential(self.service, self.username) 143 | 144 | def _get_password(self) -> credentials.Credential | None: 145 | password = get_password(self.service, self.username) 146 | return ( 147 | credentials.AnonymousCredential(password) if password is not None else None 148 | ) 149 | 150 | def do_set(self): 151 | password = self.input_password( 152 | f"Password for '{self.username}' in '{self.service}': " 153 | ) 154 | set_password(self.service, self.username, password) 155 | 156 | def do_del(self): 157 | delete_password(self.service, self.username) 158 | 159 | def diagnose(self): 160 | config_root = core._config_path() 161 | if config_root.exists(): 162 | print("config path:", config_root) 163 | else: 164 | print("config path:", config_root, "(absent)") 165 | print("data root:", platform_.data_root()) 166 | 167 | def invalid_op(self): 168 | self.parser.error(f"Specify operation ({', '.join(self.parser._operations)}).") 169 | 170 | def _load_spec_backend(self): 171 | if self.keyring_backend is None: 172 | return 173 | 174 | try: 175 | if self.keyring_path: 176 | sys.path.insert(0, self.keyring_path) 177 | set_keyring(core.load_keyring(self.keyring_backend)) 178 | except Exception as exc: 179 | # Tons of things can go wrong here: 180 | # ImportError when using "fjkljfljkl" 181 | # AttributeError when using "os.path.bar" 182 | # TypeError when using "__builtins__.str" 183 | # So, we play on the safe side, and catch everything. 184 | self.parser.error(f"Unable to load specified keyring: {exc}") 185 | 186 | def input_password(self, prompt): 187 | """Retrieve password from input.""" 188 | return self.pass_from_pipe() or getpass.getpass(prompt) 189 | 190 | @classmethod 191 | def pass_from_pipe(cls): 192 | """Return password from pipe if not on TTY, else False.""" 193 | is_pipe = not sys.stdin.isatty() 194 | return is_pipe and cls.strip_last_newline(sys.stdin.read()) 195 | 196 | @staticmethod 197 | def strip_last_newline(str): 198 | r"""Strip one last newline, if present. 199 | 200 | >>> CommandLineTool.strip_last_newline('foo') 201 | 'foo' 202 | >>> CommandLineTool.strip_last_newline('foo\n') 203 | 'foo' 204 | """ 205 | slc = slice(-1 if str.endswith('\n') else None) 206 | return str[slc] 207 | 208 | 209 | def main(argv=None): 210 | """Main command line interface.""" 211 | 212 | if argv is None: 213 | argv = sys.argv[1:] 214 | 215 | cli = CommandLineTool() 216 | return cli.run(argv) 217 | 218 | 219 | if __name__ == '__main__': 220 | sys.exit(main()) 221 | -------------------------------------------------------------------------------- /keyring/compat/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ['properties'] 2 | 3 | 4 | try: 5 | from jaraco.classes import properties 6 | except ImportError: # pragma: no cover 7 | from . import properties # type: ignore[no-redef] 8 | -------------------------------------------------------------------------------- /keyring/compat/properties.py: -------------------------------------------------------------------------------- 1 | # from jaraco.classes 3.2.2 2 | 3 | 4 | class NonDataProperty: 5 | """Much like the property builtin, but only implements __get__, 6 | making it a non-data property, and can be subsequently reset. 7 | 8 | See http://users.rcn.com/python/download/Descriptor.htm for more 9 | information. 10 | 11 | >>> class X(object): 12 | ... @NonDataProperty 13 | ... def foo(self): 14 | ... return 3 15 | >>> x = X() 16 | >>> x.foo 17 | 3 18 | >>> x.foo = 4 19 | >>> x.foo 20 | 4 21 | """ 22 | 23 | def __init__(self, fget): 24 | assert fget is not None, "fget cannot be none" 25 | assert callable(fget), "fget must be callable" 26 | self.fget = fget 27 | 28 | def __get__(self, obj, objtype=None): 29 | if obj is None: 30 | return self 31 | return self.fget(obj) 32 | 33 | 34 | class classproperty: 35 | """ 36 | Like @property but applies at the class level. 37 | 38 | 39 | >>> class X(metaclass=classproperty.Meta): 40 | ... val = None 41 | ... @classproperty 42 | ... def foo(cls): 43 | ... return cls.val 44 | ... @foo.setter 45 | ... def foo(cls, val): 46 | ... cls.val = val 47 | >>> X.foo 48 | >>> X.foo = 3 49 | >>> X.foo 50 | 3 51 | >>> x = X() 52 | >>> x.foo 53 | 3 54 | >>> X.foo = 4 55 | >>> x.foo 56 | 4 57 | 58 | Setting the property on an instance affects the class. 59 | 60 | >>> x.foo = 5 61 | >>> x.foo 62 | 5 63 | >>> X.foo 64 | 5 65 | >>> vars(x) 66 | {} 67 | >>> X().foo 68 | 5 69 | 70 | Attempting to set an attribute where no setter was defined 71 | results in an AttributeError: 72 | 73 | >>> class GetOnly(metaclass=classproperty.Meta): 74 | ... @classproperty 75 | ... def foo(cls): 76 | ... return 'bar' 77 | >>> GetOnly.foo = 3 78 | Traceback (most recent call last): 79 | ... 80 | AttributeError: can't set attribute 81 | 82 | It is also possible to wrap a classmethod or staticmethod in 83 | a classproperty. 84 | 85 | >>> class Static(metaclass=classproperty.Meta): 86 | ... @classproperty 87 | ... @classmethod 88 | ... def foo(cls): 89 | ... return 'foo' 90 | ... @classproperty 91 | ... @staticmethod 92 | ... def bar(): 93 | ... return 'bar' 94 | >>> Static.foo 95 | 'foo' 96 | >>> Static.bar 97 | 'bar' 98 | 99 | *Legacy* 100 | 101 | For compatibility, if the metaclass isn't specified, the 102 | legacy behavior will be invoked. 103 | 104 | >>> class X: 105 | ... val = None 106 | ... @classproperty 107 | ... def foo(cls): 108 | ... return cls.val 109 | ... @foo.setter 110 | ... def foo(cls, val): 111 | ... cls.val = val 112 | >>> X.foo 113 | >>> X.foo = 3 114 | >>> X.foo 115 | 3 116 | >>> x = X() 117 | >>> x.foo 118 | 3 119 | >>> X.foo = 4 120 | >>> x.foo 121 | 4 122 | 123 | Note, because the metaclass was not specified, setting 124 | a value on an instance does not have the intended effect. 125 | 126 | >>> x.foo = 5 127 | >>> x.foo 128 | 5 129 | >>> X.foo # should be 5 130 | 4 131 | >>> vars(x) # should be empty 132 | {'foo': 5} 133 | >>> X().foo # should be 5 134 | 4 135 | """ 136 | 137 | class Meta(type): 138 | def __setattr__(self, key, value): 139 | obj = self.__dict__.get(key, None) 140 | if type(obj) is classproperty: 141 | return obj.__set__(self, value) 142 | return super().__setattr__(key, value) 143 | 144 | def __init__(self, fget, fset=None): 145 | self.fget = self._ensure_method(fget) 146 | self.fset = fset 147 | fset and self.setter(fset) 148 | 149 | def __get__(self, instance, owner=None): 150 | return self.fget.__get__(None, owner)() 151 | 152 | def __set__(self, owner, value): 153 | if not self.fset: 154 | raise AttributeError("can't set attribute") 155 | if type(owner) is not classproperty.Meta: 156 | owner = type(owner) 157 | return self.fset.__get__(None, owner)(value) 158 | 159 | def setter(self, fset): 160 | self.fset = self._ensure_method(fset) 161 | return self 162 | 163 | @classmethod 164 | def _ensure_method(cls, fn): 165 | """ 166 | Ensure fn is a classmethod or staticmethod. 167 | """ 168 | needs_method = not isinstance(fn, (classmethod, staticmethod)) 169 | return classmethod(fn) if needs_method else fn 170 | -------------------------------------------------------------------------------- /keyring/compat/py312.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | __all__ = ['metadata'] 4 | 5 | 6 | if sys.version_info >= (3, 12): 7 | import importlib.metadata as metadata 8 | else: 9 | import importlib_metadata as metadata 10 | -------------------------------------------------------------------------------- /keyring/compat/py38.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | __all__ = ['files'] 4 | 5 | 6 | if sys.version_info < (3, 9): 7 | from importlib_resources import files 8 | else: 9 | from importlib.resources import files 10 | -------------------------------------------------------------------------------- /keyring/completion.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | 4 | try: 5 | import shtab 6 | except ImportError: 7 | pass 8 | 9 | from .compat.py38 import files 10 | 11 | 12 | class _MissingCompletionAction(argparse.Action): 13 | def __call__(self, parser, namespace, values, option_string): 14 | print("Install keyring[completion] for completion support.", file=sys.stderr) 15 | parser.exit(1) 16 | 17 | 18 | def add_completion_notice(parser): 19 | """Add completion argument to parser.""" 20 | parser.add_argument( 21 | "--print-completion", 22 | choices=["bash", "zsh", "tcsh"], 23 | action=_MissingCompletionAction, 24 | help="print shell completion script", 25 | ) 26 | return parser 27 | 28 | 29 | def get_action(parser, option): 30 | (match,) = (action for action in parser._actions if option in action.option_strings) 31 | return match 32 | 33 | 34 | def install_completion(parser): 35 | preamble = dict( 36 | bash=files(__package__) 37 | .joinpath('backend_complete.bash') 38 | .read_text(encoding='utf-8'), 39 | zsh=files(__package__) 40 | .joinpath('backend_complete.zsh') 41 | .read_text(encoding='utf-8'), 42 | ) 43 | shtab.add_argument_to(parser, preamble=preamble) 44 | get_action(parser, '--keyring-path').complete = shtab.DIR 45 | get_action(parser, '--keyring-backend').complete = dict( 46 | bash='_keyring_backends', 47 | zsh='backend_complete', 48 | ) 49 | return parser 50 | 51 | 52 | def install(parser): 53 | try: 54 | install_completion(parser) 55 | except NameError: 56 | add_completion_notice(parser) 57 | -------------------------------------------------------------------------------- /keyring/core.py: -------------------------------------------------------------------------------- 1 | """ 2 | Core API functions and initialization routines. 3 | """ 4 | 5 | import configparser 6 | import logging 7 | import os 8 | import sys 9 | import typing 10 | 11 | from . import backend, credentials 12 | from .backends import fail 13 | from .util import platform_ as platform 14 | 15 | LimitCallable = typing.Callable[[backend.KeyringBackend], bool] 16 | 17 | log = logging.getLogger(__name__) 18 | 19 | _keyring_backend = None 20 | 21 | 22 | def set_keyring(keyring: backend.KeyringBackend) -> None: 23 | """Set current keyring backend.""" 24 | global _keyring_backend 25 | if not isinstance(keyring, backend.KeyringBackend): 26 | raise TypeError("The keyring must be an instance of KeyringBackend") 27 | _keyring_backend = keyring 28 | 29 | 30 | def get_keyring() -> backend.KeyringBackend: 31 | """Get current keyring backend.""" 32 | if _keyring_backend is None: 33 | init_backend() 34 | return typing.cast(backend.KeyringBackend, _keyring_backend) 35 | 36 | 37 | def disable() -> None: 38 | """ 39 | Configure the null keyring as the default. 40 | 41 | >>> fs = getfixture('fs') 42 | >>> disable() 43 | >>> disable() 44 | Traceback (most recent call last): 45 | ... 46 | RuntimeError: Refusing to overwrite... 47 | """ 48 | root = platform.config_root() 49 | try: 50 | os.makedirs(root) 51 | except OSError: 52 | pass 53 | filename = os.path.join(root, 'keyringrc.cfg') 54 | if os.path.exists(filename): 55 | msg = f"Refusing to overwrite {filename}" 56 | raise RuntimeError(msg) 57 | with open(filename, 'w', encoding='utf-8') as file: 58 | file.write('[backend]\ndefault-keyring=keyring.backends.null.Keyring') 59 | 60 | 61 | def get_password(service_name: str, username: str) -> typing.Optional[str]: 62 | """Get password from the specified service.""" 63 | return get_keyring().get_password(service_name, username) 64 | 65 | 66 | def set_password(service_name: str, username: str, password: str) -> None: 67 | """Set password for the user in the specified service.""" 68 | get_keyring().set_password(service_name, username, password) 69 | 70 | 71 | def delete_password(service_name: str, username: str) -> None: 72 | """Delete the password for the user in the specified service.""" 73 | get_keyring().delete_password(service_name, username) 74 | 75 | 76 | def get_credential( 77 | service_name: str, username: typing.Optional[str] 78 | ) -> typing.Optional[credentials.Credential]: 79 | """Get a Credential for the specified service.""" 80 | return get_keyring().get_credential(service_name, username) 81 | 82 | 83 | def recommended(backend) -> bool: 84 | return backend.priority >= 1 85 | 86 | 87 | def init_backend(limit: typing.Optional[LimitCallable] = None): 88 | """ 89 | Load a detected backend. 90 | """ 91 | set_keyring(_detect_backend(limit)) 92 | 93 | 94 | def _detect_backend(limit: typing.Optional[LimitCallable] = None): 95 | """ 96 | Return a keyring specified in the config file or infer the best available. 97 | 98 | Limit, if supplied, should be a callable taking a backend and returning 99 | True if that backend should be included for consideration. 100 | """ 101 | 102 | # save the limit for the chainer to honor 103 | backend._limit = limit 104 | return ( 105 | load_env() 106 | or load_config() 107 | or max( 108 | # all keyrings passing the limit filter 109 | filter(limit, backend.get_all_keyring()), # type: ignore[arg-type] #659 110 | default=fail.Keyring(), 111 | key=backend.by_priority, 112 | ) 113 | ) 114 | 115 | 116 | def _load_keyring_class(keyring_name: str) -> typing.Type[backend.KeyringBackend]: 117 | """ 118 | Load the keyring class indicated by name. 119 | 120 | These popular names are tested to ensure their presence. 121 | 122 | >>> popular_names = [ 123 | ... 'keyring.backends.Windows.WinVaultKeyring', 124 | ... 'keyring.backends.macOS.Keyring', 125 | ... 'keyring.backends.kwallet.DBusKeyring', 126 | ... 'keyring.backends.SecretService.Keyring', 127 | ... ] 128 | >>> list(map(_load_keyring_class, popular_names)) 129 | [...] 130 | """ 131 | module_name, sep, class_name = keyring_name.rpartition('.') 132 | __import__(module_name) 133 | module = sys.modules[module_name] 134 | return getattr(module, class_name) 135 | 136 | 137 | def load_keyring(keyring_name: str) -> backend.KeyringBackend: 138 | """ 139 | Load the specified keyring by name (a fully-qualified name to the 140 | keyring, such as 'keyring.backends.file.PlaintextKeyring') 141 | """ 142 | class_ = _load_keyring_class(keyring_name) 143 | # invoke the priority to ensure it is viable, or raise a RuntimeError 144 | class_.priority # noqa: B018 145 | return class_() 146 | 147 | 148 | def load_env() -> typing.Optional[backend.KeyringBackend]: 149 | """Load a keyring configured in the environment variable.""" 150 | try: 151 | return load_keyring(os.environ['PYTHON_KEYRING_BACKEND']) 152 | except KeyError: 153 | return None 154 | 155 | 156 | def _config_path(): 157 | return platform.config_root() / 'keyringrc.cfg' 158 | 159 | 160 | def _ensure_path(path): 161 | if not path.exists(): 162 | raise FileNotFoundError(path) 163 | return path 164 | 165 | 166 | def load_config() -> typing.Optional[backend.KeyringBackend]: 167 | """Load a keyring using the config file in the config root.""" 168 | 169 | config = configparser.RawConfigParser() 170 | try: 171 | config.read(_ensure_path(_config_path()), encoding='utf-8') 172 | except FileNotFoundError: 173 | return None 174 | _load_keyring_path(config) 175 | 176 | # load the keyring class name, and then load this keyring 177 | try: 178 | if config.has_section("backend"): 179 | keyring_name = config.get("backend", "default-keyring").strip() 180 | else: 181 | return None 182 | 183 | except (configparser.NoOptionError, ImportError): 184 | logger = logging.getLogger('keyring') 185 | logger.warning( 186 | "Keyring config file contains incorrect values.\n" 187 | + f"Config file: {_config_path()}" 188 | ) 189 | return None 190 | 191 | return load_keyring(keyring_name) 192 | 193 | 194 | def _load_keyring_path(config: configparser.RawConfigParser) -> None: 195 | "load the keyring-path option (if present)" 196 | try: 197 | path = config.get("backend", "keyring-path").strip() 198 | sys.path.insert(0, os.path.expanduser(path)) 199 | except (configparser.NoOptionError, configparser.NoSectionError): 200 | pass 201 | -------------------------------------------------------------------------------- /keyring/credentials.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import os 5 | 6 | 7 | class Credential(metaclass=abc.ABCMeta): 8 | """Abstract class to manage credentials""" 9 | 10 | @abc.abstractproperty 11 | def username(self) -> str: ... 12 | 13 | @abc.abstractproperty 14 | def password(self) -> str: ... 15 | 16 | def _vars(self) -> dict[str, str]: 17 | return dict(username=self.username, password=self.password) 18 | 19 | 20 | class SimpleCredential(Credential): 21 | """Simple credentials implementation""" 22 | 23 | def __init__(self, username: str, password: str): 24 | self._username = username 25 | self._password = password 26 | 27 | @property 28 | def username(self) -> str: 29 | return self._username 30 | 31 | @property 32 | def password(self) -> str: 33 | return self._password 34 | 35 | 36 | class AnonymousCredential(SimpleCredential): 37 | def __init__(self, password: str): 38 | self._password = password 39 | 40 | @property 41 | def username(self) -> str: 42 | raise ValueError("Anonymous credential has no username") 43 | 44 | def _vars(self) -> dict[str, str]: 45 | return dict(password=self.password) 46 | 47 | 48 | class EnvironCredential(Credential): 49 | """ 50 | Source credentials from environment variables. 51 | 52 | Actual sourcing is deferred until requested. 53 | 54 | Supports comparison by equality. 55 | 56 | >>> e1 = EnvironCredential('a', 'b') 57 | >>> e2 = EnvironCredential('a', 'b') 58 | >>> e3 = EnvironCredential('a', 'c') 59 | >>> e1 == e2 60 | True 61 | >>> e2 == e3 62 | False 63 | """ 64 | 65 | def __init__(self, user_env_var: str, pwd_env_var: str): 66 | self.user_env_var = user_env_var 67 | self.pwd_env_var = pwd_env_var 68 | 69 | def __eq__(self, other: object) -> bool: 70 | return vars(self) == vars(other) 71 | 72 | def _get_env(self, env_var: str) -> str: 73 | """Helper to read an environment variable""" 74 | value = os.environ.get(env_var) 75 | if not value: 76 | raise ValueError(f'Missing environment variable:{env_var}') 77 | return value 78 | 79 | @property 80 | def username(self) -> str: 81 | return self._get_env(self.user_env_var) 82 | 83 | @property 84 | def password(self) -> str: 85 | return self._get_env(self.pwd_env_var) 86 | -------------------------------------------------------------------------------- /keyring/devpi_client.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | import pluggy 4 | from jaraco.context import suppress 5 | 6 | import keyring.errors 7 | 8 | hookimpl = pluggy.HookimplMarker("devpiclient") 9 | 10 | 11 | def restore_signature(func): 12 | # workaround for pytest-dev/pluggy#358 13 | @functools.wraps(func) 14 | def wrapper(url, username): 15 | return func(url, username) 16 | 17 | return wrapper 18 | 19 | 20 | @hookimpl() 21 | @restore_signature 22 | @suppress(keyring.errors.KeyringError) 23 | def devpiclient_get_password(url, username): 24 | """ 25 | >>> pluggy._hooks.varnames(devpiclient_get_password) 26 | (('url', 'username'), ()) 27 | >>> 28 | """ 29 | return keyring.get_password(url, username) 30 | -------------------------------------------------------------------------------- /keyring/errors.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import warnings 3 | 4 | 5 | class KeyringError(Exception): 6 | """Base class for exceptions in keyring""" 7 | 8 | 9 | class PasswordSetError(KeyringError): 10 | """Raised when the password can't be set.""" 11 | 12 | 13 | class PasswordDeleteError(KeyringError): 14 | """Raised when the password can't be deleted.""" 15 | 16 | 17 | class InitError(KeyringError): 18 | """Raised when the keyring could not be initialised""" 19 | 20 | 21 | class KeyringLocked(KeyringError): 22 | """Raised when the keyring failed unlocking""" 23 | 24 | 25 | class NoKeyringError(KeyringError, RuntimeError): 26 | """Raised when there is no keyring backend""" 27 | 28 | 29 | class ExceptionRaisedContext: 30 | """ 31 | An exception-trapping context that indicates whether an exception was 32 | raised. 33 | """ 34 | 35 | def __init__(self, ExpectedException=Exception): 36 | warnings.warn( 37 | "ExceptionRaisedContext is deprecated; use `jaraco.context.ExceptionTrap`", 38 | DeprecationWarning, 39 | stacklevel=2, 40 | ) 41 | self.ExpectedException = ExpectedException 42 | self.exc_info = None 43 | 44 | def __enter__(self): 45 | self.exc_info = object.__new__(ExceptionInfo) 46 | return self.exc_info 47 | 48 | def __exit__(self, *exc_info): 49 | self.exc_info.__init__(*exc_info) 50 | return self.exc_info.type and issubclass( 51 | self.exc_info.type, self.ExpectedException 52 | ) 53 | 54 | 55 | class ExceptionInfo: 56 | def __init__(self, *info): 57 | if not info: 58 | info = sys.exc_info() 59 | self.type, self.value, _ = info 60 | 61 | def __bool__(self): 62 | """ 63 | Return True if an exception occurred 64 | """ 65 | return bool(self.type) 66 | 67 | __nonzero__ = __bool__ 68 | -------------------------------------------------------------------------------- /keyring/http.py: -------------------------------------------------------------------------------- 1 | """ 2 | urllib2.HTTPPasswordMgr object using the keyring, for use with the 3 | urllib2.HTTPBasicAuthHandler. 4 | 5 | usage: 6 | import urllib2 7 | handlers = [urllib2.HTTPBasicAuthHandler(PasswordMgr())] 8 | urllib2.install_opener(handlers) 9 | urllib2.urlopen(...) 10 | 11 | This will prompt for a password if one is required and isn't already 12 | in the keyring. Then, it adds it to the keyring for subsequent use. 13 | """ 14 | 15 | import getpass 16 | 17 | from . import delete_password, get_password, set_password 18 | 19 | 20 | class PasswordMgr: 21 | def get_username(self, realm, authuri): 22 | return getpass.getuser() 23 | 24 | def add_password(self, realm, authuri, password): 25 | user = self.get_username(realm, authuri) 26 | set_password(realm, user, password) 27 | 28 | def find_user_password(self, realm, authuri): 29 | user = self.get_username(realm, authuri) 30 | password = get_password(realm, user) 31 | if password is None: 32 | prompt = f'password for {user}@{realm} for {authuri}: ' 33 | password = getpass.getpass(prompt) 34 | set_password(realm, user, password) 35 | return user, password 36 | 37 | def clear_password(self, realm, authuri): 38 | user = self.get_username(realm, authuri) 39 | delete_password(realm, user) 40 | -------------------------------------------------------------------------------- /keyring/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaraco/keyring/71dedcbcf21847f358ad0510ea6bff59de1449a2/keyring/py.typed -------------------------------------------------------------------------------- /keyring/testing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaraco/keyring/71dedcbcf21847f358ad0510ea6bff59de1449a2/keyring/testing/__init__.py -------------------------------------------------------------------------------- /keyring/testing/backend.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common test functionality for backends. 3 | """ 4 | 5 | import os 6 | import string 7 | 8 | import pytest 9 | 10 | from keyring import errors 11 | 12 | from .util import random_string 13 | 14 | # unicode only characters 15 | # Sourced from The Quick Brown Fox... Pangrams 16 | # http://www.columbia.edu/~fdc/utf8/ 17 | UNICODE_CHARS = ( 18 | "זהכיףסתםלשמועאיךתנצחקרפדעץטובבגן" 19 | "ξεσκεπάζωτηνψυχοφθόραβδελυγμία" 20 | "Съешьжеещёэтихмягкихфранцузскихбулокдавыпейчаю" 21 | "Жълтатадюлябешещастливачепухъткойтоцъфназамръзнакатогьон" 22 | ) 23 | 24 | # ensure no-ascii chars slip by - watch your editor! 25 | assert min(ord(char) for char in UNICODE_CHARS) > 127 26 | 27 | 28 | def is_ascii_printable(s): 29 | return all(32 <= ord(c) < 127 for c in s) 30 | 31 | 32 | class BackendBasicTests: 33 | """Test for the keyring's basic functions. password_set and password_get""" 34 | 35 | DIFFICULT_CHARS = string.whitespace + string.punctuation 36 | 37 | @pytest.fixture(autouse=True) 38 | def _init_properties(self, request): 39 | self.keyring = self.init_keyring() 40 | self.credentials_created = set() 41 | request.addfinalizer(self.cleanup) 42 | 43 | def cleanup(self): 44 | for item in self.credentials_created: 45 | self.keyring.delete_password(*item) 46 | 47 | def set_password(self, service, username, password): 48 | # set the password and save the result so the test runner can clean 49 | # up after if necessary. 50 | self.keyring.set_password(service, username, password) 51 | self.credentials_created.add((service, username)) 52 | 53 | def check_set_get(self, service, username, password): 54 | keyring = self.keyring 55 | 56 | # for the non-existent password 57 | assert keyring.get_password(service, username) is None 58 | 59 | # common usage 60 | self.set_password(service, username, password) 61 | assert keyring.get_password(service, username) == password 62 | 63 | # for the empty password 64 | self.set_password(service, username, "") 65 | assert keyring.get_password(service, username) == "" 66 | 67 | def test_password_set_get(self): 68 | password = random_string(20) 69 | username = random_string(20) 70 | service = random_string(20) 71 | self.check_set_get(service, username, password) 72 | 73 | def test_set_after_set_blank(self): 74 | service = random_string(20) 75 | username = random_string(20) 76 | self.keyring.set_password(service, username, "") 77 | self.keyring.set_password(service, username, "non-blank") 78 | 79 | def test_difficult_chars(self): 80 | password = random_string(20, self.DIFFICULT_CHARS) 81 | username = random_string(20, self.DIFFICULT_CHARS) 82 | service = random_string(20, self.DIFFICULT_CHARS) 83 | self.check_set_get(service, username, password) 84 | 85 | def test_delete_present(self): 86 | password = random_string(20, self.DIFFICULT_CHARS) 87 | username = random_string(20, self.DIFFICULT_CHARS) 88 | service = random_string(20, self.DIFFICULT_CHARS) 89 | self.keyring.set_password(service, username, password) 90 | self.keyring.delete_password(service, username) 91 | assert self.keyring.get_password(service, username) is None 92 | 93 | def test_delete_not_present(self): 94 | username = random_string(20, self.DIFFICULT_CHARS) 95 | service = random_string(20, self.DIFFICULT_CHARS) 96 | with pytest.raises(errors.PasswordDeleteError): 97 | self.keyring.delete_password(service, username) 98 | 99 | def test_delete_one_in_group(self): 100 | username1 = random_string(20, self.DIFFICULT_CHARS) 101 | username2 = random_string(20, self.DIFFICULT_CHARS) 102 | password = random_string(20, self.DIFFICULT_CHARS) 103 | service = random_string(20, self.DIFFICULT_CHARS) 104 | self.keyring.set_password(service, username1, password) 105 | self.set_password(service, username2, password) 106 | self.keyring.delete_password(service, username1) 107 | assert self.keyring.get_password(service, username2) == password 108 | 109 | def test_name_property(self): 110 | assert is_ascii_printable(self.keyring.name) 111 | 112 | def test_unicode_chars(self): 113 | password = random_string(20, UNICODE_CHARS) 114 | username = random_string(20, UNICODE_CHARS) 115 | service = random_string(20, UNICODE_CHARS) 116 | self.check_set_get(service, username, password) 117 | 118 | def test_unicode_and_ascii_chars(self): 119 | source = ( 120 | random_string(10, UNICODE_CHARS) 121 | + random_string(10) 122 | + random_string(10, self.DIFFICULT_CHARS) 123 | ) 124 | password = random_string(20, source) 125 | username = random_string(20, source) 126 | service = random_string(20, source) 127 | self.check_set_get(service, username, password) 128 | 129 | def test_different_user(self): 130 | """ 131 | Issue #47 reports that WinVault isn't storing passwords for 132 | multiple users. This test exercises that test for each of the 133 | backends. 134 | """ 135 | 136 | keyring = self.keyring 137 | self.set_password('service1', 'user1', 'password1') 138 | self.set_password('service1', 'user2', 'password2') 139 | assert keyring.get_password('service1', 'user1') == 'password1' 140 | assert keyring.get_password('service1', 'user2') == 'password2' 141 | self.set_password('service2', 'user3', 'password3') 142 | assert keyring.get_password('service1', 'user1') == 'password1' 143 | 144 | def test_credential(self): 145 | keyring = self.keyring 146 | 147 | cred = keyring.get_credential('service', None) 148 | assert cred is None 149 | 150 | self.set_password('service1', 'user1', 'password1') 151 | self.set_password('service1', 'user2', 'password2') 152 | 153 | cred = keyring.get_credential('service1', None) 154 | assert cred is None or (cred.username, cred.password) in ( 155 | ('user1', 'password1'), 156 | ('user2', 'password2'), 157 | ) 158 | 159 | cred = keyring.get_credential('service1', 'user2') 160 | assert cred is not None 161 | assert (cred.username, cred.password) in ( 162 | ('user1', 'password1'), 163 | ('user2', 'password2'), 164 | ) 165 | 166 | @pytest.mark.xfail("platform.system() == 'Windows'", reason="#668") 167 | def test_empty_username(self): 168 | with pytest.deprecated_call(): 169 | self.set_password('service1', '', 'password1') 170 | assert self.keyring.get_password('service1', '') == 'password1' 171 | 172 | def test_set_properties(self, monkeypatch): 173 | env = dict(KEYRING_PROPERTY_FOO_BAR='fizz buzz', OTHER_SETTING='ignore me') 174 | monkeypatch.setattr(os, 'environ', env) 175 | self.keyring.set_properties_from_env() 176 | assert self.keyring.foo_bar == 'fizz buzz' 177 | 178 | def test_new_with_properties(self): 179 | alt = self.keyring.with_properties(foo='bar') 180 | assert alt is not self.keyring 181 | assert alt.foo == 'bar' 182 | with pytest.raises(AttributeError): 183 | self.keyring.foo # noqa: B018 184 | 185 | def test_wrong_username_returns_none(self): 186 | keyring = self.keyring 187 | service = 'test_wrong_username_returns_none' 188 | cred = keyring.get_credential(service, None) 189 | assert cred is None 190 | 191 | password_1 = 'password1' 192 | password_2 = 'password2' 193 | self.set_password(service, 'user1', password_1) 194 | self.set_password(service, 'user2', password_2) 195 | 196 | assert keyring.get_credential(service, "user1").password == password_1 197 | assert keyring.get_credential(service, "user2").password == password_2 198 | 199 | # Missing/wrong username should not return a cred 200 | assert keyring.get_credential(service, "nobody!") is None 201 | -------------------------------------------------------------------------------- /keyring/testing/util.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os 3 | import random 4 | import string 5 | import sys 6 | 7 | 8 | class ImportKiller: 9 | "Context manager to make an import of a given name or names fail." 10 | 11 | def __init__(self, *names): 12 | self.names = names 13 | 14 | def find_module(self, fullname, path=None): 15 | if fullname in self.names: 16 | return self 17 | 18 | def load_module(self, fullname): 19 | assert fullname in self.names 20 | raise ImportError(fullname) 21 | 22 | def __enter__(self): 23 | self.original = {} 24 | for name in self.names: 25 | self.original[name] = sys.modules.pop(name, None) 26 | sys.meta_path.insert(0, self) 27 | 28 | def __exit__(self, *args): 29 | sys.meta_path.remove(self) 30 | for key, value in self.original.items(): 31 | if value is not None: 32 | sys.modules[key] = value 33 | 34 | 35 | @contextlib.contextmanager 36 | def NoNoneDictMutator(destination, **changes): 37 | """Helper context manager to make and unmake changes to a dict. 38 | 39 | A None is not a valid value for the destination, and so means that the 40 | associated name should be removed.""" 41 | original = {} 42 | for key, value in changes.items(): 43 | original[key] = destination.get(key) 44 | if value is None: 45 | if key in destination: 46 | del destination[key] 47 | else: 48 | destination[key] = value 49 | yield 50 | for key, value in original.items(): 51 | if value is None: 52 | if key in destination: 53 | del destination[key] 54 | else: 55 | destination[key] = value 56 | 57 | 58 | def Environ(**changes): 59 | """A context manager to temporarily change the os.environ""" 60 | return NoNoneDictMutator(os.environ, **changes) 61 | 62 | 63 | ALPHABET = string.ascii_letters + string.digits 64 | 65 | 66 | def random_string(k, source=ALPHABET): 67 | """Generate a random string with length k""" 68 | return ''.join(random.choice(source) for _unused in range(k)) 69 | -------------------------------------------------------------------------------- /keyring/util/__init__.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | 4 | def suppress_exceptions(callables, exceptions=Exception): 5 | """ 6 | yield the results of calling each element of callables, suppressing 7 | any indicated exceptions. 8 | """ 9 | for callable in callables: 10 | with contextlib.suppress(exceptions): 11 | yield callable() 12 | -------------------------------------------------------------------------------- /keyring/util/platform_.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import platform 4 | 5 | 6 | def _data_root_Windows(): 7 | release, version, csd, ptype = platform.win32_ver() 8 | root = pathlib.Path( 9 | os.environ.get('LOCALAPPDATA', os.environ.get('ProgramData', '.')) 10 | ) 11 | return root / 'Python Keyring' 12 | 13 | 14 | def _data_root_Linux(): 15 | """ 16 | Use freedesktop.org Base Dir Specification to determine storage 17 | location. 18 | """ 19 | fallback = pathlib.Path.home() / '.local/share' 20 | root = os.environ.get('XDG_DATA_HOME', None) or fallback 21 | return pathlib.Path(root, 'python_keyring') 22 | 23 | 24 | _config_root_Windows = _data_root_Windows 25 | 26 | 27 | def _config_root_Linux(): 28 | """ 29 | Use freedesktop.org Base Dir Specification to determine config 30 | location. 31 | """ 32 | fallback = pathlib.Path.home() / '.config' 33 | key = 'XDG_CONFIG_HOME' 34 | root = os.environ.get(key, None) or fallback 35 | return pathlib.Path(root, 'python_keyring') 36 | 37 | 38 | # by default, use Unix convention 39 | data_root = globals().get('_data_root_' + platform.system(), _data_root_Linux) 40 | config_root = globals().get('_config_root_' + platform.system(), _config_root_Linux) 41 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | # Is the project well-typed? 3 | strict = False 4 | 5 | # Early opt-in even when strict = False 6 | warn_unused_ignores = True 7 | warn_redundant_casts = True 8 | enable_error_code = ignore-without-code 9 | 10 | # Support namespace packages per https://github.com/python/mypy/issues/14057 11 | explicit_package_bases = True 12 | 13 | disable_error_code = 14 | # Disable due to many false positives 15 | overload-overlap, 16 | 17 | # TODO: Open upstream issues requesting typing 18 | [mypy-win32ctypes.*,secretstorage.*,dbus.*] 19 | ignore_missing_imports = True 20 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=77", 4 | "setuptools_scm[toml]>=3.4.1", 5 | # jaraco/skeleton#174 6 | "coherent.licensed", 7 | ] 8 | build-backend = "setuptools.build_meta" 9 | 10 | [project] 11 | name = "keyring" 12 | authors = [ 13 | { name = "Kang Zhang", email = "jobo.zh@gmail.com" }, 14 | ] 15 | maintainers = [ 16 | { name = "Jason R. Coombs", email = "jaraco@jaraco.com" }, 17 | ] 18 | description = "Store and access your passwords safely." 19 | readme = "README.rst" 20 | classifiers = [ 21 | "Development Status :: 5 - Production/Stable", 22 | "Intended Audience :: Developers", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3 :: Only", 25 | ] 26 | requires-python = ">=3.9" 27 | license = "MIT" 28 | dependencies = [ 29 | 'pywin32-ctypes>=0.2.0; sys_platform=="win32"', 30 | 'SecretStorage>=3.2; sys_platform=="linux"', 31 | 'jeepney>=0.4.2; sys_platform=="linux"', 32 | 'importlib_metadata >= 4.11.4; python_version < "3.12"', 33 | "jaraco.classes", 34 | 'importlib_resources; python_version < "3.9"', 35 | "jaraco.functools", 36 | "jaraco.context", 37 | ] 38 | dynamic = ["version"] 39 | 40 | [project.urls] 41 | Source = "https://github.com/jaraco/keyring" 42 | 43 | [project.optional-dependencies] 44 | test = [ 45 | # upstream 46 | "pytest >= 6, != 8.1.*", 47 | 48 | # local 49 | "pyfakefs", 50 | ] 51 | 52 | doc = [ 53 | # upstream 54 | "sphinx >= 3.5", 55 | "jaraco.packaging >= 9.3", 56 | "rst.linker >= 1.9", 57 | "furo", 58 | "sphinx-lint", 59 | 60 | # tidelift 61 | "jaraco.tidelift >= 1.4", 62 | 63 | # local 64 | ] 65 | 66 | check = [ 67 | "pytest-checkdocs >= 2.4", 68 | "pytest-ruff >= 0.2.1; sys_platform != 'cygwin'", 69 | ] 70 | 71 | cover = [ 72 | "pytest-cov", 73 | ] 74 | 75 | enabler = [ 76 | "pytest-enabler >= 2.2", 77 | ] 78 | 79 | type = [ 80 | # upstream 81 | "pytest-mypy", 82 | 83 | # local 84 | "pygobject-stubs", 85 | "shtab", # Optional install for completion 86 | "types-pywin32", 87 | ] 88 | 89 | completion = ["shtab >= 1.1.0"] 90 | 91 | [project.entry-points] 92 | devpi_client = {keyring = "keyring.devpi_client"} 93 | 94 | [project.entry-points."keyring.backends"] 95 | Windows = "keyring.backends.Windows" 96 | macOS = "keyring.backends.macOS" 97 | libsecret = "keyring.backends.libsecret" 98 | SecretService = "keyring.backends.SecretService" 99 | KWallet = "keyring.backends.kwallet" 100 | chainer = "keyring.backends.chainer" 101 | 102 | [project.scripts] 103 | keyring = "keyring.cli:main" 104 | 105 | 106 | [tool.setuptools_scm] 107 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs=dist build .tox .eggs 3 | addopts= 4 | --doctest-modules 5 | --import-mode importlib 6 | consider_namespace_packages=true 7 | filterwarnings= 8 | ## upstream 9 | 10 | # Ensure ResourceWarnings are emitted 11 | default::ResourceWarning 12 | 13 | # realpython/pytest-mypy#152 14 | ignore:'encoding' argument not specified::pytest_mypy 15 | 16 | # python/cpython#100750 17 | ignore:'encoding' argument not specified::platform 18 | 19 | # pypa/build#615 20 | ignore:'encoding' argument not specified::build.env 21 | 22 | # dateutil/dateutil#1284 23 | ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning:dateutil.tz.tz 24 | 25 | ## end upstream 26 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | [lint] 2 | extend-select = [ 3 | # upstream 4 | 5 | "C901", # complex-structure 6 | "I", # isort 7 | "PERF401", # manual-list-comprehension 8 | 9 | # Ensure modern type annotation syntax and best practices 10 | # Not including those covered by type-checkers or exclusive to Python 3.11+ 11 | "FA", # flake8-future-annotations 12 | "F404", # late-future-import 13 | "PYI", # flake8-pyi 14 | "UP006", # non-pep585-annotation 15 | "UP007", # non-pep604-annotation 16 | "UP010", # unnecessary-future-import 17 | "UP035", # deprecated-import 18 | "UP037", # quoted-annotation 19 | "UP043", # unnecessary-default-type-args 20 | 21 | # local 22 | ] 23 | ignore = [ 24 | # upstream 25 | 26 | # Typeshed rejects complex or non-literal defaults for maintenance and testing reasons, 27 | # irrelevant to this project. 28 | "PYI011", # typed-argument-default-in-stub 29 | # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 30 | "W191", 31 | "E111", 32 | "E114", 33 | "E117", 34 | "D206", 35 | "D300", 36 | "Q000", 37 | "Q001", 38 | "Q002", 39 | "Q003", 40 | "COM812", 41 | "COM819", 42 | 43 | # local 44 | ] 45 | 46 | [format] 47 | # Enable preview to get hugged parenthesis unwrapping and other nice surprises 48 | # See https://github.com/jaraco/skeleton/pull/133#issuecomment-2239538373 49 | preview = true 50 | # https://docs.astral.sh/ruff/settings/#format_quote-style 51 | quote-style = "preserve" 52 | -------------------------------------------------------------------------------- /tea.yaml: -------------------------------------------------------------------------------- 1 | # https://tea.xyz/what-is-this-file 2 | --- 3 | version: 1.0.0 4 | codeOwners: 5 | - '0x32392EaEA1FDE87733bEEc3b184C9006501c4A82' 6 | quorum: 1 7 | -------------------------------------------------------------------------------- /tests/backends/test_SecretService.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from keyring.backends import SecretService 4 | from keyring.testing.backend import BackendBasicTests 5 | from keyring.testing.util import NoNoneDictMutator 6 | 7 | 8 | @pytest.mark.skipif( 9 | not SecretService.Keyring.viable, 10 | reason="SecretStorage package is needed for SecretServiceKeyring", 11 | ) 12 | class TestSecretServiceKeyring(BackendBasicTests): 13 | __test__ = True 14 | 15 | def init_keyring(self): 16 | print( 17 | "Testing SecretServiceKeyring; the following " 18 | "password prompts are for this keyring" 19 | ) 20 | keyring = SecretService.Keyring() 21 | keyring.preferred_collection = '/org/freedesktop/secrets/collection/session' 22 | return keyring 23 | 24 | 25 | class TestUnits: 26 | def test_supported_no_secretstorage(self): 27 | """ 28 | SecretService Keyring is not viable if secretstorage can't be imported. 29 | """ 30 | with NoNoneDictMutator(SecretService.__dict__, secretstorage=None): 31 | assert not SecretService.Keyring.viable 32 | -------------------------------------------------------------------------------- /tests/backends/test_Windows.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | import keyring.backends.Windows 6 | from keyring.testing.backend import UNICODE_CHARS, BackendBasicTests 7 | 8 | 9 | @pytest.mark.skipif( 10 | not keyring.backends.Windows.WinVaultKeyring.viable, reason="Needs Windows" 11 | ) 12 | class TestWinVaultKeyring(BackendBasicTests): 13 | def tearDown(self): 14 | # clean up any credentials created 15 | for cred in self.credentials_created: 16 | try: 17 | self.keyring.delete_password(*cred) 18 | except Exception as e: 19 | print(e, file=sys.stderr) 20 | 21 | def init_keyring(self): 22 | return keyring.backends.Windows.WinVaultKeyring() 23 | 24 | def set_utf8_password(self, service, username, password): 25 | """ 26 | Write a UTF-8 encoded password using win32ctypes primitives 27 | """ 28 | from ctypes import c_char, cast, create_string_buffer, sizeof 29 | 30 | from win32ctypes.core import _authentication as auth 31 | from win32ctypes.core.ctypes._common import LPBYTE 32 | 33 | credential = dict( 34 | Type=1, 35 | TargetName=service, 36 | UserName=username, 37 | CredentialBlob=password, 38 | Comment="Stored using python-keyring", 39 | Persist=3, 40 | ) 41 | 42 | c_cred = auth.CREDENTIAL.fromdict(credential, 0) 43 | blob_data = create_string_buffer(password.encode("utf-8")) 44 | c_cred.CredentialBlobSize = sizeof(blob_data) - sizeof(c_char) 45 | c_cred.CredentialBlob = cast(blob_data, LPBYTE) 46 | c_cred_pointer = auth.PCREDENTIAL(c_cred) 47 | auth._CredWrite(c_cred_pointer, 0) 48 | 49 | self.credentials_created.add((service, username)) 50 | 51 | def test_long_password_nice_error(self): 52 | self.keyring.set_password('system', 'user', 'x' * 512 * 2) 53 | 54 | def test_read_utf8_password(self): 55 | """ 56 | Write a UTF-8 encoded credential and make sure it can be read back correctly. 57 | """ 58 | service = "keyring-utf8-test" 59 | username = "keyring" 60 | password = "utf8-test" + UNICODE_CHARS 61 | 62 | self.set_utf8_password(service, username, password) 63 | assert self.keyring.get_password(service, username) == password 64 | 65 | 66 | @pytest.mark.skipif('sys.platform != "win32"') 67 | def test_winvault_always_viable(): 68 | """ 69 | The WinVault backend should always be viable on Windows. 70 | """ 71 | assert keyring.backends.Windows.WinVaultKeyring.viable 72 | -------------------------------------------------------------------------------- /tests/backends/test_chainer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import keyring.backends.chainer 4 | from keyring import backend 5 | 6 | 7 | @pytest.fixture 8 | def two_keyrings(monkeypatch): 9 | def get_two(): 10 | class Keyring1(backend.KeyringBackend): 11 | priority = 1 12 | 13 | def get_password(self, system, user): 14 | return f'ring1-{system}-{user}' 15 | 16 | def set_password(self, system, user, password): 17 | pass 18 | 19 | class Keyring2(backend.KeyringBackend): 20 | priority = 2 21 | 22 | def get_password(self, system, user): 23 | return f'ring2-{system}-{user}' 24 | 25 | def set_password(self, system, user, password): 26 | raise NotImplementedError() 27 | 28 | return Keyring1(), Keyring2() 29 | 30 | monkeypatch.setattr('keyring.backend.get_all_keyring', get_two) 31 | 32 | 33 | class TestChainer: 34 | def test_chainer_gets_from_highest_priority(self, two_keyrings): 35 | chainer = keyring.backends.chainer.ChainerBackend() 36 | pw = chainer.get_password('alpha', 'bravo') 37 | assert pw == 'ring2-alpha-bravo' 38 | 39 | def test_chainer_defers_to_fail(self, monkeypatch): 40 | """ 41 | The Chainer backend should defer to the Fail backend when there are 42 | no backends to be chained. 43 | """ 44 | monkeypatch.setattr('keyring.backend.get_all_keyring', tuple) 45 | assert keyring.backend.by_priority( 46 | keyring.backends.chainer.ChainerBackend 47 | ) < keyring.backend.by_priority(keyring.backends.fail.Keyring) 48 | -------------------------------------------------------------------------------- /tests/backends/test_kwallet.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from keyring.backends import kwallet 4 | from keyring.testing.backend import BackendBasicTests 5 | 6 | 7 | @pytest.mark.skipif(not kwallet.DBusKeyring.viable, reason="KWallet5 unavailable") 8 | class TestDBusKWallet(BackendBasicTests): 9 | # Remove '@' from service name as this is not supported in service names 10 | # '@' will cause troubles during migration of kwallet entries 11 | DIFFICULT_CHARS = BackendBasicTests.DIFFICULT_CHARS.replace('@', '') 12 | 13 | def init_keyring(self): 14 | return kwallet.DBusKeyring() 15 | 16 | def cleanup(self): 17 | for item in self.credentials_created: 18 | # Suppress errors, as only one pre/post migration item will be 19 | # present 20 | try: 21 | self.keyring.delete_password(*item) 22 | except BaseException: 23 | pass 24 | 25 | # TODO Remove empty folders created during tests 26 | 27 | def set_password(self, service, username, password, old_format=False): 28 | # set the password and save the result so the test runner can clean 29 | # up after if necessary. 30 | self.credentials_created.add((service, username)) 31 | 32 | if old_format: 33 | username = username + '@' + service 34 | service = 'Python' 35 | 36 | super().set_password(service, username, password) 37 | 38 | def check_set_get(self, service, username, password): 39 | keyring = self.keyring 40 | 41 | # for the non-existent password 42 | assert keyring.get_password(service, username) is None 43 | 44 | # common usage 45 | self.set_password(service, username, password, True) 46 | # re-init keyring to force migration 47 | self.keyring = keyring = self.init_keyring() 48 | ret_password = keyring.get_password(service, username) 49 | assert ret_password == password, ( 50 | f"Incorrect password for username: '{service}' " 51 | f"on service: '{username}'. '{ret_password}' != '{password}'", 52 | ) 53 | 54 | # for the empty password 55 | self.set_password(service, username, "", True) 56 | # re-init keyring to force migration 57 | self.keyring = keyring = self.init_keyring() 58 | ret_password = keyring.get_password(service, username) 59 | assert ret_password == "" 60 | ret_password = keyring.get_password('Python', username + '@' + service) 61 | assert ret_password is None 62 | 63 | 64 | @pytest.mark.skipif( 65 | not kwallet.DBusKeyringKWallet4.viable, reason="KWallet4 unavailable" 66 | ) 67 | class TestDBusKWallet4(TestDBusKWallet): 68 | def init_keyring(self): 69 | return kwallet.DBusKeyringKWallet4() 70 | -------------------------------------------------------------------------------- /tests/backends/test_libsecret.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from keyring.backends import libsecret 4 | from keyring.testing.backend import BackendBasicTests 5 | from keyring.testing.util import NoNoneDictMutator 6 | 7 | 8 | @pytest.mark.skipif( 9 | not libsecret.Keyring.viable, 10 | reason="libsecret package is needed for LibSecretKeyring", 11 | ) 12 | class TestLibSecretKeyring(BackendBasicTests): 13 | __test__ = True 14 | 15 | def init_keyring(self): 16 | print( 17 | "Testing LibSecretKeyring; the following " 18 | "password prompts are for this keyring" 19 | ) 20 | keyring = libsecret.Keyring() 21 | keyring.collection = 'session' 22 | return keyring 23 | 24 | 25 | class TestUnits: 26 | def test_supported_no_libsecret(self): 27 | """ 28 | LibSecretKeyring is not viable if Secret can't be imported. 29 | """ 30 | with NoNoneDictMutator(libsecret.__dict__, Secret=None): 31 | assert not libsecret.Keyring.viable 32 | -------------------------------------------------------------------------------- /tests/backends/test_macOS.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import keyring 4 | from keyring.backends import macOS 5 | from keyring.testing.backend import BackendBasicTests 6 | 7 | 8 | @pytest.mark.skipif( 9 | not keyring.backends.macOS.Keyring.viable, 10 | reason="macOS backend not viable", 11 | ) 12 | class Test_macOSKeychain(BackendBasicTests): 13 | def init_keyring(self): 14 | return macOS.Keyring() 15 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import itertools 3 | import sys 4 | from unittest import mock 5 | 6 | import pytest 7 | 8 | from keyring import cli 9 | from keyring import credentials 10 | 11 | flatten = itertools.chain.from_iterable 12 | 13 | 14 | class PasswordEmitter: 15 | """ 16 | Replacement for getpass() to emit passwords: 17 | 18 | >>> pe = PasswordEmitter('foo', 'bar') 19 | >>> pe() 20 | 'foo' 21 | >>> pe() 22 | 'bar' 23 | >>> pe() 24 | 'foo' 25 | """ 26 | 27 | def __init__(self, *passwords): 28 | self.passwords = flatten(itertools.repeat(passwords)) 29 | 30 | def __call__(self, unused_prompt=None): 31 | return next(self.passwords) 32 | 33 | 34 | @pytest.fixture 35 | def mocked_set(): 36 | with mock.patch('keyring.cli.set_password') as set_password: 37 | yield set_password 38 | 39 | 40 | @pytest.fixture 41 | def mocked_get_credential(): 42 | with mock.patch('keyring.cli.get_credential') as get_credential: 43 | yield get_credential 44 | 45 | 46 | def test_set_interactive(monkeypatch, mocked_set): 47 | tool = cli.CommandLineTool() 48 | tool.service = 'svc' 49 | tool.username = 'usr' 50 | monkeypatch.setattr(sys.stdin, 'isatty', lambda: True) 51 | monkeypatch.setattr(getpass, 'getpass', PasswordEmitter('foo123')) 52 | tool.do_set() 53 | mocked_set.assert_called_once_with('svc', 'usr', 'foo123') 54 | 55 | 56 | def test_set_pipe(monkeypatch, mocked_set): 57 | tool = cli.CommandLineTool() 58 | tool.service = 'svc' 59 | tool.username = 'usr' 60 | monkeypatch.setattr(sys.stdin, 'isatty', lambda: False) 61 | monkeypatch.setattr(sys.stdin, 'read', lambda: 'foo123') 62 | tool.do_set() 63 | mocked_set.assert_called_once_with('svc', 'usr', 'foo123') 64 | 65 | 66 | def test_set_pipe_newline(monkeypatch, mocked_set): 67 | tool = cli.CommandLineTool() 68 | tool.service = 'svc' 69 | tool.username = 'usr' 70 | monkeypatch.setattr(sys.stdin, 'isatty', lambda: False) 71 | monkeypatch.setattr(sys.stdin, 'read', lambda: 'foo123\n') 72 | tool.do_set() 73 | mocked_set.assert_called_once_with('svc', 'usr', 'foo123') 74 | 75 | 76 | @pytest.mark.parametrize('format', ['json', 'plain']) 77 | def test_get_anonymous(monkeypatch, mocked_get_credential, format, capsys): 78 | mocked_get_credential.return_value = credentials.AnonymousCredential('s3cret') 79 | tool = cli.CommandLineTool() 80 | tool.service = 'svc' 81 | tool.username = None 82 | tool.get_mode = 'creds' 83 | tool.output_format = format 84 | tool.do_get() 85 | assert 's3cret' in capsys.readouterr().out 86 | 87 | 88 | @pytest.mark.parametrize('format', ['json', 'plain']) 89 | def test_get(monkeypatch, mocked_get_credential, format, capsys): 90 | mocked_get_credential.return_value = credentials.SimpleCredential('alice', 's3cret') 91 | tool = cli.CommandLineTool() 92 | tool.service = 'svc' 93 | tool.username = 'alice' 94 | tool.get_mode = 'creds' 95 | tool.output_format = format 96 | tool.do_get() 97 | assert 's3cret' in capsys.readouterr().out 98 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | import pytest 4 | 5 | import keyring.core 6 | 7 | 8 | @pytest.fixture 9 | def config_path(tmp_path, monkeypatch): 10 | path = tmp_path / 'keyringrc.cfg' 11 | monkeypatch.setattr(keyring.core, '_config_path', lambda: path) 12 | return path 13 | 14 | 15 | def test_init_recommended(monkeypatch): 16 | """ 17 | Test filtering of backends to recommended ones (#117, #423). 18 | """ 19 | monkeypatch.setattr(keyring.core, 'set_keyring', lambda kr: None) 20 | keyring.core.init_backend(keyring.core.recommended) 21 | 22 | 23 | def test_load_config_missing(caplog, config_path): 24 | assert keyring.core.load_config() is None 25 | assert not caplog.records 26 | 27 | 28 | def test_load_empty_config(caplog, config_path): 29 | config_path.write_text("", encoding='utf-8') 30 | assert keyring.core.load_config() is None 31 | assert not caplog.records 32 | 33 | 34 | fail_config = textwrap.dedent( 35 | """ 36 | [backend] 37 | default-keyring = keyring.backends.fail.Keyring 38 | """ 39 | ).lstrip() 40 | 41 | 42 | def test_load_config_extant(caplog, config_path): 43 | config_path.write_text(fail_config, encoding='utf-8') 44 | assert keyring.core.load_config() is not None 45 | assert not caplog.records 46 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | from keyring import errors 7 | 8 | 9 | class DeletionDetector: 10 | def __init__(self, state): 11 | self.state = state 12 | self.state.deleted = False 13 | 14 | def __del__(self): 15 | self.state.deleted = True 16 | 17 | 18 | class TestExceptionInfo: 19 | @pytest.mark.skipif( 20 | '__pypy__' in sys.builtin_module_names, 21 | reason="pypy garbage collection defers deletion", 22 | ) 23 | def test_traceback_not_referenced(self): 24 | """ 25 | Ensure that an ExceptionInfo does not keep a reference 26 | to the traceback, as doing so can create unintended 27 | side effects. See #386 for more info. 28 | """ 29 | state = mock.MagicMock() 30 | _ = errors.ExceptionInfo(None, None, DeletionDetector(state)) 31 | assert state.deleted 32 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | 4 | import pytest 5 | 6 | argv_manipulations = [ 7 | 'del sys.argv', 8 | 'sys.argv = []', 9 | 'sys.argv = None', 10 | ] 11 | 12 | 13 | @pytest.mark.parametrize('argv', argv_manipulations) 14 | def test_argv(argv): 15 | """ 16 | Keyrings should initialize without error even 17 | when sys.argv is malformed. Ref #445. 18 | """ 19 | code = f'import sys; {argv}; import keyring' 20 | cmd = [sys.executable, '-c', code] 21 | assert not subprocess.check_output(cmd, stderr=subprocess.STDOUT) 22 | -------------------------------------------------------------------------------- /tests/test_multiprocess.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import platform 3 | 4 | import pytest 5 | 6 | import keyring 7 | 8 | 9 | @pytest.fixture(autouse=True) 10 | def workaround_pytest_12178(monkeypatch): 11 | """ 12 | Ensure the current directory is on sys.path so that `tests` is importable. 13 | 14 | Workaround for #673. 15 | """ 16 | monkeypatch.syspath_prepend('.') 17 | 18 | 19 | def subprocess_get(): 20 | keyring.get_password('test_app', 'test_user') 21 | 22 | 23 | pytestmark = [ 24 | pytest.mark.xfail( 25 | platform.system() == 'Linux', 26 | reason="#410: keyring discovery fails intermittently", 27 | ), 28 | ] 29 | 30 | 31 | def test_multiprocess_get(): 32 | proc1 = multiprocessing.Process(target=subprocess_get) 33 | proc1.start() 34 | proc1.join() 35 | assert proc1.exitcode == 0 36 | 37 | 38 | def test_multiprocess_get_after_native_get(): 39 | keyring.get_password('test_app', 'test_user') 40 | test_multiprocess_get() 41 | -------------------------------------------------------------------------------- /tests/test_packaging.py: -------------------------------------------------------------------------------- 1 | from keyring import backend 2 | from keyring.compat.py312 import metadata 3 | 4 | 5 | def test_entry_point(): 6 | """ 7 | Keyring provides exactly one 'keyring' console script 8 | that's a callable. 9 | """ 10 | matches = metadata.entry_points(group='console_scripts', name='keyring') 11 | (script,) = matches 12 | assert callable(script.load()) 13 | 14 | 15 | def test_missing_metadata(monkeypatch): 16 | """ 17 | _load_plugins should pass when keyring metadata is missing. 18 | """ 19 | monkeypatch.setattr(metadata, 'entry_points', metadata.EntryPoints().select) 20 | backend._load_plugins() 21 | -------------------------------------------------------------------------------- /tests/util/test_platform.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from keyring.util import platform_ 4 | 5 | 6 | @pytest.mark.skipif('platform.system() != "Linux"') 7 | def test_platform_Linux(): 8 | assert platform_.config_root == platform_._config_root_Linux 9 | assert platform_.data_root == platform_._data_root_Linux 10 | 11 | 12 | @pytest.mark.skipif('platform.system() != "Windows"') 13 | def test_platform_Windows(): 14 | assert platform_.config_root == platform_._config_root_Windows 15 | assert platform_.data_root == platform_._data_root_Windows 16 | -------------------------------------------------------------------------------- /towncrier.toml: -------------------------------------------------------------------------------- 1 | [tool.towncrier] 2 | title_format = "{version}" 3 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv] 2 | description = perform primary checks (tests, style, types, coverage) 3 | deps = 4 | setenv = 5 | PYTHONWARNDEFAULTENCODING = 1 6 | commands = 7 | pytest {posargs} 8 | usedevelop = True 9 | extras = 10 | test 11 | check 12 | cover 13 | enabler 14 | type 15 | 16 | [testenv:diffcov] 17 | description = run tests and check that diff from main is covered 18 | deps = 19 | {[testenv]deps} 20 | diff-cover 21 | commands = 22 | pytest {posargs} --cov-report xml 23 | diff-cover coverage.xml --compare-branch=origin/main --html-report diffcov.html 24 | diff-cover coverage.xml --compare-branch=origin/main --fail-under=100 25 | 26 | [testenv:docs] 27 | description = build the documentation 28 | extras = 29 | doc 30 | test 31 | changedir = docs 32 | commands = 33 | python -m sphinx -W --keep-going . {toxinidir}/build/html 34 | python -m sphinxlint 35 | 36 | [testenv:finalize] 37 | description = assemble changelog and tag a release 38 | skip_install = True 39 | deps = 40 | towncrier 41 | jaraco.develop >= 7.23 42 | pass_env = * 43 | commands = 44 | python -m jaraco.develop.finalize 45 | 46 | 47 | [testenv:release] 48 | description = publish the package to PyPI and GitHub 49 | skip_install = True 50 | deps = 51 | build 52 | twine>=3 53 | jaraco.develop>=7.1 54 | pass_env = 55 | TWINE_PASSWORD 56 | GITHUB_TOKEN 57 | setenv = 58 | TWINE_USERNAME = {env:TWINE_USERNAME:__token__} 59 | commands = 60 | python -c "import shutil; shutil.rmtree('dist', ignore_errors=True)" 61 | python -m build 62 | python -m twine upload dist/* 63 | python -m jaraco.develop.create-github-release 64 | --------------------------------------------------------------------------------