├── .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 |
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 |
--------------------------------------------------------------------------------