├── .codeclimate.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── actions │ └── install-uv │ │ └── action.yml ├── config │ ├── labeler.yml │ └── labels.yml ├── pull_request_template.md ├── release-drafter.yml └── workflows │ ├── codeql.yml │ ├── lock.yml │ ├── publish.yml │ ├── release-drafter.yml │ ├── scan-pull-request.yml │ ├── stale.yml │ ├── static-analysis.yml │ ├── sync-labels.yml │ └── test.yml ├── .gitignore ├── .mise.toml ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── aiolinkding ├── __init__.py ├── bookmark.py ├── client.py ├── const.py ├── errors.py ├── py.typed ├── tag.py ├── user.py └── util │ └── __init__.py ├── examples ├── __init__.py └── test_client.py ├── pyproject.toml ├── renovate.json ├── script ├── release └── setup ├── tests ├── __init__.py ├── common.py ├── conftest.py ├── fixtures │ ├── .gitignore │ ├── bookmarks_async_get_all_response.json │ ├── bookmarks_async_get_archived_response.json │ ├── bookmarks_async_get_single_response.json │ ├── invalid_token_response.json │ ├── missing_field_response.json │ ├── tags_async_get_all_response.json │ ├── tags_async_get_single_response.json │ └── user_async_get_profile_response.json ├── test_bookmarks.py ├── test_client.py ├── test_tags.py └── test_user.py └── uv.lock /.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 | -------------------------------------------------------------------------------- /.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/actions/install-uv/action.yml: -------------------------------------------------------------------------------- 1 | name: "Install uv" 2 | description: "Installs uv (pinned to the version used by this repo)" 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - name: Get uv version from pyproject.toml 8 | shell: bash 9 | id: uv-version 10 | run: | 11 | echo "version=$(grep "uv==" pyproject.toml | awk -F'==' '{print $2'} | tr -d '",')" >> $GITHUB_OUTPUT 12 | - name: Install uv 13 | uses: astral-sh/setup-uv@v5 14 | with: 15 | cache-dependency-glob: "uv.lock" 16 | enable-cache: true 17 | version: ${{ steps.uv-version.outputs.version }} 18 | -------------------------------------------------------------------------------- /.github/config/labeler.yml: -------------------------------------------------------------------------------- 1 | "release": 2 | - base-branch: "main" 3 | -------------------------------------------------------------------------------- /.github/config/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "breaking-change" 3 | color: ee0701 4 | description: "A breaking change for existing users" 5 | 6 | - name: "help-wanted" 7 | color: 0e8a16 8 | description: "Needs a helping hang or expertise in order to resolve" 9 | 10 | - name: "no-stale" 11 | color: fef2c0 12 | description: "This issue or PR is exempted from the stale bot" 13 | 14 | - name: "release" 15 | color: d93f0b 16 | description: "A release of the library" 17 | 18 | - name: "stale" 19 | color: fef2c0 20 | description: "There has not been activity on this issue or PR for some time" 21 | 22 | - color: "ff2191" 23 | description: "A bugfix" 24 | name: "type: bugfix" 25 | 26 | - color: "ff2191" 27 | description: "A change to the local or production build system" 28 | name: "type: build" 29 | 30 | - color: "ff2191" 31 | description: "A change to a CI/CD configuration" 32 | name: "type: ci" 33 | 34 | - color: "ff2191" 35 | description: "A change that doesn't modify source or test files" 36 | name: "type: chore" 37 | 38 | - color: "ff2191" 39 | description: "A documentation change" 40 | name: "type: docs" 41 | 42 | - color: "ff2191" 43 | description: "A new feature or enhancement" 44 | name: "type: feature" 45 | 46 | - color: "ff2191" 47 | description: "A code change that improves performance" 48 | name: "type: performance" 49 | 50 | - color: "ff2191" 51 | description: "A code change that neither fixes a bug nor adds a feature" 52 | name: "type: refactor" 53 | 54 | - color: "ff2191" 55 | description: "Reverts a previous commit" 56 | name: "type: reversion" 57 | 58 | - color: "ff2191" 59 | description: "A change that does not affect the meaning of the code" 60 | name: "type: style" 61 | 62 | - color: "ff2191" 63 | description: "Adds missing tests or corrects existing tests" 64 | name: "type: test" 65 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description: 2 | 3 | 6 | 7 | - N/A, [SSIA][ssia] 8 | 9 | ## Issues Fixed: 10 | 11 | 14 | 15 | - N/A 16 | 17 | ## How To Test: 18 | 19 | 22 | 23 | - N/A 24 | 25 | ## Checklist: 26 | 27 | - [ ] I confirm that one or more new tests are written for the new functionality. 28 | - [ ] I have ensured that all tests pass (with 100% test coverage). 29 | - [ ] I have updated `README.md` with any new documentation. 30 | - [ ] I will "squash merge" this PR. 31 | 32 | ## TODOs: 33 | 34 | 37 | 38 | - N/A 39 | 40 | 41 | 42 | [ssia]: https://en.wiktionary.org/wiki/SSIA 43 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | categories: 3 | - title: "🚨 Breaking Changes" 4 | labels: 5 | - "breaking-change" 6 | 7 | - title: "🚀 Features" 8 | labels: 9 | - "type: feature" 10 | 11 | - title: "🐛 Bug Fixes" 12 | labels: 13 | - "type: bugfix" 14 | 15 | - title: "🏎️ Tuning" 16 | labels: 17 | - "type: performance" 18 | - "type: style" 19 | 20 | - title: "🛠️ Tooling" 21 | labels: 22 | - "type: build" 23 | - "type: ci" 24 | 25 | - title: "🧰 Maintenance" 26 | labels: 27 | - "type: chore" 28 | - "type: docs" 29 | - "type: refactor" 30 | - "type: reversion" 31 | - "type: test" 32 | 33 | exclude-labels: 34 | - "release" 35 | 36 | change-template: "- $TITLE (#$NUMBER)" 37 | 38 | name-template: "$NEXT_PATCH_VERSION" 39 | 40 | tag-template: "$NEXT_PATCH_VERSION" 41 | 42 | template: | 43 | $CHANGES 44 | -------------------------------------------------------------------------------- /.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/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.13 18 | id: python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: 3.13 22 | 23 | - name: 🚜 Install uv 24 | uses: ./.github/actions/install-uv 25 | 26 | - name: 🚀 Publish to PyPi 27 | run: | 28 | uv build 29 | uv publish --token ${{ secrets.PYPI_API_KEY }} 30 | -------------------------------------------------------------------------------- /.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/scan-pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Scan Pull Request 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - edited 7 | - opened 8 | - reopened 9 | - synchronize 10 | 11 | workflow_dispatch: 12 | 13 | permissions: 14 | contents: read 15 | pull-requests: write 16 | repository-projects: read 17 | 18 | jobs: 19 | lint-pr-title: 20 | name: 🏷️ Lint PR Title 21 | 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: Lint title 26 | uses: amannn/action-semantic-pull-request@v5 27 | id: lint-title 28 | env: 29 | GITHUB_TOKEN: ${{ github.token }} 30 | 31 | - name: Create error comment 32 | uses: marocchino/sticky-pull-request-comment@v2 33 | if: ${{ always() && steps.lint-title.outputs.error_message != null }} 34 | with: 35 | header: lint-title-error-comment 36 | message: | 37 | We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like this pull request's title needs to be adjusted. 38 | 39 | Details: 40 | 41 | ``` 42 | ${{ steps.lint-title.outputs.error_message }} 43 | ``` 44 | - name: Delete error comment 45 | uses: marocchino/sticky-pull-request-comment@v2 46 | if: ${{ steps.lint-title.outputs.error_message == null }} 47 | with: 48 | header: lint-title-error-comment 49 | delete: true 50 | 51 | set-labels: 52 | name: 🏷️ Set Labels 53 | 54 | runs-on: ubuntu-latest 55 | 56 | steps: 57 | - uses: actions/checkout@v4 58 | 59 | - name: Set labels 60 | uses: actions/labeler@v5 61 | with: 62 | configuration-path: ./.github/config/labeler.yml 63 | sync-labels: true 64 | 65 | - name: Assign Conventional Commit label 66 | shell: bash 67 | env: 68 | PR_CURRENT_LABELS_JSON: ${{ toJson(github.event.pull_request.labels) }} 69 | PR_TITLE: ${{ github.event.pull_request.title }} 70 | GITHUB_TOKEN: ${{ github.token }} 71 | run: | 72 | # Create a mapping between Conventional Commit prefixes and our labels: 73 | label_map='{ 74 | "build": "type: build", 75 | "chore": "type: chore", 76 | "ci": "type: ci", 77 | "docs": "type: docs", 78 | "feat": "type: feature", 79 | "fix": "type: bugfix", 80 | "perf": "type: performance", 81 | "refactor": "type: refactor", 82 | "revert": "type: reversion", 83 | "style": "type: style", 84 | "test": "type: test" 85 | }' 86 | 87 | # Strip any surrounding whitespace from the sanitized PR title: 88 | pr_title="$(echo "$PR_TITLE" | tr -d '\n' | xargs)" 89 | 90 | # Parse the existing labels: 91 | pr_current_labels=$(echo "$PR_CURRENT_LABELS_JSON" | jq '.[].name') 92 | 93 | # Determine the Conventional Commit type based upon the PR title: 94 | commit_type="$(echo "$pr_title" | cut -d: -f1 | sed 's/(.*)//g; s/!//g')" 95 | echo "Detected Conventional Commit type: '$commit_type'" 96 | 97 | if [[ -z "$commit_type" ]]; then 98 | echo "Commit type could not be extracted from PR title: '$pr_title'" 99 | exit 1 100 | fi 101 | 102 | # Pull the appropriate label based on the detected Conventional Commit type: 103 | label_to_apply="$(echo "$label_map" | jq -r --arg type "$commit_type" '.[$type] // empty')" 104 | 105 | if [[ -z "$label_to_apply" ]]; then 106 | echo "Unrecognized Conventional Commit type: '$commit_type'" 107 | exit 1 108 | fi 109 | 110 | echo "Mapping Conventional Commit type '$commit_type' to label: '$label_to_apply'" 111 | 112 | # Determine whether any outdated Conventional Commit labels need to be 113 | # removed: 114 | labels_to_remove_csv=$(echo "$PR_CURRENT_LABELS_JSON" | jq -r --argjson label_map "$label_map" --arg current_label "$label_to_apply" '.[].name | select(. != $current_label and (. as $existing | $label_map | any(.[]; . == $existing)))' | paste -sd, -) 115 | echo "Removing incorrect Conventional Commit labels: '$labels_to_remove_csv'" 116 | 117 | # If the label to add is already applied, skip it: 118 | labels_to_add_csv="" 119 | if echo "$pr_current_labels" | grep -qw "$label_to_apply"; then 120 | echo "Label already exists on the PR: '$label_to_apply'" 121 | else 122 | echo "Label should be added to the PR: '$label_to_apply'" 123 | labels_to_add_csv+="$label_to_apply" 124 | fi 125 | 126 | # Apply the label changes: 127 | if [[ -n "$labels_to_remove_csv" || -n "$labels_to_add_csv" ]]; then 128 | gh pr edit \ 129 | "${{ github.event.pull_request.number }}" \ 130 | ${labels_to_add_csv:+--add-label "$labels_to_add_csv"} \ 131 | ${labels_to_remove_csv:+--remove-label "$labels_to_remove_csv"} 132 | else 133 | echo "No label changes needed" 134 | fi 135 | -------------------------------------------------------------------------------- /.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 | workflow_dispatch: 11 | 12 | jobs: 13 | lint: 14 | name: "Linting & Static Analysis" 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: ⤵️ Check out code from GitHub 20 | uses: actions/checkout@v4 21 | 22 | - name: 🏗 Set up Python 3.13 23 | id: setup-python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: "3.13" 27 | 28 | - name: 🚜 Install uv 29 | uses: ./.github/actions/install-uv 30 | 31 | - name: 🏗 Install workflow dependencies 32 | run: | 33 | uv sync --extra lint 34 | 35 | - name: Get all changed files 36 | id: changed-files 37 | uses: tj-actions/changed-files@v45.0.6 38 | with: 39 | fetch_depth: 0 40 | 41 | - name: Run pre-commit hooks 42 | run: | 43 | uv run pre-commit run \ 44 | --files ${{ steps.changed-files.outputs.all_changed_files }} 45 | -------------------------------------------------------------------------------- /.github/workflows/sync-labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Sync Labels 3 | 4 | "on": 5 | push: 6 | branches: 7 | - dev 8 | paths: 9 | - .github/config/labels.yml 10 | - .github/config/labeler.yml 11 | 12 | workflow_dispatch: 13 | 14 | jobs: 15 | labels: 16 | name: ♻️ Sync labels 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: ⤵️ Check out code from GitHub 20 | uses: actions/checkout@v4 21 | 22 | - name: 🚀 Run Label Syncer 23 | uses: micnncim/action-label-syncer@v1.3.0 24 | with: 25 | manifest: .github/config/labels.yml 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests and Coverage 3 | 4 | "on": 5 | pull_request: 6 | branches: 7 | - dev 8 | - main 9 | 10 | workflow_dispatch: 11 | 12 | jobs: 13 | test: 14 | name: Tests 15 | 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | python-version: 21 | - "3.11" 22 | - "3.12" 23 | - "3.13" 24 | 25 | steps: 26 | - name: ⤵️ Check out code from GitHub 27 | uses: actions/checkout@v4 28 | 29 | - name: 🏗 Set up Python 30 | id: setup-python 31 | uses: actions/setup-python@v5 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | 35 | - name: 🚜 Install uv 36 | uses: ./.github/actions/install-uv 37 | 38 | - name: 🏗 Install package dependencies 39 | run: | 40 | uv sync --extra test 41 | 42 | - name: 🚀 Run pytest 43 | run: uv run pytest --cov aiolinkding tests 44 | 45 | - name: ⬆️ Upload coverage artifact 46 | uses: actions/upload-artifact@v4 47 | with: 48 | name: coverage-${{ matrix.python-version }} 49 | path: .coverage 50 | include-hidden-files: true 51 | 52 | coverage: 53 | name: Code Coverage 54 | 55 | needs: test 56 | 57 | runs-on: ubuntu-latest 58 | 59 | steps: 60 | - name: ⤵️ Check out code from GitHub 61 | uses: actions/checkout@v4 62 | 63 | - name: ⬇️ Download coverage data 64 | uses: actions/download-artifact@v4 65 | 66 | - name: 🏗 Set up Python 3.13 67 | id: setup-python 68 | uses: actions/setup-python@v5 69 | with: 70 | python-version: "3.13" 71 | 72 | - name: 🚜 Install uv 73 | uses: ./.github/actions/install-uv 74 | 75 | - name: 🏗 Install package dependencies 76 | run: | 77 | uv sync --extra test 78 | 79 | - name: 🚀 Process coverage results 80 | run: | 81 | uv run coverage combine coverage*/.coverage* 82 | uv run coverage xml -i 83 | 84 | - name: 📊 Upload coverage report to codecov.io 85 | uses: codecov/codecov-action@v4 86 | with: 87 | token: ${{ secrets.CODECOV_TOKEN }} 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | .DS_Store 3 | .coverage 4 | .mypy_cache 5 | .nox 6 | .tox 7 | .venv 8 | __pycache__ 9 | coverage.xml 10 | dist 11 | docs/_build 12 | tags 13 | -------------------------------------------------------------------------------- /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | act = { version="0.2.71" } 3 | python = { version="3.12.8" } 4 | 5 | [env] 6 | _.python.venv = { path = ".venv", create = true } 7 | -------------------------------------------------------------------------------- /.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: uv run blacken-docs 10 | require_serial: true 11 | - id: check-ast 12 | name: "🐍 Checking Python AST" 13 | language: system 14 | types: [python] 15 | entry: uv run check-ast 16 | - id: check-case-conflict 17 | name: "🔠 Checking for case conflicts" 18 | language: system 19 | entry: uv run check-case-conflict 20 | - id: check-docstring-first 21 | name: "ℹ️ Checking docstrings are first" 22 | language: system 23 | types: [python] 24 | entry: uv 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: uv run check-executables-have-shebangs 30 | stages: [pre-commit, pre-push, manual] 31 | - id: check-json 32 | name: "{ Checking JSON files" 33 | language: system 34 | types: [json] 35 | entry: uv run check-json 36 | - id: check-merge-conflict 37 | name: "💥 Checking for merge conflicts" 38 | language: system 39 | types: [text] 40 | entry: uv run check-merge-conflict 41 | - id: check-symlinks 42 | name: "🔗 Checking for broken symlinks" 43 | language: system 44 | types: [symlink] 45 | entry: uv run check-symlinks 46 | - id: check-toml 47 | name: "✅ Checking TOML files" 48 | language: system 49 | types: [toml] 50 | entry: uv run check-toml 51 | - id: codespell 52 | name: "✅ Checking code for misspellings" 53 | language: system 54 | types: [text] 55 | exclude: | 56 | (?x)^($^ 57 | |.*uv\.lock 58 | )$ 59 | entry: uv run codespell 60 | - id: debug-statements 61 | name: "🪵 Checking for debug statements and imports (Python)" 62 | language: system 63 | types: [python] 64 | entry: uv run debug-statement-hook 65 | - id: detect-private-key 66 | name: "🕵️ Detecting private keys" 67 | language: system 68 | types: [text] 69 | entry: uv run detect-private-key 70 | - id: end-of-file-fixer 71 | name: "🔚 Checking end of files" 72 | language: system 73 | types: [text] 74 | entry: uv run end-of-file-fixer 75 | stages: [pre-commit, pre-push, manual] 76 | - id: fix-byte-order-marker 77 | name: "🚏 Checking UTF-8 byte order marker" 78 | language: system 79 | types: [text] 80 | entry: uv run fix-byte-order-marker 81 | - id: format 82 | name: "☕️ Formatting code using ruff" 83 | language: system 84 | types: [python] 85 | entry: uv run ruff format 86 | exclude: | 87 | (?x)^($^ 88 | |docs/.* 89 | )$ 90 | - id: mypy 91 | name: "🆎 Performing static type checking using mypy" 92 | language: system 93 | types: [python] 94 | entry: uv run mypy 95 | - id: no-commit-to-branch 96 | name: "🛑 Checking for commit to protected branch" 97 | language: system 98 | entry: uv run no-commit-to-branch 99 | pass_filenames: false 100 | always_run: true 101 | args: 102 | - --branch=development 103 | - --branch=main 104 | - id: pylint 105 | name: "🌟 Starring code with pylint" 106 | language: system 107 | types: [python] 108 | entry: uv run pylint 109 | - id: ruff 110 | name: "👔 Enforcing style guide with ruff" 111 | language: system 112 | types: [python] 113 | entry: uv run ruff check --fix 114 | exclude: | 115 | (?x)^($^ 116 | |docs/.* 117 | )$ 118 | require_serial: true 119 | - id: trailing-whitespace 120 | name: "✄ Trimming trailing whitespace" 121 | language: system 122 | types: [text] 123 | entry: uv run trailing-whitespace-fixer 124 | stages: [pre-commit, pre-push, manual] 125 | - id: uv-lock 126 | name: "🔒 Ensure the uv.lock file is up to date" 127 | language: system 128 | entry: uv lock --locked 129 | files: pyproject.toml$ 130 | pass_filenames: false 131 | 132 | - repo: https://github.com/pre-commit/mirrors-prettier 133 | rev: "v3.0.0-alpha.4" 134 | hooks: 135 | - id: prettier 136 | name: "💄 Ensuring files are prettier" 137 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 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 | # 🔖 aiolinkding: a Python3, async library to the linkding REST API 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 | `aiolinkding` is a Python3, async library that interfaces with [linkding][linkding] 13 | instances. It is intended to be a reasonably light wrapper around the linkding API 14 | (meaning that instead of drowning the user in custom objects/etc., it focuses on 15 | returning JSON straight from the API). 16 | 17 | - [Installation](#installation) 18 | - [Python Versions](#python-versions) 19 | - [Usage](#usage) 20 | - [Creating a Client](#creating-a-client) 21 | - [Working with Bookmarks](#working-with-bookmarks) 22 | - [Getting All Bookmarks](#getting-all-bookmarks) 23 | - [Getting Archived Bookmarks](#getting-archived-bookmarks) 24 | - [Getting a Single Bookmark](#getting-a-single-bookmark-by-id) 25 | - [Creating a New Bookmark](#creating-a-new-bookmark) 26 | - [Updating an Existing Bookmark by ID](#updating-an-existing-bookmark-by-id) 27 | - [Archiving/Unarchiving a Bookmark](#archivingunarchiving-a-bookmark) 28 | - [Deleting a Bookmark](#deleting-a-bookmark) 29 | - [Working with Tags](#working-with-tags) 30 | - [Getting All Tags](#getting-all-tags) 31 | - [Getting a Single Tag](#getting-a-single-tag-by-id) 32 | - [Creating a New Tag](#creating-a-new-Tag) 33 | - [Working with User Data](#working-with-user-data) 34 | - [Getting Profile Info](#getting-profile-info) 35 | - [Connection Pooling](#connection-pooling) 36 | - [Contributing](#contributing) 37 | 38 | # Installation 39 | 40 | ```bash 41 | pip install aiolinkding 42 | ``` 43 | 44 | # Python Versions 45 | 46 | `aiolinkding` is currently supported on: 47 | 48 | - Python 3.11 49 | - Python 3.12 50 | - Python 3.13 51 | 52 | # Usage 53 | 54 | ## Creating a Client 55 | 56 | It's easy to create an API client for a linkding instance. All you need are two 57 | parameters: 58 | 59 | 1. A URL to a linkding instance 60 | 2. A linkding API token 61 | 62 | ```python 63 | import asyncio 64 | 65 | from aiolinkding import async_get_client 66 | 67 | 68 | async def main() -> None: 69 | """Use aiolinkding for fun and profit.""" 70 | client = await async_get_client("http://127.0.0.1:8000", "token_abcde12345") 71 | 72 | 73 | asyncio.run(main()) 74 | ``` 75 | 76 | ## Working with Bookmarks 77 | 78 | ### Getting All Bookmarks 79 | 80 | ```python 81 | import asyncio 82 | 83 | from aiolinkding import async_get_client 84 | 85 | 86 | async def main() -> None: 87 | """Use aiolinkding for fun and profit.""" 88 | client = await async_get_client("http://127.0.0.1:8000", "token_abcde12345") 89 | 90 | # Get all bookmarks: 91 | bookmarks = await client.bookmarks.async_get_all() 92 | # >>> { "count": 100, "next": null, "previous": null, "results": [...] } 93 | 94 | 95 | asyncio.run(main()) 96 | ``` 97 | 98 | `client.bookmarks.async_get_all()` takes three optional parameters: 99 | 100 | - `query`: a string query to filter the returned bookmarks 101 | - `limit`: the maximum number of results that should be returned 102 | - `offset`: the index from which to return results (e.g., `5` starts at the fifth bookmark) 103 | 104 | ### Getting Archived Bookmarks 105 | 106 | ```python 107 | import asyncio 108 | 109 | from aiolinkding import async_get_client 110 | 111 | 112 | async def main() -> None: 113 | """Use aiolinkding for fun and profit.""" 114 | client = await async_get_client("http://127.0.0.1:8000", "token_abcde12345") 115 | 116 | # Get all archived bookmarks: 117 | bookmarks = await client.bookmarks.async_get_archived() 118 | # >>> { "count": 100, "next": null, "previous": null, "results": [...] } 119 | 120 | 121 | asyncio.run(main()) 122 | ``` 123 | 124 | `client.bookmarks.async_get_archived()` takes three optional parameters: 125 | 126 | - `query`: a string query to filter the returned bookmarks 127 | - `limit`: the maximum number of results that should be returned 128 | - `offset`: the index from which to return results (e.g., `5` starts at the fifth bookmark) 129 | 130 | ### Getting a Single Bookmark by ID 131 | 132 | ```python 133 | import asyncio 134 | 135 | from aiolinkding import async_get_client 136 | 137 | 138 | async def main() -> None: 139 | """Use aiolinkding for fun and profit.""" 140 | client = await async_get_client("http://127.0.0.1:8000", "token_abcde12345") 141 | 142 | # Get a single bookmark: 143 | bookmark = await client.bookmarks.async_get_single(37) 144 | # >>> { "id": 37, "url": "https://example.com", "title": "Example title", ... } 145 | 146 | 147 | asyncio.run(main()) 148 | ``` 149 | 150 | ### Creating a New Bookmark 151 | 152 | ```python 153 | import asyncio 154 | 155 | from aiolinkding import async_get_client 156 | 157 | 158 | async def main() -> None: 159 | """Use aiolinkding for fun and profit.""" 160 | client = await async_get_client("http://127.0.0.1:8000", "token_abcde12345") 161 | 162 | # Create a new bookmark: 163 | created_bookmark = await client.bookmarks.async_create( 164 | "https://example.com", 165 | title="Example title", 166 | description="Example description", 167 | tag_names=[ 168 | "tag1", 169 | "tag2", 170 | ], 171 | ) 172 | # >>> { "id": 37, "url": "https://example.com", "title": "Example title", ... } 173 | 174 | 175 | asyncio.run(main()) 176 | ``` 177 | 178 | `client.bookmarks.async_create()` takes four optional parameters: 179 | 180 | - `title`: the bookmark's title 181 | - `description`: the bookmark's description 182 | - `notes`: Markdown notes to add to the bookmark 183 | - `tag_names`: the tags to assign to the bookmark (represented as a list of strings) 184 | - `is_archived`: whether the newly-created bookmark should automatically be archived 185 | - `unread`: whether the newly-created bookmark should be marked as unread 186 | - `shared`: whether the newly-created bookmark should be shareable with other linkding users 187 | 188 | ### Updating an Existing Bookmark by ID 189 | 190 | ```python 191 | import asyncio 192 | 193 | from aiolinkding import async_get_client 194 | 195 | 196 | async def main() -> None: 197 | """Use aiolinkding for fun and profit.""" 198 | client = await async_get_client("http://127.0.0.1:8000", "token_abcde12345") 199 | 200 | # Update an existing bookmark: 201 | updated_bookmark = await client.bookmarks.async_update( 202 | 37, 203 | url="https://different-example.com", 204 | title="Different example title", 205 | description="Different example description", 206 | tag_names=[ 207 | "tag1", 208 | "tag2", 209 | ], 210 | ) 211 | # >>> { "id": 37, "url": "https://different-example.com", ... } 212 | 213 | 214 | asyncio.run(main()) 215 | ``` 216 | 217 | `client.bookmarks.async_update()` takes four optional parameters (inclusion of any parameter 218 | will change that value for the existing bookmark): 219 | 220 | - `url`: the bookmark's URL 221 | - `title`: the bookmark's title 222 | - `description`: the bookmark's description 223 | - `notes`: Markdown notes to add to the bookmark 224 | - `tag_names`: the tags to assign to the bookmark (represented as a list of strings) 225 | - `unread`: whether the bookmark should be marked as unread 226 | - `shared`: whether the bookmark should be shareable with other linkding users 227 | 228 | ### Archiving/Unarchiving a Bookmark 229 | 230 | ```python 231 | import asyncio 232 | 233 | from aiolinkding import async_get_client 234 | 235 | 236 | async def main() -> None: 237 | """Use aiolinkding for fun and profit.""" 238 | client = await async_get_client("http://127.0.0.1:8000", "token_abcde12345") 239 | 240 | # Archive a bookmark by ID: 241 | await client.bookmarks.async_archive(37) 242 | 243 | # ...and unarchive it: 244 | await client.bookmarks.async_unarchive(37) 245 | 246 | 247 | asyncio.run(main()) 248 | ``` 249 | 250 | ### Deleting a Bookmark 251 | 252 | ```python 253 | import asyncio 254 | 255 | from aiolinkding import async_get_client 256 | 257 | 258 | async def main() -> None: 259 | """Use aiolinkding for fun and profit.""" 260 | client = await async_get_client("http://127.0.0.1:8000", "token_abcde12345") 261 | 262 | # Delete a bookmark by ID: 263 | await client.bookmarks.async_delete(37) 264 | 265 | 266 | asyncio.run(main()) 267 | ``` 268 | 269 | ## Working with Tags 270 | 271 | ### Getting All Tags 272 | 273 | ```python 274 | import asyncio 275 | 276 | from aiolinkding import async_get_client 277 | 278 | 279 | async def main() -> None: 280 | """Use aiolinkding for fun and profit.""" 281 | client = await async_get_client("http://127.0.0.1:8000", "token_abcde12345") 282 | 283 | # Get all tags: 284 | tags = await client.tags.async_get_all() 285 | # >>> { "count": 100, "next": null, "previous": null, "results": [...] } 286 | 287 | 288 | asyncio.run(main()) 289 | ``` 290 | 291 | `client.tags.async_get_all()` takes two optional parameters: 292 | 293 | - `limit`: the maximum number of results that should be returned 294 | - `offset`: the index from which to return results (e.g., `5` starts at the fifth bookmark) 295 | 296 | ### Getting a Single Tag by ID 297 | 298 | ```python 299 | import asyncio 300 | 301 | from aiolinkding import async_get_client 302 | 303 | 304 | async def main() -> None: 305 | """Use aiolinkding for fun and profit.""" 306 | client = await async_get_client("http://127.0.0.1:8000", "token_abcde12345") 307 | 308 | # Get a single tag: 309 | tag = await client.tags.async_get_single(22) 310 | # >>> { "id": 22, "name": "example-tag", ... } 311 | 312 | 313 | asyncio.run(main()) 314 | ``` 315 | 316 | ### Creating a New Tag 317 | 318 | ```python 319 | import asyncio 320 | 321 | from aiolinkding import async_get_client 322 | 323 | 324 | async def main() -> None: 325 | """Use aiolinkding for fun and profit.""" 326 | client = await async_get_client("http://127.0.0.1:8000", "token_abcde12345") 327 | 328 | # Create a new tag: 329 | created_tag = await client.tags.async_create("example-tag") 330 | # >>> { "id": 22, "name": "example-tag", ... } 331 | 332 | 333 | asyncio.run(main()) 334 | ``` 335 | 336 | ## Working with User Data 337 | 338 | ### Getting Profile Info 339 | 340 | ```python 341 | import asyncio 342 | 343 | from aiolinkding import async_get_client 344 | 345 | 346 | async def main() -> None: 347 | """Use aiolinkding for fun and profit.""" 348 | client = await async_get_client("http://127.0.0.1:8000", "token_abcde12345") 349 | 350 | # Get all tags: 351 | tags = await client.user.async_get_profile() 352 | # >>> { "theme": "auto", "bookmark_date_display": "relative", ... } 353 | 354 | 355 | asyncio.run(main()) 356 | ``` 357 | 358 | ## Connection Pooling 359 | 360 | By default, the library creates a new connection to linkding with each coroutine. If you 361 | are calling a large number of coroutines (or merely want to squeeze out every second of 362 | runtime savings possible), an [`aiohttp`][aiohttp] `ClientSession` can be used for 363 | connection pooling: 364 | 365 | ```python 366 | import asyncio 367 | 368 | from aiohttp import async_get_clientSession 369 | from aiolinkding import async_get_client 370 | 371 | 372 | async def main() -> None: 373 | """Use aiolinkding for fun and profit.""" 374 | async with ClientSession() as session: 375 | client = await async_get_client( 376 | "http://127.0.0.1:8000", "token_abcde12345", session=session 377 | ) 378 | 379 | # Get to work... 380 | 381 | 382 | asyncio.run(main()) 383 | ``` 384 | 385 | # Contributing 386 | 387 | Thanks to all of [our contributors][contributors] so far! 388 | 389 | 1. [Check for open features/bugs][issues] or [initiate a discussion on one][new-issue]. 390 | 2. [Fork the repository][fork]. 391 | 3. (_optional, but highly recommended_) Create a virtual environment: `python3 -m venv .venv` 392 | 4. (_optional, but highly recommended_) Enter the virtual environment: `source ./.venv/bin/activate` 393 | 5. Install the dev environment: `script/setup` 394 | 6. Code your new feature or bug fix on a new branch. 395 | 7. Write tests that cover your new functionality. 396 | 8. Run tests and ensure 100% code coverage: `poetry run pytest --cov aiolinkding tests` 397 | 9. Update `README.md` with any new documentation. 398 | 10. Submit a pull request! 399 | 400 | [aiohttp]: https://github.com/aio-libs/aiohttp 401 | [linkding]: https://github.com/sissbruecker/linkding 402 | [ci-badge]: https://img.shields.io/github/actions/workflow/status/bachya/aiolinkding/test.yml 403 | [ci]: https://github.com/bachya/aiolinkding/actions 404 | [codecov-badge]: https://codecov.io/gh/bachya/aiolinkding/branch/dev/graph/badge.svg 405 | [codecov]: https://codecov.io/gh/bachya/aiolinkding 406 | [contributors]: https://github.com/bachya/aiolinkding/graphs/contributors 407 | [fork]: https://github.com/bachya/aiolinkding/fork 408 | [issues]: https://github.com/bachya/aiolinkding/issues 409 | [license-badge]: https://img.shields.io/pypi/l/aiolinkding.svg 410 | [license]: https://github.com/bachya/aiolinkding/blob/main/LICENSE 411 | [maintainability-badge]: https://api.codeclimate.com/v1/badges/189379773edd4035a612/maintainability 412 | [maintainability]: https://codeclimate.com/github/bachya/aiolinkding/maintainability 413 | [new-issue]: https://github.com/bachya/aiolinkding/issues/new 414 | [new-issue]: https://github.com/bachya/aiolinkding/issues/new 415 | [pypi-badge]: https://img.shields.io/pypi/v/aiolinkding.svg 416 | [pypi]: https://pypi.python.org/pypi/aiolinkding 417 | [version-badge]: https://img.shields.io/pypi/pyversions/aiolinkding.svg 418 | [version]: https://pypi.python.org/pypi/aiolinkding 419 | -------------------------------------------------------------------------------- /aiolinkding/__init__.py: -------------------------------------------------------------------------------- 1 | """Define the aiolinkding package.""" 2 | 3 | from .client import Client, async_get_client 4 | 5 | __all__ = [ 6 | "Client", 7 | "async_get_client", 8 | ] 9 | -------------------------------------------------------------------------------- /aiolinkding/bookmark.py: -------------------------------------------------------------------------------- 1 | """Define an object to manage bookmark-based API requests.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Awaitable, Callable 6 | from typing import Any 7 | 8 | from aiolinkding.util import generate_api_payload 9 | 10 | 11 | class BookmarkManager: 12 | """Define the API manager object.""" 13 | 14 | def __init__(self, async_request: Callable[..., Awaitable[dict[str, Any]]]) -> 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_get_bookmarks( 25 | self, 26 | *, 27 | archived: bool = False, 28 | query: str | None = None, 29 | limit: int | None = None, 30 | offset: int | None = None, 31 | ) -> dict[str, Any]: 32 | """Return all bookmarks. 33 | 34 | Args: 35 | ---- 36 | archived: Include archived bookmarks. 37 | query: Return bookmarks matching a query string. 38 | limit: Limit the number of returned bookmarks. 39 | offset: The index at which to return results. 40 | 41 | Returns: 42 | ------- 43 | An API response payload. 44 | 45 | """ 46 | params = generate_api_payload( 47 | ( 48 | ("q", query), 49 | ("limit", limit), 50 | ("offset", offset), 51 | ) 52 | ) 53 | 54 | endpoint = "/api/bookmarks/" 55 | if archived: 56 | endpoint += "archived/" 57 | 58 | return await self._async_request("get", endpoint, params=params) 59 | 60 | async def async_archive(self, bookmark_id: int) -> None: 61 | """Archive a bookmark. 62 | 63 | Args: 64 | ---- 65 | bookmark_id: The ID of the bookmark to archive. 66 | 67 | """ 68 | await self._async_request("post", f"/api/bookmarks/{bookmark_id}/archive/") 69 | 70 | async def async_delete(self, bookmark_id: int) -> None: 71 | """Delete a bookmark. 72 | 73 | Args: 74 | ---- 75 | bookmark_id: The ID of the bookmark to delete. 76 | 77 | """ 78 | await self._async_request("delete", f"/api/bookmarks/{bookmark_id}/") 79 | 80 | async def async_get_all( 81 | self, 82 | *, 83 | query: str | None = None, 84 | limit: int | None = None, 85 | offset: int | None = None, 86 | ) -> dict[str, Any]: 87 | """Return all bookmarks. 88 | 89 | Args: 90 | ---- 91 | query: Return bookmarks matching a query string. 92 | limit: Limit the number of returned bookmarks. 93 | offset: The index at which to return results. 94 | 95 | Returns: 96 | ------- 97 | An API response payload. 98 | 99 | """ 100 | return await self._async_get_bookmarks(query=query, limit=limit, offset=offset) 101 | 102 | async def async_get_archived( 103 | self, 104 | *, 105 | query: str | None = None, 106 | limit: int | None = None, 107 | offset: int | None = None, 108 | ) -> dict[str, Any]: 109 | """Return all archived bookmarks. 110 | 111 | Args: 112 | ---- 113 | query: Return bookmarks matching a query string. 114 | limit: Limit the number of returned bookmarks. 115 | offset: The index at which to return results. 116 | 117 | Returns: 118 | ------- 119 | An API response payload. 120 | 121 | """ 122 | return await self._async_get_bookmarks( 123 | archived=True, query=query, limit=limit, offset=offset 124 | ) 125 | 126 | async def async_create( 127 | self, 128 | url: str, 129 | *, 130 | title: str | None = None, 131 | description: str | None = None, 132 | notes: str | None = None, 133 | tag_names: list[str] | None = None, 134 | is_archived: bool = False, 135 | unread: bool = False, 136 | shared: bool = False, 137 | ) -> dict[str, Any]: 138 | """Create a new bookmark. 139 | 140 | Args: 141 | ---- 142 | url: The bookmark URL. 143 | title: The bookmark title. 144 | description: The bookmark description. 145 | notes: Any Markdown-formatted notes. 146 | tag_names: A list of strings to use as tags. 147 | is_archived: Immediately archive the bookmark. 148 | unread: Immediately mark the bookmark as unread. 149 | shared: Immediately mark the bookmark as shared. 150 | 151 | Returns: 152 | ------- 153 | An API response payload. 154 | 155 | """ 156 | payload = generate_api_payload( 157 | ( 158 | ("url", url), 159 | ("title", title), 160 | ("description", description), 161 | ("notes", notes), 162 | ("tag_names", tag_names), 163 | ("is_archived", is_archived), 164 | ("unread", unread), 165 | ("shared", shared), 166 | ) 167 | ) 168 | 169 | return await self._async_request("post", "/api/bookmarks/", json=payload) 170 | 171 | async def async_get_single(self, bookmark_id: int) -> dict[str, Any]: 172 | """Return a single bookmark. 173 | 174 | Args: 175 | ---- 176 | bookmark_id: The ID of the bookmark to get. 177 | 178 | Returns: 179 | ------- 180 | An API response payload. 181 | 182 | """ 183 | return await self._async_request("get", f"/api/bookmarks/{bookmark_id}/") 184 | 185 | async def async_unarchive(self, bookmark_id: int) -> None: 186 | """Unarchive a bookmark. 187 | 188 | Args: 189 | ---- 190 | bookmark_id: The ID of the bookmark to unarchive. 191 | 192 | """ 193 | await self._async_request("post", f"/api/bookmarks/{bookmark_id}/unarchive/") 194 | 195 | async def async_update( 196 | self, 197 | bookmark_id: int, 198 | *, 199 | url: str | None = None, 200 | title: str | None = None, 201 | description: str | None = None, 202 | notes: str | None = None, 203 | tag_names: list[str] | None = None, 204 | unread: bool = False, 205 | shared: bool = False, 206 | ) -> dict[str, Any]: 207 | """Update an existing bookmark. 208 | 209 | Args: 210 | ---- 211 | bookmark_id: The ID of the bookmark to update. 212 | url: The bookmark URL. 213 | title: The bookmark title. 214 | description: The bookmark description. 215 | notes: Any Markdown-formatted notes. 216 | tag_names: A list of strings to use as tags. 217 | unread: Immediately mark the bookmark as unread. 218 | shared: Immediately mark the bookmark as shared. 219 | 220 | Returns: 221 | ------- 222 | An API response payload. 223 | 224 | """ 225 | payload = generate_api_payload( 226 | ( 227 | ("url", url), 228 | ("title", title), 229 | ("description", description), 230 | ("notes", notes), 231 | ("tag_names", tag_names), 232 | ("unread", unread), 233 | ("shared", shared), 234 | ) 235 | ) 236 | 237 | return await self._async_request( 238 | "patch", f"/api/bookmarks/{bookmark_id}/", json=payload 239 | ) 240 | -------------------------------------------------------------------------------- /aiolinkding/client.py: -------------------------------------------------------------------------------- 1 | """Define an API client.""" 2 | 3 | from __future__ import annotations 4 | 5 | from http import HTTPStatus 6 | from typing import Any 7 | 8 | from aiohttp import ClientSession, ClientTimeout 9 | from aiohttp.client_exceptions import ClientResponseError 10 | from packaging import version 11 | 12 | from aiolinkding.bookmark import BookmarkManager 13 | from aiolinkding.const import LOGGER 14 | from aiolinkding.errors import ( 15 | InvalidServerVersionError, 16 | InvalidTokenError, 17 | RequestError, 18 | UnknownEndpointError, 19 | ) 20 | from aiolinkding.tag import TagManager 21 | from aiolinkding.user import UserManager 22 | 23 | DEFAULT_REQUEST_TIMEOUT = 10 24 | 25 | SERVER_VERSION_HEALTH_CHECK_INTRODUCED = version.parse("1.17.0") 26 | SERVER_VERSION_MINIMUM_REQUIRED = version.parse("1.22.0") 27 | 28 | INVALID_SERVER_VERSION_MESSAGE = ( 29 | "Server version ({0}) is below the minimum version required " 30 | f"({SERVER_VERSION_MINIMUM_REQUIRED})" 31 | ) 32 | 33 | 34 | class Client: # pylint: disable=too-few-public-methods 35 | """Define a client for the linkding API.""" 36 | 37 | def __init__( 38 | self, url: str, token: str, *, session: ClientSession | None = None 39 | ) -> None: 40 | """Initialize. 41 | 42 | Args: 43 | ---- 44 | url: The full URL to a linkding instance. 45 | token: A linkding API token. 46 | session: An optional aiohttp ClientSession. 47 | 48 | """ 49 | self._session = session 50 | self._token = token 51 | self._url = url 52 | 53 | self.bookmarks = BookmarkManager(self.async_request) 54 | self.tags = TagManager(self.async_request) 55 | self.user = UserManager(self.async_request) 56 | 57 | async def async_request( 58 | self, method: str, endpoint: str, **kwargs: dict[str, Any] 59 | ) -> dict[str, Any]: 60 | """Make an API request. 61 | 62 | Args: 63 | ---- 64 | method: An HTTP method. 65 | endpoint: A relative API endpoint. 66 | **kwargs: Additional kwargs to send with the request. 67 | 68 | Returns: 69 | ------- 70 | An API response payload. 71 | 72 | Raises: 73 | ------ 74 | InvalidTokenError: Raised upon an invalid API token. 75 | RequestError: Raised upon an underlying HTTP error. 76 | UnknownEndpointError: Raised when requesting an unknown API endpoint. 77 | 78 | """ 79 | kwargs.setdefault("headers", {}) 80 | kwargs["headers"]["Authorization"] = f"Token {self._token}" 81 | 82 | if use_running_session := self._session and not self._session.closed: 83 | session = self._session 84 | else: 85 | session = ClientSession( 86 | timeout=ClientTimeout(total=DEFAULT_REQUEST_TIMEOUT) 87 | ) 88 | 89 | data: dict[str, Any] = {} 90 | 91 | try: 92 | async with session.request( 93 | method, f"{self._url}{endpoint}", **kwargs 94 | ) as resp: 95 | data = await resp.json() 96 | resp.raise_for_status() 97 | except ClientResponseError as err: 98 | if resp.status == HTTPStatus.NO_CONTENT: 99 | # An HTTP 204 will not return parsable JSON data, but it's still a 100 | # successful response, so we swallow the exception and return: 101 | return {} 102 | if resp.status == HTTPStatus.UNAUTHORIZED: 103 | msg = "Invalid API token" 104 | raise InvalidTokenError(msg) from err 105 | if resp.status == HTTPStatus.NOT_FOUND: 106 | # We break out this particular response for the health check; if we 107 | # catch this when querying GET /health, we can raise a better final 108 | # exception than what would normally occur: 109 | msg = f"Unknown API endpoint: {endpoint}" 110 | raise UnknownEndpointError(msg) from err 111 | msg = f"Error while requesting {endpoint}: {data}" 112 | raise RequestError(msg) from err 113 | finally: 114 | if not use_running_session: 115 | await session.close() 116 | 117 | LOGGER.debug("Data received for %s: %s", endpoint, data) 118 | 119 | return data 120 | 121 | 122 | async def async_get_client( 123 | url: str, token: str, *, session: ClientSession | None = None 124 | ) -> Client: 125 | """Get an authenticated, version-checked client. 126 | 127 | Args: 128 | ---- 129 | url: The full URL to a linkding instance. 130 | token: A linkding API token. 131 | session: An optional aiohttp ClientSession. 132 | 133 | Returns: 134 | ------- 135 | A Client object. 136 | 137 | Raises: 138 | ------ 139 | InvalidServerVersionError: Raised when the server version is too low. 140 | 141 | """ 142 | client = Client(url, token, session=session) 143 | 144 | try: 145 | health_resp = await client.async_request("get", "/health") 146 | except UnknownEndpointError as err: 147 | raise InvalidServerVersionError( 148 | INVALID_SERVER_VERSION_MESSAGE.format( 149 | f"older than {SERVER_VERSION_HEALTH_CHECK_INTRODUCED}" 150 | ) 151 | ) from err 152 | 153 | server_version = version.parse(health_resp["version"]) 154 | 155 | if server_version < SERVER_VERSION_MINIMUM_REQUIRED: 156 | raise InvalidServerVersionError( 157 | INVALID_SERVER_VERSION_MESSAGE.format(server_version) 158 | ) 159 | 160 | return client 161 | -------------------------------------------------------------------------------- /aiolinkding/const.py: -------------------------------------------------------------------------------- 1 | """Define package constants.""" 2 | 3 | import logging 4 | 5 | LOGGER = logging.getLogger(__package__) 6 | -------------------------------------------------------------------------------- /aiolinkding/errors.py: -------------------------------------------------------------------------------- 1 | """Define package exceptions.""" 2 | 3 | 4 | class LinkDingError(Exception): 5 | """Define a base exception.""" 6 | 7 | 8 | class InvalidServerVersionError(LinkDingError): 9 | """Define an error related to an invalid server version.""" 10 | 11 | 12 | class InvalidTokenError(LinkDingError): 13 | """Define an error related to an invalid API token.""" 14 | 15 | 16 | class RequestError(LinkDingError): 17 | """An error related to invalid requests.""" 18 | 19 | 20 | class UnknownEndpointError(RequestError): 21 | """An error related to an unknown endpoint.""" 22 | -------------------------------------------------------------------------------- /aiolinkding/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachya/aiolinkding/3f7f28ce9c1ef727d95223670808df3cdfecf210/aiolinkding/py.typed -------------------------------------------------------------------------------- /aiolinkding/tag.py: -------------------------------------------------------------------------------- 1 | """Define an object to manage tag-based API requests.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Awaitable, Callable 6 | from typing import Any, cast 7 | 8 | from aiolinkding.util import generate_api_payload 9 | 10 | 11 | class TagManager: 12 | """Define the API manager object.""" 13 | 14 | def __init__(self, async_request: Callable[..., Awaitable]) -> 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_create(self, tag_name: str) -> dict[str, Any]: 25 | """Create a new tag. 26 | 27 | Args: 28 | ---- 29 | tag_name: The tag to create. 30 | 31 | Returns: 32 | ------- 33 | An API response payload. 34 | 35 | """ 36 | data = await self._async_request( 37 | "post", "/api/tags/", json={"example": tag_name} 38 | ) 39 | return cast(dict[str, Any], data) 40 | 41 | async def async_get_all( 42 | self, 43 | *, 44 | limit: int | None = None, 45 | offset: int | None = None, 46 | ) -> dict[str, Any]: 47 | """Return all tags. 48 | 49 | Args: 50 | ---- 51 | limit: Limit the number of returned tags. 52 | offset: The index at which to return results. 53 | 54 | Returns: 55 | ------- 56 | An API response payload. 57 | 58 | """ 59 | params = generate_api_payload( 60 | ( 61 | ("limit", limit), 62 | ("offset", offset), 63 | ) 64 | ) 65 | 66 | data = await self._async_request("get", "/api/tags/", params=params) 67 | return cast(dict[str, Any], data) 68 | 69 | async def async_get_single(self, tag_id: int) -> dict[str, Any]: 70 | """Return a single tag. 71 | 72 | Args: 73 | ---- 74 | tag_id: The ID of the tag to get. 75 | 76 | Returns: 77 | ------- 78 | An API response payload. 79 | 80 | """ 81 | data = await self._async_request("get", f"/api/tags/{tag_id}/") 82 | return cast(dict[str, Any], data) 83 | -------------------------------------------------------------------------------- /aiolinkding/user.py: -------------------------------------------------------------------------------- 1 | """Define an object to manage user-based API requests.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Awaitable, Callable 6 | from typing import Any, cast 7 | 8 | 9 | class UserManager: # pylint: disable=too-few-public-methods 10 | """Define the API manager object.""" 11 | 12 | def __init__(self, async_request: Callable[..., Awaitable]) -> None: 13 | """Initialize. 14 | 15 | Args: 16 | ---- 17 | async_request: The request method from the Client object. 18 | 19 | """ 20 | self._async_request = async_request 21 | 22 | async def async_get_profile(self) -> dict[str, Any]: 23 | """Return user profile info. 24 | 25 | Returns 26 | ------- 27 | An API response payload. 28 | 29 | """ 30 | data = await self._async_request("get", "/api/user/profile/") 31 | return cast(dict[str, Any], data) 32 | -------------------------------------------------------------------------------- /aiolinkding/util/__init__.py: -------------------------------------------------------------------------------- 1 | """Define utilities.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | 8 | def generate_api_payload(param_pairs: tuple) -> dict[str, Any]: 9 | """Generate an aiolinkding payload dict from parameters. 10 | 11 | Args: 12 | ---- 13 | param_pairs: A tuple of parameter key/values. 14 | 15 | Returns: 16 | ------- 17 | An API response payload. 18 | 19 | """ 20 | payload = {} 21 | 22 | for key, value in param_pairs: 23 | if value is None: 24 | continue 25 | payload[key] = value 26 | 27 | return payload 28 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | """Define library examples.""" 2 | -------------------------------------------------------------------------------- /examples/test_client.py: -------------------------------------------------------------------------------- 1 | """Run a base example.""" 2 | 3 | import asyncio 4 | import logging 5 | 6 | from aiohttp import ClientSession 7 | 8 | from aiolinkding import async_get_client 9 | from aiolinkding.errors import LinkDingError 10 | 11 | _LOGGER = logging.getLogger() 12 | 13 | URL = "" 14 | TOKEN = "" # noqa: S105 15 | 16 | 17 | async def main() -> None: 18 | """Create the aiohttp session and run the example.""" 19 | logging.basicConfig(level=logging.INFO) 20 | async with ClientSession() as session: 21 | try: 22 | client = await async_get_client(URL, TOKEN, session=session) 23 | 24 | bookmarks = await client.bookmarks.async_get_all() 25 | _LOGGER.info("Bookmarks: %s", bookmarks) 26 | 27 | archived_bookmarks = await client.bookmarks.async_get_archived() 28 | _LOGGER.info("Archived Bookmarks: %s", archived_bookmarks) 29 | 30 | single_bookmark = await client.bookmarks.async_get_single(1) 31 | _LOGGER.info("Bookmark ID: %s", single_bookmark) 32 | 33 | created_bookmark = await client.bookmarks.async_create( 34 | "https://example.com", 35 | title="Example title", 36 | description="Example description", 37 | tag_names=[ 38 | "tag1", 39 | "tag2", 40 | ], 41 | ) 42 | _LOGGER.info("Created Bookmark: %s", created_bookmark) 43 | 44 | tags = await client.tags.async_get_all() 45 | _LOGGER.info("tags: %s", tags) 46 | except LinkDingError: 47 | _LOGGER.exception("There was an error: %s") 48 | 49 | 50 | asyncio.run(main()) 51 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "poetry-core==2.0.1", 4 | ] 5 | build-backend = "poetry.core.masonry.api" 6 | 7 | [project] 8 | authors = [ 9 | {name = "Aaron Bach", email = "bachya1208@gmail.com"}, 10 | ] 11 | classifiers = [ 12 | "License :: OSI Approved :: Apache Software License", 13 | "Programming Language :: Python", 14 | "Programming Language :: Python :: 3", 15 | "Programming Language :: Python :: 3.11", 16 | "Programming Language :: Python :: 3.12", 17 | "Programming Language :: Python :: 3.13", 18 | "Programming Language :: Python :: Implementation :: CPython", 19 | "Programming Language :: Python :: Implementation :: PyPy", 20 | ] 21 | dependencies = [ 22 | "aiohttp>=3.9.0", 23 | "certifi>=2023.07.22", 24 | "frozenlist==1.5.0", 25 | "packaging>=23,<25", 26 | "yarl>=1.9.2", 27 | ] 28 | description = "A Python3 library for Elexa Guardian water valves and sensors" 29 | license = "MIT" 30 | name = "aiolinkding" 31 | readme = "README.md" 32 | repository = "https://github.com/bachya/aiolinkding" 33 | requires-python = ">=3.11,<3.14" 34 | version = "2025.02.0" 35 | 36 | [project.optional-dependencies] 37 | build = [ 38 | "uv==0.5.26", 39 | ] 40 | lint = [ 41 | "blacken-docs==1.19.1", 42 | "codespell==2.4.0", 43 | "darglint==1.8.1", 44 | "mypy==1.14.1", 45 | "pre-commit-hooks==5.0.0", 46 | "pre-commit==4.1.0", 47 | "pylint==3.3.3", 48 | "pytest-asyncio==0.25.2", 49 | "pytest==8.3.4", 50 | "ruff==0.9.3", 51 | "yamllint==1.28.0", 52 | ] 53 | test = [ 54 | "aresponses>=2.1.6", 55 | "pytest-aiohttp==1.0.0", 56 | "pytest-asyncio==0.25.2", 57 | "pytest-cov==6.0.0", 58 | "pytest==8.3.4", 59 | ] 60 | 61 | [tool.coverage.report] 62 | exclude_lines = ["raise NotImplementedError", "TYPE_CHECKING", "@overload"] 63 | fail_under = 100 64 | show_missing = true 65 | 66 | [tool.coverage.run] 67 | source = ["aiolinkding"] 68 | 69 | [tool.mypy] 70 | check_untyped_defs = true 71 | disallow_incomplete_defs = true 72 | disallow_subclassing_any = true 73 | disallow_untyped_calls = true 74 | disallow_untyped_decorators = true 75 | disallow_untyped_defs = true 76 | follow_imports = "silent" 77 | ignore_missing_imports = true 78 | no_implicit_optional = true 79 | platform = "linux" 80 | python_version = "3.11" 81 | show_error_codes = true 82 | strict_equality = true 83 | warn_incomplete_stub = true 84 | warn_redundant_casts = true 85 | warn_return_any = true 86 | warn_unreachable = true 87 | warn_unused_configs = true 88 | warn_unused_ignores = true 89 | 90 | [tool.pylint.BASIC] 91 | class-const-naming-style = "any" 92 | expected-line-ending-format = "LF" 93 | 94 | [tool.pylint.DESIGN] 95 | max-attributes = 20 96 | 97 | [tool.pylint.FORMAT] 98 | max-line-length = 88 99 | 100 | [tool.pylint.MAIN] 101 | py-version = "3.11" 102 | ignore = [ 103 | "tests", 104 | ] 105 | # Use a conservative default here; 2 should speed up most setups and not hurt 106 | # any too bad. Override on command line as appropriate. 107 | jobs = 2 108 | init-hook = """\ 109 | from pathlib import Path; \ 110 | import sys; \ 111 | 112 | from pylint.config import find_default_config_files; \ 113 | 114 | sys.path.append( \ 115 | str(Path(next(find_default_config_files())).parent.joinpath('pylint/plugins')) 116 | ) \ 117 | """ 118 | load-plugins = [ 119 | "pylint.extensions.code_style", 120 | "pylint.extensions.typing", 121 | ] 122 | persistent = false 123 | fail-on = [ 124 | "I", 125 | ] 126 | 127 | [tool.pylint."MESSAGES CONTROL"] 128 | disable = [ 129 | # These are subjective and should be left up to the developer: 130 | "abstract-method", 131 | "duplicate-code", 132 | "too-many-arguments", 133 | "too-many-lines", 134 | "too-many-locals", 135 | "too-many-positional-arguments", 136 | 137 | # Handled by ruff 138 | # Ref: 139 | "anomalous-backslash-in-string", # W605 140 | "assert-on-string-literal", # PLW0129 141 | "assert-on-tuple", # F631 142 | "await-outside-async", # PLE1142 143 | "bad-classmethod-argument", # N804 144 | "bad-format-string", # W1302, F 145 | "bad-format-string-key", # W1300, F 146 | "bad-str-strip-call", # PLE1310 147 | "bad-string-format-type", # PLE1307 148 | "bare-except", # E722 149 | "bidirectional-unicode", # PLE2502 150 | "binary-op-exception", # PLW0711 151 | "broad-except", # BLE001 152 | "broad-exception-raised", # TRY002 153 | "cell-var-from-loop", # B023 154 | "comparison-of-constants", # PLR0133 155 | "comparison-with-itself", # PLR0124 156 | "consider-alternative-union-syntax", # UP007 157 | "consider-iterating-dictionary", # SIM118 158 | "consider-merging-isinstance", # PLR1701 159 | "consider-using-alias", # UP006 160 | "consider-using-dict-comprehension", # C402 161 | "consider-using-f-string", # PLC0209 162 | "consider-using-generator", # C417 163 | "consider-using-get", # SIM401 164 | "consider-using-set-comprehension", # C401 165 | "consider-using-sys-exit", # PLR1722 166 | "consider-using-ternary", # SIM108 167 | "continue-in-finally", # PLE0116 168 | "duplicate-bases", # PLE0241 169 | "duplicate-except", # B014 170 | "duplicate-key", # F601 171 | "duplicate-string-formatting-argument", # F 172 | "duplicate-value", # F 173 | "empty-docstring", # D419 174 | "eval-used", # S307 175 | "exec-used", # S102 176 | "expression-not-assigned", # B018 177 | "f-string-without-interpolation", # F541 178 | "forgotten-debug-statement", # T100 179 | "format-needs-mapping", # F502 180 | "format-string-without-interpolation", # F 181 | "function-redefined", # F811 182 | "global-variable-not-assigned", # PLW0602 183 | "implicit-str-concat", # ISC001 184 | "import-self", # PLW0406 185 | "inconsistent-quotes", # Q000 186 | "invalid-all-object", # PLE0604 187 | "invalid-character-backspace", # PLE2510 188 | "invalid-character-esc", # PLE2513 189 | "invalid-character-nul", # PLE2514 190 | "invalid-character-sub", # PLE2512 191 | "invalid-character-zero-width-space", # PLE2515 192 | "invalid-envvar-default", # PLW1508 193 | "invalid-name", # N815 194 | "keyword-arg-before-vararg", # B026 195 | "line-too-long", # E501, disabled globally 196 | "literal-comparison", # F632 197 | "logging-format-interpolation", # G 198 | "logging-fstring-interpolation", # G 199 | "logging-not-lazy", # G 200 | "logging-too-few-args", # PLE1206 201 | "logging-too-many-args", # PLE1205 202 | "misplaced-bare-raise", # PLE0704 203 | "misplaced-future", # F404 204 | "missing-class-docstring", # D101 205 | "missing-final-newline", # W292 206 | "missing-format-string-key", # F524 207 | "missing-function-docstring", # D103 208 | "missing-module-docstring", # D100 209 | "mixed-format-string", # F506 210 | "multiple-imports", #E401 211 | "named-expr-without-context", # PLW0131 212 | "nested-min-max", # PLW3301 213 | "no-else-break", # RET508 214 | "no-else-continue", # RET507 215 | "no-else-raise", # RET506 216 | "no-else-return", # RET505 217 | "no-method-argument", # N805 218 | "no-self-argument", # N805 219 | "nonexistent-operator", # B002 220 | "nonlocal-without-binding", # PLE0117 221 | "not-in-loop", # F701, F702 222 | "notimplemented-raised", # F901 223 | "pointless-statement", # B018 224 | "property-with-parameters", # PLR0206 225 | "protected-access", # SLF001 226 | "raise-missing-from", # B904 227 | "redefined-builtin", # A001 228 | "redefined-slots-in-subclass", # W0244 229 | "return-in-init", # PLE0101 230 | "return-outside-function", # F706 231 | "singleton-comparison", # E711, E712 232 | "subprocess-run-check", # PLW1510 233 | "super-with-arguments", # UP008 234 | "superfluous-parens", # UP034 235 | "syntax-error", # E999 236 | "too-few-format-args", # F524 237 | "too-many-branches", # PLR0912 238 | "too-many-format-args", # F522 239 | "too-many-return-statements", # PLR0911 240 | "too-many-star-expressions", # F622 241 | "too-many-statements", # PLR0915 242 | "trailing-comma-tuple", # COM818 243 | "truncated-format-string", # F501 244 | "try-except-raise", # TRY302 245 | "undefined-all-variable", # F822 246 | "undefined-variable", # F821 247 | "ungrouped-imports", # I001 248 | "unidiomatic-typecheck", # E721 249 | "unnecessary-comprehension", # C416 250 | "unnecessary-direct-lambda-call", # PLC3002 251 | "unnecessary-lambda-assignment", # PLC3001 252 | "unnecessary-pass", # PIE790 253 | "unneeded-not", # SIM208 254 | "unused-argument", # ARG001, we don't use it 255 | "unused-format-string-argument", #F507 256 | "unused-format-string-key", # F504 257 | "unused-import", # F401 258 | "unused-variable", # F841 259 | "use-a-generator", # C417 260 | "use-dict-literal", # C406 261 | "use-list-literal", # C405 262 | "used-prior-global-declaration", # PLE0118 263 | "useless-else-on-loop", # PLW0120 264 | "useless-import-alias", # PLC0414 265 | "useless-object-inheritance", # UP004 266 | "useless-return", # PLR1711 267 | "wildcard-import", # F403 268 | "wrong-import-order", # I001 269 | "wrong-import-position", # E402 270 | "yield-inside-async-function", # PLE1700 271 | "yield-outside-function", # F704 272 | 273 | # Handled by mypy 274 | # Ref: 275 | "abstract-class-instantiated", 276 | "arguments-differ", 277 | "assigning-non-slot", 278 | "assignment-from-no-return", 279 | "assignment-from-none", 280 | "bad-exception-cause", 281 | "bad-format-character", 282 | "bad-reversed-sequence", 283 | "bad-super-call", 284 | "bad-thread-instantiation", 285 | "catching-non-exception", 286 | "comparison-with-callable", 287 | "deprecated-class", 288 | "dict-iter-missing-items", 289 | "format-combined-specification", 290 | "global-variable-undefined", 291 | "import-error", 292 | "inconsistent-mro", 293 | "inherit-non-class", 294 | "init-is-generator", 295 | "invalid-class-object", 296 | "invalid-enum-extension", 297 | "invalid-envvar-value", 298 | "invalid-format-returned", 299 | "invalid-hash-returned", 300 | "invalid-metaclass", 301 | "invalid-overridden-method", 302 | "invalid-repr-returned", 303 | "invalid-sequence-index", 304 | "invalid-slice-index", 305 | "invalid-slots", 306 | "invalid-slots-object", 307 | "invalid-star-assignment-target", 308 | "invalid-str-returned", 309 | "invalid-unary-operand-type", 310 | "invalid-unicode-codec", 311 | "isinstance-second-argument-not-valid-type", 312 | "method-hidden", 313 | "misplaced-format-function", 314 | "missing-format-argument-key", 315 | "missing-format-attribute", 316 | "missing-kwoa", 317 | "no-member", 318 | "no-value-for-parameter", 319 | "non-iterator-returned", 320 | "non-str-assignment-to-dunder-name", 321 | "nonlocal-and-global", 322 | "not-a-mapping", 323 | "not-an-iterable", 324 | "not-async-context-manager", 325 | "not-callable", 326 | "not-context-manager", 327 | "overridden-final-method", 328 | "raising-bad-type", 329 | "raising-non-exception", 330 | "redundant-keyword-arg", 331 | "relative-beyond-top-level", 332 | "self-cls-assignment", 333 | "signature-differs", 334 | "star-needs-assignment-target", 335 | "subclassed-final-class", 336 | "super-without-brackets", 337 | "too-many-function-args", 338 | "typevar-double-variance", 339 | "typevar-name-mismatch", 340 | "unbalanced-dict-unpacking", 341 | "unbalanced-tuple-unpacking", 342 | "unexpected-keyword-arg", 343 | "unhashable-member", 344 | "unpacking-non-sequence", 345 | "unsubscriptable-object", 346 | "unsupported-assignment-operation", 347 | "unsupported-binary-operation", 348 | "unsupported-delete-operation", 349 | "unsupported-membership-test", 350 | "used-before-assignment", 351 | "using-final-decorator-in-unsupported-version", 352 | "wrong-exception-operation", 353 | ] 354 | enable = [ 355 | "useless-suppression", 356 | "use-symbolic-message-instead", 357 | ] 358 | 359 | [tool.pylint.TYPING] 360 | runtime-typing = false 361 | 362 | [tool.pylint.CODE_STYLE] 363 | max-line-length-suggestions = 72 364 | 365 | [tool.ruff] 366 | target-version = "py311" 367 | 368 | [tool.ruff.lint] 369 | select = [ 370 | "ALL" 371 | ] 372 | 373 | ignore = [ 374 | "A005", # Shadowing a Python standard-library module 375 | "D202", # No blank lines allowed after function docstring 376 | "D203", # 1 blank line required before class docstring 377 | "D213", # Multi-line docstring summary should start at the second line 378 | "PLR0913", # This is subjective 379 | "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target 380 | "PT012", # `pytest.raises()` block should contain a single simple statement 381 | "TCH", # flake8-type-checking 382 | 383 | # May conflict with the formatter: 384 | # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 385 | "COM812", 386 | "COM819", 387 | "D206", 388 | "D300", 389 | "E111", 390 | "E114", 391 | "E117", 392 | "ISC001", 393 | "ISC002", 394 | "Q000", 395 | "Q001", 396 | "Q002", 397 | "Q003", 398 | "W191", 399 | ] 400 | 401 | [tool.ruff.lint.isort] 402 | force-sort-within-sections = true 403 | known-first-party = [ 404 | "aiolinkding", 405 | "examples", 406 | "tests", 407 | ] 408 | combine-as-imports = true 409 | split-on-trailing-comma = false 410 | 411 | [tool.ruff.lint.per-file-ignores] 412 | "tests/*" = [ 413 | "ARG001", # Tests ofen have unused arguments 414 | "FBT001", # Test fixtures may be boolean values 415 | "PLR2004", # Checking for magic values in tests isn't helpful 416 | "S101", # Assertions are fine in tests 417 | "SLF001", # We'll access a lot of private third-party members in tests 418 | ] 419 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended", ":semanticCommitTypeAll(chore)"], 4 | 5 | "dependencyDashboard": true, 6 | "internalChecksFilter": "strict", 7 | "labels": ["dependencies"], 8 | "minimumReleaseAge": "3 days", 9 | "prConcurrentLimit": 3, 10 | "prCreation": "immediate", 11 | "prHourlyLimit": 3, 12 | "rangeStrategy": "pin", 13 | "rebaseWhen": "behind-base-branch", 14 | "semanticCommits": "enabled" 15 | } 16 | -------------------------------------------------------------------------------- /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 | function generate_version { 12 | latest_tag="$(git tag --sort=committerdate | tail -1)" 13 | month="$(date +'%Y.%m')" 14 | 15 | if [[ "$latest_tag" =~ "$month".* ]]; then 16 | patch="$(echo "$latest_tag" | cut -d . -f 3)" 17 | ((patch=patch+1)) 18 | echo "$month.$patch" 19 | else 20 | echo "$month.0" 21 | fi 22 | } 23 | 24 | # Temporarily uninstall pre-commit hooks so that we can push to dev and main: 25 | pre-commit uninstall 26 | 27 | # Pull the latest dev: 28 | git pull origin dev 29 | 30 | # Generate the next version (in the format YEAR.MONTH.RELEASE_NUMER): 31 | new_version=$(generate_version) 32 | 33 | # Update the PyPI package version: 34 | sed -i "" "s/^version = \".*\"/version = \"$new_version\"/g" "$REPO_PATH/pyproject.toml" 35 | git add pyproject.toml 36 | 37 | # Update the uv lockfile: 38 | uv lock 39 | git add uv.lock 40 | 41 | # Commit, tag, and push: 42 | git commit -m "Bump version to $new_version" 43 | git tag "$new_version" 44 | git push && git push --tags 45 | 46 | # Merge dev into main: 47 | git checkout main 48 | git merge dev 49 | git push 50 | git checkout dev 51 | 52 | # Re-initialize pre-commit: 53 | pre-commit install 54 | -------------------------------------------------------------------------------- /script/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -Eeuo pipefail 3 | trap cleanup SIGINT SIGTERM ERR EXIT 4 | 5 | SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P) 6 | REPO_DIR=$(dirname "$SCRIPT_DIR") 7 | 8 | usage() { 9 | cat <&2 -e "${1-}" 27 | } 28 | 29 | fail() { 30 | local msg=$1 31 | local code=${2-1} 32 | msg "${RED}$msg${NOFORMAT}" 33 | printf "\n" 34 | usage 35 | exit "$code" 36 | } 37 | 38 | parse_params() { 39 | args=() 40 | while [[ $# -gt 0 ]]; do 41 | case "$1" in 42 | -h | --help) usage && exit 0 ;; 43 | -v | --verbose) set -x ;; 44 | -?*) fail "Unknown option: $1" ;; 45 | *) args+=("$1") ;; 46 | esac 47 | shift 48 | done 49 | return 0 50 | } 51 | 52 | setup_colors() { 53 | if [[ -t 2 ]] && [[ -z "${NO_COLOR-}" ]] && [[ "${TERM-}" != "dumb" ]]; then 54 | NOFORMAT="\033[0m" RED="\033[0;31m" BLUE="\033[0;34m" GREEN='\033[0;32m' 55 | else 56 | NOFORMAT="" RED="" BLUE="" GREEN="" 57 | fi 58 | } 59 | 60 | validate_dependencies_exist() { 61 | local dependencies=( 62 | "pre-commit" 63 | "python" 64 | ) 65 | 66 | for dependency in "${dependencies[@]}"; do 67 | if ! command -v "$dependency" &>/dev/null; then 68 | fail "Missing dependency: $dependency" 69 | fi 70 | done 71 | } 72 | 73 | main() { 74 | setup_colors 75 | parse_params "$@" 76 | 77 | if command -v "mise"; then 78 | msg "${BLUE}🔍 mise detected; configuring runtimes...${NOFORMAT}" 79 | mise install -y 80 | fi 81 | 82 | # Check if we're running in Python a virtual environment (creating one if not): 83 | if [[ -z "${VIRTUAL_ENV-}" ]]; then 84 | msg "${BLUE}🚜 Creating Python virtual environment...${NOFORMAT}" 85 | python -m venv "$REPO_DIR/.venv" 86 | # shellcheck disable=SC1091 87 | source "$REPO_DIR/.venv/bin/activate" 88 | fi 89 | 90 | msg "${BLUE}🚜 Installing dependencies ...${NOFORMAT}" 91 | if ! command -v "uv" &>/dev/null; then 92 | if ! command -v "pip" &>/dev/null; then 93 | python -m ensurepip 94 | fi 95 | uv_version="$(grep "uv==" "$REPO_DIR/pyproject.toml" | awk -F'==' '{print $2}' | tr -d '",')" 96 | python -m pip install uv=="$uv_version" 97 | fi 98 | uv sync --all-extras 99 | 100 | msg "${BLUE}🚜 Installing pre-commit hooks...${NOFORMAT}" 101 | pre-commit install 102 | 103 | # At this stage, we should have all of our dependencies installed; confirm that: 104 | validate_dependencies_exist 105 | 106 | msg "${GREEN}✅ Setup complete!${NOFORMAT}" 107 | } 108 | 109 | main "$@" 110 | -------------------------------------------------------------------------------- /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_TOKEN = "abcde12345" # noqa: S105 6 | TEST_URL = "http://127.0.0.1:8000" 7 | 8 | 9 | def load_fixture(filename: str) -> str: 10 | """Load a fixture. 11 | 12 | Args: 13 | ---- 14 | filename: The filename of the fixtures/ file to load. 15 | 16 | Returns: 17 | ------- 18 | A string containing the contents of the file. 19 | 20 | """ 21 | path = Path(f"{Path(__file__).parent}/fixtures/{filename}") 22 | with Path.open(path, encoding="utf-8") as fptr: 23 | return fptr.read() 24 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Define dynamic fixtures.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | from typing import Any, cast 7 | 8 | import aiohttp 9 | from aresponses import ResponsesMockServer 10 | import pytest 11 | 12 | from aiolinkding.client import SERVER_VERSION_MINIMUM_REQUIRED 13 | from tests.common import load_fixture 14 | 15 | 16 | @pytest.fixture(name="authenticated_linkding_api_server") 17 | def authenticated_linkding_api_server_fixture( 18 | health_response: dict[str, Any], 19 | ) -> ResponsesMockServer: 20 | """Return a fixture that mocks an authenticated linkding API server. 21 | 22 | Args: 23 | ---- 24 | health_response: An API response payload 25 | 26 | """ 27 | server = ResponsesMockServer() 28 | server.add( 29 | "127.0.0.1:8000", 30 | "/health", 31 | "get", 32 | response=aiohttp.web_response.json_response(health_response, status=200), 33 | ) 34 | return server 35 | 36 | 37 | @pytest.fixture(name="bookmarks_async_get_all_response", scope="session") 38 | def bookmarks_async_get_all_response_fixture() -> dict[str, Any]: 39 | """Define a fixture to return all bookmarks.""" 40 | return cast( 41 | dict[str, Any], 42 | json.loads(load_fixture("bookmarks_async_get_all_response.json")), 43 | ) 44 | 45 | 46 | @pytest.fixture(name="bookmarks_async_get_archived_response", scope="session") 47 | def bookmarks_async_get_archived_response_fixture() -> dict[str, Any]: 48 | """Define a fixture to return all archived bookmarks.""" 49 | return cast( 50 | dict[str, Any], 51 | json.loads(load_fixture("bookmarks_async_get_archived_response.json")), 52 | ) 53 | 54 | 55 | @pytest.fixture(name="bookmarks_async_get_single_response", scope="session") 56 | def bookmarks_async_get_single_response_fixture() -> dict[str, Any]: 57 | """Define a fixture to return a single bookmark.""" 58 | return cast( 59 | dict[str, Any], 60 | json.loads(load_fixture("bookmarks_async_get_single_response.json")), 61 | ) 62 | 63 | 64 | @pytest.fixture(name="health_response") 65 | def health_response_fixture() -> dict[str, Any]: 66 | """Define a fixture to return a healthy health response.""" 67 | return { 68 | "version": str(SERVER_VERSION_MINIMUM_REQUIRED), 69 | "status": "healthy", 70 | } 71 | 72 | 73 | @pytest.fixture(name="invalid_token_response", scope="session") 74 | def invalid_token_response_fixture() -> dict[str, Any]: 75 | """Define a fixture to return an invalid token response.""" 76 | return cast(dict[str, Any], json.loads(load_fixture("invalid_token_response.json"))) 77 | 78 | 79 | @pytest.fixture(name="missing_field_response", scope="session") 80 | def missing_field_response_fixture() -> dict[str, Any]: 81 | """Define a fixture to return a missing field response.""" 82 | return cast(dict[str, Any], json.loads(load_fixture("missing_field_response.json"))) 83 | 84 | 85 | @pytest.fixture(name="tags_async_get_all_response", scope="session") 86 | def tags_async_get_all_response_fixture() -> dict[str, Any]: 87 | """Define a fixture to return all tags.""" 88 | return cast( 89 | dict[str, Any], json.loads(load_fixture("tags_async_get_all_response.json")) 90 | ) 91 | 92 | 93 | @pytest.fixture(name="tags_async_get_single_response", scope="session") 94 | def tags_async_get_single_response_fixture() -> dict[str, Any]: 95 | """Define a fixture to return a single tag.""" 96 | return cast( 97 | dict[str, Any], json.loads(load_fixture("tags_async_get_single_response.json")) 98 | ) 99 | 100 | 101 | @pytest.fixture(name="user_async_get_profile_response", scope="session") 102 | def user_async_get_profile_response_fixture() -> dict[str, Any]: 103 | """Define a fixture to return user profile data.""" 104 | return cast( 105 | dict[str, Any], 106 | json.loads(load_fixture("user_async_get_profile_response.json")), 107 | ) 108 | -------------------------------------------------------------------------------- /tests/fixtures/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachya/aiolinkding/3f7f28ce9c1ef727d95223670808df3cdfecf210/tests/fixtures/.gitignore -------------------------------------------------------------------------------- /tests/fixtures/bookmarks_async_get_all_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 123, 3 | "next": "http://127.0.0.1:8000/api/bookmarks/?limit=100&offset=100", 4 | "previous": null, 5 | "results": [ 6 | { 7 | "id": 1, 8 | "url": "https://example.com", 9 | "title": "Example title", 10 | "description": "Example description", 11 | "website_title": "Website title", 12 | "website_description": "Website description", 13 | "is_archived": false, 14 | "unread": false, 15 | "shared": false, 16 | "tag_names": ["tag1", "tag2"], 17 | "date_added": "2020-09-26T09:46:23.006313Z", 18 | "date_modified": "2020-09-26T16:01:14.275335Z" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /tests/fixtures/bookmarks_async_get_archived_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 123, 3 | "next": "http://127.0.0.1:8000/api/bookmarks/?limit=100&offset=100", 4 | "previous": null, 5 | "results": [ 6 | { 7 | "id": 1, 8 | "url": "https://example.com", 9 | "title": "Example title", 10 | "description": "Example description", 11 | "website_title": "Website title", 12 | "website_description": "Website description", 13 | "is_archived": true, 14 | "unread": false, 15 | "shared": false, 16 | "tag_names": ["tag1", "tag2"], 17 | "date_added": "2020-09-26T09:46:23.006313Z", 18 | "date_modified": "2020-09-26T16:01:14.275335Z" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /tests/fixtures/bookmarks_async_get_single_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "url": "https://example.com", 4 | "title": "Example title", 5 | "description": "Example description", 6 | "website_title": "Website title", 7 | "website_description": "Website description", 8 | "is_archived": false, 9 | "unread": false, 10 | "shared": false, 11 | "tag_names": ["tag1", "tag2"], 12 | "date_added": "2020-09-26T09:46:23.006313Z", 13 | "date_modified": "2020-09-26T16:01:14.275335Z" 14 | } 15 | -------------------------------------------------------------------------------- /tests/fixtures/invalid_token_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "detail": "Invalid token." 3 | } 4 | -------------------------------------------------------------------------------- /tests/fixtures/missing_field_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": ["This field is required."] 3 | } 4 | -------------------------------------------------------------------------------- /tests/fixtures/tags_async_get_all_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 123, 3 | "next": "http://127.0.0.1:8000/api/tags/?limit=100&offset=100", 4 | "previous": null, 5 | "results": [ 6 | { 7 | "id": 1, 8 | "name": "example", 9 | "date_added": "2020-09-26T09:46:23.006313Z" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /tests/fixtures/tags_async_get_single_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "name": "example-tag", 4 | "date_added": "2022-05-14T02:06:20.627370Z" 5 | } 6 | -------------------------------------------------------------------------------- /tests/fixtures/user_async_get_profile_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme": "auto", 3 | "bookmark_date_display": "relative", 4 | "bookmark_link_target": "_blank", 5 | "web_archive_integration": "enabled", 6 | "tag_search": "lax", 7 | "enable_sharing": true, 8 | "enable_public_sharing": true, 9 | "enable_favicons": false, 10 | "display_url": false, 11 | "permanent_notes": false, 12 | "search_preferences": { 13 | "sort": "title_asc", 14 | "shared": "off", 15 | "unread": "off" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/test_bookmarks.py: -------------------------------------------------------------------------------- 1 | """Define tests for bookmark 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 aiolinkding import async_get_client 12 | 13 | from .common import TEST_TOKEN, TEST_URL 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_archive( 18 | aresponses: ResponsesMockServer, 19 | authenticated_linkding_api_server: ResponsesMockServer, 20 | ) -> None: 21 | """Test archiving a bookmark. 22 | 23 | Args: 24 | ---- 25 | aresponses: An aresponses server. 26 | authenticated_linkding_api_server: A mock authenticated linkding API server. 27 | 28 | """ 29 | async with authenticated_linkding_api_server: 30 | authenticated_linkding_api_server.add( 31 | "127.0.0.1:8000", 32 | "/api/bookmarks/1/archive/", 33 | "post", 34 | response=aresponses.Response(status=204), 35 | ) 36 | 37 | async with aiohttp.ClientSession() as session: 38 | client = await async_get_client(TEST_URL, TEST_TOKEN, session=session) 39 | await client.bookmarks.async_archive(1) 40 | 41 | aresponses.assert_plan_strictly_followed() 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_create( 46 | aresponses: ResponsesMockServer, 47 | authenticated_linkding_api_server: ResponsesMockServer, 48 | bookmarks_async_get_single_response: dict[str, Any], 49 | ) -> None: 50 | """Test creating a single bookmark. 51 | 52 | Args: 53 | ---- 54 | aresponses: An aresponses server. 55 | authenticated_linkding_api_server: A mock authenticated linkding API server. 56 | bookmarks_async_get_single_response: An API response payload. 57 | 58 | """ 59 | async with authenticated_linkding_api_server: 60 | authenticated_linkding_api_server.add( 61 | "127.0.0.1:8000", 62 | "/api/bookmarks/", 63 | "post", 64 | response=aiohttp.web_response.json_response( 65 | bookmarks_async_get_single_response, status=201 66 | ), 67 | ) 68 | 69 | async with aiohttp.ClientSession() as session: 70 | client = await async_get_client(TEST_URL, TEST_TOKEN, session=session) 71 | created_bookmark = await client.bookmarks.async_create( 72 | "https://example.com", 73 | title="Example title", 74 | description="Example description", 75 | tag_names=[ 76 | "tag1", 77 | "tag2", 78 | ], 79 | ) 80 | assert created_bookmark == bookmarks_async_get_single_response 81 | 82 | aresponses.assert_plan_strictly_followed() 83 | 84 | 85 | @pytest.mark.asyncio 86 | async def test_delete( 87 | aresponses: ResponsesMockServer, 88 | authenticated_linkding_api_server: ResponsesMockServer, 89 | ) -> None: 90 | """Test deleting a bookmark. 91 | 92 | Args: 93 | ---- 94 | aresponses: An aresponses server. 95 | authenticated_linkding_api_server: A mock authenticated linkding API server. 96 | 97 | """ 98 | async with authenticated_linkding_api_server: 99 | authenticated_linkding_api_server.add( 100 | "127.0.0.1:8000", 101 | "/api/bookmarks/1/", 102 | "delete", 103 | response=aresponses.Response(status=204), 104 | ) 105 | 106 | async with aiohttp.ClientSession() as session: 107 | client = await async_get_client(TEST_URL, TEST_TOKEN, session=session) 108 | await client.bookmarks.async_delete(1) 109 | 110 | aresponses.assert_plan_strictly_followed() 111 | 112 | 113 | @pytest.mark.asyncio 114 | async def test_get_all( 115 | aresponses: ResponsesMockServer, 116 | authenticated_linkding_api_server: ResponsesMockServer, 117 | bookmarks_async_get_all_response: dict[str, Any], 118 | ) -> None: 119 | """Test getting all bookmarks. 120 | 121 | Args: 122 | ---- 123 | aresponses: An aresponses server. 124 | authenticated_linkding_api_server: A mock authenticated linkding API server. 125 | bookmarks_async_get_all_response: An API response payload. 126 | 127 | """ 128 | async with authenticated_linkding_api_server: 129 | authenticated_linkding_api_server.add( 130 | "127.0.0.1:8000", 131 | "/api/bookmarks/?limit=100", 132 | "get", 133 | response=aiohttp.web_response.json_response( 134 | bookmarks_async_get_all_response, status=200 135 | ), 136 | match_querystring=True, 137 | ) 138 | 139 | async with aiohttp.ClientSession() as session: 140 | client = await async_get_client(TEST_URL, TEST_TOKEN, session=session) 141 | # Include limit to exercise the inclusion of request parameters: 142 | bookmarks = await client.bookmarks.async_get_all(limit=100) 143 | assert bookmarks == bookmarks_async_get_all_response 144 | 145 | aresponses.assert_plan_strictly_followed() 146 | 147 | 148 | @pytest.mark.asyncio 149 | async def test_get_all_no_explicit_session( 150 | aresponses: ResponsesMockServer, 151 | authenticated_linkding_api_server: ResponsesMockServer, 152 | bookmarks_async_get_all_response: dict[str, Any], 153 | ) -> None: 154 | """Test getting all bookmarks without an explicit ClientSession. 155 | 156 | Args: 157 | ---- 158 | aresponses: An aresponses server. 159 | authenticated_linkding_api_server: A mock authenticated linkding API server. 160 | bookmarks_async_get_all_response: An API response payload. 161 | 162 | """ 163 | async with authenticated_linkding_api_server: 164 | authenticated_linkding_api_server.add( 165 | "127.0.0.1:8000", 166 | "/api/bookmarks/", 167 | "get", 168 | response=aiohttp.web_response.json_response( 169 | bookmarks_async_get_all_response, status=200 170 | ), 171 | ) 172 | 173 | client = await async_get_client(TEST_URL, TEST_TOKEN) 174 | assert client._session is None # pylint: disable=protected-access 175 | 176 | bookmarks = await client.bookmarks.async_get_all() 177 | assert bookmarks == bookmarks_async_get_all_response 178 | 179 | aresponses.assert_plan_strictly_followed() 180 | 181 | 182 | @pytest.mark.asyncio 183 | async def test_get_archived( 184 | aresponses: ResponsesMockServer, 185 | authenticated_linkding_api_server: ResponsesMockServer, 186 | bookmarks_async_get_archived_response: dict[str, Any], 187 | ) -> None: 188 | """Test getting archived bookmarks. 189 | 190 | Args: 191 | ---- 192 | aresponses: An aresponses server. 193 | authenticated_linkding_api_server: A mock authenticated linkding API server. 194 | bookmarks_async_get_archived_response: An API response payload. 195 | 196 | """ 197 | async with authenticated_linkding_api_server: 198 | authenticated_linkding_api_server.add( 199 | "127.0.0.1:8000", 200 | "/api/bookmarks/archived/", 201 | "get", 202 | response=aiohttp.web_response.json_response( 203 | bookmarks_async_get_archived_response, status=200 204 | ), 205 | ) 206 | 207 | async with aiohttp.ClientSession() as session: 208 | client = await async_get_client(TEST_URL, TEST_TOKEN, session=session) 209 | archived_bookmarks = await client.bookmarks.async_get_archived() 210 | assert archived_bookmarks == bookmarks_async_get_archived_response 211 | 212 | aresponses.assert_plan_strictly_followed() 213 | 214 | 215 | @pytest.mark.asyncio 216 | async def test_get_single( 217 | aresponses: ResponsesMockServer, 218 | authenticated_linkding_api_server: ResponsesMockServer, 219 | bookmarks_async_get_single_response: dict[str, Any], 220 | ) -> None: 221 | """Test getting a single bookmark. 222 | 223 | Args: 224 | ---- 225 | aresponses: An aresponses server. 226 | authenticated_linkding_api_server: A mock authenticated linkding API server. 227 | bookmarks_async_get_single_response: An API response payload. 228 | 229 | """ 230 | async with authenticated_linkding_api_server: 231 | authenticated_linkding_api_server.add( 232 | "127.0.0.1:8000", 233 | "/api/bookmarks/1/", 234 | "get", 235 | response=aiohttp.web_response.json_response( 236 | bookmarks_async_get_single_response, status=200 237 | ), 238 | ) 239 | 240 | async with aiohttp.ClientSession() as session: 241 | client = await async_get_client(TEST_URL, TEST_TOKEN, session=session) 242 | single_bookmark = await client.bookmarks.async_get_single(1) 243 | assert single_bookmark == bookmarks_async_get_single_response 244 | 245 | aresponses.assert_plan_strictly_followed() 246 | 247 | 248 | @pytest.mark.asyncio 249 | async def test_unarchive( 250 | aresponses: ResponsesMockServer, 251 | authenticated_linkding_api_server: ResponsesMockServer, 252 | ) -> None: 253 | """Test unarchiving a bookmark. 254 | 255 | Args: 256 | ---- 257 | aresponses: An aresponses server. 258 | authenticated_linkding_api_server: A mock authenticated linkding API server. 259 | 260 | """ 261 | async with authenticated_linkding_api_server: 262 | authenticated_linkding_api_server.add( 263 | "127.0.0.1:8000", 264 | "/api/bookmarks/1/unarchive/", 265 | "post", 266 | response=aresponses.Response(status=204), 267 | ) 268 | 269 | async with aiohttp.ClientSession() as session: 270 | client = await async_get_client(TEST_URL, TEST_TOKEN, session=session) 271 | await client.bookmarks.async_unarchive(1) 272 | 273 | aresponses.assert_plan_strictly_followed() 274 | 275 | 276 | @pytest.mark.asyncio 277 | async def test_update( 278 | aresponses: ResponsesMockServer, 279 | authenticated_linkding_api_server: ResponsesMockServer, 280 | bookmarks_async_get_single_response: dict[str, Any], 281 | ) -> None: 282 | """Test creating a single bookmark. 283 | 284 | Args: 285 | ---- 286 | aresponses: An aresponses server. 287 | authenticated_linkding_api_server: A mock authenticated linkding API server. 288 | bookmarks_async_get_single_response: An API response payload. 289 | 290 | """ 291 | async with authenticated_linkding_api_server: 292 | authenticated_linkding_api_server.add( 293 | "127.0.0.1:8000", 294 | "/api/bookmarks/1/", 295 | "patch", 296 | response=aiohttp.web_response.json_response( 297 | bookmarks_async_get_single_response, status=200 298 | ), 299 | ) 300 | 301 | async with aiohttp.ClientSession() as session: 302 | client = await async_get_client(TEST_URL, TEST_TOKEN, session=session) 303 | updated_bookmark = await client.bookmarks.async_update( 304 | 1, 305 | url="https://example.com", 306 | title="Example title", 307 | description="Example description", 308 | tag_names=[ 309 | "tag1", 310 | "tag2", 311 | ], 312 | ) 313 | assert updated_bookmark == bookmarks_async_get_single_response 314 | 315 | aresponses.assert_plan_strictly_followed() 316 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | """Define tests for the client.""" 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 aiolinkding import async_get_client 12 | from aiolinkding.client import ( 13 | SERVER_VERSION_HEALTH_CHECK_INTRODUCED, 14 | SERVER_VERSION_MINIMUM_REQUIRED, 15 | ) 16 | from aiolinkding.errors import ( 17 | InvalidServerVersionError, 18 | InvalidTokenError, 19 | RequestError, 20 | ) 21 | 22 | from .common import TEST_TOKEN, TEST_URL 23 | 24 | 25 | @pytest.mark.asyncio 26 | async def test_health_endpiont_missing( 27 | aresponses: ResponsesMockServer, 28 | ) -> None: 29 | """Test an API call when /health missing. 30 | 31 | Args: 32 | ---- 33 | aresponses: An aresponses server. 34 | 35 | """ 36 | aresponses.add( 37 | "127.0.0.1:8000", 38 | "/health", 39 | "get", 40 | response=aresponses.Response( 41 | text=None, status=404, headers={"Content-Type": "text/html; charset=utf-8"} 42 | ), 43 | ) 44 | 45 | async with aiohttp.ClientSession() as session: 46 | with pytest.raises(InvalidServerVersionError) as err: 47 | _ = await async_get_client(TEST_URL, TEST_TOKEN, session=session) 48 | assert str(err.value) == ( 49 | f"Server version (older than {SERVER_VERSION_HEALTH_CHECK_INTRODUCED}) is " 50 | f"below the minimum version required ({SERVER_VERSION_MINIMUM_REQUIRED})" 51 | ) 52 | 53 | aresponses.assert_plan_strictly_followed() 54 | 55 | 56 | @pytest.mark.asyncio 57 | @pytest.mark.parametrize( 58 | "health_response", 59 | [ 60 | { 61 | "version": "1.0.0", 62 | "status": "healthy", 63 | } 64 | ], 65 | ) 66 | async def test_invalid_server_version( 67 | aresponses: ResponsesMockServer, 68 | health_response: dict[str, Any], 69 | ) -> None: 70 | """Test an API call with an invalid server version. 71 | 72 | Args: 73 | ---- 74 | aresponses: An aresponses server. 75 | health_response: An API response payload. 76 | 77 | """ 78 | aresponses.add( 79 | "127.0.0.1:8000", 80 | "/health", 81 | "get", 82 | response=aiohttp.web_response.json_response(health_response, status=200), 83 | ) 84 | 85 | async with aiohttp.ClientSession() as session: 86 | with pytest.raises(InvalidServerVersionError) as err: 87 | _ = await async_get_client(TEST_URL, TEST_TOKEN, session=session) 88 | assert str(err.value) == ( 89 | f"Server version (1.0.0) is below the minimum version required " 90 | f"({SERVER_VERSION_MINIMUM_REQUIRED})" 91 | ) 92 | 93 | aresponses.assert_plan_strictly_followed() 94 | 95 | 96 | @pytest.mark.asyncio 97 | async def test_invalid_token( 98 | aresponses: ResponsesMockServer, 99 | authenticated_linkding_api_server: ResponsesMockServer, 100 | invalid_token_response: dict[str, Any], 101 | ) -> None: 102 | """Test an API call with an invalid token. 103 | 104 | Args: 105 | ---- 106 | aresponses: An aresponses server. 107 | authenticated_linkding_api_server: A mock authenticated linkding API server. 108 | invalid_token_response: An API response payload. 109 | 110 | """ 111 | async with authenticated_linkding_api_server: 112 | authenticated_linkding_api_server.add( 113 | "127.0.0.1:8000", 114 | "/api/whatever/", 115 | "get", 116 | response=aiohttp.web_response.json_response( 117 | invalid_token_response, status=401 118 | ), 119 | ) 120 | 121 | async with aiohttp.ClientSession() as session: 122 | with pytest.raises(InvalidTokenError): 123 | client = await async_get_client(TEST_URL, TEST_TOKEN, session=session) 124 | await client.async_request("get", "/api/whatever/") 125 | 126 | aresponses.assert_plan_strictly_followed() 127 | 128 | 129 | @pytest.mark.asyncio 130 | async def test_request_error( 131 | aresponses: ResponsesMockServer, 132 | authenticated_linkding_api_server: ResponsesMockServer, 133 | missing_field_response: dict[str, Any], 134 | ) -> None: 135 | """Test an API call with a general request error. 136 | 137 | Args: 138 | ---- 139 | aresponses: An aresponses server. 140 | authenticated_linkding_api_server: A mock authenticated linkding API server. 141 | missing_field_response: An API response payload. 142 | 143 | """ 144 | async with authenticated_linkding_api_server: 145 | authenticated_linkding_api_server.add( 146 | "127.0.0.1:8000", 147 | "/api/whatever/", 148 | "get", 149 | response=aiohttp.web_response.json_response( 150 | missing_field_response, status=400 151 | ), 152 | ) 153 | 154 | async with aiohttp.ClientSession() as session: 155 | with pytest.raises(RequestError) as err: 156 | client = await async_get_client(TEST_URL, TEST_TOKEN, session=session) 157 | await client.async_request("get", "/api/whatever/") 158 | assert "This field is required" in str(err) 159 | 160 | aresponses.assert_plan_strictly_followed() 161 | -------------------------------------------------------------------------------- /tests/test_tags.py: -------------------------------------------------------------------------------- 1 | """Define tests for tag 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 aiolinkding import async_get_client 12 | 13 | from .common import TEST_TOKEN, TEST_URL 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_create( 18 | aresponses: ResponsesMockServer, 19 | authenticated_linkding_api_server: ResponsesMockServer, 20 | tags_async_get_single_response: dict[str, Any], 21 | ) -> None: 22 | """Test creating a single tag. 23 | 24 | Args: 25 | ---- 26 | aresponses: An aresponses server. 27 | authenticated_linkding_api_server: A mock authenticated linkding API server. 28 | tags_async_get_single_response: An API response payload. 29 | 30 | """ 31 | async with authenticated_linkding_api_server: 32 | authenticated_linkding_api_server.add( 33 | "127.0.0.1:8000", 34 | "/api/tags/", 35 | "post", 36 | response=aiohttp.web_response.json_response( 37 | tags_async_get_single_response, status=201 38 | ), 39 | ) 40 | 41 | async with aiohttp.ClientSession() as session: 42 | client = await async_get_client(TEST_URL, TEST_TOKEN, session=session) 43 | created_bookmark = await client.tags.async_create("example-tag") 44 | assert created_bookmark == tags_async_get_single_response 45 | 46 | aresponses.assert_plan_strictly_followed() 47 | 48 | 49 | @pytest.mark.asyncio 50 | async def test_get_all( 51 | aresponses: ResponsesMockServer, 52 | authenticated_linkding_api_server: ResponsesMockServer, 53 | tags_async_get_all_response: dict[str, Any], 54 | ) -> None: 55 | """Test getting all tags. 56 | 57 | Args: 58 | ---- 59 | aresponses: An aresponses server. 60 | authenticated_linkding_api_server: A mock authenticated linkding API server. 61 | tags_async_get_all_response: An API response payload. 62 | 63 | """ 64 | async with authenticated_linkding_api_server: 65 | authenticated_linkding_api_server.add( 66 | "127.0.0.1:8000", 67 | "/api/tags/?limit=100", 68 | "get", 69 | response=aiohttp.web_response.json_response( 70 | tags_async_get_all_response, status=200 71 | ), 72 | match_querystring=True, 73 | ) 74 | 75 | async with aiohttp.ClientSession() as session: 76 | client = await async_get_client(TEST_URL, TEST_TOKEN, session=session) 77 | # Include limit to exercise the inclusion of request parameters: 78 | tags = await client.tags.async_get_all(limit=100) 79 | assert tags == tags_async_get_all_response 80 | 81 | aresponses.assert_plan_strictly_followed() 82 | 83 | 84 | @pytest.mark.asyncio 85 | async def test_get_single( 86 | aresponses: ResponsesMockServer, 87 | authenticated_linkding_api_server: ResponsesMockServer, 88 | tags_async_get_single_response: dict[str, Any], 89 | ) -> None: 90 | """Test getting a single tag. 91 | 92 | Args: 93 | ---- 94 | aresponses: An aresponses server. 95 | authenticated_linkding_api_server: A mock authenticated linkding API server. 96 | tags_async_get_single_response: An API response payload. 97 | 98 | """ 99 | async with authenticated_linkding_api_server: 100 | authenticated_linkding_api_server.add( 101 | "127.0.0.1:8000", 102 | "/api/tags/1/", 103 | "get", 104 | response=aiohttp.web_response.json_response( 105 | tags_async_get_single_response, status=200 106 | ), 107 | ) 108 | 109 | async with aiohttp.ClientSession() as session: 110 | client = await async_get_client(TEST_URL, TEST_TOKEN, session=session) 111 | single_tag = await client.tags.async_get_single(1) 112 | assert single_tag == tags_async_get_single_response 113 | 114 | aresponses.assert_plan_strictly_followed() 115 | -------------------------------------------------------------------------------- /tests/test_user.py: -------------------------------------------------------------------------------- 1 | """Define tests for user 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 aiolinkding import async_get_client 12 | 13 | from .common import TEST_TOKEN, TEST_URL 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_get_profile( 18 | aresponses: ResponsesMockServer, 19 | authenticated_linkding_api_server: ResponsesMockServer, 20 | user_async_get_profile_response: dict[str, Any], 21 | ) -> None: 22 | """Test getting all tags. 23 | 24 | Args: 25 | ---- 26 | aresponses: An aresponses server. 27 | authenticated_linkding_api_server: A mock authenticated linkding API server. 28 | user_async_get_profile_response: An API response payload. 29 | 30 | """ 31 | async with authenticated_linkding_api_server: 32 | authenticated_linkding_api_server.add( 33 | "127.0.0.1:8000", 34 | "/api/user/profile/", 35 | "get", 36 | response=aiohttp.web_response.json_response( 37 | user_async_get_profile_response, status=200 38 | ), 39 | ) 40 | 41 | async with aiohttp.ClientSession() as session: 42 | client = await async_get_client(TEST_URL, TEST_TOKEN, session=session) 43 | profile_info = await client.user.async_get_profile() 44 | assert profile_info == user_async_get_profile_response 45 | 46 | aresponses.assert_plan_strictly_followed() 47 | --------------------------------------------------------------------------------