├── .codeclimate.yml ├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── labels.yml ├── pull_request_template.md ├── release-drafter.yml └── workflows │ ├── codeql.yml │ ├── labels.yml │ ├── lock.yml │ ├── publish.yml │ ├── release-drafter.yml │ ├── requirements.txt │ ├── stale.yml │ ├── static-analysis.yml │ └── test.yml ├── .gitignore ├── .mise.toml ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── aiopinboard ├── __init__.py ├── api.py ├── bookmark.py ├── errors.py ├── helpers │ ├── __init__.py │ └── types.py ├── note.py ├── py.typed └── tag.py ├── examples ├── __init__.py └── test_api.py ├── poetry.lock ├── pyproject.toml ├── script ├── release └── setup └── tests ├── __init__.py ├── common.py ├── conftest.py ├── fixtures ├── __init__.py ├── error_response.json ├── notes_get_response.json ├── posts_add_response.json ├── posts_all_response.json ├── posts_dates_response.json ├── posts_delete_response.json ├── posts_get_empty_response.json ├── posts_get_response.json ├── posts_recent_response.json ├── posts_suggest_response.json ├── posts_update_response.json ├── tags_delete_response.json ├── tags_get_response.json └── tags_rename_response.json ├── test_bookmark_api.py ├── test_errors.py ├── test_note_api.py └── test_tag_api.py /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | duplication: 4 | enabled: true 5 | config: 6 | languages: 7 | - python 8 | fixme: 9 | enabled: true 10 | radon: 11 | enabled: true 12 | ratings: 13 | paths: 14 | - "**.py" 15 | exclude_paths: 16 | - dist/ 17 | - docs/ 18 | - tests/ 19 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203,E266,E501,F811,W503 3 | max-line-length = 80 4 | max-complexity = 18 5 | per-file-ignores = tests/*:DAR,S101 6 | select = B,B9,LK,C,D,E,F,I,S,W 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | --- 5 | 6 | **Describe the bug** 7 | A clear and concise description of what the bug is. 8 | 9 | **To Reproduce** 10 | Steps to reproduce the behavior: 11 | 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | --- 5 | 6 | **Is your feature request related to a problem? Please describe.** 7 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 8 | 9 | **Describe the solution you'd like** 10 | A clear and concise description of what you want to happen. 11 | 12 | **Additional context** 13 | Add any other context or screenshots about the feature request here. 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: pip 5 | directory: "/" 6 | schedule: 7 | interval: daily 8 | time: "06:00" 9 | 10 | - package-ecosystem: pip 11 | directory: "/.github/workflows" 12 | schedule: 13 | interval: daily 14 | time: "06:00" 15 | 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: daily 20 | time: "06:00" 21 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "breaking-change" 3 | color: ee0701 4 | description: "A breaking change for existing users" 5 | - name: "bug" 6 | color: ee0701 7 | description: "Bugs or issues which will cause a problem for users" 8 | - name: "documentation" 9 | color: 0052cc 10 | description: "Project documentation" 11 | - name: "enhancement" 12 | color: 1d76db 13 | description: "Enhancement of the code, not introducing new features." 14 | - name: "maintenance" 15 | color: 2af79e 16 | description: "Generic library tasks" 17 | - name: "dependencies" 18 | color: 1d76db 19 | description: "Upgrade or downgrade of project dependencies" 20 | 21 | - name: "in-progress" 22 | color: fbca04 23 | description: "Issue is currently being resolved by a developer" 24 | - name: "stale" 25 | color: fef2c0 26 | description: "There has not been activity on this issue or PR for some time" 27 | - name: "no-stale" 28 | color: fef2c0 29 | description: "This issue or PR is exempted from the stale bot" 30 | 31 | - name: "security" 32 | color: ee0701 33 | description: "Marks a security issue that needs to be resolved ASAP" 34 | - name: "incomplete" 35 | color: fef2c0 36 | description: "Marks a PR or issue that is missing information" 37 | - name: "invalid" 38 | color: fef2c0 39 | description: "Marks a PR or issue that is missing information" 40 | 41 | - name: "help-wanted" 42 | color: 0e8a16 43 | description: "Needs a helping hang or expertise in order to resolve" 44 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **Describe what the PR does:** 2 | 3 | **Does this fix a specific issue?** 4 | 5 | Fixes https://github.com/bachya/aiopinboard/issues/ 6 | 7 | **Checklist:** 8 | 9 | - [ ] Confirm that one or more new tests are written for the new functionality. 10 | - [ ] Run tests and ensure everything passes (with 100% test coverage). 11 | - [ ] Update `README.md` with any new documentation. 12 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | categories: 3 | - title: "🚨 Breaking Changes" 4 | labels: 5 | - "breaking-change" 6 | 7 | - title: "🚀 Features" 8 | labels: 9 | - "enhancement" 10 | 11 | - title: "🐛 Bug Fixes" 12 | labels: 13 | - "bug" 14 | 15 | - title: "📕 Documentation" 16 | labels: 17 | - "documentation" 18 | 19 | - title: "🧰 Maintenance" 20 | labels: 21 | - "dependencies" 22 | - "maintenance" 23 | - "tooling" 24 | 25 | change-template: "- $TITLE (#$NUMBER)" 26 | name-template: "$NEXT_PATCH_VERSION" 27 | tag-template: "$NEXT_PATCH_VERSION" 28 | template: | 29 | $CHANGES 30 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CodeQL 3 | 4 | "on": 5 | push: 6 | branches: 7 | - dev 8 | - main 9 | 10 | pull_request: 11 | branches: 12 | - dev 13 | - main 14 | 15 | workflow_dispatch: 16 | 17 | schedule: 18 | - cron: "30 1 * * 0" 19 | 20 | jobs: 21 | codeql: 22 | name: Scanning 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: ⤵️ Check out code from GitHub 26 | uses: actions/checkout@v4 27 | 28 | - name: 🏗 Initialize CodeQL 29 | uses: github/codeql-action/init@v3 30 | 31 | - name: 🚀 Perform CodeQL Analysis 32 | uses: github/codeql-action/analyze@v3 33 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Sync Labels 3 | 4 | "on": 5 | push: 6 | branches: 7 | - main 8 | paths: 9 | - .github/labels.yml 10 | 11 | workflow_dispatch: 12 | 13 | jobs: 14 | labels: 15 | name: ♻️ Sync labels 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: ⤵️ Check out code from GitHub 19 | uses: actions/checkout@v4 20 | 21 | - name: 🚀 Run Label Syncer 22 | uses: micnncim/action-label-syncer@v1.3.0 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/lock.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lock Closed Issues and PRs 3 | 4 | "on": 5 | schedule: 6 | - cron: "0 9 * * *" 7 | 8 | workflow_dispatch: 9 | 10 | jobs: 11 | lock: 12 | name: 🔒 Lock! 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: dessant/lock-threads@v5.0.1 16 | with: 17 | github-token: ${{ github.token }} 18 | issue-inactive-days: "30" 19 | issue-lock-reason: "" 20 | pr-inactive-days: "1" 21 | pr-lock-reason: "" 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Publish to PyPI 3 | 4 | "on": 5 | push: 6 | tags: 7 | - "*" 8 | 9 | jobs: 10 | publish_to_pypi: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: ⤵️ Check out code from GitHub 15 | uses: actions/checkout@v4 16 | 17 | - name: 🏗 Set up Python 3.12 18 | id: python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: 3.12 22 | 23 | - name: 🚀 Publish to PyPi 24 | run: | 25 | pip install poetry 26 | poetry publish --build -u __token__ -p ${{ secrets.PYPI_API_KEY }} 27 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release Drafter 3 | 4 | "on": 5 | push: 6 | branches: 7 | - main 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | update_release_draft: 13 | name: ✏️ Draft Release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: 🚀 Run Release Drafter 17 | uses: release-drafter/release-drafter@v6.0.0 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/requirements.txt: -------------------------------------------------------------------------------- 1 | poetry==1.8.4 2 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Stale 3 | 4 | "on": 5 | schedule: 6 | - cron: "0 8 * * *" 7 | 8 | workflow_dispatch: 9 | 10 | jobs: 11 | stale: 12 | name: 🧹 Clean up stale issues and PRs 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: 🚀 Run stale 16 | uses: actions/stale@v9 17 | with: 18 | repo-token: ${{ secrets.GITHUB_TOKEN }} 19 | days-before-stale: 30 20 | days-before-close: 7 21 | remove-stale-when-updated: true 22 | stale-issue-label: "stale" 23 | exempt-issue-labels: "no-stale,help-wanted" 24 | stale-issue-message: > 25 | There hasn't been any activity on this issue recently, so it 26 | has been marked as stale. 27 | 28 | Please make sure to update to the latest version and 29 | check if that solves the issue. Let us know if that works for you 30 | by leaving a comment. 31 | 32 | This issue will be closed if no further activity occurs. Thanks! 33 | stale-pr-label: "stale" 34 | exempt-pr-labels: "no-stale" 35 | stale-pr-message: > 36 | There hasn't been any activity on this pull request recently, so 37 | it has automatically been marked as stale and will be closed if 38 | no further action occurs within 7 days. Thank you for your 39 | contributions. 40 | -------------------------------------------------------------------------------- /.github/workflows/static-analysis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Linting and Static Analysis 3 | 4 | "on": 5 | pull_request: 6 | branches: 7 | - dev 8 | - main 9 | 10 | push: 11 | branches: 12 | - dev 13 | - main 14 | 15 | jobs: 16 | lint: 17 | name: "Linting & Static Analysis" 18 | 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: ⤵️ Check out code from GitHub 23 | uses: actions/checkout@v4 24 | 25 | - name: 🏗 Set up Python 3.12 26 | id: setup-python 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: 3.12 30 | 31 | - name: ⤵️ Get pip cache directory 32 | id: pip-cache 33 | run: | 34 | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 35 | 36 | - name: ⤵️ Establish pip cache 37 | uses: actions/cache@v4 38 | with: 39 | path: ${{ steps.pip-cache.outputs.dir }} 40 | key: "${{ runner.os }}-pip-\ 41 | ${{ hashFiles('.github/workflows/requirements.txt') }}" 42 | restore-keys: | 43 | ${{ runner.os }}-pip- 44 | 45 | - name: 🏗 Install workflow dependencies 46 | run: | 47 | pip install -r .github/workflows/requirements.txt 48 | poetry config virtualenvs.create true 49 | poetry config virtualenvs.in-project true 50 | 51 | - name: ⤵️ Establish poetry cache 52 | uses: actions/cache@v4 53 | with: 54 | path: .venv 55 | key: "venv-${{ steps.setup-python.outputs.python-version }}-\ 56 | ${{ hashFiles('poetry.lock') }}" 57 | restore-keys: | 58 | venv-${{ steps.setup-python.outputs.python-version }}- 59 | 60 | - name: 🏗 Install package dependencies 61 | run: | 62 | poetry install --no-interaction 63 | 64 | - name: 🚀 Run pre-commit hooks 65 | uses: pre-commit/action@v3.0.1 66 | env: 67 | SKIP: no-commit-to-branch,pytest 68 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests and Coverage 3 | 4 | "on": 5 | pull_request: 6 | branches: 7 | - dev 8 | - main 9 | 10 | push: 11 | branches: 12 | - dev 13 | - main 14 | 15 | jobs: 16 | test: 17 | name: Tests 18 | 19 | runs-on: ubuntu-latest 20 | 21 | strategy: 22 | matrix: 23 | python-version: 24 | - "3.10" 25 | - "3.11" 26 | - "3.12" 27 | 28 | steps: 29 | - name: ⤵️ Check out code from GitHub 30 | uses: actions/checkout@v4 31 | 32 | - name: 🏗 Set up Python 33 | id: setup-python 34 | uses: actions/setup-python@v5 35 | with: 36 | python-version: ${{ matrix.python-version }} 37 | 38 | - name: ⤵️ Get pip cache directory 39 | id: pip-cache 40 | run: | 41 | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 42 | 43 | - name: ⤵️ Establish pip cache 44 | uses: actions/cache@v4 45 | with: 46 | path: ${{ steps.pip-cache.outputs.dir }} 47 | key: "${{ runner.os }}-pip-\ 48 | ${{ hashFiles('.github/workflows/requirements.txt') }}" 49 | restore-keys: | 50 | ${{ runner.os }}-pip- 51 | 52 | - name: 🏗 Install workflow dependencies 53 | run: | 54 | pip install -r .github/workflows/requirements.txt 55 | poetry config virtualenvs.create true 56 | poetry config virtualenvs.in-project true 57 | 58 | - name: ⤵️ Establish poetry cache 59 | uses: actions/cache@v4 60 | with: 61 | path: .venv 62 | key: "venv-${{ steps.setup-python.outputs.python-version }}-\ 63 | ${{ hashFiles('poetry.lock') }}" 64 | restore-keys: | 65 | venv-${{ steps.setup-python.outputs.python-version }}- 66 | 67 | - name: 🏗 Install package dependencies 68 | run: | 69 | poetry install --no-interaction 70 | 71 | - name: 🚀 Run pytest 72 | run: poetry run pytest --cov aiopinboard tests 73 | 74 | - name: ⬆️ Upload coverage artifact 75 | uses: actions/upload-artifact@v4 76 | with: 77 | name: coverage-${{ matrix.python-version }} 78 | path: .coverage 79 | include-hidden-files: true 80 | 81 | coverage: 82 | name: Code Coverage 83 | 84 | needs: test 85 | 86 | runs-on: ubuntu-latest 87 | 88 | steps: 89 | - name: ⤵️ Check out code from GitHub 90 | uses: actions/checkout@v4 91 | 92 | - name: ⬇️ Download coverage data 93 | uses: actions/download-artifact@v4 94 | 95 | - name: 🏗 Set up Python 3.12 96 | id: setup-python 97 | uses: actions/setup-python@v5 98 | with: 99 | python-version: 3.12 100 | 101 | - name: ⤵️ Get pip cache directory 102 | id: pip-cache 103 | run: | 104 | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 105 | 106 | - name: ⤵️ Establish pip cache 107 | uses: actions/cache@v4 108 | with: 109 | path: ${{ steps.pip-cache.outputs.dir }} 110 | key: "${{ runner.os }}-pip-\ 111 | ${{ hashFiles('.github/workflows/requirements.txt') }}" 112 | restore-keys: | 113 | ${{ runner.os }}-pip- 114 | 115 | - name: 🏗 Install workflow dependencies 116 | run: | 117 | pip install -r .github/workflows/requirements.txt 118 | poetry config virtualenvs.create true 119 | poetry config virtualenvs.in-project true 120 | 121 | - name: ⤵️ Establish poetry cache 122 | uses: actions/cache@v4 123 | with: 124 | path: .venv 125 | key: "venv-${{ steps.setup-python.outputs.python-version }}-\ 126 | ${{ hashFiles('poetry.lock') }}" 127 | restore-keys: | 128 | venv-${{ steps.setup-python.outputs.python-version }}- 129 | 130 | - name: 🏗 Install package dependencies 131 | run: | 132 | poetry install --no-interaction 133 | 134 | - name: 🚀 Process coverage results 135 | run: | 136 | poetry run coverage combine coverage*/.coverage* 137 | poetry run coverage xml -i 138 | 139 | - name: 📊 Upload coverage report to codecov.io 140 | uses: codecov/codecov-action@v4 141 | with: 142 | token: ${{ secrets.CODECOV_TOKEN }} 143 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | .DS_Store 3 | .coverage 4 | .mypy_cache 5 | .nox 6 | .tox 7 | .venv 8 | __pycache__ 9 | coverage.xml 10 | docs/_build 11 | tags 12 | -------------------------------------------------------------------------------- /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | python = { version="3.12", virtualenv=".venv" } 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: blacken-docs 6 | name: ☕️ Format documentation using black 7 | language: system 8 | files: '\.(rst|md|markdown|py|tex)$' 9 | entry: poetry run blacken-docs 10 | require_serial: true 11 | - id: check-ast 12 | name: 🐍 Checking Python AST 13 | language: system 14 | types: [python] 15 | entry: poetry run check-ast 16 | - id: check-case-conflict 17 | name: 🔠 Checking for case conflicts 18 | language: system 19 | entry: poetry run check-case-conflict 20 | - id: check-docstring-first 21 | name: ℹ️ Checking docstrings are first 22 | language: system 23 | types: [python] 24 | entry: poetry run check-docstring-first 25 | - id: check-executables-have-shebangs 26 | name: 🧐 Checking that executables have shebangs 27 | language: system 28 | types: [text, executable] 29 | entry: poetry run check-executables-have-shebangs 30 | stages: [commit, push, manual] 31 | - id: check-json 32 | name: { Checking JSON files 33 | language: system 34 | types: [json] 35 | entry: poetry run check-json 36 | - id: check-merge-conflict 37 | name: 💥 Checking for merge conflicts 38 | language: system 39 | types: [text] 40 | entry: poetry run check-merge-conflict 41 | - id: check-symlinks 42 | name: 🔗 Checking for broken symlinks 43 | language: system 44 | types: [symlink] 45 | entry: poetry run check-symlinks 46 | - id: check-toml 47 | name: ✅ Checking TOML files 48 | language: system 49 | types: [toml] 50 | entry: poetry run check-toml 51 | - id: check-yaml 52 | name: ✅ Checking YAML files 53 | language: system 54 | types: [yaml] 55 | entry: poetry run check-yaml 56 | - id: codespell 57 | name: ✅ Checking code for misspellings 58 | language: system 59 | types: [text] 60 | exclude: ^poetry\.lock$ 61 | entry: poetry run codespell 62 | - id: debug-statements 63 | name: 🪵 Checking for debug statements and imports (Python) 64 | language: system 65 | types: [python] 66 | entry: poetry run debug-statement-hook 67 | - id: detect-private-key 68 | name: 🕵️ Detecting private keys 69 | language: system 70 | types: [text] 71 | entry: poetry run detect-private-key 72 | - id: end-of-file-fixer 73 | name: 🔚 Checking end of files 74 | language: system 75 | types: [text] 76 | entry: poetry run end-of-file-fixer 77 | stages: [commit, push, manual] 78 | - id: fix-byte-order-marker 79 | name: 🚏 Checking UTF-8 byte order marker 80 | language: system 81 | types: [text] 82 | entry: poetry run fix-byte-order-marker 83 | - id: format 84 | name: ☕️ Formatting code using ruff 85 | language: system 86 | types: [python] 87 | entry: poetry run ruff format 88 | - id: mypy 89 | name: 🆎 Performing static type checking using mypy 90 | language: system 91 | types: [python] 92 | entry: poetry run mypy 93 | - id: no-commit-to-branch 94 | name: 🛑 Checking for commit to protected branch 95 | language: system 96 | entry: poetry run no-commit-to-branch 97 | pass_filenames: false 98 | always_run: true 99 | args: 100 | - --branch=dev 101 | - --branch=main 102 | - id: poetry 103 | name: 📜 Checking pyproject with Poetry 104 | language: system 105 | entry: poetry check 106 | pass_filenames: false 107 | always_run: true 108 | - id: pylint 109 | name: 🌟 Starring code with pylint 110 | language: system 111 | types: [python] 112 | entry: poetry run pylint 113 | - id: ruff 114 | name: 👔 Enforcing style guide with ruff 115 | language: system 116 | types: [python] 117 | entry: poetry run ruff check --fix 118 | - id: trailing-whitespace 119 | name: ✄ Trimming trailing whitespace 120 | language: system 121 | types: [text] 122 | entry: poetry run trailing-whitespace-fixer 123 | stages: [commit, push, manual] 124 | 125 | - repo: https://github.com/pre-commit/mirrors-prettier 126 | rev: "v3.0.0-alpha.4" 127 | hooks: 128 | - id: prettier 129 | name: 💄 Ensuring files are prettier 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2024 Aaron Bach 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📌 aiopinboard: A Python 3 Library for Pinboard 2 | 3 | [![CI][ci-badge]][ci] 4 | [![PyPI][pypi-badge]][pypi] 5 | [![Version][version-badge]][version] 6 | [![License][license-badge]][license] 7 | [![Code Coverage][codecov-badge]][codecov] 8 | [![Maintainability][maintainability-badge]][maintainability] 9 | 10 | Buy Me A Coffee 11 | 12 | `aiopinboard` is a Python3, `asyncio`-focused library for interacting with the 13 | [Pinboard][pinboard] API. 14 | 15 | - [Installation](#installation) 16 | - [Python Versions](#python-versions) 17 | - [API Token](#api-token) 18 | - [Usage](#usage) 19 | - [Bookmarks](#bookmarks) 20 | - [The `Bookmark` Object](#the--bookmark--object) 21 | - [Getting the Last Change Datetime](#getting-the-last-change-datetime) 22 | - [Getting Bookmarks](#getting-bookmarks) 23 | - [Adding a Bookmark](#adding-a-bookmark) 24 | - [Deleting a Bookmark](#deleting-a-bookmark) 25 | - [Tags](#tags) 26 | - [Getting Tags](#getting-tags) 27 | - [Getting Suggested Tags](#getting-suggested-tags) 28 | - [Deleting a Tag](#deleting-a-tag) 29 | - [Renaming a Tag](#renaming-a-tag) 30 | - [Notes](#notes) 31 | - [The `Note` Object](#the--note--object) 32 | - [Getting Notes](#getting-notes) 33 | - [Contributing](#contributing) 34 | 35 | # Installation 36 | 37 | ```bash 38 | pip install aiopinboard 39 | ``` 40 | 41 | # Python Versions 42 | 43 | `aiopinboard` is currently supported on: 44 | 45 | - Python 3.10 46 | - Python 3.11 47 | - Python 3.12 48 | 49 | # API Token 50 | 51 | You can retrieve your Pinboard API token via 52 | [your account's settings page][pinboard-settings]. 53 | 54 | # Usage 55 | 56 | `aiopinboard` endeavors to replicate all of the endpoints in 57 | [the Pinboard API documentation][pinboard-api] with sane, usable responses. 58 | 59 | All API usage starts with creating an `API` object that contains your Pinboard API token: 60 | 61 | ```python 62 | import asyncio 63 | 64 | from aiopinboard import API 65 | 66 | 67 | async def main() -> None: 68 | api = API("") 69 | # do things! 70 | 71 | 72 | asyncio.run(main()) 73 | ``` 74 | 75 | ## Bookmarks 76 | 77 | ### The `Bookmark` Object 78 | 79 | API endpoints that retrieve one or more bookmarks will return `Bookmark` objects, which 80 | carry all of the expected properties of a bookmark: 81 | 82 | - `hash`: the unique identifier of the bookmark 83 | - `href`: the bookmark's URL 84 | - `title`: the bookmark's title 85 | - `description`: the bookmark's description 86 | - `last_modified`: the UTC date the bookmark was last modified 87 | - `tags`: a list of tags applied to the bookmark 88 | - `unread`: whether the bookmark is unread 89 | - `shared`: whether the bookmark is shared 90 | 91 | ### Getting the Last Change Datetime 92 | 93 | To get the UTC datetime of the last "change" (bookmark added, updated, or deleted): 94 | 95 | ```python 96 | import asyncio 97 | 98 | from aiopinboard import API 99 | 100 | 101 | async def main() -> None: 102 | """Run!""" 103 | api = API("") 104 | last_change_dt = await api.bookmark.async_get_last_change_datetime() 105 | # >>> datetime.datetime(2020, 9, 3, 13, 7, 19, tzinfo=) 106 | 107 | 108 | asyncio.run(main()) 109 | ``` 110 | 111 | This method should be used to determine whether additional API calls should be made – 112 | for example, if nothing has changed since the last time a request was made, the 113 | implementing library can halt. 114 | 115 | ### Getting Bookmarks 116 | 117 | To get a bookmark by its URL: 118 | 119 | ```python 120 | import asyncio 121 | 122 | from aiopinboard import API 123 | 124 | 125 | async def main() -> None: 126 | api = API("") 127 | await api.bookmark.async_get_bookmark_by_url("https://my.com/bookmark") 128 | # >>> 129 | 130 | 131 | asyncio.run(main()) 132 | ``` 133 | 134 | To get all bookmarks 135 | 136 | ```python 137 | import asyncio 138 | 139 | from aiopinboard import API 140 | 141 | 142 | async def main() -> None: 143 | api = API("") 144 | await api.bookmark.async_get_all_bookmarks() 145 | # >>> [, ] 146 | 147 | 148 | asyncio.run(main()) 149 | ``` 150 | 151 | You can specify several optional parameters while getting all bookmarks: 152 | 153 | - `tags`: an optional list of tags to filter results by 154 | - `start`: the optional starting index to return (defaults to the start) 155 | - `results`: the optional number of results (defaults to all) 156 | - `from_dt`: the optional datetime to start from 157 | - `to_dt`: the optional datetime to end at 158 | 159 | To get all bookmarks created on a certain date: 160 | 161 | ```python 162 | import asyncio 163 | from datetime import date 164 | 165 | from aiopinboard import API 166 | 167 | 168 | async def main() -> None: 169 | """Run!""" 170 | api = API("") 171 | await api.bookmark.async_get_bookmarks_by_date(date.today()) 172 | # >>> [, ] 173 | 174 | # Optionally filter the results with a list of tags – note that only bookmarks that 175 | # have all tags will be returned: 176 | await api.bookmark.async_get_bookmarks_by_date(date.today(), tags=["tag1", "tag2"]) 177 | # >>> [, ] 178 | 179 | 180 | asyncio.run(main()) 181 | ``` 182 | 183 | To get recent bookmarks: 184 | 185 | ```python 186 | import asyncio 187 | 188 | from aiopinboard import API 189 | 190 | 191 | async def main() -> None: 192 | api = API("") 193 | await api.bookmark.async_get_recent_bookmarks(count=10) 194 | # >>> [, ] 195 | 196 | # Optionally filter the results with a list of tags – note that only bookmarks that 197 | # have all tags will be returned: 198 | await api.bookmark.async_get_recent_bookmarks(count=20, tags=["tag1", "tag2"]) 199 | # >>> [, ] 200 | 201 | 202 | asyncio.run(main()) 203 | ``` 204 | 205 | To get a summary of dates and how many bookmarks were created on those dates: 206 | 207 | ```python 208 | import asyncio 209 | 210 | from aiopinboard import API 211 | 212 | 213 | async def main() -> None: 214 | api = API("") 215 | dates = await api.bookmark.async_get_dates() 216 | # >>> {datetime.date(2020, 09, 05): 4, ...} 217 | 218 | 219 | asyncio.run(main()) 220 | ``` 221 | 222 | ### Adding a Bookmark 223 | 224 | To add a bookmark: 225 | 226 | ```python 227 | import asyncio 228 | 229 | from aiopinboard import API 230 | 231 | 232 | async def main() -> None: 233 | api = API("") 234 | await api.bookmark.async_add_bookmark("https://my.com/bookmark", "My New Bookmark") 235 | 236 | 237 | asyncio.run(main()) 238 | ``` 239 | 240 | You can specify several optional parameters while adding a bookmark: 241 | 242 | - `description`: the optional description of the bookmark 243 | - `tags`: an optional list of tags to assign to the bookmark 244 | - `created_datetime`: the optional creation datetime to use (defaults to now) 245 | - `replace`: whether this should replace a bookmark with the same URL 246 | - `shared`: whether this bookmark should be shared 247 | - `toread`: whether this bookmark should be unread 248 | 249 | ### Deleting a Bookmark 250 | 251 | To delete a bookmark by its URL: 252 | 253 | ```python 254 | import asyncio 255 | 256 | from aiopinboard import API 257 | 258 | 259 | async def main() -> None: 260 | api = API("") 261 | await api.bookmark.async_delete_bookmark("https://my.com/bookmark") 262 | 263 | 264 | asyncio.run(main()) 265 | ``` 266 | 267 | ## Tags 268 | 269 | ### Getting Tags 270 | 271 | To get all tags for an account (and a count of how often each tag is used): 272 | 273 | ```python 274 | import asyncio 275 | 276 | from aiopinboard import API 277 | 278 | 279 | async def main() -> None: 280 | api = API("") 281 | await api.tag.async_get_tags() 282 | # >>> {"tag1": 3, "tag2": 8} 283 | 284 | 285 | asyncio.run(main()) 286 | ``` 287 | 288 | ### Getting Suggested Tags 289 | 290 | To get lists of popular (used by the community) and recommended (used by you) tags for a 291 | particular URL: 292 | 293 | ```python 294 | import asyncio 295 | 296 | from aiopinboard import API 297 | 298 | 299 | async def main() -> None: 300 | api = API("") 301 | await api.bookmark.async_get_suggested_tags("https://my.com/bookmark") 302 | # >>> {"popular": ["tag1", "tag2"], "recommended": ["tag3"]} 303 | 304 | 305 | asyncio.run(main()) 306 | ``` 307 | 308 | ### Deleting a Tag 309 | 310 | To delete a tag: 311 | 312 | ```python 313 | import asyncio 314 | 315 | from aiopinboard import API 316 | 317 | 318 | async def main() -> None: 319 | api = API("") 320 | await api.tag.async_delete_tag("tag1") 321 | 322 | 323 | asyncio.run(main()) 324 | ``` 325 | 326 | ### Renaming a Tag 327 | 328 | To rename a tag: 329 | 330 | ```python 331 | import asyncio 332 | 333 | from aiopinboard import API 334 | 335 | 336 | async def main() -> None: 337 | api = API("") 338 | await api.tag.async_rename_tag("old-tag", "new-tag") 339 | 340 | 341 | asyncio.run(main()) 342 | ``` 343 | 344 | ## Notes 345 | 346 | ### The `Note` Object 347 | 348 | API endpoints that retrieve one or more notes will return `Note` objects, which 349 | carry all of the expected properties of a note: 350 | 351 | - `note_id`: the unique ID 352 | - `title`: the title 353 | - `hash`: the computed hash 354 | - `created_at`: the UTC datetime the note was created 355 | - `updated_at`: the UTC datetime the note was updated 356 | - `length`: the length 357 | 358 | ### Getting Notes 359 | 360 | To get all notes for an account: 361 | 362 | ```python 363 | import asyncio 364 | 365 | from aiopinboard import API 366 | 367 | 368 | async def main() -> None: 369 | api = API("") 370 | await api.note.async_get_notes() 371 | # >>> [, ] 372 | 373 | 374 | asyncio.run(main()) 375 | ``` 376 | 377 | # Contributing 378 | 379 | Thanks to all of [our contributors][contributors] so far! 380 | 381 | 1. [Check for open features/bugs][issues] or [initiate a discussion on one][new-issue]. 382 | 2. [Fork the repository][fork]. 383 | 3. (_optional, but highly recommended_) Create a virtual environment: `python3 -m venv .venv` 384 | 4. (_optional, but highly recommended_) Enter the virtual environment: `source ./.venv/bin/activate` 385 | 5. Install the dev environment: `script/setup` 386 | 6. Code your new feature or bug fix on a new branch. 387 | 7. Write tests that cover your new functionality. 388 | 8. Run tests and ensure 100% code coverage: `poetry run pytest --cov aiopinboard tests` 389 | 9. Update `README.md` with any new documentation. 390 | 10. Submit a pull request! 391 | 392 | [aiohttp]: https://github.com/aio-libs/aiohttp 393 | [ambient-weather-dashboard]: https://dashboard.ambientweather.net 394 | [ambient-weather-rate-limiting]: https://ambientweather.docs.apiary.io/#introduction/rate-limiting 395 | [ambient-weather]: https://ambientweather.net 396 | [ci-badge]: https://img.shields.io/github/actions/workflow/status/bachya/aiopinboard/test.yml 397 | [ci]: https://github.com/bachya/aiopinboard/actions 398 | [codecov-badge]: https://codecov.io/gh/bachya/aiopinboard/branch/dev/graph/badge.svg 399 | [codecov]: https://codecov.io/gh/bachya/aiopinboard 400 | [contributors]: https://github.com/bachya/aiopinboard/graphs/contributors 401 | [fork]: https://github.com/bachya/aiopinboard/fork 402 | [issues]: https://github.com/bachya/aiopinboard/issues 403 | [license-badge]: https://img.shields.io/pypi/l/aiopinboard.svg 404 | [license]: https://github.com/bachya/aiopinboard/blob/main/LICENSE 405 | [maintainability-badge]: https://api.codeclimate.com/v1/badges/4c0360a07493d3c1fd03/maintainability 406 | [maintainability]: https://codeclimate.com/github/bachya/aiopinboard/maintainability 407 | [new-issue]: https://github.com/bachya/aiopinboard/issues/new 408 | [new-issue]: https://github.com/bachya/aiopinboard/issues/new 409 | [pinboard-api]: https://pinboard.in/api 410 | [pinboard-settings]: https://pinboard.in/settings/password 411 | [pinboard]: https://pinboard.in 412 | [pypi-badge]: https://img.shields.io/pypi/v/aiopinboard.svg 413 | [pypi]: https://pypi.python.org/pypi/aiopinboard 414 | [version-badge]: https://img.shields.io/pypi/pyversions/aiopinboard.svg 415 | [version]: https://pypi.python.org/pypi/aiopinboard 416 | -------------------------------------------------------------------------------- /aiopinboard/__init__.py: -------------------------------------------------------------------------------- 1 | """Define the aiopinboard package.""" 2 | 3 | from aiopinboard.api import API 4 | 5 | __all__ = ["API"] 6 | -------------------------------------------------------------------------------- /aiopinboard/api.py: -------------------------------------------------------------------------------- 1 | """Define an API object to interact with the Pinboard API.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any, cast 7 | 8 | from aiohttp import ClientSession, ClientTimeout 9 | from aiohttp.client_exceptions import ClientError 10 | 11 | from aiopinboard.bookmark import BookmarkAPI 12 | from aiopinboard.errors import RequestError, raise_on_response_error 13 | from aiopinboard.helpers.types import ResponseType 14 | from aiopinboard.note import NoteAPI 15 | from aiopinboard.tag import TagAPI 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | API_URL_BASE: str = "https://api.pinboard.in/v1" 20 | 21 | DEFAULT_TIMEOUT: int = 10 22 | 23 | 24 | class API: # pylint: disable=too-few-public-methods 25 | """Define an API object. 26 | 27 | :param api_token: A Pinboard API token 28 | :type api_token: ``str`` 29 | :param session: An optional ``aiohttp`` ``ClientSession`` 30 | :type api_token: ``Optional[ClientSession]`` 31 | """ 32 | 33 | def __init__(self, api_token: str, *, session: ClientSession | None = None) -> None: 34 | """Initialize. 35 | 36 | Args: 37 | ---- 38 | api_token: A Pinboard API token. 39 | session: An optional aiohttp ClientSession. 40 | 41 | """ 42 | self._api_token = api_token 43 | self._session = session 44 | 45 | self.bookmark = BookmarkAPI(self._async_request) 46 | self.note = NoteAPI(self._async_request) 47 | self.tag = TagAPI(self._async_request) 48 | 49 | async def _async_request( 50 | self, method: str, endpoint: str, **kwargs: dict[str, Any] 51 | ) -> ResponseType: 52 | """Make a request to the API and return the XML response. 53 | 54 | Args: 55 | ---- 56 | method: An HTTP method. 57 | endpoint: A relative API endpoint. 58 | **kwargs: Additional kwargs to send with the request. 59 | 60 | Returns: 61 | ------- 62 | An API response payload. 63 | 64 | Raises: 65 | ------ 66 | RequestError: Raised upon an underlying HTTP error. 67 | 68 | """ 69 | kwargs.setdefault("params", {}) 70 | kwargs["params"]["auth_token"] = self._api_token 71 | kwargs["params"]["format"] = "json" 72 | 73 | if use_running_session := self._session and not self._session.closed: 74 | session = self._session 75 | else: 76 | session = ClientSession(timeout=ClientTimeout(total=DEFAULT_TIMEOUT)) 77 | 78 | try: 79 | async with session.request( 80 | method, f"{API_URL_BASE}/{endpoint}", **kwargs 81 | ) as resp: 82 | resp.raise_for_status() 83 | data = await resp.json(content_type="text/json") 84 | 85 | _LOGGER.debug("Response data for %s: %s", endpoint, data) 86 | 87 | raise_on_response_error(data) 88 | 89 | return cast(ResponseType, data) 90 | except ClientError as err: 91 | raise RequestError(err) from None 92 | finally: 93 | if not use_running_session: 94 | await session.close() 95 | -------------------------------------------------------------------------------- /aiopinboard/bookmark.py: -------------------------------------------------------------------------------- 1 | """Define API endpoints for bookmarks.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Awaitable, Callable 6 | from dataclasses import dataclass 7 | from datetime import date, datetime 8 | from typing import Any, cast 9 | 10 | import arrow 11 | 12 | from aiopinboard.helpers.types import DictType, ListDictType, ResponseType 13 | 14 | DEFAULT_RECENT_BOOKMARKS_COUNT: int = 15 15 | 16 | 17 | @dataclass 18 | class Bookmark: 19 | """Define a representation of a Pinboard bookmark.""" 20 | 21 | hash: str 22 | href: str 23 | title: str 24 | description: str 25 | last_modified: datetime 26 | tags: list[str] 27 | unread: bool 28 | shared: bool 29 | 30 | @classmethod 31 | def from_api_response(cls: type[Bookmark], data: dict[str, Any]) -> Bookmark: 32 | """Create a bookmark from an API response. 33 | 34 | Args: 35 | ---- 36 | data: The API response data. 37 | 38 | Returns: 39 | ------- 40 | A Bookmark object. 41 | 42 | """ 43 | return Bookmark( 44 | data["hash"], 45 | data["href"], 46 | data["description"], 47 | data["extended"], 48 | arrow.get(data["time"]).datetime, 49 | data["tags"].split(), 50 | data["toread"] == "yes", 51 | data["shared"] != "no", 52 | ) 53 | 54 | 55 | class BookmarkAPI: 56 | """Define an API "manager" object.""" 57 | 58 | def __init__(self, async_request: Callable[..., Awaitable[ResponseType]]) -> None: 59 | """Initialize. 60 | 61 | Args: 62 | ---- 63 | async_request: The request method from the Client object. 64 | 65 | """ 66 | self._async_request = async_request 67 | 68 | async def async_add_bookmark( 69 | self, 70 | url: str, 71 | title: str, 72 | *, 73 | description: str | None = None, 74 | tags: list[str] | None = None, 75 | created_datetime: datetime | None = None, 76 | replace: bool = True, 77 | shared: bool = False, 78 | toread: bool = False, 79 | ) -> None: 80 | """Add a new bookmark. 81 | 82 | Args: 83 | ---- 84 | url: The URL of the bookmark. 85 | title: The title of the bookmark. 86 | description: The optional description of the bookmark. 87 | tags: An optional list of tags to assign to the bookmark. 88 | created_datetime: The optional creation datetime to use (defaults to now). 89 | replace: Whether this should replace a bookmark with the same URL. 90 | shared: Whether this bookmark should be shared. 91 | toread: Whether this bookmark should be unread. 92 | 93 | """ 94 | params: dict[str, Any] = {"url": url, "description": title} 95 | 96 | if description: 97 | params["extended"] = description 98 | if tags: 99 | params["tags"] = " ".join(tags) 100 | if created_datetime: 101 | params["dt"] = created_datetime.isoformat() 102 | 103 | params["replace"] = "yes" if replace else "no" 104 | params["shared"] = "yes" if shared else "no" 105 | params["toread"] = "yes" if toread else "no" 106 | 107 | await self._async_request("get", "posts/add", params=params) 108 | 109 | async def async_delete_bookmark(self, url: str) -> None: 110 | """Delete a bookmark by URL. 111 | 112 | Args: 113 | ---- 114 | url: The URL of the bookmark to delete. 115 | 116 | """ 117 | await self._async_request("get", "posts/delete", params={"url": url}) 118 | 119 | async def async_get_all_bookmarks( 120 | self, 121 | *, 122 | tags: list[str] | None = None, 123 | start: int = 0, 124 | results: int | None = None, 125 | from_dt: datetime | None = None, 126 | to_dt: datetime | None = None, 127 | ) -> list[Bookmark]: 128 | """Get recent bookmarks. 129 | 130 | Args: 131 | ---- 132 | tags: An optional list of tags to filter results by. 133 | start: The optional starting index to return (defaults to the start). 134 | results: The optional number of results (defaults to all). 135 | from_dt: The optional datetime to start from. 136 | to_dt: The optional datetime to end at. 137 | 138 | Returns: 139 | ------- 140 | A list of Bookmark objects. 141 | 142 | """ 143 | params: dict[str, Any] = {"start": start} 144 | 145 | if tags: 146 | params["tags"] = " ".join([str(tag) for tag in tags]) 147 | if results: 148 | params["results"] = results 149 | if from_dt: 150 | params["fromdt"] = from_dt.isoformat() 151 | if to_dt: 152 | params["todt"] = to_dt.isoformat() 153 | 154 | data = cast( 155 | ListDictType, await self._async_request("get", "posts/all", params=params) 156 | ) 157 | return [Bookmark.from_api_response(bookmark) for bookmark in data] 158 | 159 | async def async_get_bookmark_by_url(self, url: str) -> Bookmark | None: 160 | """Get bookmark by a URL. 161 | 162 | Args: 163 | ---- 164 | url: The URL of the bookmark to get 165 | 166 | Returns: 167 | ------- 168 | A bookmark object (or None if no bookmark exists for the URL). 169 | 170 | """ 171 | data = cast( 172 | DictType, await self._async_request("get", "posts/get", params={"url": url}) 173 | ) 174 | 175 | try: 176 | return Bookmark.from_api_response(data["posts"][0]) 177 | except IndexError: 178 | return None 179 | 180 | async def async_get_bookmarks_by_date( 181 | self, bookmarked_on: date, *, tags: list[str] | None = None 182 | ) -> list[Bookmark]: 183 | """Get bookmarks that were created on a specific date. 184 | 185 | Args: 186 | ---- 187 | bookmarked_on: The date to examine. 188 | tags: An optional list of tags to filter results by. 189 | 190 | Returns: 191 | ------- 192 | A list of Bookmark objects. 193 | 194 | """ 195 | params: dict[str, Any] = {"dt": str(bookmarked_on)} 196 | 197 | if tags: 198 | params["tags"] = " ".join([str(tag) for tag in tags]) 199 | 200 | data = cast( 201 | DictType, await self._async_request("get", "posts/get", params=params) 202 | ) 203 | return [Bookmark.from_api_response(bookmark) for bookmark in data["posts"]] 204 | 205 | async def async_get_dates( 206 | self, *, tags: list[str] | None = None 207 | ) -> dict[date, int]: 208 | """Get a dictionary of dates and the number of bookmarks created on that date. 209 | 210 | Args: 211 | ---- 212 | tags: An optional list of tags to filter results by. 213 | 214 | Returns: 215 | ------- 216 | A dictionary of dates and the number of bookmarks for that date. 217 | 218 | """ 219 | params: dict[str, Any] = {} 220 | 221 | if tags: 222 | params["tags"] = " ".join([str(tag) for tag in tags]) 223 | 224 | data = cast(DictType, await self._async_request("get", "posts/dates")) 225 | 226 | return { 227 | arrow.get(date).datetime.date(): count 228 | for date, count in data["dates"].items() 229 | } 230 | 231 | async def async_get_last_change_datetime(self) -> datetime: 232 | """Return the most recent time a bookmark was added, updated or deleted. 233 | 234 | Returns 235 | ------- 236 | A datetime object. 237 | 238 | """ 239 | data = cast(DictType, await self._async_request("get", "posts/update")) 240 | parsed = arrow.get(data["update_time"]) 241 | return parsed.datetime 242 | 243 | async def async_get_recent_bookmarks( 244 | self, 245 | *, 246 | count: int = DEFAULT_RECENT_BOOKMARKS_COUNT, 247 | tags: list[str] | None = None, 248 | ) -> list[Bookmark]: 249 | """Get recent bookmarks. 250 | 251 | Args: 252 | ---- 253 | count: The number of bookmarks to return (max of 100). 254 | tags: An optional list of tags to filter results by. 255 | 256 | Returns: 257 | ------- 258 | A list of Bookmark objects. 259 | 260 | """ 261 | params: dict[str, Any] = {"count": count} 262 | 263 | if tags: 264 | params["tags"] = " ".join([str(tag) for tag in tags]) 265 | 266 | data = cast( 267 | DictType, await self._async_request("get", "posts/recent", params=params) 268 | ) 269 | return [Bookmark.from_api_response(bookmark) for bookmark in data["posts"]] 270 | 271 | async def async_get_suggested_tags(self, url: str) -> dict[str, list[str]]: 272 | """Return a dictionary of popular and recommended tags for a URL. 273 | 274 | Args: 275 | ---- 276 | url: The URL of the bookmark to delete. 277 | 278 | Returns: 279 | ------- 280 | A dictionary of tags. 281 | 282 | """ 283 | data = cast( 284 | ListDictType, 285 | await self._async_request("get", "posts/suggest", params={"url": url}), 286 | ) 287 | return {k: v for d in data for k, v in d.items()} 288 | -------------------------------------------------------------------------------- /aiopinboard/errors.py: -------------------------------------------------------------------------------- 1 | """Define exception types for ``aiopinboard``.""" 2 | 3 | from __future__ import annotations 4 | 5 | from aiopinboard.helpers.types import ResponseType 6 | 7 | 8 | class PinboardError(Exception): 9 | """Define a base Pinboard exception.""" 10 | 11 | 12 | class RequestError(PinboardError): 13 | """Define a exception related to HTTP request errors.""" 14 | 15 | 16 | def raise_on_response_error(data: ResponseType) -> None: 17 | """Raise an error if the data indicates that something went wrong. 18 | 19 | Args: 20 | ---- 21 | data: A response payload from the API. 22 | 23 | Raises: 24 | ------ 25 | RequestError: Raised upon any error from the API. 26 | 27 | """ 28 | if isinstance(data, list): 29 | return 30 | 31 | if (code := data.get("result_code")) is None: 32 | return 33 | 34 | if code == "done": 35 | return 36 | 37 | raise RequestError(code) 38 | -------------------------------------------------------------------------------- /aiopinboard/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | """Define helpers.""" 2 | -------------------------------------------------------------------------------- /aiopinboard/helpers/types.py: -------------------------------------------------------------------------------- 1 | """Define typing helpers.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | DictType = dict[str, Any] 8 | ListDictType = list[DictType] 9 | ResponseType = DictType | ListDictType 10 | -------------------------------------------------------------------------------- /aiopinboard/note.py: -------------------------------------------------------------------------------- 1 | """Define API endpoints for nodes.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Awaitable, Callable 6 | from dataclasses import dataclass 7 | from datetime import datetime 8 | from typing import Any, cast 9 | 10 | import arrow 11 | 12 | from aiopinboard.helpers.types import DictType, ResponseType 13 | 14 | 15 | @dataclass 16 | class Note: 17 | """Define a representation of a Pinboard note.""" 18 | 19 | note_id: str 20 | title: str 21 | hash: str 22 | created_at: datetime 23 | updated_at: datetime 24 | length: int 25 | 26 | @classmethod 27 | def from_api_response(cls: type[Note], data: dict[str, Any]) -> Note: 28 | """Create a note from an API response. 29 | 30 | Args: 31 | ---- 32 | data: The API response data. 33 | 34 | Returns: 35 | ------- 36 | A Note object. 37 | 38 | """ 39 | return cls( 40 | data["id"], 41 | data["title"], 42 | data["hash"], 43 | arrow.get(data["created_at"]).datetime, 44 | arrow.get(data["updated_at"]).datetime, 45 | data["length"], 46 | ) 47 | 48 | 49 | class NoteAPI: # pylint: disable=too-few-public-methods 50 | """Define a note "manager" object.""" 51 | 52 | def __init__(self, async_request: Callable[..., Awaitable[ResponseType]]) -> None: 53 | """Initialize. 54 | 55 | Args: 56 | ---- 57 | async_request: The request method from the Client object. 58 | 59 | """ 60 | self._async_request = async_request 61 | 62 | async def async_get_notes(self) -> list[Note]: 63 | """Get all notes. 64 | 65 | Returns 66 | ------- 67 | A list of Note objects. 68 | 69 | """ 70 | data = cast(DictType, await self._async_request("get", "notes/list")) 71 | return [Note.from_api_response(note) for note in data["notes"]] 72 | -------------------------------------------------------------------------------- /aiopinboard/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachya/aiopinboard/38e3b002cfa08edb7cdbb140f0ceed4484607ebf/aiopinboard/py.typed -------------------------------------------------------------------------------- /aiopinboard/tag.py: -------------------------------------------------------------------------------- 1 | """Define API endpoints for tags.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Awaitable, Callable 6 | from typing import cast 7 | 8 | from aiopinboard.helpers.types import ResponseType 9 | 10 | 11 | class TagAPI: 12 | """Define a tag "manager" object.""" 13 | 14 | def __init__(self, async_request: Callable[..., Awaitable[ResponseType]]) -> None: 15 | """Initialize. 16 | 17 | Args: 18 | ---- 19 | async_request: The request method from the Client object. 20 | 21 | """ 22 | self._async_request = async_request 23 | 24 | async def async_delete_tag(self, tag: str) -> None: 25 | """Delete a tag. 26 | 27 | Args: 28 | ---- 29 | tag: The tag to delete. 30 | 31 | """ 32 | await self._async_request("get", "tags/delete", params={"tag": tag}) 33 | 34 | async def async_get_tags(self) -> dict[str, int]: 35 | """Get a mapping of all tags in this account and how many times each is used. 36 | 37 | Returns 38 | ------- 39 | A dictionary of tags and usage count. 40 | 41 | """ 42 | return cast(dict[str, int], await self._async_request("get", "tags/get")) 43 | 44 | async def async_rename_tag(self, old: str, new: str) -> None: 45 | """Rename a tag. 46 | 47 | Args: 48 | ---- 49 | old: The tag to rename. 50 | new: The new name. 51 | 52 | """ 53 | await self._async_request("get", "tags/rename", params={"old": old, "new": new}) 54 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | """Define examples.""" 2 | -------------------------------------------------------------------------------- /examples/test_api.py: -------------------------------------------------------------------------------- 1 | """Run an example script to quickly test the guardian.""" 2 | 3 | import asyncio 4 | import logging 5 | 6 | from aiopinboard import API 7 | from aiopinboard.errors import RequestError 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | async def main() -> None: 13 | """Create the aiohttp session and run the example.""" 14 | logging.basicConfig(level=logging.INFO) 15 | 16 | api = API("") 17 | 18 | try: 19 | last_change_dt = await api.bookmark.async_get_last_change_datetime() 20 | _LOGGER.info(last_change_dt) 21 | except RequestError as err: 22 | _LOGGER.info(err) 23 | 24 | 25 | asyncio.run(main()) 26 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core>=1.0.0"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.black] 6 | target-version = ["py39"] 7 | 8 | [tool.coverage.report] 9 | exclude_lines = ["raise NotImplementedError", "TYPE_CHECKING"] 10 | fail_under = 100 11 | show_missing = true 12 | 13 | [tool.coverage.run] 14 | source = ["aiopinboard"] 15 | 16 | [tool.isort] 17 | known_first_party = "aiopinboard,examples,tests" 18 | multi_line_output = 3 19 | profile = "black" 20 | 21 | [tool.mypy] 22 | check_untyped_defs = true 23 | disallow_incomplete_defs = true 24 | disallow_subclassing_any = true 25 | disallow_untyped_calls = true 26 | disallow_untyped_decorators = true 27 | disallow_untyped_defs = true 28 | follow_imports = "silent" 29 | ignore_missing_imports = true 30 | no_implicit_optional = true 31 | platform = "linux" 32 | python_version = "3.10" 33 | show_error_codes = true 34 | strict_equality = true 35 | warn_incomplete_stub = true 36 | warn_redundant_casts = true 37 | warn_return_any = true 38 | warn_unreachable = true 39 | warn_unused_configs = true 40 | warn_unused_ignores = true 41 | 42 | [tool.poetry] 43 | name = "aiopinboard" 44 | version = "2024.01.0" 45 | description = "A Python 3, asyncio-based library for the Pinboard API" 46 | readme = "README.md" 47 | authors = ["Aaron Bach "] 48 | license = "MIT" 49 | repository = "https://github.com/bachya/aiopinboard" 50 | classifiers = [ 51 | "License :: OSI Approved :: MIT License", 52 | "Programming Language :: Python", 53 | "Programming Language :: Python :: 3", 54 | "Programming Language :: Python :: 3.10", 55 | "Programming Language :: Python :: 3.11", 56 | "Programming Language :: Python :: 3.12", 57 | "Programming Language :: Python :: Implementation :: CPython", 58 | "Programming Language :: Python :: Implementation :: PyPy", 59 | "Typing :: Typed", 60 | ] 61 | include = [ 62 | "aiopinboard/py.typed" 63 | ] 64 | 65 | [tool.poetry.dependencies] 66 | aiohttp = ">=3.8.0" 67 | arrow = "^1.3.0" 68 | certifi = ">=2023.07.22" 69 | frozenlist = "^1.4.0" 70 | python = "^3.10" 71 | yarl = ">=1.9.2" 72 | 73 | [tool.poetry.group.dev.dependencies] 74 | GitPython = ">=3.1.35" 75 | Pygments = ">=2.15.0" 76 | aresponses = ">=2.1.6,<4.0.0" 77 | blacken-docs = "^1.12.1" 78 | codespell = "^2.2.2" 79 | coverage = {version = ">=6.5,<8.0", extras = ["toml"]} 80 | darglint = "^1.8.1" 81 | isort = "^5.10.1" 82 | mypy = "^1.2.0" 83 | pre-commit = ">=2.20,<5.0" 84 | pre-commit-hooks = ">=4.3,<6.0" 85 | pylint = ">=2.15.5,<4.0.0" 86 | pytest = ">=7.2,<9.0" 87 | pytest-aiohttp = "^1.0.0" 88 | pytest-asyncio = ">=0.20.1,<0.25.0" 89 | pytest-cov = ">=4,<7" 90 | pyupgrade = "^3.1.0" 91 | pyyaml = "^6.0.1" 92 | requests = ">=2.31.0" 93 | ruff = ">=0.5.1,<0.7.4" 94 | yamllint = "^1.28.0" 95 | 96 | [tool.poetry.urls] 97 | "Bug Tracker" = "https://github.com/bachya/aiopinboard/issues" 98 | Changelog = "https://github.com/bachya/aiopinboard/releases" 99 | 100 | [tool.pylint.BASIC] 101 | class-const-naming-style = "any" 102 | expected-line-ending-format = "LF" 103 | 104 | [tool.pylint.DESIGN] 105 | max-attributes = 20 106 | 107 | [tool.pylint.FORMAT] 108 | max-line-length = 88 109 | 110 | [tool.pylint.MAIN] 111 | py-version = "3.12" 112 | ignore = [ 113 | "tests", 114 | ] 115 | # Use a conservative default here; 2 should speed up most setups and not hurt 116 | # any too bad. Override on command line as appropriate. 117 | jobs = 2 118 | init-hook = """\ 119 | from pathlib import Path; \ 120 | import sys; \ 121 | 122 | from pylint.config import find_default_config_files; \ 123 | 124 | sys.path.append( \ 125 | str(Path(next(find_default_config_files())).parent.joinpath('pylint/plugins')) 126 | ) \ 127 | """ 128 | load-plugins = [ 129 | "pylint.extensions.code_style", 130 | "pylint.extensions.typing", 131 | ] 132 | persistent = false 133 | fail-on = [ 134 | "I", 135 | ] 136 | 137 | [tool.pylint."MESSAGES CONTROL"] 138 | disable = [ 139 | # These are subjective and should be left up to the developer: 140 | "too-many-arguments", 141 | "too-many-lines", 142 | "too-many-locals", 143 | "duplicate-code", 144 | 145 | # Handled by ruff: 146 | # Ref: 147 | "anomalous-backslash-in-string", # W605 148 | "assert-on-string-literal", # PLW0129 149 | "assert-on-tuple", # F631 150 | "await-outside-async", # PLE1142 151 | "bad-classmethod-argument", # N804 152 | "bad-format-string", # W1302, F 153 | "bad-format-string-key", # W1300, F 154 | "bad-str-strip-call", # PLE1310 155 | "bad-string-format-type", # PLE1307 156 | "bare-except", # E722 157 | "bidirectional-unicode", # PLE2502 158 | "binary-op-exception", # PLW0711 159 | "broad-exception-caught", # W0718 160 | "cell-var-from-loop", # B023 161 | "comparison-of-constants", # PLR0133 162 | "comparison-with-itself", # PLR0124 163 | "consider-alternative-union-syntax", # UP007 164 | "consider-iterating-dictionary", # SIM118 165 | "consider-merging-isinstance", # PLR1701 166 | "consider-using-alias", # UP006 167 | "consider-using-dict-comprehension", # C402 168 | "consider-using-generator", # C417 169 | "consider-using-get", # SIM401 170 | "consider-using-set-comprehension", # C401 171 | "consider-using-sys-exit", # PLR1722 172 | "consider-using-ternary", # SIM108 173 | "continue-in-finally", # PLE0116 174 | "duplicate-bases", # PLE0241 175 | "duplicate-except", # B014 176 | "duplicate-key", # F601 177 | "duplicate-string-formatting-argument", # F 178 | "duplicate-value", # F 179 | "empty-docstring", # D419 180 | "eval-used", # S307 181 | "exec-used", # S102 182 | "expression-not-assigned", # B018 183 | "f-string-without-interpolation", # F541 184 | "forgotten-debug-statement", # T100 185 | "format-needs-mapping", # F502 186 | "format-string-without-interpolation", # F 187 | "function-redefined", # F811 188 | "global-variable-not-assigned", # PLW0602 189 | "implicit-str-concat", # ISC001 190 | "import-self", # PLW0406 191 | "inconsistent-quotes", # Q000 192 | "invalid-all-object", # PLE0604 193 | "invalid-character-backspace", # PLE2510 194 | "invalid-character-esc", # PLE2513 195 | "invalid-character-nul", # PLE2514 196 | "invalid-character-sub", # PLE2512 197 | "invalid-character-zero-width-space", # PLE2515 198 | "invalid-envvar-default", # PLW1508 199 | "invalid-name", # N815 200 | "keyword-arg-before-vararg", # B026 201 | "line-too-long", # E501 202 | "literal-comparison", # F632 203 | "logging-format-interpolation", # G 204 | "logging-fstring-interpolation", # G 205 | "logging-not-lazy", # G 206 | "logging-too-few-args", # PLE1206 207 | "logging-too-many-args", # PLE1205 208 | "misplaced-future", # F404 209 | "missing-class-docstring", # D101 210 | "missing-final-newline", # W292 211 | "missing-format-string-key", # F524 212 | "missing-function-docstring", # D103 213 | "missing-module-docstring", # D100 214 | "mixed-format-string", # F506 215 | "multiple-imports", #E401 216 | "named-expr-without-context", # PLW0131 217 | "nested-min-max", # PLW3301 218 | "no-method-argument", # N805 219 | "no-self-argument", # N805 220 | "nonexistent-operator", # B002 221 | "nonlocal-without-binding", # PLE0117 222 | "not-in-loop", # F701, F702 223 | "notimplemented-raised", # F901 224 | "pointless-statement", # B018 225 | "property-with-parameters", # PLR0206 226 | "raise-missing-from", # B904 227 | "return-in-init", # PLE0101 228 | "return-outside-function", # F706 229 | "singleton-comparison", # E711, E712 230 | "subprocess-run-check", # PLW1510 231 | "super-with-arguments", # UP008 232 | "superfluous-parens", # UP034 233 | "syntax-error", # E999 234 | "too-few-format-args", # F524 235 | "too-many-branches", # PLR0912 236 | "too-many-format-args", # F522 237 | "too-many-return-statements", # PLR0911 238 | "too-many-star-expressions", # F622 239 | "too-many-statements", # PLR0915 240 | "trailing-comma-tuple", # COM818 241 | "truncated-format-string", # F501 242 | "try-except-raise", # TRY302 243 | "undefined-all-variable", # F822 244 | "undefined-variable", # F821 245 | "ungrouped-imports", # I001 246 | "unidiomatic-typecheck", # E721 247 | "unnecessary-comprehension", # C416 248 | "unnecessary-direct-lambda-call", # PLC3002 249 | "unnecessary-lambda-assignment", # PLC3001 250 | "unnecessary-pass", # PIE790 251 | "unneeded-not", # SIM208 252 | "unused-argument", # ARG001 253 | "unused-format-string-argument", # F507 254 | "unused-format-string-key", # F504 255 | "unused-import", # F401 256 | "unused-variable", # F841 257 | "use-a-generator", # C417 258 | "use-dict-literal", # C406 259 | "use-list-literal", # C405 260 | "used-prior-global-declaration", # PLE0118 261 | "useless-else-on-loop", # PLW0120 262 | "useless-import-alias", # PLC0414 263 | "useless-object-inheritance", # UP004 264 | "useless-return", # PLR1711 265 | "wildcard-import", # F403 266 | "wrong-import-order", # I001 267 | "wrong-import-position", # E402 268 | "yield-inside-async-function", # PLE1700 269 | "yield-outside-function", # F704 270 | 271 | # Handled by mypy: 272 | # Ref: 273 | "abstract-class-instantiated", 274 | "arguments-differ", 275 | "assigning-non-slot", 276 | "assignment-from-no-return", 277 | "assignment-from-none", 278 | "bad-exception-cause", 279 | "bad-format-character", 280 | "bad-reversed-sequence", 281 | "bad-super-call", 282 | "bad-thread-instantiation", 283 | "catching-non-exception", 284 | "comparison-with-callable", 285 | "deprecated-class", 286 | "dict-iter-missing-items", 287 | "format-combined-specification", 288 | "global-variable-undefined", 289 | "import-error", 290 | "inconsistent-mro", 291 | "inherit-non-class", 292 | "init-is-generator", 293 | "invalid-class-object", 294 | "invalid-enum-extension", 295 | "invalid-envvar-value", 296 | "invalid-format-returned", 297 | "invalid-hash-returned", 298 | "invalid-metaclass", 299 | "invalid-overridden-method", 300 | "invalid-repr-returned", 301 | "invalid-sequence-index", 302 | "invalid-slice-index", 303 | "invalid-slots", 304 | "invalid-slots-object", 305 | "invalid-star-assignment-target", 306 | "invalid-str-returned", 307 | "invalid-unary-operand-type", 308 | "invalid-unicode-codec", 309 | "isinstance-second-argument-not-valid-type", 310 | "method-hidden", 311 | "misplaced-format-function", 312 | "missing-format-argument-key", 313 | "missing-format-attribute", 314 | "missing-kwoa", 315 | "no-member", 316 | "no-value-for-parameter", 317 | "non-iterator-returned", 318 | "non-str-assignment-to-dunder-name", 319 | "nonlocal-and-global", 320 | "not-a-mapping", 321 | "not-an-iterable", 322 | "not-async-context-manager", 323 | "not-callable", 324 | "not-context-manager", 325 | "overridden-final-method", 326 | "raising-bad-type", 327 | "raising-non-exception", 328 | "redundant-keyword-arg", 329 | "relative-beyond-top-level", 330 | "self-cls-assignment", 331 | "signature-differs", 332 | "star-needs-assignment-target", 333 | "subclassed-final-class", 334 | "super-without-brackets", 335 | "too-many-function-args", 336 | "typevar-double-variance", 337 | "typevar-name-mismatch", 338 | "unbalanced-dict-unpacking", 339 | "unbalanced-tuple-unpacking", 340 | "unexpected-keyword-arg", 341 | "unhashable-member", 342 | "unpacking-non-sequence", 343 | "unsubscriptable-object", 344 | "unsupported-assignment-operation", 345 | "unsupported-binary-operation", 346 | "unsupported-delete-operation", 347 | "unsupported-membership-test", 348 | "used-before-assignment", 349 | "using-final-decorator-in-unsupported-version", 350 | "wrong-exception-operation", 351 | ] 352 | enable = [ 353 | "useless-suppression", 354 | "use-symbolic-message-instead", 355 | ] 356 | 357 | [tool.pylint.TYPING] 358 | runtime-typing = false 359 | 360 | [tool.pylint.CODE_STYLE] 361 | max-line-length-suggestions = 72 362 | 363 | [tool.ruff.lint] 364 | select = [ 365 | "ALL" 366 | ] 367 | 368 | ignore = [ 369 | "ANN101", # Missing type annotation for self 370 | "D202", # No blank lines allowed after function docstring 371 | "D203", # 1 blank line required before class docstring 372 | "PLR0913", # This is subjective 373 | "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target 374 | "PT012", # `pytest.raises()` block should contain a single simple statement 375 | "TCH", # flake8-type-checking 376 | 377 | # May conflict with the formatter: 378 | # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 379 | "COM812", 380 | "COM819", 381 | "D206", 382 | "D300", 383 | "E111", 384 | "E114", 385 | "E117", 386 | "ISC001", 387 | "ISC002", 388 | "Q000", 389 | "Q001", 390 | "Q002", 391 | "Q003", 392 | "W191", 393 | ] 394 | 395 | [tool.ruff.lint.isort] 396 | force-sort-within-sections = true 397 | known-first-party = [ 398 | "aiopinboard", 399 | "examples", 400 | "tests", 401 | ] 402 | combine-as-imports = true 403 | split-on-trailing-comma = false 404 | 405 | [tool.ruff.lint.per-file-ignores] 406 | "tests/*" = [ 407 | "ARG001", # Tests ofen have unused arguments 408 | "FBT001", # Test fixtures may be boolean values 409 | "PLR2004", # Checking for magic values in tests isn't helpful 410 | "S101", # Assertions are fine in tests 411 | "SLF001", # We'll access a lot of private third-party members in tests 412 | ] 413 | -------------------------------------------------------------------------------- /script/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | REPO_PATH="$( dirname "$( cd "$(dirname "$0")" ; pwd -P )" )" 5 | 6 | if [ "$(git rev-parse --abbrev-ref HEAD)" != "dev" ]; then 7 | echo "Refusing to publish a release from a branch other than dev" 8 | exit 1 9 | fi 10 | 11 | if [ -z "$(command -v poetry)" ]; then 12 | echo "Poetry needs to be installed to run this script: pip3 install poetry" 13 | exit 1 14 | fi 15 | 16 | function generate_version { 17 | latest_tag="$(git tag --sort=committerdate | tail -1)" 18 | month="$(date +'%Y.%m')" 19 | 20 | if [[ "$latest_tag" =~ "$month".* ]]; then 21 | patch="$(echo "$latest_tag" | cut -d . -f 3)" 22 | ((patch=patch+1)) 23 | echo "$month.$patch" 24 | else 25 | echo "$month.0" 26 | fi 27 | } 28 | 29 | # Temporarily uninstall pre-commit hooks so that we can push to dev and main: 30 | pre-commit uninstall 31 | 32 | # Pull the latest dev: 33 | git pull origin dev 34 | 35 | # Generate the next version (in the format YEAR.MONTH.RELEASE_NUMER): 36 | new_version=$(generate_version) 37 | 38 | # Update the PyPI package version: 39 | sed -i "" "s/^version = \".*\"/version = \"$new_version\"/g" "$REPO_PATH/pyproject.toml" 40 | git add pyproject.toml 41 | 42 | # Commit, tag, and push: 43 | git commit -m "Bump version to $new_version" 44 | git tag "$new_version" 45 | git push && git push --tags 46 | 47 | # Merge dev into main: 48 | git checkout main 49 | git merge dev 50 | git push 51 | git checkout dev 52 | 53 | # Re-initialize pre-commit: 54 | pre-commit install 55 | -------------------------------------------------------------------------------- /script/setup: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if command -v "mise"; then 5 | mise install 6 | fi 7 | 8 | # Install all dependencies: 9 | pip3 install poetry 10 | poetry install 11 | 12 | # Install pre-commit hooks: 13 | pre-commit install 14 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Define package tests.""" 2 | -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | """Define common test utilities.""" 2 | 3 | from pathlib import Path 4 | 5 | TEST_API_TOKEN = "user:abcde12345" # noqa: S105 6 | 7 | 8 | def load_fixture(filename: str) -> str: 9 | """Load a fixture. 10 | 11 | Args: 12 | ---- 13 | filename: The filename of the fixtures/ file to load. 14 | 15 | Returns: 16 | ------- 17 | A string containing the contents of the file. 18 | 19 | """ 20 | path = Path(f"{Path(__file__).parent}/fixtures/{filename}") 21 | with Path.open(path, encoding="utf-8") as fptr: 22 | return fptr.read() 23 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Define dynamic test fixtures.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | from typing import Any, cast 7 | 8 | import pytest 9 | 10 | from tests.common import load_fixture 11 | 12 | 13 | @pytest.fixture(name="error_response", scope="session") 14 | def error_response_fixture() -> dict[str, Any]: 15 | """Return a fixture for a failed auth response payload.""" 16 | return cast(dict[str, Any], json.loads(load_fixture("error_response.json"))) 17 | 18 | 19 | @pytest.fixture(name="notes_get_response", scope="session") 20 | def notes_get_response_fixture() -> dict[str, Any]: 21 | """Return a fixture for a notes response payload.""" 22 | return cast(dict[str, Any], json.loads(load_fixture("notes_get_response.json"))) 23 | 24 | 25 | @pytest.fixture(name="posts_add_response", scope="session") 26 | def posts_add_response_fixture() -> dict[str, Any]: 27 | """Return a fixture for a posts/add response payload.""" 28 | return cast(dict[str, Any], json.loads(load_fixture("posts_add_response.json"))) 29 | 30 | 31 | @pytest.fixture(name="posts_all_response", scope="session") 32 | def posts_all_response_fixture() -> dict[str, Any]: 33 | """Return a fixture for a posts/all response payload.""" 34 | return cast(dict[str, Any], json.loads(load_fixture("posts_all_response.json"))) 35 | 36 | 37 | @pytest.fixture(name="posts_dates_response", scope="session") 38 | def posts_dates_response_fixture() -> dict[str, Any]: 39 | """Return a fixture for a posts/dates response payload.""" 40 | return cast(dict[str, Any], json.loads(load_fixture("posts_dates_response.json"))) 41 | 42 | 43 | @pytest.fixture(name="posts_delete_response", scope="session") 44 | def posts_delete_response_fixture() -> dict[str, Any]: 45 | """Return a fixture for a posts/delete response payload.""" 46 | return cast(dict[str, Any], json.loads(load_fixture("posts_delete_response.json"))) 47 | 48 | 49 | @pytest.fixture(name="posts_get_empty_response", scope="session") 50 | def posts_get_empty_response_fixture() -> dict[str, Any]: 51 | """Return a fixture for a posts/get response payload.""" 52 | return cast( 53 | dict[str, Any], json.loads(load_fixture("posts_get_empty_response.json")) 54 | ) 55 | 56 | 57 | @pytest.fixture(name="posts_get_response", scope="session") 58 | def posts_get_response_fixture() -> dict[str, Any]: 59 | """Return a fixture for a posts/get response payload.""" 60 | return cast(dict[str, Any], json.loads(load_fixture("posts_get_response.json"))) 61 | 62 | 63 | @pytest.fixture(name="posts_recent_response", scope="session") 64 | def posts_recent_response_fixture() -> dict[str, Any]: 65 | """Return a fixture for a posts/recent response payload.""" 66 | return cast(dict[str, Any], json.loads(load_fixture("posts_recent_response.json"))) 67 | 68 | 69 | @pytest.fixture(name="posts_suggest_response", scope="session") 70 | def posts_suggest_response_fixture() -> dict[str, Any]: 71 | """Return a fixture for a posts/suggest response payload.""" 72 | return cast(dict[str, Any], json.loads(load_fixture("posts_suggest_response.json"))) 73 | 74 | 75 | @pytest.fixture(name="posts_update_response", scope="session") 76 | def posts_update_response_fixture() -> dict[str, Any]: 77 | """Return a fixture for a posts/update response payload.""" 78 | return cast(dict[str, Any], json.loads(load_fixture("posts_update_response.json"))) 79 | 80 | 81 | @pytest.fixture(name="tags_delete_response", scope="session") 82 | def tags_delete_response_fixture() -> dict[str, Any]: 83 | """Return a fixture for a tags/delete response payload.""" 84 | return cast(dict[str, Any], json.loads(load_fixture("tags_delete_response.json"))) 85 | 86 | 87 | @pytest.fixture(name="tags_get_response", scope="session") 88 | def tags_get_response_fixture() -> dict[str, Any]: 89 | """Return a fixture for a tags/get response payload.""" 90 | return cast(dict[str, Any], json.loads(load_fixture("tags_get_response.json"))) 91 | 92 | 93 | @pytest.fixture(name="tags_rename_response", scope="session") 94 | def tags_rename_response_fixture() -> dict[str, Any]: 95 | """Return a fixture for a tags/rename response payload.""" 96 | return cast(dict[str, Any], json.loads(load_fixture("tags_rename_response.json"))) 97 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | """Define fixtures to use in tests.""" 2 | -------------------------------------------------------------------------------- /tests/fixtures/error_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "result_code": "item not found" 3 | } 4 | -------------------------------------------------------------------------------- /tests/fixtures/notes_get_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 1, 3 | "notes": [ 4 | { 5 | "0": "xxxxxxxxxxxxxxxxxxxx", 6 | "id": "xxxxxxxxxxxxxxxxxxxx", 7 | "1": "xxxxxxxxxxxxxxxxxxxx", 8 | "hash": "xxxxxxxxxxxxxxxxxxxx", 9 | "2": "Test", 10 | "title": "Test", 11 | "3": 14, 12 | "length": 14, 13 | "4": "2020-09-06 05:59:47", 14 | "created_at": "2020-09-06 05:59:47", 15 | "5": "2020-09-06 05:59:47", 16 | "updated_at": "2020-09-06 05:59:47" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /tests/fixtures/posts_add_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "result_code": "done" 3 | } 4 | -------------------------------------------------------------------------------- /tests/fixtures/posts_all_response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "href": "https://mylink.com", 4 | "description": "A really neat website!", 5 | "extended": "I saved this bookmark to Pinboard", 6 | "meta": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 7 | "hash": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 8 | "time": "2020-09-02T03:59:55Z", 9 | "shared": "no", 10 | "toread": "yes", 11 | "tags": "tag1 tag2" 12 | } 13 | ] 14 | -------------------------------------------------------------------------------- /tests/fixtures/posts_dates_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": "auser", 3 | "tag": "", 4 | "dates": { 5 | "2020-09-05": 1, 6 | "2020-09-04": 1, 7 | "2020-09-03": 3 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/fixtures/posts_delete_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "result_code": "done" 3 | } 4 | -------------------------------------------------------------------------------- /tests/fixtures/posts_get_empty_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "date": "2020-09-02T03:43:26Z", 3 | "user": "auser", 4 | "posts": [] 5 | } 6 | -------------------------------------------------------------------------------- /tests/fixtures/posts_get_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "date": "2020-09-02T03:43:26Z", 3 | "user": "auser", 4 | "posts": [ 5 | { 6 | "href": "https://mylink.com", 7 | "description": "A really neat website!", 8 | "extended": "I saved this bookmark to Pinboard", 9 | "meta": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 10 | "hash": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 11 | "time": "2020-09-02T03:59:55Z", 12 | "shared": "no", 13 | "toread": "yes", 14 | "tags": "tag1 tag2" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tests/fixtures/posts_recent_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "date": "2020-09-02T03:43:26Z", 3 | "user": "auser", 4 | "posts": [ 5 | { 6 | "href": "https://mylink.com", 7 | "description": "A really neat website!", 8 | "extended": "I saved this bookmark to Pinboard", 9 | "meta": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 10 | "hash": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 11 | "time": "2020-09-02T03:59:55Z", 12 | "shared": "no", 13 | "toread": "yes", 14 | "tags": "tag1 tag2" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tests/fixtures/posts_suggest_response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "popular": ["linux", "ssh"] 4 | }, 5 | { 6 | "recommended": ["ssh", "linux"] 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /tests/fixtures/posts_update_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "update_time": "2020-09-03T13:07:19Z" 3 | } 4 | -------------------------------------------------------------------------------- /tests/fixtures/tags_delete_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "result_code": "done" 3 | } 4 | -------------------------------------------------------------------------------- /tests/fixtures/tags_get_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "tag1": 3, 3 | "tag2": 1, 4 | "tag3": 2 5 | } 6 | -------------------------------------------------------------------------------- /tests/fixtures/tags_rename_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "result_code": "done" 3 | } 4 | -------------------------------------------------------------------------------- /tests/test_bookmark_api.py: -------------------------------------------------------------------------------- 1 | """Test the API.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime, timedelta, timezone 6 | from typing import Any 7 | 8 | import aiohttp 9 | from aresponses import ResponsesMockServer 10 | import arrow 11 | import pytest 12 | 13 | from aiopinboard import API 14 | from aiopinboard.bookmark import Bookmark 15 | from tests.common import TEST_API_TOKEN 16 | 17 | 18 | @pytest.mark.asyncio 19 | async def test_add_bookmark( 20 | aresponses: ResponsesMockServer, posts_add_response: dict[str, Any] 21 | ) -> None: 22 | """Test deleting a bookmark. 23 | 24 | Args: 25 | ---- 26 | aresponses: An aresponses server. 27 | posts_add_response: A fixture for a posts/add response payload. 28 | 29 | """ 30 | aresponses.add( 31 | "api.pinboard.in", 32 | "/v1/posts/add", 33 | "get", 34 | response=aiohttp.web_response.json_response( 35 | posts_add_response, content_type="text/json", status=200 36 | ), 37 | ) 38 | 39 | async with aiohttp.ClientSession() as session: 40 | api = API(TEST_API_TOKEN, session=session) 41 | 42 | # A unsuccessful request will throw an exception, so if no exception is thrown, 43 | # we can count this as a successful test: 44 | await api.bookmark.async_add_bookmark( 45 | "http://test.url", 46 | "My Test Bookmark", 47 | description="I like this bookmark", 48 | tags=["tag1", "tag2"], 49 | created_datetime=datetime.now(tz=timezone.utc), 50 | replace=True, 51 | shared=True, 52 | toread=True, 53 | ) 54 | 55 | 56 | @pytest.mark.asyncio 57 | async def test_delete_bookmark( 58 | aresponses: ResponsesMockServer, posts_delete_response: dict[str, Any] 59 | ) -> None: 60 | """Test deleting a bookmark. 61 | 62 | Args: 63 | ---- 64 | aresponses: An aresponses server. 65 | posts_delete_response: A fixture for a posts/delete response payload. 66 | 67 | """ 68 | aresponses.add( 69 | "api.pinboard.in", 70 | "/v1/posts/delete", 71 | "get", 72 | response=aiohttp.web_response.json_response( 73 | posts_delete_response, content_type="text/json", status=200 74 | ), 75 | ) 76 | 77 | async with aiohttp.ClientSession() as session: 78 | api = API(TEST_API_TOKEN, session=session) 79 | 80 | # A unsuccessful request will throw an exception, so if no exception is thrown, 81 | # we can count this as a successful test: 82 | await api.bookmark.async_delete_bookmark("http://test.url") 83 | 84 | 85 | @pytest.mark.asyncio 86 | async def test_get_all_bookmarks( 87 | aresponses: ResponsesMockServer, posts_all_response: dict[str, Any] 88 | ) -> None: 89 | """Test getting recent bookmarks. 90 | 91 | Args: 92 | ---- 93 | aresponses: An aresponses server. 94 | posts_all_response: A fixture for a posts/all response payload. 95 | 96 | """ 97 | aresponses.add( 98 | "api.pinboard.in", 99 | "/v1/posts/all", 100 | "get", 101 | response=aiohttp.web_response.json_response( 102 | posts_all_response, content_type="text/json", status=200 103 | ), 104 | ) 105 | 106 | async with aiohttp.ClientSession() as session: 107 | api = API(TEST_API_TOKEN, session=session) 108 | 109 | # Define a static datetime to test against: 110 | fixture_bookmark_date = datetime.strptime( 111 | "2020-09-02T03:59:55Z", "%Y-%m-%dT%H:%M:%SZ" 112 | ).replace(tzinfo=timezone.utc) 113 | bookmarks = await api.bookmark.async_get_all_bookmarks( 114 | tags=["tag1"], 115 | start=2, 116 | results=1, 117 | # It is implied that `from_dt <= to_dt` and `from_dt <= posts <= to_dt`: 118 | from_dt=fixture_bookmark_date - timedelta(days=2), 119 | to_dt=fixture_bookmark_date + timedelta(days=1), 120 | ) 121 | assert len(bookmarks) == 1 122 | assert bookmarks[0] == Bookmark( 123 | "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 124 | "https://mylink.com", 125 | "A really neat website!", 126 | "I saved this bookmark to Pinboard", 127 | datetime(2020, 9, 2, 3, 59, 55, tzinfo=timezone.utc), 128 | tags=["tag1", "tag2"], 129 | unread=True, 130 | shared=False, 131 | ) 132 | 133 | 134 | @pytest.mark.asyncio 135 | async def test_get_bookmark_by_url( 136 | aresponses: ResponsesMockServer, 137 | posts_get_empty_response: dict[str, Any], 138 | posts_get_response: dict[str, Any], 139 | ) -> None: 140 | """Test getting bookmarks by date. 141 | 142 | Args: 143 | ---- 144 | aresponses: An aresponses server. 145 | posts_get_empty_response: A fixture for a posts/get response payload. 146 | posts_get_response: A fixture for a posts/get response payload. 147 | 148 | """ 149 | aresponses.add( 150 | "api.pinboard.in", 151 | "/v1/posts/get", 152 | "get", 153 | response=aiohttp.web_response.json_response( 154 | posts_get_response, content_type="text/json", status=200 155 | ), 156 | ) 157 | aresponses.add( 158 | "api.pinboard.in", 159 | "/v1/posts/get", 160 | "get", 161 | response=aiohttp.web_response.json_response( 162 | posts_get_empty_response, content_type="text/json", status=200 163 | ), 164 | ) 165 | 166 | async with aiohttp.ClientSession() as session: 167 | api = API(TEST_API_TOKEN, session=session) 168 | 169 | bookmark = await api.bookmark.async_get_bookmark_by_url("https://mylink.com") 170 | assert bookmark == Bookmark( 171 | "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 172 | "https://mylink.com", 173 | "A really neat website!", 174 | "I saved this bookmark to Pinboard", 175 | datetime(2020, 9, 2, 3, 59, 55, tzinfo=timezone.utc), 176 | tags=["tag1", "tag2"], 177 | unread=True, 178 | shared=False, 179 | ) 180 | 181 | bookmark = await api.bookmark.async_get_bookmark_by_url( 182 | "https://doesntexist.com" 183 | ) 184 | assert not bookmark 185 | 186 | 187 | @pytest.mark.asyncio 188 | async def test_get_bookmarks_by_date( 189 | aresponses: ResponsesMockServer, 190 | posts_get_empty_response: dict[str, Any], 191 | posts_get_response: dict[str, Any], 192 | ) -> None: 193 | """Test getting bookmarks by date. 194 | 195 | Args: 196 | ---- 197 | aresponses: An aresponses server. 198 | posts_get_empty_response: A fixture for a posts/get response payload. 199 | posts_get_response: A fixture for a posts/get response payload. 200 | 201 | """ 202 | aresponses.add( 203 | "api.pinboard.in", 204 | "/v1/posts/get", 205 | "get", 206 | response=aiohttp.web_response.json_response( 207 | posts_get_response, content_type="text/json", status=200 208 | ), 209 | ) 210 | aresponses.add( 211 | "api.pinboard.in", 212 | "/v1/posts/get", 213 | "get", 214 | response=aiohttp.web_response.json_response( 215 | posts_get_empty_response, content_type="text/json", status=200 216 | ), 217 | ) 218 | 219 | async with aiohttp.ClientSession() as session: 220 | api = API(TEST_API_TOKEN, session=session) 221 | 222 | bookmarks = await api.bookmark.async_get_bookmarks_by_date( 223 | datetime(2020, 9, 3, 13, 7, 19, tzinfo=timezone.utc) 224 | ) 225 | assert len(bookmarks) == 1 226 | assert bookmarks[0] == Bookmark( 227 | "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 228 | "https://mylink.com", 229 | "A really neat website!", 230 | "I saved this bookmark to Pinboard", 231 | datetime(2020, 9, 2, 3, 59, 55, tzinfo=timezone.utc), 232 | tags=["tag1", "tag2"], 233 | unread=True, 234 | shared=False, 235 | ) 236 | 237 | bookmarks = await api.bookmark.async_get_bookmarks_by_date( 238 | datetime(2020, 9, 3, 13, 7, 19, tzinfo=timezone.utc), tags=["non-tag1"] 239 | ) 240 | assert not bookmarks 241 | 242 | 243 | @pytest.mark.asyncio 244 | async def test_get_dates( 245 | aresponses: ResponsesMockServer, posts_dates_response: dict[str, Any] 246 | ) -> None: 247 | """Test getting bookmarks by date. 248 | 249 | Args: 250 | ---- 251 | aresponses: An aresponses server. 252 | posts_dates_response: A fixture for a posts/dates response payload. 253 | 254 | """ 255 | aresponses.add( 256 | "api.pinboard.in", 257 | "/v1/posts/dates", 258 | "get", 259 | response=aiohttp.web_response.json_response( 260 | posts_dates_response, content_type="text/json", status=200 261 | ), 262 | ) 263 | 264 | async with aiohttp.ClientSession() as session: 265 | api = API(TEST_API_TOKEN, session=session) 266 | 267 | dates = await api.bookmark.async_get_dates(tags=["tag1", "tag2"]) 268 | assert dates == { 269 | arrow.get("2020-09-05").datetime.date(): 1, 270 | arrow.get("2020-09-04").datetime.date(): 1, 271 | arrow.get("2020-09-03").datetime.date(): 3, 272 | } 273 | 274 | 275 | @pytest.mark.asyncio 276 | async def test_get_last_change_datetime( 277 | aresponses: ResponsesMockServer, posts_update_response: dict[str, Any] 278 | ) -> None: 279 | """Test getting the last time a bookmark was altered. 280 | 281 | Args: 282 | ---- 283 | aresponses: An aresponses server. 284 | posts_update_response: A fixture for a posts/update response payload. 285 | 286 | """ 287 | aresponses.add( 288 | "api.pinboard.in", 289 | "/v1/posts/update", 290 | "get", 291 | response=aiohttp.web_response.json_response( 292 | posts_update_response, content_type="text/json", status=200 293 | ), 294 | ) 295 | 296 | async with aiohttp.ClientSession() as session: 297 | api = API(TEST_API_TOKEN, session=session) 298 | most_recent_dt = await api.bookmark.async_get_last_change_datetime() 299 | 300 | assert most_recent_dt == datetime(2020, 9, 3, 13, 7, 19, tzinfo=timezone.utc) 301 | 302 | 303 | @pytest.mark.asyncio 304 | async def test_get_last_change_datetime_no_session( 305 | aresponses: ResponsesMockServer, posts_update_response: dict[str, Any] 306 | ) -> None: 307 | """Test getting the last time a bookmark was altered. 308 | 309 | Note that this test also tests a created-on-the-fly aiohttp.ClientSession. 310 | 311 | Args: 312 | ---- 313 | aresponses: An aresponses server. 314 | posts_update_response: A fixture for a posts/update response payload. 315 | 316 | """ 317 | aresponses.add( 318 | "api.pinboard.in", 319 | "/v1/posts/update", 320 | "get", 321 | response=aiohttp.web_response.json_response( 322 | posts_update_response, content_type="text/json", status=200 323 | ), 324 | ) 325 | 326 | api = API(TEST_API_TOKEN) 327 | most_recent_dt = await api.bookmark.async_get_last_change_datetime() 328 | 329 | assert most_recent_dt == datetime(2020, 9, 3, 13, 7, 19, tzinfo=timezone.utc) 330 | 331 | 332 | @pytest.mark.asyncio 333 | async def test_get_recent_bookmarks( 334 | aresponses: ResponsesMockServer, posts_recent_response: dict[str, Any] 335 | ) -> None: 336 | """Test getting recent bookmarks. 337 | 338 | Args: 339 | ---- 340 | aresponses: An aresponses server. 341 | posts_recent_response: A fixture for a posts/recent response payload. 342 | 343 | """ 344 | aresponses.add( 345 | "api.pinboard.in", 346 | "/v1/posts/recent", 347 | "get", 348 | response=aiohttp.web_response.json_response( 349 | posts_recent_response, content_type="text/json", status=200 350 | ), 351 | ) 352 | 353 | async with aiohttp.ClientSession() as session: 354 | api = API(TEST_API_TOKEN, session=session) 355 | 356 | bookmarks = await api.bookmark.async_get_recent_bookmarks( 357 | count=1, tags=["tag1"] 358 | ) 359 | assert len(bookmarks) == 1 360 | assert bookmarks[0] == Bookmark( 361 | "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 362 | "https://mylink.com", 363 | "A really neat website!", 364 | "I saved this bookmark to Pinboard", 365 | datetime(2020, 9, 2, 3, 59, 55, tzinfo=timezone.utc), 366 | tags=["tag1", "tag2"], 367 | unread=True, 368 | shared=False, 369 | ) 370 | 371 | 372 | @pytest.mark.asyncio 373 | async def test_get_suggested_tags( 374 | aresponses: ResponsesMockServer, posts_suggest_response: dict[str, Any] 375 | ) -> None: 376 | """Test getting recent bookmarks. 377 | 378 | Args: 379 | ---- 380 | aresponses: An aresponses server. 381 | posts_suggest_response: A fixture for a posts/suggest response payload. 382 | 383 | """ 384 | aresponses.add( 385 | "api.pinboard.in", 386 | "/v1/posts/suggest", 387 | "get", 388 | response=aiohttp.web_response.json_response( 389 | posts_suggest_response, content_type="text/json", status=200 390 | ), 391 | ) 392 | 393 | async with aiohttp.ClientSession() as session: 394 | api = API(TEST_API_TOKEN, session=session) 395 | 396 | tags = await api.bookmark.async_get_suggested_tags("https://mylink.com") 397 | assert tags == { 398 | "popular": ["linux", "ssh"], 399 | "recommended": ["ssh", "linux"], 400 | } 401 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | """Define tests for errors.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | import aiohttp 8 | from aresponses import ResponsesMockServer 9 | import pytest 10 | 11 | from aiopinboard import API 12 | from aiopinboard.errors import RequestError 13 | from tests.common import TEST_API_TOKEN 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_data_error( 18 | aresponses: ResponsesMockServer, error_response: dict[str, Any] 19 | ) -> None: 20 | """Test that a Pinboard data error is handled properly. 21 | 22 | Args: 23 | ---- 24 | aresponses: An aresponses server. 25 | error_response: A Pinboard error response. 26 | 27 | """ 28 | aresponses.add( 29 | "api.pinboard.in", 30 | "/v1/posts/delete", 31 | "get", 32 | response=aiohttp.web_response.json_response( 33 | error_response, content_type="text/json", status=200 34 | ), 35 | ) 36 | 37 | async with aiohttp.ClientSession() as session: 38 | api = API(TEST_API_TOKEN, session=session) 39 | with pytest.raises(RequestError) as err: 40 | await api.bookmark.async_delete_bookmark("http://test.url") 41 | assert str(err.value) == "item not found" 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_http_error(aresponses: ResponsesMockServer) -> None: 46 | """Test that an HTTP error is handled properly. 47 | 48 | Args: 49 | ---- 50 | aresponses: An aresponses server. 51 | 52 | """ 53 | aresponses.add( 54 | "api.pinboard.in", 55 | "/v1/posts/delete", 56 | "get", 57 | response=aresponses.Response(text=None, status=500), 58 | ) 59 | 60 | async with aiohttp.ClientSession() as session: 61 | api = API(TEST_API_TOKEN, session=session) 62 | with pytest.raises(RequestError): 63 | await api.bookmark.async_delete_bookmark("http://test.url") 64 | -------------------------------------------------------------------------------- /tests/test_note_api.py: -------------------------------------------------------------------------------- 1 | """Test note API endpoints.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime, timezone 6 | from typing import Any 7 | 8 | import aiohttp 9 | from aresponses import ResponsesMockServer 10 | import pytest 11 | 12 | from aiopinboard import API 13 | from aiopinboard.note import Note 14 | from tests.common import TEST_API_TOKEN 15 | 16 | 17 | @pytest.mark.asyncio 18 | async def test_get_notes( 19 | aresponses: ResponsesMockServer, notes_get_response: dict[str, Any] 20 | ) -> None: 21 | """Test getting notes. 22 | 23 | Args: 24 | ---- 25 | aresponses: An aresponses server. 26 | notes_get_response: A notes get response. 27 | 28 | """ 29 | aresponses.add( 30 | "api.pinboard.in", 31 | "/v1/notes/list", 32 | "get", 33 | response=aiohttp.web_response.json_response( 34 | notes_get_response, content_type="text/json", status=200 35 | ), 36 | ) 37 | 38 | async with aiohttp.ClientSession() as session: 39 | api = API(TEST_API_TOKEN, session=session) 40 | 41 | notes = await api.note.async_get_notes() 42 | assert len(notes) == 1 43 | assert notes[0] == Note( 44 | "xxxxxxxxxxxxxxxxxxxx", 45 | "Test", 46 | "xxxxxxxxxxxxxxxxxxxx", 47 | datetime(2020, 9, 6, 5, 59, 47, tzinfo=timezone.utc), 48 | datetime(2020, 9, 6, 5, 59, 47, tzinfo=timezone.utc), 49 | 14, 50 | ) 51 | -------------------------------------------------------------------------------- /tests/test_tag_api.py: -------------------------------------------------------------------------------- 1 | """Test tag API endpoints.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | import aiohttp 8 | from aresponses import ResponsesMockServer 9 | import pytest 10 | 11 | from aiopinboard import API 12 | from tests.common import TEST_API_TOKEN 13 | 14 | 15 | @pytest.mark.asyncio 16 | async def test_delete_tag( 17 | aresponses: ResponsesMockServer, tags_delete_response: dict[str, Any] 18 | ) -> None: 19 | """Test deleting a tag. 20 | 21 | Args: 22 | ---- 23 | aresponses: An aresponses server. 24 | tags_delete_response: A fixture for a tags/delete response payload. 25 | 26 | """ 27 | aresponses.add( 28 | "api.pinboard.in", 29 | "/v1/tags/delete", 30 | "get", 31 | response=aiohttp.web_response.json_response( 32 | tags_delete_response, content_type="text/json", status=200 33 | ), 34 | ) 35 | 36 | async with aiohttp.ClientSession() as session: 37 | api = API(TEST_API_TOKEN, session=session) 38 | 39 | # A unsuccessful request will throw an exception, so if no exception is thrown, 40 | # we can count this as a successful test: 41 | await api.tag.async_delete_tag("tag1") 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_get_tags( 46 | aresponses: ResponsesMockServer, tags_get_response: dict[str, Any] 47 | ) -> None: 48 | """Test getting tags. 49 | 50 | Args: 51 | ---- 52 | aresponses: An aresponses server. 53 | tags_get_response: A fixture for a tags/get response payload. 54 | 55 | """ 56 | aresponses.add( 57 | "api.pinboard.in", 58 | "/v1/tags/get", 59 | "get", 60 | response=aiohttp.web_response.json_response( 61 | tags_get_response, content_type="text/json", status=200 62 | ), 63 | ) 64 | 65 | async with aiohttp.ClientSession() as session: 66 | api = API(TEST_API_TOKEN, session=session) 67 | 68 | tags = await api.tag.async_get_tags() 69 | assert tags == {"tag1": 3, "tag2": 1, "tag3": 2} 70 | 71 | 72 | @pytest.mark.asyncio 73 | async def test_rename_tag( 74 | aresponses: ResponsesMockServer, tags_rename_response: dict[str, Any] 75 | ) -> None: 76 | """Test renaming a tag. 77 | 78 | Args: 79 | ---- 80 | aresponses: An aresponses server. 81 | tags_rename_response: A fixture for a tags/rename response payload. 82 | 83 | """ 84 | aresponses.add( 85 | "api.pinboard.in", 86 | "/v1/tags/rename", 87 | "get", 88 | response=aiohttp.web_response.json_response( 89 | tags_rename_response, content_type="text/json", status=200 90 | ), 91 | ) 92 | 93 | async with aiohttp.ClientSession() as session: 94 | api = API(TEST_API_TOKEN, session=session) 95 | 96 | # A unsuccessful request will throw an exception, so if no exception is thrown, 97 | # we can count this as a successful test: 98 | await api.tag.async_rename_tag("tag1", "new-tag1") 99 | --------------------------------------------------------------------------------