├── .bumpversion.cfg ├── .coveragerc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .python-version ├── CONTRIBUTORS ├── LICENSE ├── MANIFEST.in ├── Makefile ├── PUBLISH.md ├── README.md ├── poetry.lock ├── pw.sublime-project ├── pw ├── __init__.py ├── __main__.py ├── _gpg.py └── store.py ├── pyproject.toml └── test ├── add_a_line.py ├── conftest.py ├── db.pw ├── db.pw.asc ├── db.pw.gpg ├── keys ├── pubring.gpg ├── secring.gpg └── trustdb.gpg ├── test_cli.py ├── test_gpg.py └── test_store.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.14.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:pyproject.toml] 7 | search = version = "{current_version}" 8 | replace = version = "{new_version}" 9 | 10 | [bumpversion:file:pw/__init__.py] 11 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | except ImportError 4 | if __name__ == '__main__': 5 | def handle_sigint 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ["3.9", "3.10", "3.11", "3.12"] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | sudo apt-get install -y xclip gnupg2 29 | python -m pip install --upgrade pip 30 | pip install poetry 31 | poetry install 32 | - name: Test with pytest 33 | run: PW_GPG=gpg2 xvfb-run make test 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .tox 3 | /dist/ 4 | /build/ 5 | /pw.egg-info/ 6 | pw.sublime-workspace 7 | /test/.coverage 8 | /test/htmlcov 9 | /test/keys/random_seed 10 | /test/keys/trustdb.gpg.lock 11 | /test/keys/.gpg-v21-migrated 12 | /test/keys/private-keys-v1.d 13 | /.cache 14 | /.eggs 15 | /.vscode 16 | .mypy_cache 17 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11.3 2 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | Michael Walter 2 | Marc Brinkmann 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2014 Michael Walter 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE CONTRIBUTORS 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test test-coverage upload-release pretty encrypt-test-db 2 | 3 | PW_GPG ?= gpg 4 | 5 | test: 6 | poetry run pytest 7 | 8 | pw-with-test-db: 9 | poetry run pw --file test/db.pw ${ARGS} 10 | 11 | pretty: 12 | # poetry run yapf -i *.py pw/*.py test/*.py 13 | poetry run black pw/*.py test/*.py 14 | 15 | encrypt-test-db: 16 | $(PW_GPG) --batch --yes --homedir test/keys --encrypt --recipient "test.user@localhost" --output test/db.pw.gpg test/db.pw 17 | $(PW_GPG) --batch --yes --homedir test/keys --encrypt --recipient "test.user@localhost" --output test/db.pw.asc --armor test/db.pw 18 | 19 | mypy: 20 | poetry run mypy -m pw --strict 21 | -------------------------------------------------------------------------------- /PUBLISH.md: -------------------------------------------------------------------------------- 1 | How to publish a new release? 2 | ============================= 3 | 4 | 1. `poetry run bumpversion [major|minor|patch]` 5 | 3. `poetry build` 6 | 4. `poetry publish` 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pw [![Build Status](https://github.com/catch22/pw/actions/workflows/ci.yml/badge.svg)](https://github.com/catch22/pw/actions/workflows/ci.yml) [![Latest Version](https://img.shields.io/pypi/v/pw.svg)](https://pypi.python.org/pypi/pw/) [![Supported Python Versions](https://img.shields.io/pypi/pyversions/pw.svg)](https://pypi.python.org/pypi/pw/) 2 | 3 | 4 | `pw` is a Python tool to search in a GPG-encrypted password database. 5 | 6 | ``` 7 | Usage: pw [OPTIONS] [USER@][KEY] [USER] 8 | 9 | Search for USER and KEY in GPG-encrypted password file. 10 | 11 | Options: 12 | -C, --copy Display account information, but copy password to clipboard (default mode). 13 | -E, --echo Display account information as well as password in plaintext (alternative mode). 14 | -R, --raw Only display password in plaintext (alternative mode). 15 | -S, --strict Fail unless precisely a single result has been found. 16 | -U, --user Copy or display username instead of password. 17 | -f, --file PATH Path to password file. 18 | --edit Launch editor to edit password database and exit. 19 | --gen Generate a random password and exit. 20 | --version Show the version and exit. 21 | --help Show this message and exit. 22 | ``` 23 | 24 | 25 | ## Installation 26 | 27 | To install `pw`, simply run: 28 | 29 | ```bash 30 | $ pip install pw 31 | ``` 32 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "black" 5 | version = "24.3.0" 6 | description = "The uncompromising code formatter." 7 | optional = false 8 | python-versions = ">=3.8" 9 | files = [ 10 | {file = "black-24.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7d5e026f8da0322b5662fa7a8e752b3fa2dac1c1cbc213c3d7ff9bdd0ab12395"}, 11 | {file = "black-24.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9f50ea1132e2189d8dff0115ab75b65590a3e97de1e143795adb4ce317934995"}, 12 | {file = "black-24.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2af80566f43c85f5797365077fb64a393861a3730bd110971ab7a0c94e873e7"}, 13 | {file = "black-24.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:4be5bb28e090456adfc1255e03967fb67ca846a03be7aadf6249096100ee32d0"}, 14 | {file = "black-24.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4f1373a7808a8f135b774039f61d59e4be7eb56b2513d3d2f02a8b9365b8a8a9"}, 15 | {file = "black-24.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aadf7a02d947936ee418777e0247ea114f78aff0d0959461057cae8a04f20597"}, 16 | {file = "black-24.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c02e4ea2ae09d16314d30912a58ada9a5c4fdfedf9512d23326128ac08ac3d"}, 17 | {file = "black-24.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf21b7b230718a5f08bd32d5e4f1db7fc8788345c8aea1d155fc17852b3410f5"}, 18 | {file = "black-24.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:2818cf72dfd5d289e48f37ccfa08b460bf469e67fb7c4abb07edc2e9f16fb63f"}, 19 | {file = "black-24.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4acf672def7eb1725f41f38bf6bf425c8237248bb0804faa3965c036f7672d11"}, 20 | {file = "black-24.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7ed6668cbbfcd231fa0dc1b137d3e40c04c7f786e626b405c62bcd5db5857e4"}, 21 | {file = "black-24.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:56f52cfbd3dabe2798d76dbdd299faa046a901041faf2cf33288bc4e6dae57b5"}, 22 | {file = "black-24.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:79dcf34b33e38ed1b17434693763301d7ccbd1c5860674a8f871bd15139e7837"}, 23 | {file = "black-24.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e19cb1c6365fd6dc38a6eae2dcb691d7d83935c10215aef8e6c38edee3f77abd"}, 24 | {file = "black-24.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b76c275e4c1c5ce6e9870911384bff5ca31ab63d19c76811cb1fb162678213"}, 25 | {file = "black-24.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:b5991d523eee14756f3c8d5df5231550ae8993e2286b8014e2fdea7156ed0959"}, 26 | {file = "black-24.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c45f8dff244b3c431b36e3224b6be4a127c6aca780853574c00faf99258041eb"}, 27 | {file = "black-24.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6905238a754ceb7788a73f02b45637d820b2f5478b20fec82ea865e4f5d4d9f7"}, 28 | {file = "black-24.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7de8d330763c66663661a1ffd432274a2f92f07feeddd89ffd085b5744f85e7"}, 29 | {file = "black-24.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:7bb041dca0d784697af4646d3b62ba4a6b028276ae878e53f6b4f74ddd6db99f"}, 30 | {file = "black-24.3.0-py3-none-any.whl", hash = "sha256:41622020d7120e01d377f74249e677039d20e6344ff5851de8a10f11f513bf93"}, 31 | {file = "black-24.3.0.tar.gz", hash = "sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f"}, 32 | ] 33 | 34 | [package.dependencies] 35 | click = ">=8.0.0" 36 | mypy-extensions = ">=0.4.3" 37 | packaging = ">=22.0" 38 | pathspec = ">=0.9.0" 39 | platformdirs = ">=2" 40 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 41 | typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} 42 | 43 | [package.extras] 44 | colorama = ["colorama (>=0.4.3)"] 45 | d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] 46 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 47 | uvloop = ["uvloop (>=0.15.2)"] 48 | 49 | [[package]] 50 | name = "bump2version" 51 | version = "1.0.1" 52 | description = "Version-bump your software with a single command!" 53 | optional = false 54 | python-versions = ">=3.5" 55 | files = [ 56 | {file = "bump2version-1.0.1-py2.py3-none-any.whl", hash = "sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410"}, 57 | {file = "bump2version-1.0.1.tar.gz", hash = "sha256:762cb2bfad61f4ec8e2bdf452c7c267416f8c70dd9ecb1653fd0bbb01fa936e6"}, 58 | ] 59 | 60 | [[package]] 61 | name = "bumpversion" 62 | version = "0.6.0" 63 | description = "Version-bump your software with a single command!" 64 | optional = false 65 | python-versions = "*" 66 | files = [ 67 | {file = "bumpversion-0.6.0-py2.py3-none-any.whl", hash = "sha256:4eb3267a38194d09f048a2179980bb4803701969bff2c85fa8f6d1ce050be15e"}, 68 | {file = "bumpversion-0.6.0.tar.gz", hash = "sha256:4ba55e4080d373f80177b4dabef146c07ce73c7d1377aabf9d3c3ae1f94584a6"}, 69 | ] 70 | 71 | [package.dependencies] 72 | bump2version = "*" 73 | 74 | [[package]] 75 | name = "click" 76 | version = "8.1.7" 77 | description = "Composable command line interface toolkit" 78 | optional = false 79 | python-versions = ">=3.7" 80 | files = [ 81 | {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, 82 | {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, 83 | ] 84 | 85 | [package.dependencies] 86 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 87 | 88 | [[package]] 89 | name = "colorama" 90 | version = "0.4.6" 91 | description = "Cross-platform colored terminal text." 92 | optional = false 93 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 94 | files = [ 95 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 96 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 97 | ] 98 | 99 | [[package]] 100 | name = "exceptiongroup" 101 | version = "1.2.0" 102 | description = "Backport of PEP 654 (exception groups)" 103 | optional = false 104 | python-versions = ">=3.7" 105 | files = [ 106 | {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, 107 | {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, 108 | ] 109 | 110 | [package.extras] 111 | test = ["pytest (>=6)"] 112 | 113 | [[package]] 114 | name = "iniconfig" 115 | version = "2.0.0" 116 | description = "brain-dead simple config-ini parsing" 117 | optional = false 118 | python-versions = ">=3.7" 119 | files = [ 120 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 121 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 122 | ] 123 | 124 | [[package]] 125 | name = "mypy" 126 | version = "1.8.0" 127 | description = "Optional static typing for Python" 128 | optional = false 129 | python-versions = ">=3.8" 130 | files = [ 131 | {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, 132 | {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, 133 | {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, 134 | {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, 135 | {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, 136 | {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, 137 | {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, 138 | {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, 139 | {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, 140 | {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, 141 | {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, 142 | {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, 143 | {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, 144 | {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, 145 | {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, 146 | {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, 147 | {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, 148 | {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, 149 | {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, 150 | {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, 151 | {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, 152 | {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, 153 | {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, 154 | {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, 155 | {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, 156 | {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, 157 | {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, 158 | ] 159 | 160 | [package.dependencies] 161 | mypy-extensions = ">=1.0.0" 162 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 163 | typing-extensions = ">=4.1.0" 164 | 165 | [package.extras] 166 | dmypy = ["psutil (>=4.0)"] 167 | install-types = ["pip"] 168 | mypyc = ["setuptools (>=50)"] 169 | reports = ["lxml"] 170 | 171 | [[package]] 172 | name = "mypy-extensions" 173 | version = "1.0.0" 174 | description = "Type system extensions for programs checked with the mypy type checker." 175 | optional = false 176 | python-versions = ">=3.5" 177 | files = [ 178 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 179 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 180 | ] 181 | 182 | [[package]] 183 | name = "packaging" 184 | version = "23.2" 185 | description = "Core utilities for Python packages" 186 | optional = false 187 | python-versions = ">=3.7" 188 | files = [ 189 | {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, 190 | {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, 191 | ] 192 | 193 | [[package]] 194 | name = "pathspec" 195 | version = "0.12.1" 196 | description = "Utility library for gitignore style pattern matching of file paths." 197 | optional = false 198 | python-versions = ">=3.8" 199 | files = [ 200 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 201 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 202 | ] 203 | 204 | [[package]] 205 | name = "platformdirs" 206 | version = "4.1.0" 207 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 208 | optional = false 209 | python-versions = ">=3.8" 210 | files = [ 211 | {file = "platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380"}, 212 | {file = "platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"}, 213 | ] 214 | 215 | [package.extras] 216 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] 217 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] 218 | 219 | [[package]] 220 | name = "pluggy" 221 | version = "1.3.0" 222 | description = "plugin and hook calling mechanisms for python" 223 | optional = false 224 | python-versions = ">=3.8" 225 | files = [ 226 | {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, 227 | {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, 228 | ] 229 | 230 | [package.extras] 231 | dev = ["pre-commit", "tox"] 232 | testing = ["pytest", "pytest-benchmark"] 233 | 234 | [[package]] 235 | name = "pyperclip" 236 | version = "1.8.2" 237 | description = "A cross-platform clipboard module for Python. (Only handles plain text for now.)" 238 | optional = false 239 | python-versions = "*" 240 | files = [ 241 | {file = "pyperclip-1.8.2.tar.gz", hash = "sha256:105254a8b04934f0bc84e9c24eb360a591aaf6535c9def5f29d92af107a9bf57"}, 242 | ] 243 | 244 | [[package]] 245 | name = "pytest" 246 | version = "7.4.4" 247 | description = "pytest: simple powerful testing with Python" 248 | optional = false 249 | python-versions = ">=3.7" 250 | files = [ 251 | {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, 252 | {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, 253 | ] 254 | 255 | [package.dependencies] 256 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 257 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 258 | iniconfig = "*" 259 | packaging = "*" 260 | pluggy = ">=0.12,<2.0" 261 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 262 | 263 | [package.extras] 264 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 265 | 266 | [[package]] 267 | name = "tomli" 268 | version = "2.0.1" 269 | description = "A lil' TOML parser" 270 | optional = false 271 | python-versions = ">=3.7" 272 | files = [ 273 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 274 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 275 | ] 276 | 277 | [[package]] 278 | name = "typing-extensions" 279 | version = "4.9.0" 280 | description = "Backported and Experimental Type Hints for Python 3.8+" 281 | optional = false 282 | python-versions = ">=3.8" 283 | files = [ 284 | {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, 285 | {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, 286 | ] 287 | 288 | [metadata] 289 | lock-version = "2.0" 290 | python-versions = "^3.9" 291 | content-hash = "cc711f4e6111f8550749bb97195014df9006c4d1077f6ece062d83f7a615902c" 292 | -------------------------------------------------------------------------------- /pw.sublime-project: -------------------------------------------------------------------------------- 1 | { 2 | "folders": 3 | [ 4 | { 5 | "follow_symlinks": true, 6 | "path": ".", 7 | "folder_exclude_patterns": [".tox", "build", "dist", "pw.egg-info"], 8 | } 9 | ], 10 | "build_systems": 11 | [ 12 | { 13 | "name": "Test", 14 | "shell_cmd": "make test", 15 | "working_dir": "$project_path" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /pw/__init__.py: -------------------------------------------------------------------------------- 1 | from .store import Store, Entry 2 | 3 | __version__ = "0.14.1" 4 | -------------------------------------------------------------------------------- /pw/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from functools import partial 3 | import os, os.path, random, signal, string, sys 4 | import click 5 | from . import __version__, Store, _gpg 6 | 7 | 8 | class Mode(object): 9 | COPY = "Mode.COPY" 10 | ECHO = "Mode.ECHO" 11 | RAW = "Mode.RAW" 12 | 13 | 14 | def default_path(): 15 | return os.environ.get("PW_PATH") or click.get_app_dir("passwords.pw.asc") 16 | 17 | 18 | style_match = partial(click.style, fg="yellow", bold=True) 19 | style_error = style_password = partial(click.style, fg="red", bold=True) 20 | style_success = partial(click.style, fg="green", bold=True, reverse=True) 21 | 22 | 23 | def highlight_match(pattern, str): 24 | return style_match(pattern).join(str.split(pattern)) if pattern else str 25 | 26 | 27 | RANDOM_PASSWORD_DEFAULT_LENGTH = 32 28 | RANDOM_PASSWORD_ALPHABET = string.ascii_letters + string.digits 29 | 30 | 31 | @click.command() 32 | @click.argument("key_pattern", metavar="[USER@][KEY]", default="") 33 | @click.argument("user_pattern", metavar="[USER]", default="") 34 | @click.option( 35 | "--copy", 36 | "-C", 37 | "mode", 38 | flag_value=Mode.COPY, 39 | default=True, 40 | help="Display account information, but copy password to clipboard (default mode).", 41 | ) 42 | @click.option( 43 | "--echo", 44 | "-E", 45 | "mode", 46 | flag_value=Mode.ECHO, 47 | help="Display account information as well as password in plaintext (alternative mode).", 48 | ) 49 | @click.option( 50 | "--raw", 51 | "-R", 52 | "mode", 53 | flag_value=Mode.RAW, 54 | help="Only display password in plaintext (alternative mode).", 55 | ) 56 | @click.option( 57 | "--strict", 58 | "-S", 59 | "strict_flag", 60 | is_flag=True, 61 | help="Fail unless precisely a single result has been found.", 62 | ) 63 | @click.option( 64 | "--user", 65 | "-U", 66 | "user_flag", 67 | is_flag=True, 68 | help="Copy or display username instead of password.", 69 | ) 70 | @click.option( 71 | "--file", 72 | "-f", 73 | metavar="PATH", 74 | default=default_path(), 75 | help="Path to password file.", 76 | ) 77 | @click.option( 78 | "--edit", 79 | "edit_subcommand", 80 | is_flag=True, 81 | help="Launch editor to edit password database and exit.", 82 | ) 83 | @click.option( 84 | "--gen", "gen_subcommand", is_flag=True, help="Generate a random password and exit." 85 | ) 86 | @click.version_option( 87 | version=__version__, message="pw version %(version)s\npython " + sys.version 88 | ) 89 | @click.pass_context 90 | def pw( 91 | ctx, 92 | key_pattern, 93 | user_pattern, 94 | mode, 95 | strict_flag, 96 | user_flag, 97 | file, 98 | edit_subcommand, 99 | gen_subcommand, 100 | ): 101 | """Search for USER and KEY in GPG-encrypted password file.""" 102 | 103 | # install silent Ctrl-C handler 104 | def handle_sigint(*_): 105 | click.echo() 106 | ctx.exit(1) 107 | 108 | signal.signal(signal.SIGINT, handle_sigint) 109 | 110 | # invoke a subcommand? 111 | if gen_subcommand: 112 | length = int(key_pattern) if key_pattern else None 113 | generate_password(mode, length) 114 | return 115 | elif edit_subcommand: 116 | launch_editor(ctx, file) 117 | return 118 | 119 | # verify that database file is present 120 | if not os.path.exists(file): 121 | click.echo("error: password store not found at '%s'" % file, err=True) 122 | ctx.exit(1) 123 | 124 | # load database 125 | store = Store.load(file) 126 | 127 | # if no user query provided, split key query according to right-most "@" sign (since usernames are typically email addresses) 128 | if not user_pattern: 129 | user_pattern, _, key_pattern = key_pattern.rpartition("@") 130 | 131 | # search database 132 | results = store.search(key_pattern, user_pattern) 133 | results = list(results) 134 | 135 | # if strict flag is enabled, check that precisely a single record was found 136 | if strict_flag and len(results) != 1: 137 | click.echo( 138 | "error: multiple or no records found (but using --strict flag)", err=True 139 | ) 140 | ctx.exit(2) 141 | 142 | # raw mode? 143 | if mode == Mode.RAW: 144 | for entry in results: 145 | click.echo(entry.user if user_flag else entry.password) 146 | return 147 | 148 | # print results 149 | for idx, entry in enumerate(results): 150 | # start with key and user 151 | line = highlight_match(key_pattern, entry.key) 152 | if entry.user: 153 | line += ": " + highlight_match(user_pattern, entry.user) 154 | 155 | # add password or copy&paste sucess message 156 | if mode == Mode.ECHO and not user_flag: 157 | line += " | " + style_password(entry.password) 158 | elif mode == Mode.COPY and idx == 0: 159 | try: 160 | import pyperclip 161 | 162 | pyperclip.copy(entry.user if user_flag else entry.password) 163 | result = style_success( 164 | "*** %s COPIED TO CLIPBOARD ***" 165 | % ("USERNAME" if user_flag else "PASSWORD") 166 | ) 167 | except ImportError: 168 | result = style_error('*** PYTHON PACKAGE "PYPERCLIP" NOT FOUND ***') 169 | line += " | " + result 170 | 171 | # add notes 172 | if entry.notes: 173 | if idx == 0: 174 | line += "\n" 175 | line += "\n".join(" " + line for line in entry.notes.splitlines()) 176 | else: 177 | lines = entry.notes.splitlines() 178 | line += " | " + lines[0] 179 | if len(lines) > 1: 180 | line += " (...)" 181 | click.echo(line) 182 | 183 | 184 | def launch_editor(ctx, file): 185 | """launch editor with decrypted password database""" 186 | # do not use EDITOR environment variable (rather force user to make a concious choice) 187 | editor = os.environ.get("PW_EDITOR") 188 | if not editor: 189 | click.echo("error: no editor set in PW_EDITOR environment variables") 190 | ctx.exit(1) 191 | 192 | # verify that database file is present 193 | if not os.path.exists(file): 194 | click.echo("error: password store not found at '%s'" % file, err=True) 195 | ctx.exit(1) 196 | 197 | # load source (decrypting if necessary) 198 | is_encrypted = _gpg.is_encrypted(file) 199 | if is_encrypted: 200 | original = _gpg.decrypt(file) 201 | else: 202 | original = open(file, "rb").read() 203 | 204 | # if encrypted, determine recipient 205 | if is_encrypted: 206 | recipient = os.environ.get("PW_GPG_RECIPIENT") 207 | if not recipient: 208 | click.echo( 209 | "error: no recipient set in PW_GPG_RECIPIENT environment variables" 210 | ) 211 | ctx.exit(1) 212 | 213 | # launch the editor 214 | ext = _gpg.unencrypted_ext(file) 215 | modified = click.edit( 216 | original.decode("utf-8"), editor=editor, require_save=True, extension=ext 217 | ) 218 | if modified is None: 219 | click.echo("not modified") 220 | return 221 | modified = modified.encode("utf-8") 222 | 223 | # not encrypted? simply overwrite file 224 | if not is_encrypted: 225 | with open(file, "wb") as fp: 226 | fp.write(modified) 227 | return 228 | 229 | # otherwise, the process is somewhat more complicated 230 | _gpg.encrypt(recipient=recipient, dest_path=file, content=modified) 231 | 232 | 233 | def generate_password(mode, length): 234 | """generate a random password""" 235 | # generate random password 236 | r = random.SystemRandom() 237 | length = length or RANDOM_PASSWORD_DEFAULT_LENGTH 238 | password = "".join(r.choice(RANDOM_PASSWORD_ALPHABET) for _ in range(length)) 239 | 240 | # copy or echo generated password 241 | if mode == Mode.ECHO: 242 | click.echo(style_password(password)) 243 | elif mode == Mode.COPY: 244 | try: 245 | import pyperclip 246 | 247 | pyperclip.copy(password) 248 | result = style_success("*** PASSWORD COPIED TO CLIPBOARD ***") 249 | except ImportError: 250 | result = style_error('*** PYTHON PACKAGE "PYPERCLIP" NOT FOUND ***') 251 | click.echo(result) 252 | elif mode == Mode.RAW: 253 | click.echo(password) 254 | 255 | 256 | if __name__ == "__main__": 257 | pw(prog_name="pw") 258 | -------------------------------------------------------------------------------- /pw/_gpg.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import subprocess 3 | from typing import List, Optional, cast 4 | 5 | _HAS_ARMOR = {".gpg": False, ".asc": True} 6 | _EXTENSIONS = _HAS_ARMOR.keys() 7 | _OVERRIDE_HOMEDIR = None # type: Optional[str] # useful for unit tests 8 | 9 | 10 | def is_encrypted(path: str) -> bool: 11 | _, ext = os.path.splitext(path) 12 | return ext in _EXTENSIONS 13 | 14 | 15 | def has_armor(path: str) -> bool: 16 | _, ext = os.path.splitext(path) 17 | if ext not in _EXTENSIONS: 18 | raise ValueError("File extension not recognized as encrypted (%r)." % ext) 19 | return _HAS_ARMOR[ext] 20 | 21 | 22 | def unencrypted_ext(path: str) -> str: 23 | root, ext = os.path.splitext(path) 24 | if ext in _EXTENSIONS: 25 | _, ext = os.path.splitext(root) 26 | return ext 27 | 28 | 29 | def _base_args() -> List[str]: 30 | binary = os.environ.get("PW_GPG", "gpg") 31 | args = [binary, "--use-agent", "--quiet", "--batch", "--yes"] 32 | if _OVERRIDE_HOMEDIR is not None: 33 | args += ["--homedir", _OVERRIDE_HOMEDIR] 34 | return args 35 | 36 | 37 | def decrypt(path: str) -> bytes: 38 | args = ["--decrypt", path] 39 | return cast(bytes, subprocess.check_output(_base_args() + args)) 40 | 41 | 42 | def encrypt(recipient: str, dest_path: str, content: bytes) -> None: 43 | args = ["--encrypt"] 44 | if has_armor(dest_path): 45 | args += ["--armor"] 46 | args += ["--recipient", recipient, "--output", dest_path] 47 | popen = subprocess.Popen( 48 | _base_args() + args, 49 | stdin=subprocess.PIPE, 50 | stdout=subprocess.PIPE, 51 | stderr=subprocess.PIPE, 52 | ) 53 | stdout, stderr = popen.communicate(content) 54 | assert popen.returncode == 0, stderr 55 | -------------------------------------------------------------------------------- /pw/store.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from io import StringIO 3 | from shlex import shlex 4 | from typing import List, Iterable 5 | from . import _gpg 6 | 7 | Entry = namedtuple("Entry", ["key", "user", "password", "notes"]) 8 | 9 | 10 | def _normalized_key(key: str) -> str: 11 | return key.replace(" ", "_").lower() 12 | 13 | 14 | class Store: 15 | """Password store.""" 16 | 17 | def __init__(self, path: str, entries: Iterable[Entry]) -> None: 18 | # normalize keys 19 | self.entries = [e._replace(key=_normalized_key(e.key)) for e in entries] 20 | self.path = path 21 | 22 | def search(self, key_pattern: str, user_pattern: str) -> List[Entry]: 23 | """Search database for given key and user pattern.""" 24 | # normalize key 25 | key_pattern = _normalized_key(key_pattern) 26 | 27 | # search 28 | results = [] 29 | for entry in self.entries: 30 | if key_pattern in entry.key and user_pattern in entry.user: 31 | results.append(entry) 32 | 33 | # sort results according to key (stability of sorted() ensures that the order of accounts for any given key remains untouched) 34 | return sorted(results, key=lambda e: e.key) 35 | 36 | @staticmethod 37 | def load(path: str) -> "Store": 38 | """Load password store from file.""" 39 | # load source (decrypting if necessary) 40 | if _gpg.is_encrypted(path): 41 | src_bytes = _gpg.decrypt(path) 42 | else: 43 | src_bytes = open(path, "rb").read() 44 | src = src_bytes.decode("utf-8") 45 | 46 | # parse database source 47 | ext = _gpg.unencrypted_ext(path) 48 | assert ext not in [ 49 | ".yml", 50 | ".yaml", 51 | ], "YAML support was removed in version 0.12.0" 52 | entries = _parse_entries(src) 53 | 54 | return Store(path, entries) 55 | 56 | 57 | class SyntaxError(Exception): 58 | def __init__(self, lineno: int, line: str, reason: str) -> None: 59 | super(SyntaxError, self).__init__( 60 | "line %s: %s (%r)" % (lineno + 1, reason, line) 61 | ) 62 | 63 | 64 | _EXPECT_ENTRY = "expecting entry" 65 | _EXPECT_ENTRY_OR_NOTES = "expecting entry or notes" 66 | 67 | 68 | def _parse_entries(src: str) -> List[Entry]: 69 | entries = [] # type: List[Entry] 70 | state = _EXPECT_ENTRY 71 | 72 | for lineno, line in enumerate(src.splitlines()): 73 | # empty lines are skipped (but also terminate the notes section) 74 | sline = line.strip() 75 | if not sline or line.startswith("#"): 76 | state = _EXPECT_ENTRY 77 | continue 78 | 79 | # non-empty line with leading spaces is interpreted as a notes line 80 | if line[0] in [" ", "\t"]: 81 | if state != _EXPECT_ENTRY_OR_NOTES: 82 | raise SyntaxError(lineno, line, state) 83 | 84 | # add line of notes 85 | notes = entries[-1].notes 86 | if notes: 87 | notes += "\n" 88 | notes += sline 89 | entries[-1] = entries[-1]._replace(notes=notes) 90 | continue 91 | 92 | # otherwise, parse as an entry 93 | sio = StringIO(line) 94 | lexer = shlex(sio, posix=True) # type: ignore 95 | lexer.whitespace_split = True 96 | 97 | try: 98 | key = lexer.get_token() 99 | except ValueError as e: 100 | raise SyntaxError(lineno, line, str(e)) 101 | key = key.rstrip(":") 102 | assert key 103 | 104 | try: 105 | user = lexer.get_token() 106 | except ValueError as e: 107 | raise SyntaxError(lineno, line, str(e)) 108 | 109 | try: 110 | password = lexer.get_token() 111 | except ValueError as e: 112 | raise SyntaxError(lineno, line, str(e)) 113 | 114 | if not user and not password: 115 | raise SyntaxError(lineno, line, state) 116 | 117 | if not password: 118 | password = user 119 | user = notes = "" 120 | else: 121 | password = password 122 | notes = sio.read().strip() 123 | 124 | entries.append(Entry(key, user, password, notes)) 125 | state = _EXPECT_ENTRY_OR_NOTES 126 | 127 | return entries 128 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pw" 3 | version = "0.14.1" 4 | description = "Search in GPG-encrypted password file." 5 | authors = ["Michael Walter "] 6 | license = "MIT" 7 | homepage = "https://github.com/catch22/pw" 8 | classifiers = ["Environment :: Console"] 9 | readme = "README.md" 10 | 11 | [tool.poetry.scripts] 12 | pw = 'pw.__main__:pw' 13 | 14 | [tool.poetry.dependencies] 15 | python = "^3.9" 16 | click = "^8.1.7" 17 | colorama = "^0.4.6" 18 | pyperclip = "^1.8.2" 19 | 20 | [tool.poetry.group.dev.dependencies] 21 | pytest = "^7.4.4" 22 | black = ">=23.12.1,<25.0.0" 23 | bumpversion = "^0.6.0" 24 | mypy = "^1.8.0" 25 | -------------------------------------------------------------------------------- /test/add_a_line.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | # this script is a faux PW_EDITOR, which simply appends the string argv[1] to 4 | # the file argv[2]. it is called from test_cli.py:test_with_changes(). 5 | assert len(sys.argv) == 3 6 | with open(sys.argv[2], "a") as fp: 7 | fp.write("\n%s\n" % sys.argv[1]) 8 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import os.path 3 | import pw 4 | 5 | 6 | @pytest.fixture(scope="session") 7 | def dirname(): 8 | return os.path.dirname(os.path.abspath(__file__)) 9 | 10 | 11 | @pytest.fixture(scope="session", autouse=True) 12 | def override_gpg_homedir(dirname): 13 | pw._gpg._OVERRIDE_HOMEDIR = os.path.join(dirname, "keys") 14 | -------------------------------------------------------------------------------- /test/db.pw: -------------------------------------------------------------------------------- 1 | # single account 2 | laptop: alice 4l1c3 default user 3 | laptop: bob b0b 4 | 5 | # multiple accounts 6 | goggles: alice@gogglemail.com 12345 https://mail.goggles.com/ 7 | second line 8 | goggles: bob+spam@gogglemail.com abcde 9 | 10 | # utf-8 11 | router: ädmin "gamma zeta" 12 | multiple 13 | lines 14 | of 15 | notes 16 | 17 | # nested accounts 18 | phones.myphone: 0000 19 | phones.samson: 111 20 | # phones.ignore_this_old_phone: 123 21 | -------------------------------------------------------------------------------- /test/db.pw.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP MESSAGE----- 2 | Version: GnuPG v2 3 | 4 | hQEMA9GRc+s17h8dAQf+OxTBPsXsIT3HshRYvx+MQHDH85yuzNSIU0VoAw//iimv 5 | 2bZovjm74qX9rJMPPSsrgIMNZahFBm+wf8lIJWs4LXE68YwEVHH8wsgKuSG21IkF 6 | 2ROHUWI36JLkAthhTBcNgDv9HGFcRjdMd7CBXTsqpBMyFWhg2EOSSl9PVzcqmePh 7 | rPufj2iR+BniuvlbdR8HO9DoSyftEzveVB5CegqnzZxO477lHzoMkGf6KjiArKtY 8 | U7ba0o/Od0OHE7XnUAH0ASf6XzAPoj1jxd+sgcgfx9UoDQQhp5d3b/WCvYQ1j8o6 9 | eVYD/d1fLWtNC3xYUfRO5H0+DnZ8aX4c6Lmh5//pHtLAZgEww8Z6pcNPz8QkRkvz 10 | 9od9p4kKpAVAhikc/sKWU3YKzOcGPxhhaXAwj2Dcr9xgcFf2woyBuvTWY1+BgXlt 11 | 05nl/vNHOe44l9T2BggUrHMQuOGwrkMX/MDfYhdfE9I6iPWsylKmhD4jo5+lpMXU 12 | 2MMvFSAglTru1ItIvkpwVP6tEMMeUZOoz5Gn3+/MP7aA/TMD8IUr9mf65b51MChf 13 | ou1kMZkulQmPnLUc1QVAO9GYMJJarD7xtO5aGk48NlI+nB+k9vcBvvSqAXFR9F9P 14 | M4eVHTiWyd0/gAEPnqj/aVq3Hm7WNJlpNVW9nGmn7Abur7bIr2MekAsNNFkE5+X5 15 | 82Ny9Vm5fpeXR1LHzkeR8PctY0X8+tSOHju7ClJMDjKds0+4dn54hw== 16 | =8cx0 17 | -----END PGP MESSAGE----- 18 | -------------------------------------------------------------------------------- /test/db.pw.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catch22/pw/6acadd5677bc839868c3b519343af811b8161c84/test/db.pw.gpg -------------------------------------------------------------------------------- /test/keys/pubring.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catch22/pw/6acadd5677bc839868c3b519343af811b8161c84/test/keys/pubring.gpg -------------------------------------------------------------------------------- /test/keys/secring.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catch22/pw/6acadd5677bc839868c3b519343af811b8161c84/test/keys/secring.gpg -------------------------------------------------------------------------------- /test/keys/trustdb.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catch22/pw/6acadd5677bc839868c3b519343af811b8161c84/test/keys/trustdb.gpg -------------------------------------------------------------------------------- /test/test_cli.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from click.testing import CliRunner 3 | import os.path, sys, tempfile 4 | import pytest 5 | import pw, pw.__main__ 6 | import pyperclip 7 | 8 | 9 | @pytest.fixture(scope="module", params=["db.pw", "db.pw.gpg", "db.pw.asc"]) 10 | def runner(request, dirname): 11 | runner = CliRunner() 12 | abspath = os.path.join(dirname, request.param) 13 | return lambda *args: runner.invoke(pw.__main__.pw, ("--file", abspath) + args) 14 | 15 | 16 | @pytest.fixture(scope="module", params=["db.pw.gpg", "db.pw.asc"]) 17 | def encrypted_runner(request, dirname): 18 | runner = CliRunner() 19 | abspath = os.path.join(dirname, request.param) 20 | return lambda *args: runner.invoke(pw.__main__.pw, ("--file", abspath) + args) 21 | 22 | 23 | def test_version(runner): 24 | result = runner("--version") 25 | assert result.exit_code == 0 26 | assert result.output.strip().startswith("pw version ") 27 | 28 | 29 | @pytest.mark.parametrize( 30 | "args, exit_code, output_expected", 31 | [ 32 | # default query 33 | ( 34 | [], 35 | 0, 36 | """ 37 | goggles: alice@gogglemail.com 38 | https://mail.goggles.com/ 39 | second line 40 | goggles: bob+spam@gogglemail.com 41 | laptop: alice | default user 42 | laptop: bob 43 | phones.myphone 44 | phones.samson 45 | router: ädmin | multiple (...) 46 | """, 47 | ), 48 | # querying for path and user 49 | ( 50 | ["goggle"], 51 | 0, 52 | """ 53 | goggles: alice@gogglemail.com 54 | https://mail.goggles.com/ 55 | second line 56 | goggles: bob+spam@gogglemail.com 57 | """, 58 | ), 59 | ( 60 | ["bob@"], 61 | 0, 62 | """ 63 | goggles: bob+spam@gogglemail.com 64 | laptop: bob 65 | """, 66 | ), 67 | (["bob@goggle"], 0, "goggles: bob+spam@gogglemail.com"), 68 | (["goggle", "bob"], 0, "goggles: bob+spam@gogglemail.com"), 69 | (["bob@goggle", "bob"], 0, ""), 70 | # strictness 71 | (["--strict", "myphone"], 0, "phones.myphone"), 72 | ( 73 | ["--strict", "phones"], 74 | 2, 75 | "error: multiple or no records found (but using --strict flag)", 76 | ), 77 | ], 78 | ) 79 | def test_query(runner, args, exit_code, output_expected): 80 | result = runner("--echo", "--user", *args) 81 | assert result.exit_code == exit_code 82 | assert result.output.strip() == output_expected.strip() 83 | 84 | 85 | def test_missing(): 86 | runner = CliRunner() 87 | result = runner.invoke(pw.__main__.pw, ("--file", "XXX")) 88 | assert result.exit_code == 1 89 | assert "error: password store not found at 'XXX'" == result.output.strip() 90 | 91 | 92 | CLIPBOARD_NOT_TOUCHED = "CLIPBOARD_NOT_TOUCHED" 93 | 94 | 95 | @pytest.mark.parametrize( 96 | "args, output_expected, clipboard_expected", 97 | [ 98 | ( 99 | ["laptop", "bob"], 100 | "laptop: bob | *** PASSWORD COPIED TO CLIPBOARD ***", 101 | "b0b", 102 | ), 103 | ( 104 | ["--copy", "laptop", "bob"], 105 | "laptop: bob | *** PASSWORD COPIED TO CLIPBOARD ***", 106 | "b0b", 107 | ), 108 | ( 109 | ["--copy", "--user", "laptop", "bob"], 110 | "laptop: bob | *** USERNAME COPIED TO CLIPBOARD ***", 111 | "bob", 112 | ), 113 | (["--echo", "laptop", "bob"], "laptop: bob | b0b", CLIPBOARD_NOT_TOUCHED), 114 | (["--echo", "--user", "laptop", "bob"], "laptop: bob", CLIPBOARD_NOT_TOUCHED), 115 | (["--raw", "laptop", "bob"], "b0b", CLIPBOARD_NOT_TOUCHED), 116 | (["--raw", "--user", "laptop", "bob"], "bob", CLIPBOARD_NOT_TOUCHED), 117 | ], 118 | ) 119 | def test_modes(runner, args, output_expected, clipboard_expected): 120 | pyperclip.copy(CLIPBOARD_NOT_TOUCHED) 121 | result = runner(*args) 122 | assert result.exit_code == 0 123 | assert result.output.strip() == output_expected.strip() 124 | assert pyperclip.paste() == clipboard_expected.strip() 125 | 126 | 127 | @pytest.mark.parametrize( 128 | "args, use_clipboard, length_expected", 129 | [ 130 | (["--gen"], True, pw.__main__.RANDOM_PASSWORD_DEFAULT_LENGTH), 131 | (["--gen", "--copy"], True, pw.__main__.RANDOM_PASSWORD_DEFAULT_LENGTH), 132 | (["--gen", "--echo"], False, pw.__main__.RANDOM_PASSWORD_DEFAULT_LENGTH), 133 | ( 134 | ["--gen", "--echo", "--raw"], 135 | False, 136 | pw.__main__.RANDOM_PASSWORD_DEFAULT_LENGTH, 137 | ), 138 | (["--gen", "8"], True, 8), 139 | (["--gen", "--copy", "8"], True, 8), 140 | (["--gen", "--echo", "8"], False, 8), 141 | (["--gen", "--echo", "--raw", "8"], False, 8), 142 | ], 143 | ) 144 | def test_gen(runner, args, use_clipboard, length_expected): 145 | pyperclip.copy(CLIPBOARD_NOT_TOUCHED) 146 | result = runner(*args) 147 | assert result.exit_code == 0 148 | output = result.output.strip() 149 | clipboard = pyperclip.paste() 150 | if use_clipboard: 151 | assert output == "*** PASSWORD COPIED TO CLIPBOARD ***" 152 | assert len(clipboard) == length_expected 153 | assert all(c in pw.__main__.RANDOM_PASSWORD_ALPHABET for c in clipboard) 154 | else: 155 | assert len(output) == length_expected 156 | assert all(c in pw.__main__.RANDOM_PASSWORD_ALPHABET for c in output) 157 | assert clipboard == CLIPBOARD_NOT_TOUCHED 158 | 159 | 160 | def test_edit_without_changes(runner): 161 | os.environ["PW_EDITOR"] = "echo CALLED FOR" 162 | os.environ["PW_GPG_RECIPIENT"] = "test.user@localhost" 163 | result = runner("--edit") 164 | assert result.exit_code == 0 165 | assert result.output.strip() == "not modified" 166 | 167 | 168 | def test_edit_editor_missing(runner): 169 | os.environ.pop("PW_EDITOR", None) 170 | os.environ["PW_GPG_RECIPIENT"] = "test.user@localhost" 171 | result = runner("--edit") 172 | assert result.exit_code == 1 173 | assert result.output.strip().startswith("error: no editor set") 174 | 175 | 176 | def test_edit_recipient_missing(encrypted_runner): 177 | os.environ["PW_EDITOR"] = "echo CALLED FOR" 178 | os.environ.pop("PW_GPG_RECIPIENT", None) 179 | result = encrypted_runner("--edit") 180 | assert result.exit_code == 1 181 | assert result.output.strip().startswith("error: no recipient set") 182 | 183 | 184 | def test_edit_file_missing(): 185 | os.environ["PW_EDITOR"] = "echo CALLED FOR" 186 | os.environ["PW_GPG_RECIPIENT"] = "test.user@localhost" 187 | runner = CliRunner() 188 | result = runner.invoke(pw.__main__.pw, ("--file", "XXX", "--edit")) 189 | assert result.exit_code == 1 190 | assert "error: password store not found at 'XXX'" == result.output.strip() 191 | 192 | 193 | @pytest.mark.parametrize( 194 | "filename, addendum", 195 | [ 196 | ("db.pw", "fancy_new_entry: user pass interesting notes"), 197 | ("db.pw.gpg", "fancy_new_entry: user pass interesting notes"), 198 | ("db.pw.asc", "fancy_new_entry: user pass interesting notes"), 199 | ], 200 | ) 201 | def test_edit_with_changes(dirname, filename, addendum): 202 | fp = tempfile.NamedTemporaryFile(delete=False, suffix=filename) 203 | try: 204 | # copy password file to temporary file 205 | abspath = os.path.join(dirname, filename) 206 | original = open(abspath, "rb").read() 207 | fp.write(original) 208 | fp.close() 209 | 210 | # call pw --edit and modify file 211 | editor = os.path.join(dirname, "add_a_line.py") 212 | os.environ["PW_EDITOR"] = '%s "%s" "%s"' % (sys.executable, editor, addendum) 213 | os.environ["PW_GPG_RECIPIENT"] = "test.user@localhost" 214 | runner = CliRunner() 215 | result = runner.invoke(pw.__main__.pw, ("--file", fp.name, "--edit")) 216 | assert result.exit_code == 0 217 | assert result.output.strip() == "" 218 | 219 | # try to find new entry 220 | store = pw.Store.load(fp.name) 221 | result = store.search(key_pattern="fancy", user_pattern="") 222 | assert len(result) == 1 223 | assert result[0] == pw.Entry( 224 | "fancy_new_entry", "user", "pass", "interesting notes" 225 | ) 226 | finally: 227 | os.unlink(fp.name) 228 | -------------------------------------------------------------------------------- /test/test_gpg.py: -------------------------------------------------------------------------------- 1 | import os.path, tempfile 2 | import pytest 3 | import pw._gpg 4 | from pw._gpg import is_encrypted, has_armor, unencrypted_ext, decrypt, encrypt 5 | 6 | 7 | def test_detection(): 8 | # is_encrypted 9 | assert not is_encrypted("test.txt") 10 | assert is_encrypted("test.asc") 11 | assert is_encrypted("test.txt.asc") 12 | assert is_encrypted("test.gpg") 13 | assert is_encrypted("test.txt.gpg") 14 | 15 | # has_armor 16 | with pytest.raises(ValueError): 17 | has_armor("test.txt") 18 | assert has_armor("test.asc") 19 | assert has_armor("test.txt.asc") 20 | assert not has_armor("test.gpg") 21 | assert not has_armor("test.txt.gpg") 22 | 23 | # unencrypted_ext 24 | assert unencrypted_ext("test.txt") == ".txt" 25 | assert unencrypted_ext("test.asc") == "" 26 | assert unencrypted_ext("test.txt.asc") == ".txt" 27 | assert unencrypted_ext("test.gpg") == "" 28 | assert unencrypted_ext("test.txt.gpg") == ".txt" 29 | 30 | 31 | @pytest.mark.parametrize("filename", ["db.pw.asc", "db.pw.gpg"]) 32 | def test_decrypt(dirname, filename): 33 | # manually decrypt password file & compare with unencrypted file in repository 34 | decrypted = decrypt(os.path.join(dirname, filename)) 35 | unencrypted = open(os.path.join(dirname, "db.pw"), "rb").read() 36 | assert decrypted == unencrypted 37 | 38 | 39 | @pytest.mark.parametrize("filename", ["db.pw.asc", "db.pw.gpg"]) 40 | def test_encrypt(dirname, filename): 41 | # load unencrypted password file 42 | unencrypted = open(os.path.join(dirname, "db.pw"), "rb").read() 43 | 44 | # encrypt into temporary file, decrypt again, and compare result 45 | fp = tempfile.NamedTemporaryFile(delete=False, suffix=filename) 46 | try: 47 | fp.close() 48 | 49 | encrypt(recipient="test.user@localhost", dest_path=fp.name, content=unencrypted) 50 | decrypted = decrypt(fp.name) 51 | assert decrypted == unencrypted 52 | finally: 53 | os.unlink(fp.name) 54 | -------------------------------------------------------------------------------- /test/test_store.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import pytest 3 | import os.path 4 | import pw 5 | from pw.store import _normalized_key, _parse_entries, Entry, Store, SyntaxError 6 | 7 | 8 | def test_normalized_key(): 9 | assert _normalized_key("My secret aCcOuNt") == "my_secret_account" 10 | 11 | 12 | @pytest.mark.parametrize( 13 | "src, expected", 14 | [ 15 | ("", []), 16 | ( 17 | """ 18 | # this is a comment 19 | """, 20 | [], 21 | ), 22 | ( 23 | """ 24 | key pass 25 | key: pass 26 | # 27 | "key word" pass 28 | "key word": pass 29 | # 30 | key "pass word" 31 | key: "pass word" 32 | # 33 | "key word" "pass word" 34 | "key word": "pass word" 35 | """, 36 | [ 37 | Entry(key="key", user="", password="pass", notes=""), 38 | Entry(key="key", user="", password="pass", notes=""), 39 | Entry(key="key word", user="", password="pass", notes=""), 40 | Entry(key="key word", user="", password="pass", notes=""), 41 | Entry(key="key", user="", password="pass word", notes=""), 42 | Entry(key="key", user="", password="pass word", notes=""), 43 | Entry(key="key word", user="", password="pass word", notes=""), 44 | Entry(key="key word", user="", password="pass word", notes=""), 45 | ], 46 | ), 47 | ( 48 | """ 49 | key user pass 50 | key: user pass 51 | # 52 | "key word" "user name" "pass word" 53 | "key word": "user name" "pass word" 54 | """, 55 | [ 56 | Entry(key="key", user="user", password="pass", notes=""), 57 | Entry(key="key", user="user", password="pass", notes=""), 58 | Entry(key="key word", user="user name", password="pass word", notes=""), 59 | Entry(key="key word", user="user name", password="pass word", notes=""), 60 | ], 61 | ), 62 | ( 63 | """ 64 | key user pass these are some interesting notes 65 | key: user pass these are some interesting notes 66 | # 67 | "key word" "user name" "pass word" these are some interesting notes 68 | "key word": "user name" "pass word" these are some interesting notes 69 | """, 70 | [ 71 | Entry( 72 | key="key", 73 | user="user", 74 | password="pass", 75 | notes="these are some interesting notes", 76 | ), 77 | Entry( 78 | key="key", 79 | user="user", 80 | password="pass", 81 | notes="these are some interesting notes", 82 | ), 83 | Entry( 84 | key="key word", 85 | user="user name", 86 | password="pass word", 87 | notes="these are some interesting notes", 88 | ), 89 | Entry( 90 | key="key word", 91 | user="user name", 92 | password="pass word", 93 | notes="these are some interesting notes", 94 | ), 95 | ], 96 | ), 97 | ( 98 | """ 99 | key pass 100 | notes line 1 101 | notes line 2 102 | key: pass 103 | notes line 1 104 | notes line 2 105 | # 106 | key "" pass notes line 0 107 | notes line 1 108 | notes line 2 109 | key: "" pass notes line 0 110 | notes line 1 111 | notes line 2 112 | # 113 | key "" pass notes line 0 114 | # notes line 0.5 115 | notes line 1 116 | # notes line 1.5 117 | notes line 2 118 | key "" pass notes line 0 119 | # notes line 0.5 120 | notes line 1 121 | # notes line 1.5 122 | notes line 2 123 | """, 124 | [ 125 | Entry( 126 | key="key", 127 | user="", 128 | password="pass", 129 | notes="notes line 1\nnotes line 2", 130 | ), 131 | Entry( 132 | key="key", 133 | user="", 134 | password="pass", 135 | notes="notes line 1\nnotes line 2", 136 | ), 137 | Entry( 138 | key="key", 139 | user="", 140 | password="pass", 141 | notes="notes line 0\nnotes line 1\nnotes line 2", 142 | ), 143 | Entry( 144 | key="key", 145 | user="", 146 | password="pass", 147 | notes="notes line 0\nnotes line 1\nnotes line 2", 148 | ), 149 | Entry( 150 | key="key", 151 | user="", 152 | password="pass", 153 | notes="notes line 0\n# notes line 0.5\nnotes line 1\n# notes line 1.5\nnotes line 2", 154 | ), 155 | Entry( 156 | key="key", 157 | user="", 158 | password="pass", 159 | notes="notes line 0\n# notes line 0.5\nnotes line 1\n# notes line 1.5\nnotes line 2", 160 | ), 161 | ], 162 | ), 163 | ( 164 | """ 165 | key user pass 166 | notes line 1 167 | notes line 2 168 | key: user pass 169 | notes line 1 170 | notes line 2 171 | # 172 | key user pass notes line 0 173 | notes line 1 174 | notes line 2 175 | key: user pass notes line 0 176 | notes line 1 177 | notes line 2 178 | # 179 | key user pass notes line 0 180 | # notes line 0.5 181 | notes line 1 182 | # notes line 1.5 183 | notes line 2 184 | key user pass notes line 0 185 | # notes line 0.5 186 | notes line 1 187 | # notes line 1.5 188 | notes line 2 189 | """, 190 | [ 191 | Entry( 192 | key="key", 193 | user="user", 194 | password="pass", 195 | notes="notes line 1\nnotes line 2", 196 | ), 197 | Entry( 198 | key="key", 199 | user="user", 200 | password="pass", 201 | notes="notes line 1\nnotes line 2", 202 | ), 203 | Entry( 204 | key="key", 205 | user="user", 206 | password="pass", 207 | notes="notes line 0\nnotes line 1\nnotes line 2", 208 | ), 209 | Entry( 210 | key="key", 211 | user="user", 212 | password="pass", 213 | notes="notes line 0\nnotes line 1\nnotes line 2", 214 | ), 215 | Entry( 216 | key="key", 217 | user="user", 218 | password="pass", 219 | notes="notes line 0\n# notes line 0.5\nnotes line 1\n# notes line 1.5\nnotes line 2", 220 | ), 221 | Entry( 222 | key="key", 223 | user="user", 224 | password="pass", 225 | notes="notes line 0\n# notes line 0.5\nnotes line 1\n# notes line 1.5\nnotes line 2", 226 | ), 227 | ], 228 | ), 229 | ( 230 | """ 231 | "السلام عليكم": 임요환 Нет Εις το επανιδείν 232 | """, 233 | [ 234 | Entry( 235 | key="السلام عليكم", 236 | user="임요환", 237 | password="Нет", 238 | notes="Εις το επανιδείν", 239 | ) 240 | ], 241 | ), 242 | ], 243 | ) # yapf: disable 244 | def test_parse_entries(src, expected): 245 | entries = _parse_entries(src.strip()) 246 | assert entries == expected 247 | 248 | 249 | @pytest.mark.parametrize( 250 | "src, expected_error_prefix", 251 | [ 252 | ( 253 | """ 254 | foo: bar 255 | note line 1 256 | 257 | note line 2 258 | """, 259 | "line 4: expecting entry (", 260 | ), 261 | ( 262 | """ 263 | foo: bar 264 | note line 1 265 | # non-indented comment 266 | note line 2 267 | """, 268 | "line 4: expecting entry (", 269 | ), 270 | ( 271 | """ 272 | foo: bar 273 | baz 274 | boink: zonk 275 | """, 276 | "line 2: expecting entry or notes (", 277 | ), 278 | ( 279 | """ 280 | foo": bar 281 | """, 282 | "line 1: No closing quotation (", 283 | ), 284 | ( 285 | """ 286 | foo: "bar 287 | """, 288 | "line 1: No closing quotation (", 289 | ), 290 | ( 291 | """ 292 | foo: bar "baz 293 | """, 294 | "line 1: No closing quotation (", 295 | ), 296 | ], 297 | ) # yapf: disable 298 | def test_parse_entries_syntax_errors(src, expected_error_prefix): 299 | with pytest.raises(SyntaxError) as excinfo: 300 | _parse_entries(src.strip()) 301 | assert str(excinfo.value).startswith(expected_error_prefix) 302 | 303 | 304 | @pytest.fixture(scope="module", params=["db.pw", "db.pw.gpg", "db.pw.asc"]) 305 | def store(request, dirname): 306 | abspath = os.path.join(dirname, request.param) 307 | return Store.load(abspath) 308 | 309 | 310 | def test_store_entries(store): 311 | expected = [ 312 | Entry("laptop", "alice", "4l1c3", "default user"), 313 | Entry("laptop", "bob", "b0b", ""), 314 | Entry( 315 | "goggles", 316 | "alice@gogglemail.com", 317 | "12345", 318 | "https://mail.goggles.com/\nsecond line", 319 | ), 320 | Entry("goggles", "bob+spam@gogglemail.com", "abcde", ""), 321 | Entry("router", "ädmin", "gamma zeta", "multiple\nlines\nof\nnotes"), 322 | Entry("phones.myphone", "", "0000", ""), 323 | Entry("phones.samson", "", "111", ""), 324 | ] # yapf: disable 325 | expected = sorted(expected, key=lambda e: e.key) 326 | got = sorted(store.entries, key=lambda e: e.key) 327 | assert got == expected 328 | 329 | 330 | def test_store_search(store): 331 | # search for key 332 | got = store.search(key_pattern="oggle", user_pattern="") 333 | expected = [ 334 | Entry( 335 | "goggles", 336 | "alice@gogglemail.com", 337 | "12345", 338 | "https://mail.goggles.com/\nsecond line", 339 | ), 340 | Entry("goggles", "bob+spam@gogglemail.com", "abcde", ""), 341 | ] 342 | assert got == expected 343 | 344 | # search for user 345 | got = store.search(key_pattern="", user_pattern="bob") 346 | expected = [ 347 | Entry("goggles", "bob+spam@gogglemail.com", "abcde", ""), 348 | Entry("laptop", "bob", "b0b", ""), 349 | ] 350 | assert got == expected 351 | 352 | # search for both 353 | got = store.search(key_pattern="oggle", user_pattern="spam") 354 | expected = [Entry("goggles", "bob+spam@gogglemail.com", "abcde", "")] 355 | assert got == expected 356 | --------------------------------------------------------------------------------