├── .codeclimate.yml ├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── labels.yml ├── pull_request_template.md ├── release-drafter.yml └── workflows │ ├── codeql.yml │ ├── labels.yml │ ├── lock.yml │ ├── publish.yml │ ├── release-drafter.yml │ ├── requirements.txt │ ├── stale.yml │ ├── static-analysis.yml │ └── test.yml ├── .gitignore ├── .mise.toml ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── LICENSE ├── README.md ├── examples └── __init__.py ├── linkding_cli ├── __init__.py ├── cli.py ├── commands │ ├── __init__.py │ ├── bookmark.py │ ├── tag.py │ └── user.py ├── config.py ├── const.py ├── core.py ├── errors.py ├── helpers │ ├── __init__.py │ └── logging.py ├── py.typed └── util │ └── __init__.py ├── poetry.lock ├── pyproject.toml ├── script ├── docs ├── release └── setup └── tests ├── __init__.py ├── common.py ├── conftest.py ├── test_bookmarks.py ├── test_cli.py ├── test_tags.py └── test_user.py /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | argument-count: 4 | enabled: false 5 | duplication: 6 | enabled: true 7 | config: 8 | languages: 9 | - python 10 | fixme: 11 | enabled: true 12 | radon: 13 | enabled: true 14 | ratings: 15 | paths: 16 | - "**.py" 17 | exclude_paths: 18 | - dist/ 19 | - docs/ 20 | - tests/ 21 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203,E266,E501,F811,W503 3 | max-line-length = 80 4 | max-complexity = 18 5 | per-file-ignores = linkding_cli/cli.py:B008,linkding_cli/commands/*:B008,tests/*:DAR,S101 6 | select = B,B9,LK,C,D,E,F,I,S,W 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | --- 5 | 6 | **Describe the bug** 7 | A clear and concise description of what the bug is. 8 | 9 | **To Reproduce** 10 | Steps to reproduce the behavior: 11 | 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | --- 5 | 6 | **Is your feature request related to a problem? Please describe.** 7 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 8 | 9 | **Describe the solution you'd like** 10 | A clear and concise description of what you want to happen. 11 | 12 | **Additional context** 13 | Add any other context or screenshots about the feature request here. 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: pip 5 | directory: "/" 6 | schedule: 7 | interval: daily 8 | time: "06:00" 9 | 10 | - package-ecosystem: pip 11 | directory: "/.github/workflows" 12 | schedule: 13 | interval: daily 14 | time: "06:00" 15 | 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: daily 20 | time: "06:00" 21 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "breaking-change" 3 | color: ee0701 4 | description: "A breaking change for existing users" 5 | - name: "bug" 6 | color: ee0701 7 | description: "Bugs or issues which will cause a problem for users" 8 | - name: "documentation" 9 | color: 0052cc 10 | description: "Project documentation" 11 | - name: "enhancement" 12 | color: 1d76db 13 | description: "Enhancement of the code, not introducing new features." 14 | - name: "maintenance" 15 | color: 2af79e 16 | description: "Generic library tasks" 17 | - name: "dependencies" 18 | color: 1d76db 19 | description: "Upgrade or downgrade of project dependencies" 20 | 21 | - name: "in-progress" 22 | color: fbca04 23 | description: "Issue is currently being resolved by a developer" 24 | - name: "stale" 25 | color: fef2c0 26 | description: "There has not been activity on this issue or PR for some time" 27 | - name: "no-stale" 28 | color: fef2c0 29 | description: "This issue or PR is exempted from the stale bot" 30 | 31 | - name: "security" 32 | color: ee0701 33 | description: "Marks a security issue that needs to be resolved ASAP" 34 | - name: "incomplete" 35 | color: fef2c0 36 | description: "Marks a PR or issue that is missing information" 37 | - name: "invalid" 38 | color: fef2c0 39 | description: "Marks a PR or issue that is missing information" 40 | 41 | - name: "help-wanted" 42 | color: 0e8a16 43 | description: "Needs a helping hang or expertise in order to resolve" 44 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **Describe what the PR does:** 2 | 3 | **Does this fix a specific issue?** 4 | 5 | Fixes https://github.com/bachya/linkding-cli/issues/ 6 | 7 | **Checklist:** 8 | 9 | - [ ] Confirm that one or more new tests are written for the new functionality. 10 | - [ ] Run tests and ensure everything passes (with 100% test coverage). 11 | - [ ] Update `README.md` with any new documentation. 12 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | categories: 3 | - title: "🚨 Breaking Changes" 4 | labels: 5 | - "breaking-change" 6 | 7 | - title: "🚀 Features" 8 | labels: 9 | - "enhancement" 10 | 11 | - title: "🐛 Bug Fixes" 12 | labels: 13 | - "bug" 14 | 15 | - title: "📕 Documentation" 16 | labels: 17 | - "documentation" 18 | 19 | - title: "🧰 Maintenance" 20 | labels: 21 | - "dependencies" 22 | - "maintenance" 23 | - "tooling" 24 | 25 | change-template: "- $TITLE (#$NUMBER)" 26 | name-template: "$NEXT_PATCH_VERSION" 27 | tag-template: "$NEXT_PATCH_VERSION" 28 | template: | 29 | $CHANGES 30 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CodeQL 3 | 4 | "on": 5 | push: 6 | branches: 7 | - dev 8 | - main 9 | 10 | pull_request: 11 | branches: 12 | - dev 13 | - main 14 | 15 | workflow_dispatch: 16 | 17 | schedule: 18 | - cron: "30 1 * * 0" 19 | 20 | jobs: 21 | codeql: 22 | name: Scanning 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: ⤵️ Check out code from GitHub 26 | uses: actions/checkout@v4 27 | 28 | - name: 🏗 Initialize CodeQL 29 | uses: github/codeql-action/init@v3 30 | 31 | - name: 🚀 Perform CodeQL Analysis 32 | uses: github/codeql-action/analyze@v3 33 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Sync Labels 3 | 4 | "on": 5 | push: 6 | branches: 7 | - main 8 | paths: 9 | - .github/labels.yml 10 | 11 | workflow_dispatch: 12 | 13 | jobs: 14 | labels: 15 | name: ♻️ Sync labels 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: ⤵️ Check out code from GitHub 19 | uses: actions/checkout@v4 20 | 21 | - name: 🚀 Run Label Syncer 22 | uses: micnncim/action-label-syncer@v1.3.0 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/lock.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lock Closed Issues and PRs 3 | 4 | "on": 5 | schedule: 6 | - cron: "0 9 * * *" 7 | 8 | workflow_dispatch: 9 | 10 | jobs: 11 | lock: 12 | name: 🔒 Lock! 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: dessant/lock-threads@v5.0.1 16 | with: 17 | github-token: ${{ github.token }} 18 | issue-inactive-days: "30" 19 | issue-lock-reason: "" 20 | pr-inactive-days: "1" 21 | pr-lock-reason: "" 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Publish to PyPI 3 | 4 | "on": 5 | push: 6 | tags: 7 | - "*" 8 | 9 | jobs: 10 | publish_to_pypi: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: ⤵️ Check out code from GitHub 15 | uses: actions/checkout@v4 16 | 17 | - name: 🏗 Set up Python 3.12 18 | id: python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: 3.12 22 | 23 | - name: 🚀 Publish to PyPi 24 | run: | 25 | pip install poetry 26 | poetry publish --build -u __token__ -p ${{ secrets.PYPI_API_KEY }} 27 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release Drafter 3 | 4 | "on": 5 | push: 6 | branches: 7 | - main 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | update_release_draft: 13 | name: ✏️ Draft Release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: 🚀 Run Release Drafter 17 | uses: release-drafter/release-drafter@v6.0.0 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/requirements.txt: -------------------------------------------------------------------------------- 1 | poetry==1.8.4 2 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Stale 3 | 4 | "on": 5 | schedule: 6 | - cron: "0 8 * * *" 7 | 8 | workflow_dispatch: 9 | 10 | jobs: 11 | stale: 12 | name: 🧹 Clean up stale issues and PRs 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: 🚀 Run stale 16 | uses: actions/stale@v9 17 | with: 18 | repo-token: ${{ secrets.GITHUB_TOKEN }} 19 | days-before-stale: 30 20 | days-before-close: 7 21 | remove-stale-when-updated: true 22 | stale-issue-label: "stale" 23 | exempt-issue-labels: "no-stale,help-wanted" 24 | stale-issue-message: > 25 | There hasn't been any activity on this issue recently, so it 26 | has been marked as stale. 27 | 28 | Please make sure to update to the latest version and 29 | check if that solves the issue. Let us know if that works for you 30 | by leaving a comment. 31 | 32 | This issue will be closed if no further activity occurs. Thanks! 33 | stale-pr-label: "stale" 34 | exempt-pr-labels: "no-stale" 35 | stale-pr-message: > 36 | There hasn't been any activity on this pull request recently, so 37 | it has automatically been marked as stale and will be closed if 38 | no further action occurs within 7 days. Thank you for your 39 | contributions. 40 | -------------------------------------------------------------------------------- /.github/workflows/static-analysis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Linting and Static Analysis 3 | 4 | "on": 5 | pull_request: 6 | branches: 7 | - dev 8 | - main 9 | 10 | push: 11 | branches: 12 | - dev 13 | - main 14 | 15 | jobs: 16 | lint: 17 | name: "Linting & Static Analysis" 18 | 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: ⤵️ Check out code from GitHub 23 | uses: actions/checkout@v4 24 | 25 | - name: 🏗 Set up Python 3.12 26 | id: setup-python 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: 3.12 30 | 31 | - name: ⤵️ Get pip cache directory 32 | id: pip-cache 33 | run: | 34 | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 35 | 36 | - name: ⤵️ Establish pip cache 37 | uses: actions/cache@v4 38 | with: 39 | path: ${{ steps.pip-cache.outputs.dir }} 40 | key: "${{ runner.os }}-pip-\ 41 | ${{ hashFiles('.github/workflows/requirements.txt') }}" 42 | restore-keys: | 43 | ${{ runner.os }}-pip- 44 | 45 | - name: 🏗 Install workflow dependencies 46 | run: | 47 | pip install -r .github/workflows/requirements.txt 48 | poetry config virtualenvs.create true 49 | poetry config virtualenvs.in-project true 50 | 51 | - name: ⤵️ Establish poetry cache 52 | uses: actions/cache@v4 53 | with: 54 | path: .venv 55 | key: "venv-${{ steps.setup-python.outputs.python-version }}-\ 56 | ${{ hashFiles('poetry.lock') }}" 57 | restore-keys: | 58 | venv-${{ steps.setup-python.outputs.python-version }}- 59 | 60 | - name: 🏗 Install package dependencies 61 | run: | 62 | poetry install --no-interaction 63 | 64 | - name: 🚀 Run pre-commit hooks 65 | uses: pre-commit/action@v3.0.1 66 | env: 67 | SKIP: no-commit-to-branch,pytest 68 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests and Coverage 3 | 4 | "on": 5 | pull_request: 6 | branches: 7 | - dev 8 | - main 9 | 10 | push: 11 | branches: 12 | - dev 13 | - main 14 | 15 | jobs: 16 | test: 17 | name: Tests 18 | 19 | runs-on: ubuntu-latest 20 | 21 | strategy: 22 | matrix: 23 | python-version: 24 | - "3.10" 25 | - "3.11" 26 | - "3.12" 27 | 28 | steps: 29 | - name: ⤵️ Check out code from GitHub 30 | uses: actions/checkout@v4 31 | 32 | - name: 🏗 Set up Python 33 | id: setup-python 34 | uses: actions/setup-python@v5 35 | with: 36 | python-version: ${{ matrix.python-version }} 37 | 38 | - name: ⤵️ Get pip cache directory 39 | id: pip-cache 40 | run: | 41 | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 42 | 43 | - name: ⤵️ Establish pip cache 44 | uses: actions/cache@v4 45 | with: 46 | path: ${{ steps.pip-cache.outputs.dir }} 47 | key: "${{ runner.os }}-pip-\ 48 | ${{ hashFiles('.github/workflows/requirements.txt') }}" 49 | restore-keys: | 50 | ${{ runner.os }}-pip- 51 | 52 | - name: 🏗 Install workflow dependencies 53 | run: | 54 | pip install -r .github/workflows/requirements.txt 55 | poetry config virtualenvs.create true 56 | poetry config virtualenvs.in-project true 57 | 58 | - name: ⤵️ Establish poetry cache 59 | uses: actions/cache@v4 60 | with: 61 | path: .venv 62 | key: "venv-${{ steps.setup-python.outputs.python-version }}-\ 63 | ${{ hashFiles('poetry.lock') }}" 64 | restore-keys: | 65 | venv-${{ steps.setup-python.outputs.python-version }}- 66 | 67 | - name: 🏗 Install package dependencies 68 | run: | 69 | poetry install --no-interaction 70 | 71 | - name: 🚀 Run pytest 72 | run: poetry run pytest --cov linkding_cli tests 73 | 74 | - name: ⬆️ Upload coverage artifact 75 | uses: actions/upload-artifact@v4 76 | with: 77 | name: coverage-${{ matrix.python-version }} 78 | path: .coverage 79 | include-hidden-files: true 80 | 81 | coverage: 82 | name: Code Coverage 83 | 84 | needs: test 85 | 86 | runs-on: ubuntu-latest 87 | 88 | steps: 89 | - name: ⤵️ Check out code from GitHub 90 | uses: actions/checkout@v4 91 | 92 | - name: ⬇️ Download coverage data 93 | uses: actions/download-artifact@v4 94 | 95 | - name: 🏗 Set up Python 3.12 96 | id: setup-python 97 | uses: actions/setup-python@v5 98 | with: 99 | python-version: 3.12 100 | 101 | - name: ⤵️ Get pip cache directory 102 | id: pip-cache 103 | run: | 104 | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 105 | 106 | - name: ⤵️ Establish pip cache 107 | uses: actions/cache@v4 108 | with: 109 | path: ${{ steps.pip-cache.outputs.dir }} 110 | key: "${{ runner.os }}-pip-\ 111 | ${{ hashFiles('.github/workflows/requirements.txt') }}" 112 | restore-keys: | 113 | ${{ runner.os }}-pip- 114 | 115 | - name: 🏗 Install workflow dependencies 116 | run: | 117 | pip install -r .github/workflows/requirements.txt 118 | poetry config virtualenvs.create true 119 | poetry config virtualenvs.in-project true 120 | 121 | - name: ⤵️ Establish poetry cache 122 | uses: actions/cache@v4 123 | with: 124 | path: .venv 125 | key: "venv-${{ steps.setup-python.outputs.python-version }}-\ 126 | ${{ hashFiles('poetry.lock') }}" 127 | restore-keys: | 128 | venv-${{ steps.setup-python.outputs.python-version }}- 129 | 130 | - name: 🏗 Install package dependencies 131 | run: | 132 | poetry install --no-interaction 133 | 134 | - name: 🚀 Process coverage results 135 | run: | 136 | poetry run coverage combine coverage*/.coverage* 137 | poetry run coverage xml -i 138 | 139 | - name: 📊 Upload coverage report to codecov.io 140 | uses: codecov/codecov-action@v4 141 | with: 142 | token: ${{ secrets.CODECOV_TOKEN }} 143 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | .DS_Store 3 | .coverage 4 | .mypy_cache 5 | .nox 6 | .tox 7 | .venv 8 | __pycache__ 9 | coverage.xml 10 | docs/_build 11 | tags 12 | -------------------------------------------------------------------------------- /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | python = { version="3.12", virtualenv=".venv" } 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: blacken-docs 6 | name: ☕️ Format documentation using black 7 | language: system 8 | files: '\.(rst|md|markdown|py|tex)$' 9 | entry: poetry run blacken-docs 10 | require_serial: true 11 | - id: check-ast 12 | name: 🐍 Checking Python AST 13 | language: system 14 | types: [python] 15 | entry: poetry run check-ast 16 | - id: check-case-conflict 17 | name: 🔠 Checking for case conflicts 18 | language: system 19 | entry: poetry run check-case-conflict 20 | - id: check-docstring-first 21 | name: ℹ️ Checking docstrings are first 22 | language: system 23 | types: [python] 24 | entry: poetry run check-docstring-first 25 | - id: check-executables-have-shebangs 26 | name: 🧐 Checking that executables have shebangs 27 | language: system 28 | types: [text, executable] 29 | entry: poetry run check-executables-have-shebangs 30 | stages: [commit, push, manual] 31 | - id: check-json 32 | name: { Checking JSON files 33 | language: system 34 | types: [json] 35 | entry: poetry run check-json 36 | - id: check-merge-conflict 37 | name: 💥 Checking for merge conflicts 38 | language: system 39 | types: [text] 40 | entry: poetry run check-merge-conflict 41 | - id: check-symlinks 42 | name: 🔗 Checking for broken symlinks 43 | language: system 44 | types: [symlink] 45 | entry: poetry run check-symlinks 46 | - id: check-toml 47 | name: ✅ Checking TOML files 48 | language: system 49 | types: [toml] 50 | entry: poetry run check-toml 51 | - id: check-yaml 52 | name: ✅ Checking YAML files 53 | language: system 54 | types: [yaml] 55 | entry: poetry run check-yaml 56 | - id: codespell 57 | name: ✅ Checking code for misspellings 58 | language: system 59 | types: [text] 60 | exclude: ^poetry\.lock$ 61 | entry: poetry run codespell 62 | - id: debug-statements 63 | name: 🪵 Checking for debug statements and imports (Python) 64 | language: system 65 | types: [python] 66 | entry: poetry run debug-statement-hook 67 | - id: detect-private-key 68 | name: 🕵️ Detecting private keys 69 | language: system 70 | types: [text] 71 | entry: poetry run detect-private-key 72 | - id: end-of-file-fixer 73 | name: 🔚 Checking end of files 74 | language: system 75 | types: [text] 76 | entry: poetry run end-of-file-fixer 77 | stages: [commit, push, manual] 78 | - id: fix-byte-order-marker 79 | name: 🚏 Checking UTF-8 byte order marker 80 | language: system 81 | types: [text] 82 | entry: poetry run fix-byte-order-marker 83 | - id: format 84 | name: ☕️ Formatting code using ruff 85 | language: system 86 | types: [python] 87 | entry: poetry run ruff format 88 | - id: isort 89 | name: 🔀 Sorting all imports with isort 90 | language: system 91 | types: [python] 92 | entry: poetry run isort 93 | - id: mypy 94 | name: 🆎 Performing static type checking using mypy 95 | language: system 96 | types: [python] 97 | entry: poetry run mypy 98 | require_serial: true 99 | - id: no-commit-to-branch 100 | name: 🛑 Checking for commit to protected branch 101 | language: system 102 | entry: poetry run no-commit-to-branch 103 | pass_filenames: false 104 | always_run: true 105 | args: 106 | - --branch=dev 107 | - --branch=main 108 | - id: poetry 109 | name: 📜 Checking pyproject with Poetry 110 | language: system 111 | entry: poetry check 112 | pass_filenames: false 113 | always_run: true 114 | - id: pylint 115 | name: 🌟 Starring code with pylint 116 | language: system 117 | types: [python] 118 | entry: poetry run pylint 119 | - id: pyupgrade 120 | name: 🆙 Checking for upgradable syntax with pyupgrade 121 | language: system 122 | types: [python] 123 | entry: poetry run pyupgrade 124 | args: [--py39-plus, --keep-runtime-typing] 125 | - id: ruff 126 | name: 👔 Enforcing style guide with ruff 127 | language: system 128 | types: [python] 129 | entry: poetry run ruff check --fix 130 | - id: trailing-whitespace 131 | name: ✄ Trimming trailing whitespace 132 | language: system 133 | types: [text] 134 | entry: poetry run trailing-whitespace-fixer 135 | stages: [commit, push, manual] 136 | - id: vulture 137 | name: 🔍 Finding unused Python code with Vulture 138 | language: system 139 | types: [python] 140 | entry: poetry run vulture 141 | pass_filenames: false 142 | require_serial: true 143 | - id: yamllint 144 | name: 🎗 Checking YAML files with yamllint 145 | language: system 146 | types: [yaml] 147 | entry: poetry run yamllint 148 | 149 | - repo: https://github.com/pre-commit/mirrors-prettier 150 | rev: "v3.0.0-alpha.4" 151 | hooks: 152 | - id: prettier 153 | name: 💄 Ensuring files are prettier 154 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | 4 | sphinx: 5 | configuration: docs/conf.py 6 | 7 | python: 8 | version: 3.7 9 | install: 10 | - method: pip 11 | path: . 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2022 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 | # 🔖 linkding-cli: A CLI to interact with a linkding instance 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 | `linkding-cli` is a CLI to interact with a [linkding][linkding] instance. 11 | 12 | - [Installation](#installation) 13 | - [Python Versions](#python-versions) 14 | - [Usage](#usage) 15 | - [Main Help](#main-help) 16 | - [Configuration](#configuration) 17 | - [Example: CLI Options](#example--cli-options) 18 | - [Example: Environment Variables](#example--environment-variables) 19 | - [Example: Configuration File](#example--configuration-file) 20 | - [Merging Configuration Options](#merging-configuration-options) 21 | - [Bookmarks](#bookmarks) 22 | - [The `bookmarks all` command](#the-bookmarks-all-command) 23 | - [The `bookmarks archive` command](#the-bookmarks-archive-command) 24 | - [The `bookmarks create` command](#the-bookmarks-create-command) 25 | - [The `bookmarks delete` command](#the-bookmarks-delete-command) 26 | - [The `bookmarks get` command](#the-bookmarks-get-command) 27 | - [The `bookmarks unarchive` command](#the-bookmarks-unarchive-command) 28 | - [The `bookmarks update` command](#the-bookmarks-update-command) 29 | - [Tags](#tags) 30 | - [The `tags all` command](#the-tags-all-command) 31 | - [The `tags create` command](#the-tags-create-command) 32 | - [The `tags get` command](#the-tags-get-command) 33 | - [User Info](#user-info) 34 | - [The `user profile` command](#the-user-profile-command) 35 | - [Misc.](#misc) 36 | - [Parsing and Pretty Printing Data](#parsing-and-pretty-printing-data) 37 | - [Contributing](#contributing) 38 | 39 | # Installation 40 | 41 | ```bash 42 | pip install linkding-cli 43 | ``` 44 | 45 | # Python Versions 46 | 47 | `linkding-cli` is currently supported on: 48 | 49 | - Python 3.10 50 | - Python 3.11 51 | - Python 3.12 52 | 53 | # Usage 54 | 55 | ## Main Help 56 | 57 | ``` 58 | $ linkding --help 59 | Usage: linkding [OPTIONS] COMMAND [ARGS]... 60 | 61 | Interact with a linkding instance. 62 | 63 | Options: 64 | -c, --config PATH A path to a config file. [env var: LINKDING_CONFIG] 65 | -t, --token TOKEN A linkding API token. [env var: LINKDING_TOKEN] 66 | -u, --url URL A URL to a linkding instance. [env var: LINKDING_URL] 67 | -v, --verbose Increase verbosity of standard output. 68 | --install-completion Install completion for the current shell. 69 | --show-completion Show completion for the current shell, to copy it or 70 | customize the installation. 71 | --help Show this message and exit. 72 | 73 | Commands: 74 | bookmarks Work with bookmarks. 75 | tags Work with tags. 76 | ``` 77 | 78 | ## Configuration 79 | 80 | Configuration can be provided via a variety of sources: 81 | 82 | - CLI Options 83 | - Environment Variables 84 | - Configuration File 85 | 86 | ### Example: CLI Options 87 | 88 | ``` 89 | $ linkding -u http://127.0.0.1:8000 -t abcde12345 ... 90 | ``` 91 | 92 | ### Example: Environment Variables 93 | 94 | ``` 95 | $ LINKDING_URL=http://127.0.0.1:8000 LINKDING_TOKEN=abcde12345 linkding ... 96 | ``` 97 | 98 | ### Example: Configuration File 99 | 100 | The configuration file can be formatted as either JSON: 101 | 102 | ```json 103 | { 104 | "token": "abcde12345", 105 | "url": "http://127.0.0.1:8000", 106 | "verbose": false 107 | } 108 | ``` 109 | 110 | ...or YAML 111 | 112 | ```yaml 113 | --- 114 | token: "abcde12345" 115 | url: "http://127.0.0.1:8000" 116 | verbose: false 117 | ``` 118 | 119 | Then, the linkding file can be provided via either `-c` or `--config`. 120 | 121 | ``` 122 | $ linkding -c ~/.config/linkding.json ... 123 | ``` 124 | 125 | ### Merging Configuration Options 126 | 127 | When parsing configuration options, `linkding-cli` looks at the configuration sources in 128 | the following order: 129 | 130 | 1. Configuration File 131 | 2. Environment Variables 132 | 3. CLI Options 133 | 134 | This allows you to mix and match sources – for instance, you might have "defaults" in 135 | the configuration file and override them via environment variables. 136 | 137 | ## Bookmarks 138 | 139 | ``` 140 | Usage: linkding bookmarks [OPTIONS] COMMAND [ARGS]... 141 | 142 | Work with bookmarks. 143 | 144 | Options: 145 | --help Show this message and exit. 146 | 147 | Commands: 148 | all Get all bookmarks. 149 | archive Archive a bookmark by its linkding ID. 150 | create Create a bookmark. 151 | delete Delete a bookmark by its linkding ID. 152 | get Get a bookmark by its linkding ID. 153 | unarchive Unarchive a bookmark by its linkding ID. 154 | update Update a bookmark by its linkding ID. 155 | ``` 156 | 157 | ### The `bookmarks all` command 158 | 159 | ``` 160 | Usage: linkding bookmarks all [OPTIONS] 161 | 162 | Get all bookmarks. 163 | 164 | Options: 165 | -a, --archived Return archived bookmarks. 166 | -l, --limit INTEGER The number of bookmarks to return. 167 | -o, --offset INTEGER The index from which to return results. 168 | -q, --query TEXT Return bookmarks containing a query string. 169 | --help Show this message and exit. 170 | ``` 171 | 172 | #### Examples: 173 | 174 | ```sh 175 | # Get all bookmarks, but limit the results to 10: 176 | $ linkding bookmarks all --limit 10 177 | 178 | # Get all archived bookmarks that contain "software": 179 | $ linkding bookmarks all --archived --query software 180 | ``` 181 | 182 | ### The `bookmarks archive` command 183 | 184 | ``` 185 | Usage: linkding bookmarks archive [OPTIONS] [BOOKMARK_ID] 186 | 187 | Archive a bookmark by its linkding ID. 188 | 189 | Arguments: 190 | [BOOKMARK_ID] The ID of a bookmark to archive. 191 | 192 | Options: 193 | --help Show this message and exit. 194 | ``` 195 | 196 | #### Examples: 197 | 198 | ```sh 199 | # Archive bookmark 12: 200 | $ linkding bookmarks archive 12 201 | ``` 202 | 203 | ### The `bookmarks create` command 204 | 205 | ``` 206 | Usage: linkding bookmarks create [OPTIONS] URL 207 | 208 | Create a bookmark. 209 | 210 | Arguments: 211 | URL The URL to bookmark. [required] 212 | 213 | Options: 214 | -a, --archived Whether the newly-created bookmark should be 215 | immediately archived. 216 | -d, --description DESCRIPTION The description to give the bookmark. 217 | -n, --notes NOTES Any Markdown-formatted notes to add to the bookmark. 218 | --shared Whether the newly-created bookmark should be 219 | shareable with other linkding users 220 | --tags TAG1,TAG2,... The tags to apply to the bookmark. 221 | -t, --title TITLE The title to give the bookmark. 222 | --unread Whether the newly-created bookmark should be 223 | marked as unread. 224 | --help Show this message and exit. 225 | ``` 226 | 227 | #### Examples: 228 | 229 | ```sh 230 | # Create a bookmark: 231 | $ linkding bookmarks create https://example.com 232 | 233 | # Create a bookmark and immeditely archive it: 234 | $ linkding bookmarks create -a https://example.com 235 | 236 | # Create a bookmark with title, description, and tags: 237 | $ linkding bookmarks create https://example.com -t Example -d "A description" --tags tag1,tag2 238 | ``` 239 | 240 | ### The `bookmarks delete` command 241 | 242 | ``` 243 | Usage: linkding bookmarks delete [OPTIONS] [BOOKMARK_ID] 244 | 245 | Delete a bookmark by its linkding ID. 246 | 247 | Arguments: 248 | [BOOKMARK_ID] The ID of a bookmark to delete. 249 | 250 | Options: 251 | --help Show this message and exit. 252 | ``` 253 | 254 | #### Examples: 255 | 256 | ```sh 257 | # Delete the bookmark with an ID of 12: 258 | $ linkding bookmarks delete 12 259 | ``` 260 | 261 | ### The `bookmarks get` command 262 | 263 | ``` 264 | Usage: linkding bookmarks get [OPTIONS] [BOOKMARK_ID] 265 | 266 | Get a bookmark by its linkding ID. 267 | 268 | Arguments: 269 | [BOOKMARK_ID] The ID of a bookmark to retrieve. 270 | 271 | Options: 272 | --help Show this message and exit. 273 | ``` 274 | 275 | #### Examples: 276 | 277 | ```sh 278 | # Get bookmark 12: 279 | $ linkding bookmarks get 12 280 | ``` 281 | 282 | ### The `bookmarks unarchive` command 283 | 284 | ``` 285 | Usage: linkding bookmarks unarchive [OPTIONS] [BOOKMARK_ID] 286 | 287 | Unarchive a bookmark by its linkding ID. 288 | 289 | Arguments: 290 | [BOOKMARK_ID] The ID of a bookmark to unarchive. 291 | 292 | Options: 293 | --help Show this message and exit. 294 | ``` 295 | 296 | #### Examples: 297 | 298 | ```sh 299 | # Unarchive bookmark 12: 300 | $ linkding bookmarks unarchive 12 301 | ``` 302 | 303 | ### The `bookmarks update` command 304 | 305 | ``` 306 | Usage: linkding bookmarks update [OPTIONS] BOOKMARK_ID 307 | 308 | Update a bookmark by its linkdingn ID. 309 | 310 | Arguments: 311 | BOOKMARK_ID The ID of a bookmark to update. [required] 312 | 313 | Options: 314 | -u, --url URL The URL to assign to the bookmark. 315 | -d, --description DESCRIPTION The description to give the bookmark. 316 | -n, --notes NOTES Any Markdown-formatted notes to add to the bookmark. 317 | --shared Whether the -created bookmark should be 318 | shareable with other linkding users 319 | --tags TAG1,TAG2,... The tags to apply to the bookmark. 320 | -t, --title TITLE The title to give the bookmark. 321 | --unread Whether the bookmark should be marked as 322 | unread. 323 | --help Show this message and exit. 324 | ``` 325 | 326 | #### Examples: 327 | 328 | ```sh 329 | # Update a bookmark with a new url: 330 | $ linkding bookmarks update 12 -u https://example.com 331 | 332 | # Update a bookmark with title, description, and tags: 333 | $ linkding bookmarks update 12 -t Example -d "A description" --tags tag1,tag2 334 | ``` 335 | 336 | ## Tags 337 | 338 | ``` 339 | Usage: linkding tags [OPTIONS] COMMAND [ARGS]... 340 | 341 | Work with tags. 342 | 343 | Options: 344 | --help Show this message and exit. 345 | 346 | Commands: 347 | all Get all tags. 348 | create Create a tag. 349 | get Get a tag by its linkding ID. 350 | ``` 351 | 352 | ### The `tags all` command 353 | 354 | ``` 355 | Usage: linkding tags all [OPTIONS] 356 | 357 | Get all tags. 358 | 359 | Options: 360 | -l, --limit INTEGER The number of tags to return. 361 | -o, --offset INTEGER The index from which to return results. 362 | --help Show this message and exit. 363 | ``` 364 | 365 | #### Examples: 366 | 367 | ```sh 368 | # Get all tags, but limit the results to 10: 369 | $ linkding tags all --limit 10 370 | ``` 371 | 372 | ### The `tags create` command 373 | 374 | ``` 375 | Usage: linkding tags create [OPTIONS] TAG_NAME 376 | 377 | Create a tag. 378 | 379 | Arguments: 380 | TAG_NAME The tag to create. [required] 381 | 382 | Options: 383 | --help Show this message and exit. 384 | ``` 385 | 386 | #### Examples: 387 | 388 | ```sh 389 | # Create a tag: 390 | $ linkding tags create sample-tag 391 | ``` 392 | 393 | ### The `tags get` command 394 | 395 | ``` 396 | Usage: linkding tags get [OPTIONS] TAG_ID 397 | 398 | Get a tag by its linkding ID. 399 | 400 | Arguments: 401 | TAG_ID The ID of a tag to retrieve. [required] 402 | 403 | Options: 404 | --help Show this message and exit. 405 | ``` 406 | 407 | #### Examples: 408 | 409 | ```sh 410 | # Get tag 12: 411 | $ linkding tags get 12 412 | ``` 413 | 414 | ## Misc. 415 | 416 | ### Parsing and Pretty Printing Data 417 | 418 | `linkding-cli` doesn't have built-in utilities for modifying JSON output in any way. 419 | Instead, it's recommended to use a tool like [`jq`][jq]. This allows for multiple new 420 | outcomes, like pretty-printing: 421 | 422 | ``` 423 | $ linkding bookmarks all | jq 424 | { 425 | "count": 123, 426 | "next": "http://127.0.0.1:8000/api/bookmarks/?limit=100&offset=100", 427 | "previous": null, 428 | "results": [ 429 | { 430 | "id": 1, 431 | "url": "https://example.com", 432 | "title": "Example title", 433 | "description": "Example description", 434 | "website_title": "Website title", 435 | "website_description": "Website description", 436 | "tag_names": [ 437 | "tag1", 438 | "tag2" 439 | ], 440 | "date_added": "2020-09-26T09:46:23.006313Z", 441 | "date_modified": "2020-09-26T16:01:14.275335Z" 442 | } 443 | ] 444 | } 445 | ``` 446 | 447 | ...and slicing/parsing data: 448 | 449 | ``` 450 | $ linkding bookmarks all | jq '.results[0].title' 451 | "Example title" 452 | ``` 453 | 454 | ## User Info 455 | 456 | ``` 457 | Usage: linkding user [OPTIONS] COMMAND [ARGS]... 458 | 459 | Work with user info. 460 | 461 | Options: 462 | --help Show this message and exit. 463 | 464 | Commands: 465 | profile Get user profile info. 466 | ``` 467 | 468 | ### The `user profile` command 469 | 470 | ``` 471 | Usage: linkding user profile [OPTIONS] 472 | 473 | Get user profile info. 474 | 475 | Options: 476 | --help Show this message and exit. 477 | ``` 478 | 479 | #### Examples: 480 | 481 | ```sh 482 | $ linkding user profile 483 | ``` 484 | 485 | # Contributing 486 | 487 | Thanks to all of [our contributors][contributors] so far! 488 | 489 | 1. [Check for open features/bugs][issues] or [initiate a discussion on one][new-issue]. 490 | 2. [Fork the repository][fork]. 491 | 3. (_optional, but highly recommended_) Create a virtual environment: `python3 -m venv .venv` 492 | 4. (_optional, but highly recommended_) Enter the virtual environment: `source ./.venv/bin/activate` 493 | 5. Install the dev environment: `script/setup` 494 | 6. Code your new feature or bug fix on a new branch. 495 | 7. Write tests that cover your new functionality. 496 | 8. Run tests and ensure 100% code coverage: `poetry run pytest --cov linkding_cli tests` 497 | 9. Update `README.md` with any new documentation. 498 | 10. Submit a pull request! 499 | 500 | [ci-badge]: https://img.shields.io/github/actions/workflow/status/bachya/linkding-cli/test.yml 501 | [ci]: https://github.com/bachya/linkding-cli/actions 502 | [codecov-badge]: https://codecov.io/gh/bachya/linkding-cli/branch/dev/graph/badge.svg 503 | [codecov]: https://codecov.io/gh/bachya/linkding-cli 504 | [contributors]: https://github.com/bachya/linkding-cli/graphs/contributors 505 | [fork]: https://github.com/bachya/linkding-cli/fork 506 | [issues]: https://github.com/bachya/linkding-cli/issues 507 | [jq]: https://stedolan.github.io/jq/ 508 | [license-badge]: https://img.shields.io/pypi/l/linkding-cli.svg 509 | [license]: https://github.com/bachya/linkding-cli/blob/main/LICENSE 510 | [linkding]: https://github.com/sissbruecker/linkding 511 | [maintainability-badge]: https://api.codeclimate.com/v1/badges/f01be3cd230902508636/maintainability 512 | [maintainability]: https://codeclimate.com/github/bachya/linkding-cli/maintainability 513 | [new-issue]: https://github.com/bachya/linkding-cli/issues/new 514 | [new-issue]: https://github.com/bachya/linkding-cli/issues/new 515 | [pypi-badge]: https://img.shields.io/pypi/v/linkding-cli.svg 516 | [pypi]: https://pypi.python.org/pypi/linkding-cli 517 | [version-badge]: https://img.shields.io/pypi/pyversions/linkding-cli.svg 518 | [version]: https://pypi.python.org/pypi/linkding-cli 519 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | """Define library examples.""" 2 | -------------------------------------------------------------------------------- /linkding_cli/__init__.py: -------------------------------------------------------------------------------- 1 | """Define the linkding-cli package.""" 2 | -------------------------------------------------------------------------------- /linkding_cli/cli.py: -------------------------------------------------------------------------------- 1 | """Define the main interface to the CLI.""" 2 | 3 | # pylint: disable=unused-argument 4 | from __future__ import annotations 5 | 6 | from pathlib import Path 7 | 8 | import typer 9 | 10 | from linkding_cli.commands.bookmark import BOOKMARK_APP 11 | from linkding_cli.commands.tag import TAG_APP 12 | from linkding_cli.commands.user import USER_APP 13 | from linkding_cli.const import ENV_CONFIG, ENV_TOKEN, ENV_URL 14 | from linkding_cli.core import LinkDing 15 | from linkding_cli.helpers.logging import log_exception 16 | 17 | 18 | @log_exception() 19 | def main( 20 | ctx: typer.Context, 21 | config: Path = typer.Option( 22 | None, 23 | "--config", 24 | "-c", 25 | envvar=[ENV_CONFIG], 26 | exists=True, 27 | file_okay=True, 28 | dir_okay=False, 29 | help="A path to a config file.", 30 | metavar="PATH", 31 | resolve_path=True, 32 | ), 33 | token: str = typer.Option( 34 | None, 35 | "--token", 36 | "-t", 37 | envvar=[ENV_TOKEN], 38 | help="A linkding API token.", 39 | metavar="TOKEN", 40 | ), 41 | url: str = typer.Option( 42 | None, 43 | "--url", 44 | "-u", 45 | envvar=[ENV_URL], 46 | help="A URL to a linkding instance.", 47 | metavar="URL", 48 | ), 49 | verbose: bool = typer.Option( 50 | False, 51 | "--verbose", 52 | "-v", 53 | help="Increase verbosity of standard output.", 54 | ), 55 | ) -> None: 56 | """Interact with a linkding instance. 57 | 58 | Args: 59 | ctx: A Typer Context object. 60 | config: A path to a config file 61 | token: A linkding API token. 62 | url: A URL to a linkding instance. 63 | verbose: Increase verbosity of standard output. 64 | """ 65 | ctx.obj = LinkDing(ctx) 66 | 67 | 68 | APP = typer.Typer(callback=main) 69 | APP.add_typer(BOOKMARK_APP, name="bookmarks", help="Work with bookmarks.") 70 | APP.add_typer(TAG_APP, name="tags", help="Work with tags.") 71 | APP.add_typer(USER_APP, name="user", help="Work with user info.") 72 | -------------------------------------------------------------------------------- /linkding_cli/commands/__init__.py: -------------------------------------------------------------------------------- 1 | """Define commands.""" 2 | -------------------------------------------------------------------------------- /linkding_cli/commands/bookmark.py: -------------------------------------------------------------------------------- 1 | """Define the bookmark command.""" 2 | # pylint: disable=too-many-positional-arguments 3 | 4 | from __future__ import annotations 5 | 6 | import asyncio 7 | import json 8 | 9 | import typer 10 | 11 | from linkding_cli.const import CONF_LIMIT, CONF_OFFSET 12 | from linkding_cli.helpers.logging import log_exception 13 | from linkding_cli.util import generate_api_payload 14 | 15 | CONF_DESCRIPTION = "description" 16 | CONF_IS_ARCHIVED = "is_archived" 17 | CONF_QUERY = "query" 18 | CONF_SHARED = "shared" 19 | CONF_TAG_NAMES = "tag_names" 20 | CONF_TITLE = "title" 21 | CONF_UNREAD = "unread" 22 | CONF_URL = "url" 23 | CONF_NOTES = "notes" 24 | 25 | 26 | @log_exception() 27 | def archive( 28 | ctx: typer.Context, 29 | bookmark_id: int = typer.Argument(..., help="The ID of a bookmark to archive."), 30 | ) -> None: 31 | """Archive a bookmark by its linkding ID. 32 | 33 | Args: 34 | ctx: A Typer Context object. 35 | bookmark_id: The ID of the bookmark to archive. 36 | """ 37 | asyncio.run(ctx.obj.client.bookmarks.async_archive(bookmark_id)) 38 | typer.echo(f"Bookmark {bookmark_id} archived.") 39 | 40 | 41 | @log_exception() 42 | def create( 43 | ctx: typer.Context, 44 | url: str = typer.Argument(..., help="The URL to bookmark."), 45 | archived: bool = typer.Option( 46 | False, 47 | "--archived", 48 | "-a", 49 | help="Whether the newly-created bookmark should be immediately archived.", 50 | ), 51 | description: str = typer.Option( 52 | None, 53 | "--description", 54 | "-d", 55 | help="The description to give the bookmark.", 56 | metavar="DESCRIPTION", 57 | ), 58 | notes: str = typer.Option( 59 | None, 60 | "--notes", 61 | "-n", 62 | help="Any Markdown-formatted notes to add to the bookmark.", 63 | metavar="NOTES", 64 | ), 65 | shared: bool = typer.Option( 66 | False, 67 | "--shared", 68 | help=( 69 | "Whether the newly-created bookmark should be shareable with other users" 70 | ), 71 | ), 72 | tag_names: str = typer.Option( 73 | None, 74 | "--tags", 75 | help="The tags to apply to the bookmark.", 76 | metavar="TAG1,TAG2,...", 77 | ), 78 | title: str = typer.Option( 79 | None, 80 | "--title", 81 | "-t", 82 | help="The title to give the bookmark.", 83 | metavar="TITLE", 84 | ), 85 | unread: bool = typer.Option( 86 | False, 87 | "--unread", 88 | help="Whether the newly-created bookmark should be marked as unread.", 89 | ), 90 | ) -> None: 91 | """Create a bookmark. 92 | 93 | Args: 94 | ctx: A Typer Context object. 95 | url: The URL to bookmark. 96 | archived: Whether the newly-created bookmark should be immediately archived. 97 | description: The description to give the bookmark. 98 | notes: Any Markdown-formatted notes to add to the bookmark. 99 | shared: Whether the newly-created bookmark should be shareable with other users. 100 | tag_names: The tags to apply to the bookmark. 101 | title: The title to give the bookmark. 102 | unread: Whether to mark the bookmark as unread. 103 | """ 104 | if tag_names: 105 | tags = tag_names.split(",") 106 | else: 107 | tags = None 108 | 109 | payload = generate_api_payload( 110 | ( 111 | (CONF_DESCRIPTION, description), 112 | (CONF_IS_ARCHIVED, archived), 113 | (CONF_NOTES, notes), 114 | (CONF_SHARED, shared), 115 | (CONF_TAG_NAMES, tags), 116 | (CONF_TITLE, title), 117 | (CONF_UNREAD, unread), 118 | ) 119 | ) 120 | 121 | data = asyncio.run(ctx.obj.client.bookmarks.async_create(url, **payload)) 122 | typer.echo(json.dumps(data)) 123 | 124 | 125 | @log_exception() 126 | def delete( 127 | ctx: typer.Context, 128 | bookmark_id: int = typer.Argument(..., help="The ID of a bookmark to delete."), 129 | ) -> None: 130 | """Delete a bookmark by its linkding ID. 131 | 132 | Args: 133 | ctx: A Typer Context object. 134 | bookmark_id: The ID of the bookmark to delete. 135 | """ 136 | asyncio.run(ctx.obj.client.bookmarks.async_delete(bookmark_id)) 137 | typer.echo(f"Bookmark {bookmark_id} deleted.") 138 | 139 | 140 | @log_exception() 141 | def get_all( 142 | ctx: typer.Context, 143 | archived: bool = typer.Option( 144 | False, 145 | "--archived", 146 | "-a", 147 | help="Return archived bookmarks.", 148 | ), 149 | limit: int = typer.Option( 150 | None, 151 | "--limit", 152 | "-l", 153 | help="The number of bookmarks to return.", 154 | ), 155 | offset: int = typer.Option( 156 | None, 157 | "--offset", 158 | "-o", 159 | help="The index from which to return results.", 160 | ), 161 | query: str = typer.Option( 162 | None, 163 | "--query", 164 | "-q", 165 | help="Return bookmarks containing a query string.", 166 | metavar="QUERY", 167 | ), 168 | ) -> None: 169 | """Get all bookmarks. 170 | 171 | Args: 172 | ctx: A Typer Context object. 173 | archived: Return archived bokomarks. 174 | limit: The number of bookmarks to return. 175 | offset: The index from which to return results. 176 | query: Return bookmarks containing a query string. 177 | """ 178 | api_kwargs = generate_api_payload( 179 | ( 180 | (CONF_LIMIT, limit), 181 | (CONF_OFFSET, offset), 182 | (CONF_QUERY, query), 183 | ) 184 | ) 185 | 186 | if archived: 187 | api_func = ctx.obj.client.bookmarks.async_get_archived 188 | else: 189 | api_func = ctx.obj.client.bookmarks.async_get_all 190 | data = asyncio.run(api_func(**api_kwargs)) 191 | typer.echo(json.dumps(data)) 192 | 193 | 194 | @log_exception() 195 | def get_by_id( 196 | ctx: typer.Context, 197 | bookmark_id: int = typer.Argument(..., help="The ID of a bookmark to retrieve."), 198 | ) -> None: 199 | """Get a bookmark by its linkding ID. 200 | 201 | Args: 202 | ctx: A Typer Context object. 203 | bookmark_id: The ID of the bookmark to retrieve. 204 | """ 205 | data = asyncio.run(ctx.obj.client.bookmarks.async_get_single(bookmark_id)) 206 | typer.echo(json.dumps(data)) 207 | 208 | 209 | @log_exception() 210 | def main(_: typer.Context) -> None: 211 | """Interact with bookmarks.""" 212 | pass 213 | 214 | 215 | @log_exception() 216 | def unarchive( 217 | ctx: typer.Context, 218 | bookmark_id: int = typer.Argument(..., help="The ID of a bookmark to archive."), 219 | ) -> None: 220 | """Unarchive a bookmark by its linkding ID. 221 | 222 | Args: 223 | ctx: A Typer Context object. 224 | bookmark_id: The ID of the bookmark to archive. 225 | """ 226 | asyncio.run(ctx.obj.client.bookmarks.async_unarchive(bookmark_id)) 227 | typer.echo(f"Bookmark {bookmark_id} unarchived.") 228 | 229 | 230 | @log_exception() 231 | def update( 232 | ctx: typer.Context, 233 | bookmark_id: int = typer.Argument(..., help="The ID of a bookmark to update."), 234 | url: str = typer.Option( 235 | None, 236 | "--url", 237 | "-u", 238 | help="The URL to assign to the bookmark.", 239 | metavar="URL", 240 | ), 241 | description: str = typer.Option( 242 | None, 243 | "--description", 244 | "-d", 245 | help="The description to give the bookmark.", 246 | metavar="DESCRIPTION", 247 | ), 248 | notes: str = typer.Option( 249 | None, 250 | "--notes", 251 | "-n", 252 | help="Any Markdown-formatted notes to add to the bookmark.", 253 | metavar="NOTES", 254 | ), 255 | shared: bool = typer.Option( 256 | False, 257 | "--shared", 258 | help=("Whether the -created bookmark should be shareable with other users"), 259 | ), 260 | tag_names: str = typer.Option( 261 | None, 262 | "--tags", 263 | help="The tags to apply to the bookmark.", 264 | metavar="TAG1,TAG2,...", 265 | ), 266 | title: str = typer.Option( 267 | None, 268 | "--title", 269 | "-t", 270 | help="The title to give the bookmark.", 271 | metavar="TITLE", 272 | ), 273 | unread: bool = typer.Option( 274 | False, 275 | "--unread", 276 | help="Whether the bookmark should be marked as unread.", 277 | ), 278 | ) -> None: 279 | """Update a bookmark by its linkding ID. 280 | 281 | Args: 282 | ctx: A Typer Context object. 283 | bookmark_id: The ID of a bookmark to update. 284 | url: The URL to bookmark. 285 | description: The description to give the bookmark. 286 | notes: Any Markdown-formatted notes to add to the bookmark. 287 | shared: Whether the newly-created bookmark should be shareable with other users. 288 | tag_names: The tags to apply to the bookmark. 289 | title: The title to give the bookmark. 290 | unread: Whether to mark the bookmark as unread. 291 | """ 292 | if tag_names: 293 | tags = tag_names.split(",") 294 | else: 295 | tags = None 296 | 297 | payload = generate_api_payload( 298 | ( 299 | (CONF_DESCRIPTION, description), 300 | (CONF_NOTES, notes), 301 | (CONF_SHARED, shared), 302 | (CONF_TAG_NAMES, tags), 303 | (CONF_TITLE, title), 304 | (CONF_UNREAD, unread), 305 | (CONF_URL, url), 306 | ) 307 | ) 308 | 309 | data = asyncio.run(ctx.obj.client.bookmarks.async_update(bookmark_id, **payload)) 310 | typer.echo(json.dumps(data)) 311 | 312 | 313 | BOOKMARK_APP = typer.Typer(callback=main) 314 | BOOKMARK_APP.command(name="all")(get_all) 315 | BOOKMARK_APP.command(name="archive")(archive) 316 | BOOKMARK_APP.command(name="create")(create) 317 | BOOKMARK_APP.command(name="delete")(delete) 318 | BOOKMARK_APP.command(name="get")(get_by_id) 319 | BOOKMARK_APP.command(name="unarchive")(unarchive) 320 | BOOKMARK_APP.command(name="update")(update) 321 | -------------------------------------------------------------------------------- /linkding_cli/commands/tag.py: -------------------------------------------------------------------------------- 1 | """Define the tag command.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import json 7 | 8 | import typer 9 | 10 | from linkding_cli.const import CONF_LIMIT, CONF_OFFSET 11 | from linkding_cli.helpers.logging import log_exception 12 | from linkding_cli.util import generate_api_payload 13 | 14 | 15 | @log_exception() 16 | def create( 17 | ctx: typer.Context, 18 | tag_name: str = typer.Argument(..., help="The tag to create."), 19 | ) -> None: 20 | """Create a tag. 21 | 22 | Args: 23 | ctx: A Typer Context object. 24 | tag_name: The tag to create. 25 | """ 26 | data = asyncio.run(ctx.obj.client.tags.async_create(tag_name)) 27 | typer.echo(json.dumps(data)) 28 | 29 | 30 | @log_exception() 31 | def get_all( 32 | ctx: typer.Context, 33 | limit: int = typer.Option( 34 | None, 35 | "--limit", 36 | "-l", 37 | help="The number of tags to return.", 38 | ), 39 | offset: int = typer.Option( 40 | None, 41 | "--offset", 42 | "-o", 43 | help="The index from which to return results.", 44 | ), 45 | ) -> None: 46 | """Get all tags. 47 | 48 | Args: 49 | ctx: A Typer Context object. 50 | limit: The number of tags to return. 51 | offset: The index from which to return results. 52 | """ 53 | api_kwargs = generate_api_payload( 54 | ( 55 | (CONF_LIMIT, limit), 56 | (CONF_OFFSET, offset), 57 | ) 58 | ) 59 | 60 | data = asyncio.run(ctx.obj.client.tags.async_get_all(**api_kwargs)) 61 | typer.echo(json.dumps(data)) 62 | 63 | 64 | @log_exception() 65 | def get_by_id( 66 | ctx: typer.Context, 67 | tag_id: int = typer.Argument(..., help="The ID of a tag to retrieve."), 68 | ) -> None: 69 | """Get a tag by its linkding ID. 70 | 71 | Args: 72 | ctx: A Typer Context object. 73 | tag_id: The ID of a tag to retrieve. 74 | """ 75 | data = asyncio.run(ctx.obj.client.tags.async_get_single(tag_id)) 76 | typer.echo(json.dumps(data)) 77 | 78 | 79 | @log_exception() 80 | def main(_: typer.Context) -> None: 81 | """Interact with tags.""" 82 | pass 83 | 84 | 85 | TAG_APP = typer.Typer(callback=main) 86 | TAG_APP.command(name="all")(get_all) 87 | TAG_APP.command(name="create")(create) 88 | TAG_APP.command(name="get")(get_by_id) 89 | -------------------------------------------------------------------------------- /linkding_cli/commands/user.py: -------------------------------------------------------------------------------- 1 | """Define the user command.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import json 7 | 8 | import typer 9 | 10 | from linkding_cli.helpers.logging import log_exception 11 | 12 | 13 | @log_exception() 14 | def get_profile_info(ctx: typer.Context) -> None: 15 | """Get all tags. 16 | 17 | Args: 18 | ctx: A Typer Context object. 19 | """ 20 | data = asyncio.run(ctx.obj.client.user.async_get_profile()) 21 | typer.echo(json.dumps(data)) 22 | 23 | 24 | @log_exception() 25 | def main(_: typer.Context) -> None: 26 | """Interact with user info.""" 27 | pass 28 | 29 | 30 | USER_APP = typer.Typer(callback=main) 31 | USER_APP.command(name="profile")(get_profile_info) 32 | -------------------------------------------------------------------------------- /linkding_cli/config.py: -------------------------------------------------------------------------------- 1 | """Define configuration management.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import cast 6 | 7 | import typer 8 | from ruamel.yaml import YAML 9 | 10 | from linkding_cli.const import CONF_TOKEN, CONF_URL, CONF_VERBOSE, LOGGER 11 | from linkding_cli.errors import ConfigError 12 | 13 | CONF_CONFIG = "config" 14 | 15 | 16 | class Config: 17 | """Define the config manager object.""" 18 | 19 | def __init__(self, ctx: typer.Context) -> None: 20 | """Initialize. 21 | 22 | Args: 23 | ctx: A Typer Context object. 24 | 25 | Raises: 26 | ConfigError: Raised upon invalid config 27 | """ 28 | LOGGER.debug("Command: %s", ctx.invoked_subcommand) 29 | LOGGER.debug("Arguments: %s", ctx.args) 30 | LOGGER.debug("Options: %s", ctx.params) 31 | 32 | self._config = {} 33 | 34 | # If the user provides a config file, attempt to load it: 35 | if config_path := ctx.params[CONF_CONFIG]: 36 | parser = YAML(typ="safe") 37 | with open(config_path, encoding="utf-8") as config_file: 38 | self._config = parser.load(config_file) 39 | 40 | if not isinstance(self._config, dict): 41 | raise ConfigError(f"Unable to parse config file: {config_path}") 42 | 43 | # Merge the CLI options/environment variables in using this logic: 44 | # 1. If the value is not None, its an override and we should use it 45 | # 2. If a key doesn't exist in self._config yet, include it 46 | for key, value in ctx.params.items(): 47 | if value is not None or key not in self._config: 48 | self._config[key] = value 49 | 50 | # If, after all the configuration loading, we don't have a URL or a token, we 51 | # can't proceed: 52 | for param in (CONF_TOKEN, CONF_URL): 53 | if not self._config[param]: 54 | raise ConfigError(f"Missing required option: --{param}") 55 | 56 | LOGGER.debug("Loaded Config: %s", self) 57 | 58 | def __str__(self) -> str: 59 | """Define the string representation. 60 | 61 | Returns: 62 | A string representation. 63 | """ 64 | return f"" 65 | 66 | @property 67 | def token(self) -> str: 68 | """Return the linkding API token. 69 | 70 | Returns: 71 | The linkding API token. 72 | """ 73 | return cast(str, self._config[CONF_TOKEN]) 74 | 75 | @property 76 | def url(self) -> str: 77 | """Return the linkding URL. 78 | 79 | Returns: 80 | The linkding API token. 81 | """ 82 | return cast(str, self._config[CONF_URL]) 83 | 84 | @property 85 | def verbose(self) -> bool: 86 | """Return the verbosity level. 87 | 88 | Returns: 89 | The verbosity level. 90 | """ 91 | return cast(bool, self._config[CONF_VERBOSE]) 92 | -------------------------------------------------------------------------------- /linkding_cli/const.py: -------------------------------------------------------------------------------- 1 | """Define package constants.""" 2 | 3 | import logging 4 | 5 | LOGGER = logging.getLogger(__package__) 6 | 7 | CONF_LIMIT = "limit" 8 | CONF_OFFSET = "offset" 9 | CONF_TOKEN = "token" # noqa: S105, # nosec 10 | CONF_URL = "url" 11 | CONF_VERBOSE = "verbose" 12 | 13 | ENV_CONFIG = "LINKDING_CONFIG" 14 | ENV_TOKEN = "LINKDING_TOKEN" # noqa: S105, # nosec 15 | ENV_URL = "LINKDING_URL" 16 | -------------------------------------------------------------------------------- /linkding_cli/core.py: -------------------------------------------------------------------------------- 1 | """Define a data object.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | import typer 8 | from aiolinkding import Client 9 | 10 | from linkding_cli.config import Config 11 | from linkding_cli.const import CONF_VERBOSE 12 | from linkding_cli.helpers.logging import TyperLoggerHandler 13 | 14 | 15 | class LinkDing: # pylint: disable=too-few-public-methods 16 | """Define a master linkding manager object.""" 17 | 18 | def __init__(self, ctx: typer.Context) -> None: 19 | """Initialize. 20 | 21 | Args: 22 | ctx: A Typer Context object. 23 | """ 24 | if ctx.params[CONF_VERBOSE]: 25 | log_level = logging.DEBUG 26 | else: 27 | log_level = logging.INFO 28 | 29 | typer_handler = TyperLoggerHandler() 30 | logging.basicConfig( 31 | level=log_level, 32 | format="%(asctime)s | %(name)s | %(levelname)s | %(message)s", 33 | handlers=(typer_handler,), 34 | ) 35 | 36 | self.config = Config(ctx) 37 | self.client = Client(self.config.url, self.config.token) 38 | -------------------------------------------------------------------------------- /linkding_cli/errors.py: -------------------------------------------------------------------------------- 1 | """Define package exceptions.""" 2 | 3 | 4 | class LinkDingCliError(Exception): 5 | """Define a base exception.""" 6 | 7 | pass 8 | 9 | 10 | class ConfigError(LinkDingCliError): 11 | """Define an exception related to bad configuration.""" 12 | 13 | pass 14 | -------------------------------------------------------------------------------- /linkding_cli/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | """Define helpers.""" 2 | -------------------------------------------------------------------------------- /linkding_cli/helpers/logging.py: -------------------------------------------------------------------------------- 1 | """Define logging helpers.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import traceback 7 | from collections.abc import Callable 8 | from functools import wraps 9 | from typing import Any, TypeVar, cast 10 | 11 | import typer 12 | 13 | from linkding_cli.const import LOGGER 14 | 15 | 16 | class TyperLoggerHandler(logging.Handler): 17 | """Define a logging handler that works with Typer.""" 18 | 19 | def emit(self, record: logging.LogRecord) -> None: 20 | """Emit a log record. 21 | 22 | Args: 23 | record: The log record. 24 | """ 25 | foreground = None 26 | if record.levelno == logging.CRITICAL: 27 | foreground = typer.colors.BRIGHT_RED 28 | elif record.levelno == logging.DEBUG: 29 | foreground = typer.colors.BRIGHT_BLUE 30 | elif record.levelno == logging.ERROR: 31 | foreground = typer.colors.BRIGHT_RED 32 | elif record.levelno == logging.INFO: 33 | foreground = typer.colors.BRIGHT_GREEN 34 | elif record.levelno == logging.WARNING: 35 | foreground = typer.colors.BRIGHT_YELLOW 36 | typer.secho(self.format(record), fg=foreground) 37 | 38 | 39 | _T = TypeVar("_T") 40 | _CallableThatFailsType = Callable[..., _T] 41 | 42 | 43 | def log_exception( 44 | *, 45 | exit_code: int = 1, 46 | ) -> Callable[[_CallableThatFailsType], _CallableThatFailsType]: 47 | """Define a dectorator to handle exceptions via typer output. 48 | 49 | Args: 50 | exit_code: The code to exit with upon exception. 51 | 52 | Returns: 53 | The decorated callable. 54 | """ 55 | 56 | def decorator(func: _CallableThatFailsType) -> _CallableThatFailsType: 57 | """Decorate. 58 | 59 | Args: 60 | func: The callable to decorate. 61 | 62 | Returns: 63 | The decorated callable. 64 | """ 65 | 66 | @wraps(func) 67 | def wrapper(*args: Any, **kwargs: dict[str, Any]) -> dict[str, Any]: 68 | """Wrap. 69 | 70 | Args: 71 | args: The callable's arguments. 72 | kwargs: The callable's keyword arguments. 73 | 74 | Returns: 75 | The original callable's return type. 76 | 77 | Raises: 78 | Exit: Raised when the command fails in any way. 79 | """ 80 | try: 81 | return cast(dict[str, Any], func(*args, **kwargs)) 82 | except Exception as err: # pylint: disable=broad-except 83 | LOGGER.error(err) 84 | LOGGER.debug("".join(traceback.format_tb(err.__traceback__))) 85 | raise typer.Exit(code=exit_code) from err 86 | 87 | return wrapper 88 | 89 | return decorator 90 | -------------------------------------------------------------------------------- /linkding_cli/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachya/linkding-cli/4ac4435807de12e7ed184bd492d72454931c7db8/linkding_cli/py.typed -------------------------------------------------------------------------------- /linkding_cli/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 a set of param key/values. 10 | 11 | Args: 12 | param_pairs: A tuple of parameter key/value pairs. 13 | 14 | Returns: 15 | An API request payload. 16 | """ 17 | payload = {} 18 | 19 | for key, value in param_pairs: 20 | if value is not None: 21 | payload[key] = value 22 | 23 | return payload 24 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core==1.9.0"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.black] 6 | target-version = ["py39"] 7 | 8 | [tool.coverage.report] 9 | exclude_lines = ["raise NotImplementedError", "TYPE_CHECKING"] 10 | fail_under = 100 11 | show_missing = true 12 | 13 | [tool.coverage.run] 14 | source = ["linkding_cli"] 15 | 16 | [tool.isort] 17 | known_first_party = "linkding_cli,examples,tests" 18 | multi_line_output = 3 19 | profile = "black" 20 | 21 | [tool.mypy] 22 | check_untyped_defs = true 23 | disallow_incomplete_defs = true 24 | disallow_subclassing_any = true 25 | disallow_untyped_calls = true 26 | disallow_untyped_decorators = true 27 | disallow_untyped_defs = true 28 | follow_imports = "silent" 29 | ignore_missing_imports = true 30 | no_implicit_optional = true 31 | platform = "linux" 32 | python_version = "3.10" 33 | show_error_codes = true 34 | strict_equality = true 35 | warn_incomplete_stub = true 36 | warn_redundant_casts = true 37 | warn_return_any = true 38 | warn_unreachable = true 39 | warn_unused_configs = true 40 | warn_unused_ignores = true 41 | 42 | [tool.poetry] 43 | name = "linkding_cli" 44 | version = "2024.09.0" 45 | description = "A CLI to interact with a linkding instance" 46 | readme = "README.md" 47 | authors = ["Aaron Bach "] 48 | license = "MIT" 49 | repository = "https://github.com/bachya/linkding-cli" 50 | classifiers = [ 51 | "License :: OSI Approved :: MIT License", 52 | "Programming Language :: Python", 53 | "Programming Language :: Python :: 3", 54 | "Programming Language :: Python :: 3.10", 55 | "Programming Language :: Python :: 3.11", 56 | "Programming Language :: Python :: 3.12", 57 | "Programming Language :: Python :: Implementation :: CPython", 58 | "Programming Language :: Python :: Implementation :: PyPy", 59 | ] 60 | packages = [ 61 | { include = "linkding_cli" } 62 | ] 63 | 64 | [tool.poetry.dependencies] 65 | "ruamel.yaml" = ">=0.2.8" 66 | aiohttp = ">=3.9.0" 67 | aiolinkding = ">=2023.10.0" 68 | frozenlist = "^1.4.0" 69 | multidict = ">=6.0.5" 70 | python = "^3.10" 71 | shellingham = ">=1.5.4" 72 | typer = {extras = ["all"], version = ">=0.6,<0.14"} 73 | yarl = ">=1.9.2" 74 | 75 | [tool.poetry.group.dev.dependencies] 76 | blacken-docs = "^1.12.1" 77 | codespell = "^2.2.2" 78 | coverage = {version = ">=6.5,<8.0", extras = ["toml"]} 79 | darglint = "^1.8.1" 80 | isort = "^5.10.1" 81 | mypy = "^1.2.0" 82 | pre-commit = ">=2.20,<5.0" 83 | pre-commit-hooks = ">=4.3,<6.0" 84 | pylint = ">=3.0.2,<4.0.0" 85 | pytest = ">=7.2,<9.0" 86 | pytest-cov = ">=4,<7" 87 | pyupgrade = "^3.1.0" 88 | pyyaml = "^6.0.1" 89 | ruff = ">=0.5.1,<0.7.4" 90 | typing-extensions = "^4.8.0" 91 | vulture = "^2.6" 92 | yamllint = "^1.28.0" 93 | 94 | [tool.poetry.scripts] 95 | linkding = "linkding_cli.cli:APP" 96 | 97 | [tool.poetry.urls] 98 | "Bug Tracker" = "https://github.com/bachya/linkding_cli/issues" 99 | Changelog = "https://github.com/bachya/linkding_cli/releases" 100 | 101 | [tool.pylint.BASIC] 102 | expected-line-ending-format = "LF" 103 | 104 | [tool.pylint.DESIGN] 105 | max-attributes = 20 106 | 107 | [tool.pylint.FORMAT] 108 | max-line-length = 88 109 | 110 | [tool.pylint.MASTER] 111 | ignore = [ 112 | "tests", 113 | ] 114 | load-plugins = [ 115 | "pylint.extensions.bad_builtin", 116 | "pylint.extensions.code_style", 117 | "pylint.extensions.docparams", 118 | "pylint.extensions.docstyle", 119 | "pylint.extensions.empty_comment", 120 | "pylint.extensions.overlapping_exceptions", 121 | "pylint.extensions.typing", 122 | ] 123 | 124 | [tool.pylint."MESSAGES CONTROL"] 125 | # Reasons disabled: 126 | # too-many-arguments – Typer command can have a lot of arguments 127 | # unnecessary-pass - This can hurt readability 128 | disable = [ 129 | "too-many-arguments", 130 | "unnecessary-pass", 131 | ] 132 | 133 | [tool.pylint.REPORTS] 134 | score = false 135 | 136 | [tool.pylint.SIMILARITIES] 137 | # Minimum lines number of a similarity. 138 | # We set this higher because of some cases where V2 and V3 functionality are 139 | # similar, but abstracting them isn't feasible. 140 | min-similarity-lines = 8 141 | 142 | # Ignore comments when computing similarities. 143 | ignore-comments = true 144 | 145 | # Ignore docstrings when computing similarities. 146 | ignore-docstrings = true 147 | 148 | # Ignore imports when computing similarities. 149 | ignore-imports = true 150 | 151 | [tool.vulture] 152 | min_confidence = 80 153 | paths = ["linkding_cli", "tests"] 154 | verbose = false 155 | -------------------------------------------------------------------------------- /script/docs: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | poetry run sphinx-build ./docs ./docs/_build 5 | -------------------------------------------------------------------------------- /script/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | REPO_PATH="$( dirname "$( cd "$(dirname "$0")" ; pwd -P )" )" 5 | 6 | if [ "$(git rev-parse --abbrev-ref HEAD)" != "dev" ]; then 7 | echo "Refusing to publish a release from a branch other than dev" 8 | exit 1 9 | fi 10 | 11 | if [ -z "$(command -v poetry)" ]; then 12 | echo "Poetry needs to be installed to run this script: pip3 install poetry" 13 | exit 1 14 | fi 15 | 16 | function generate_version { 17 | latest_tag="$(git tag --sort=committerdate | tail -1)" 18 | month="$(date +'%Y.%m')" 19 | 20 | if [[ "$latest_tag" =~ "$month".* ]]; then 21 | patch="$(echo "$latest_tag" | cut -d . -f 3)" 22 | ((patch=patch+1)) 23 | echo "$month.$patch" 24 | else 25 | echo "$month.0" 26 | fi 27 | } 28 | 29 | # Temporarily uninstall pre-commit hooks so that we can push to dev and main: 30 | pre-commit uninstall 31 | 32 | # Pull the latest dev: 33 | git pull origin dev 34 | 35 | # Generate the next version (in the format YEAR.MONTH.RELEASE_NUMER): 36 | new_version=$(generate_version) 37 | 38 | # Update the PyPI package version: 39 | sed -i "" "s/^version = \".*\"/version = \"$new_version\"/g" "$REPO_PATH/pyproject.toml" 40 | git add pyproject.toml 41 | 42 | # Commit, tag, and push: 43 | git commit -m "Bump version to $new_version" 44 | git tag "$new_version" 45 | git push && git push --tags 46 | 47 | # Merge dev into main: 48 | git checkout main 49 | git merge dev 50 | git push 51 | git checkout dev 52 | 53 | # Re-initialize pre-commit: 54 | pre-commit install 55 | -------------------------------------------------------------------------------- /script/setup: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if command -v "mise"; then 5 | mise install 6 | fi 7 | 8 | # Install all dependencies: 9 | pip3 install poetry 10 | poetry install 11 | 12 | # Install pre-commit hooks: 13 | pre-commit install 14 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Define package tests.""" 2 | -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | """Define common test utilities.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | 7 | from linkding_cli.const import CONF_TOKEN, CONF_URL, CONF_VERBOSE 8 | 9 | TEST_TOKEN = "abcde_1234" # noqa: S105 10 | TEST_URL = "http://127.0.0.1:8080" 11 | 12 | TEST_RAW_JSON = json.dumps( 13 | { 14 | CONF_TOKEN: TEST_TOKEN, 15 | CONF_URL: TEST_URL, 16 | CONF_VERBOSE: False, 17 | } 18 | ) 19 | TEST_RAW_YAML = f""" 20 | --- 21 | {CONF_TOKEN}: {TEST_TOKEN} 22 | {CONF_URL}: {TEST_URL} 23 | {CONF_VERBOSE}: false 24 | """ 25 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Define dynamic fixtures.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pytest 6 | from typer.testing import CliRunner 7 | 8 | from linkding_cli.const import ENV_TOKEN, ENV_URL 9 | 10 | from .common import TEST_RAW_JSON, TEST_TOKEN, TEST_URL 11 | 12 | 13 | @pytest.fixture(name="config", scope="session") 14 | def config_fixture() -> str: 15 | """Define a fixture to return raw configuration data. 16 | 17 | Returns: 18 | A JSON string. 19 | """ 20 | return TEST_RAW_JSON 21 | 22 | 23 | @pytest.fixture(name="config_filepath") 24 | def config_filepath_fixture(config: str, tmp_path: str) -> str: 25 | """Define a fixture to return a config filepath. 26 | 27 | Args: 28 | config: A JSON string. 29 | tmp_path: A basepath to save the config at. 30 | 31 | Returns: 32 | A full config filepath. 33 | """ 34 | config_filepath = f"{tmp_path}/config.json" 35 | with open(config_filepath, "w", encoding="utf-8") as config_file: 36 | config_file.write(config) 37 | return config_filepath 38 | 39 | 40 | @pytest.fixture(name="runner") 41 | def runner_fixture() -> CliRunner: 42 | """Define a fixture to return a typer CLI test runner. 43 | 44 | Returns: 45 | A Typer CliRunner object. 46 | """ 47 | return CliRunner(env={ENV_TOKEN: TEST_TOKEN, ENV_URL: TEST_URL}) 48 | -------------------------------------------------------------------------------- /tests/test_bookmarks.py: -------------------------------------------------------------------------------- 1 | """Define tests for the bookmark-related operations.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | from typing import Any 7 | from unittest.mock import AsyncMock, patch 8 | 9 | import pytest 10 | from typer.testing import CliRunner 11 | 12 | from linkding_cli.cli import APP 13 | 14 | BOOKMARKS_ALL_RESPONSE = { 15 | "count": 123, 16 | "next": "http://127.0.0.1:8000/api/bookmarks/?limit=100&offset=100", 17 | "previous": None, 18 | "results": [ 19 | { 20 | "id": 1, 21 | "url": "https://example.com", 22 | "title": "Example title", 23 | "description": "Example description", 24 | "notes": "Example notes", 25 | "website_title": "Website title", 26 | "website_description": "Website description", 27 | "is_archived": False, 28 | "unread": False, 29 | "shared": False, 30 | "tag_names": ["tag1", "tag2"], 31 | "date_added": "2020-09-26T09:46:23.006313Z", 32 | "date_modified": "2020-09-26T16:01:14.275335Z", 33 | } 34 | ], 35 | } 36 | BOOKMARKS_SINGLE_RESPONSE = { 37 | "id": 1, 38 | "url": "https://example.com", 39 | "title": "Example title", 40 | "description": "Example description", 41 | "notes": "Example notes", 42 | "website_title": "Website title", 43 | "website_description": "Website description", 44 | "is_archived": False, 45 | "unread": False, 46 | "shared": False, 47 | "tag_names": ["tag1", "tag2"], 48 | "date_added": "2020-09-26T09:46:23.006313Z", 49 | "date_modified": "2020-09-26T16:01:14.275335Z", 50 | } 51 | 52 | 53 | @pytest.mark.parametrize( 54 | "args,api_coro_path,api_coro_args,api_coro_kwargs,api_output,stdout_output", 55 | [ 56 | ( 57 | ["bookmarks", "all"], 58 | "aiolinkding.bookmark.BookmarkManager.async_get_all", 59 | [], 60 | {}, 61 | BOOKMARKS_ALL_RESPONSE, 62 | json.dumps(BOOKMARKS_ALL_RESPONSE), 63 | ), 64 | ( 65 | ["bookmarks", "all", "--archived"], 66 | "aiolinkding.bookmark.BookmarkManager.async_get_archived", 67 | [], 68 | {}, 69 | BOOKMARKS_ALL_RESPONSE, 70 | json.dumps(BOOKMARKS_ALL_RESPONSE), 71 | ), 72 | ( 73 | ["bookmarks", "all", "-l", "10"], 74 | "aiolinkding.bookmark.BookmarkManager.async_get_all", 75 | [], 76 | {"limit": 10}, 77 | BOOKMARKS_ALL_RESPONSE, 78 | json.dumps(BOOKMARKS_ALL_RESPONSE), 79 | ), 80 | ( 81 | ["bookmarks", "all", "-o", "5"], 82 | "aiolinkding.bookmark.BookmarkManager.async_get_all", 83 | [], 84 | {"offset": 5}, 85 | BOOKMARKS_ALL_RESPONSE, 86 | json.dumps(BOOKMARKS_ALL_RESPONSE), 87 | ), 88 | ( 89 | ["bookmarks", "all", "-q", "Example"], 90 | "aiolinkding.bookmark.BookmarkManager.async_get_all", 91 | [], 92 | {"query": "Example"}, 93 | BOOKMARKS_ALL_RESPONSE, 94 | json.dumps(BOOKMARKS_ALL_RESPONSE), 95 | ), 96 | ( 97 | ["bookmarks", "archive", "12"], 98 | "aiolinkding.bookmark.BookmarkManager.async_archive", 99 | [12], 100 | {}, 101 | None, 102 | "Bookmark 12 archived.", 103 | ), 104 | ( 105 | ["bookmarks", "create", "https://example.com"], 106 | "aiolinkding.bookmark.BookmarkManager.async_create", 107 | ["https://example.com"], 108 | {"is_archived": False, "unread": False, "shared": False}, 109 | BOOKMARKS_SINGLE_RESPONSE, 110 | json.dumps(BOOKMARKS_SINGLE_RESPONSE), 111 | ), 112 | ( 113 | ["bookmarks", "create", "https://example.com", "-a", "--shared"], 114 | "aiolinkding.bookmark.BookmarkManager.async_create", 115 | ["https://example.com"], 116 | {"is_archived": True, "unread": False, "shared": True}, 117 | BOOKMARKS_SINGLE_RESPONSE, 118 | json.dumps(BOOKMARKS_SINGLE_RESPONSE), 119 | ), 120 | ( 121 | ["bookmarks", "create", "https://example.com", "-n", "Example notes"], 122 | "aiolinkding.bookmark.BookmarkManager.async_create", 123 | ["https://example.com"], 124 | { 125 | "is_archived": False, 126 | "unread": False, 127 | "shared": False, 128 | "notes": "Example notes", 129 | }, 130 | BOOKMARKS_SINGLE_RESPONSE, 131 | json.dumps(BOOKMARKS_SINGLE_RESPONSE), 132 | ), 133 | ( 134 | ["bookmarks", "create", "https://example.com", "-t", "Example"], 135 | "aiolinkding.bookmark.BookmarkManager.async_create", 136 | ["https://example.com"], 137 | { 138 | "is_archived": False, 139 | "title": "Example", 140 | "unread": False, 141 | "shared": False, 142 | }, 143 | BOOKMARKS_SINGLE_RESPONSE, 144 | json.dumps(BOOKMARKS_SINGLE_RESPONSE), 145 | ), 146 | ( 147 | [ 148 | "bookmarks", 149 | "create", 150 | "https://example.com", 151 | "-t", 152 | "Example", 153 | "-d", 154 | "A site description", 155 | ], 156 | "aiolinkding.bookmark.BookmarkManager.async_create", 157 | ["https://example.com"], 158 | { 159 | "description": "A site description", 160 | "is_archived": False, 161 | "shared": False, 162 | "title": "Example", 163 | "unread": False, 164 | }, 165 | BOOKMARKS_SINGLE_RESPONSE, 166 | json.dumps(BOOKMARKS_SINGLE_RESPONSE), 167 | ), 168 | ( 169 | [ 170 | "bookmarks", 171 | "create", 172 | "https://example.com", 173 | "-t", 174 | "Example", 175 | "-d", 176 | "A site description", 177 | "--tags", 178 | "single-tag", 179 | ], 180 | "aiolinkding.bookmark.BookmarkManager.async_create", 181 | ["https://example.com"], 182 | { 183 | "description": "A site description", 184 | "is_archived": False, 185 | "shared": False, 186 | "tag_names": ["single-tag"], 187 | "title": "Example", 188 | "unread": False, 189 | }, 190 | BOOKMARKS_SINGLE_RESPONSE, 191 | json.dumps(BOOKMARKS_SINGLE_RESPONSE), 192 | ), 193 | ( 194 | [ 195 | "bookmarks", 196 | "create", 197 | "https://example.com", 198 | "-t", 199 | "Example", 200 | "-d", 201 | "A site description", 202 | "--tags", 203 | "tag1,tag2,tag3", 204 | ], 205 | "aiolinkding.bookmark.BookmarkManager.async_create", 206 | ["https://example.com"], 207 | { 208 | "description": "A site description", 209 | "is_archived": False, 210 | "shared": False, 211 | "tag_names": ["tag1", "tag2", "tag3"], 212 | "title": "Example", 213 | "unread": False, 214 | }, 215 | BOOKMARKS_SINGLE_RESPONSE, 216 | json.dumps(BOOKMARKS_SINGLE_RESPONSE), 217 | ), 218 | ( 219 | ["bookmarks", "create", "https://example.com", "--unread"], 220 | "aiolinkding.bookmark.BookmarkManager.async_create", 221 | ["https://example.com"], 222 | {"is_archived": False, "unread": True, "shared": False}, 223 | BOOKMARKS_SINGLE_RESPONSE, 224 | json.dumps(BOOKMARKS_SINGLE_RESPONSE), 225 | ), 226 | ( 227 | ["bookmarks", "delete", "12"], 228 | "aiolinkding.bookmark.BookmarkManager.async_delete", 229 | [12], 230 | {}, 231 | None, 232 | "Bookmark 12 deleted.", 233 | ), 234 | ( 235 | ["bookmarks", "get", "12"], 236 | "aiolinkding.bookmark.BookmarkManager.async_get_single", 237 | [12], 238 | {}, 239 | BOOKMARKS_SINGLE_RESPONSE, 240 | json.dumps(BOOKMARKS_SINGLE_RESPONSE), 241 | ), 242 | ( 243 | ["bookmarks", "unarchive", "12"], 244 | "aiolinkding.bookmark.BookmarkManager.async_unarchive", 245 | [12], 246 | {}, 247 | None, 248 | "Bookmark 12 unarchived.", 249 | ), 250 | ( 251 | ["bookmarks", "update", "12", "-u", "https://example.com", "--shared"], 252 | "aiolinkding.bookmark.BookmarkManager.async_update", 253 | [12], 254 | {"url": "https://example.com", "unread": False, "shared": True}, 255 | BOOKMARKS_SINGLE_RESPONSE, 256 | json.dumps(BOOKMARKS_SINGLE_RESPONSE), 257 | ), 258 | ( 259 | ["bookmarks", "update", "12", "-t", "Updated Title"], 260 | "aiolinkding.bookmark.BookmarkManager.async_update", 261 | [12], 262 | {"title": "Updated Title", "unread": False, "shared": False}, 263 | BOOKMARKS_SINGLE_RESPONSE, 264 | json.dumps(BOOKMARKS_SINGLE_RESPONSE), 265 | ), 266 | ( 267 | ["bookmarks", "update", "12", "--tags", "different-tag1,different-tag2"], 268 | "aiolinkding.bookmark.BookmarkManager.async_update", 269 | [12], 270 | { 271 | "tag_names": ["different-tag1", "different-tag2"], 272 | "unread": False, 273 | "shared": False, 274 | }, 275 | BOOKMARKS_SINGLE_RESPONSE, 276 | json.dumps(BOOKMARKS_SINGLE_RESPONSE), 277 | ), 278 | ( 279 | ["bookmarks", "update", "12", "--unread"], 280 | "aiolinkding.bookmark.BookmarkManager.async_update", 281 | [12], 282 | {"unread": True, "shared": False}, 283 | BOOKMARKS_SINGLE_RESPONSE, 284 | json.dumps(BOOKMARKS_SINGLE_RESPONSE), 285 | ), 286 | ], 287 | ) 288 | def test_bookmark_command_api_calls( # pylint: disable=too-many-positional-arguments 289 | args: list[str], 290 | api_coro_path: str, 291 | api_coro_args: list[int | str], 292 | api_coro_kwargs: dict[str, Any], 293 | api_output: dict[str, Any], 294 | runner: CliRunner, 295 | stdout_output: str, 296 | ) -> None: 297 | """Test various `linkding bookmarks` commands/API calls. 298 | 299 | Args: 300 | args: The arguments to pass to the command. 301 | api_coro_path: The module path to a coroutine function. 302 | api_coro_args: The arguments to pass to the coroutine function. 303 | api_coro_kwargs: The keyword arguments to pass to the coroutine function. 304 | api_output: An API response payload. 305 | runner: A Typer CliRunner object. 306 | stdout_output: The output displayed on stdout. 307 | """ 308 | with patch(api_coro_path, AsyncMock(return_value=api_output)) as mocked_api_call: 309 | result = runner.invoke(APP, args) 310 | mocked_api_call.assert_awaited_with(*api_coro_args, **api_coro_kwargs) 311 | assert stdout_output in result.stdout 312 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """Define tests for configuration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from unittest.mock import Mock 7 | 8 | import pytest 9 | from typer.testing import CliRunner 10 | 11 | from linkding_cli.cli import APP 12 | from linkding_cli.const import CONF_TOKEN, CONF_URL, ENV_TOKEN 13 | from linkding_cli.helpers.logging import TyperLoggerHandler 14 | 15 | from .common import TEST_RAW_JSON, TEST_RAW_YAML, TEST_TOKEN, TEST_URL 16 | 17 | 18 | @pytest.mark.parametrize("runner", [CliRunner()]) 19 | @pytest.mark.parametrize("config", [TEST_RAW_JSON, TEST_RAW_YAML]) 20 | def test_config_file(caplog: Mock, config_filepath: str, runner: CliRunner) -> None: 21 | """Test successfully loading a valid config file. 22 | 23 | Args: 24 | caplog: A mock logging utility. 25 | config_filepath: A path to a config file. 26 | runner: A Typer CliRunner object 27 | """ 28 | caplog.set_level(logging.DEBUG) 29 | runner.invoke(APP, ["-v", "-c", config_filepath, "bookmarks"]) 30 | assert ( 31 | f"" in caplog.messages[3] 32 | ) 33 | assert not any( 34 | level for _, level, _ in caplog.record_tuples if level == logging.ERROR 35 | ) 36 | 37 | 38 | @pytest.mark.parametrize("runner", [CliRunner()]) 39 | @pytest.mark.parametrize("config", ["{}"]) 40 | def test_config_file_empty( 41 | caplog: Mock, config_filepath: str, runner: CliRunner 42 | ) -> None: 43 | """Test an empty config file with no overrides. 44 | 45 | Args: 46 | caplog: A mock logging utility. 47 | config_filepath: A path to a config file. 48 | runner: A Typer CliRunner object 49 | """ 50 | runner.invoke(APP, ["-c", config_filepath, "bookmarks"]) 51 | assert "Missing required option: --token" in caplog.messages[0] 52 | 53 | 54 | @pytest.mark.parametrize("runner", [CliRunner()]) 55 | def test_config_file_overrides_cli( 56 | caplog: Mock, config_filepath: str, runner: CliRunner 57 | ) -> None: 58 | """Test a config file with CLI option overrides. 59 | 60 | Args: 61 | caplog: A mock logging utility. 62 | config_filepath: A path to a config file. 63 | runner: A Typer CliRunner object 64 | """ 65 | caplog.set_level(logging.DEBUG) 66 | runner.invoke(APP, ["-v", "-c", config_filepath, "-t", "TEST_TOKEN", "bookmarks"]) 67 | assert any( 68 | m 69 | for m in caplog.messages 70 | if f"" in m 71 | ) 72 | 73 | runner.invoke(APP, ["-v", "-c", config_filepath, "-u", "TEST_URL", "bookmarks"]) 74 | assert any( 75 | m 76 | for m in caplog.messages 77 | if f"" in m 78 | ) 79 | 80 | 81 | @pytest.mark.parametrize("runner", [CliRunner(env={ENV_TOKEN: "TEST_TOKEN"})]) 82 | def test_config_file_overrides_env_vars( 83 | caplog: Mock, config_filepath: str, runner: CliRunner 84 | ) -> None: 85 | """Test a config file with environment variable overrides. 86 | 87 | Args: 88 | caplog: A mock logging utility. 89 | config_filepath: A path to a config file. 90 | runner: A Typer CliRunner object 91 | """ 92 | caplog.set_level(logging.DEBUG) 93 | runner.invoke(APP, ["-v", "-c", config_filepath, "bookmarks"]) 94 | assert ( 95 | f"" in caplog.messages[3] 96 | ) 97 | 98 | 99 | @pytest.mark.parametrize("runner", [CliRunner()]) 100 | @pytest.mark.parametrize("config", ["Fake configuration!"]) 101 | def test_config_file_unparsable( 102 | caplog: Mock, config_filepath: str, runner: CliRunner 103 | ) -> None: 104 | """Test a config file that can't be parsed as JSON or YAML. 105 | 106 | Args: 107 | caplog: A mock logging utility. 108 | config_filepath: A path to a config file. 109 | runner: A Typer CliRunner object 110 | """ 111 | caplog.set_level(logging.DEBUG) 112 | runner.invoke(APP, ["-c", config_filepath, "bookmarks"]) 113 | assert "Unable to parse config file" in caplog.messages[3] 114 | 115 | 116 | def test_missing_command(runner: CliRunner) -> None: 117 | """Test a missing command. 118 | 119 | Args: 120 | runner: A Typer CliRunner object 121 | """ 122 | result = runner.invoke(APP, []) 123 | assert result.exit_code == 2 124 | assert "Missing command" in result.stdout 125 | 126 | 127 | @pytest.mark.parametrize("runner", [CliRunner()]) 128 | @pytest.mark.parametrize( 129 | "args,missing_arg", 130 | [ 131 | (["bookmarks"], CONF_TOKEN), 132 | (["-u", TEST_URL, "bookmarks"], CONF_TOKEN), 133 | (["-t", TEST_TOKEN, "bookmarks"], CONF_URL), 134 | ], 135 | ) 136 | def test_missing_required_cli_options( 137 | args: list[str], caplog: Mock, missing_arg: str, runner: CliRunner 138 | ) -> None: 139 | """Test missing required options when only using the CLI. 140 | 141 | Args: 142 | args: A list of CLI arguments 143 | caplog: A mock logging utility. 144 | missing_arg: A single missing argument. 145 | runner: A Typer CliRunner object 146 | """ 147 | runner.invoke(APP, args) 148 | assert "Missing required option" in caplog.messages[0] 149 | for arg in missing_arg: 150 | assert arg in caplog.messages[0] 151 | 152 | 153 | def test_startup_logging(caplog: Mock, runner: CliRunner) -> None: 154 | """Test startup logging at various levels. 155 | 156 | Args: 157 | caplog: A mock logging utility. 158 | runner: A Typer CliRunner object 159 | """ 160 | caplog.set_level(logging.INFO) 161 | runner.invoke(APP, ["bookmarks"]) 162 | info_log_messages = caplog.messages 163 | 164 | caplog.set_level(logging.DEBUG) 165 | runner.invoke(APP, ["-v", "bookmarks"]) 166 | debug_log_messages = caplog.messages 167 | 168 | # There should be more DEBUG-level logs than INFO-level logs: 169 | assert len(debug_log_messages) > len(info_log_messages) 170 | 171 | 172 | def test_typer_logging_handler(caplog: Mock) -> None: 173 | """Test the TyperLoggerHandler helper. 174 | 175 | Args: 176 | caplog: A mock logging utility. 177 | """ 178 | caplog.set_level(logging.DEBUG) 179 | 180 | handler = TyperLoggerHandler() 181 | logger = logging.getLogger("test") 182 | logger.addHandler(handler) 183 | 184 | logger.critical("Test Critical Message") 185 | logger.debug("Test Debug Message") 186 | logger.error("Test Error Message") 187 | logger.info("Test Info Message") 188 | logger.warning("Test Warning Message") 189 | 190 | assert len(caplog.messages) == 5 191 | 192 | 193 | @pytest.mark.parametrize("runner", [CliRunner()]) 194 | def test_url_and_token_via_arguments(caplog: Mock, runner: CliRunner) -> None: 195 | """Test passing linkding URL and token via explicit CLI arguments. 196 | 197 | Args: 198 | caplog: A mock logging utility. 199 | runner: A Typer CliRunner object 200 | """ 201 | caplog.set_level(logging.DEBUG) 202 | runner.invoke(APP, ["-v", "-u", TEST_URL, "-t", TEST_TOKEN, "bookmarks"]) 203 | assert ( 204 | f" None: 209 | """Test passing linkding URL and token via environment variables. 210 | 211 | Args: 212 | caplog: A mock logging utility. 213 | runner: A Typer CliRunner object 214 | """ 215 | caplog.set_level(logging.DEBUG) 216 | runner.invoke(APP, ["-v", "bookmarks"]) 217 | assert ( 218 | f" None: 224 | """Test verbose logging. 225 | 226 | Args: 227 | args: A list of CLI arguments 228 | caplog: A mock logging utility. 229 | runner: A Typer CliRunner object 230 | """ 231 | caplog.set_level(logging.INFO) 232 | runner.invoke(APP, args) 233 | info_log_messages = caplog.messages 234 | 235 | caplog.set_level(logging.DEBUG) 236 | runner.invoke(APP, ["-v"] + args) 237 | debug_log_messages = caplog.messages 238 | 239 | # There should be more DEBUG-level logs than INFO-level logs: 240 | assert len(debug_log_messages) > len(info_log_messages) 241 | -------------------------------------------------------------------------------- /tests/test_tags.py: -------------------------------------------------------------------------------- 1 | """Define tests for the tag-related operations.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | from typing import Any 7 | from unittest.mock import AsyncMock, patch 8 | 9 | import pytest 10 | from typer.testing import CliRunner 11 | 12 | from linkding_cli.cli import APP 13 | 14 | TAGS_ALL_RESPONSE = { 15 | "count": 123, 16 | "next": "http://127.0.0.1:8000/api/tags/?limit=100&offset=100", 17 | "previous": None, 18 | "results": [ 19 | { 20 | "id": 1, 21 | "name": "example", 22 | "date_added": "2020-09-26T09:46:23.006313Z", 23 | } 24 | ], 25 | } 26 | TAGS_SINGLE_RESPONSE = { 27 | "id": 1, 28 | "name": "example-tag", 29 | "date_added": "2022-05-14T02:06:20.627370Z", 30 | } 31 | 32 | 33 | @pytest.mark.parametrize( 34 | "args,api_coro_path,api_coro_args,api_coro_kwargs,api_output,stdout_output", 35 | [ 36 | ( 37 | ["tags", "all"], 38 | "aiolinkding.tag.TagManager.async_get_all", 39 | [], 40 | {}, 41 | TAGS_ALL_RESPONSE, 42 | json.dumps(TAGS_ALL_RESPONSE), 43 | ), 44 | ( 45 | ["tags", "all", "-l", "10"], 46 | "aiolinkding.tag.TagManager.async_get_all", 47 | [], 48 | {"limit": 10}, 49 | TAGS_ALL_RESPONSE, 50 | json.dumps(TAGS_ALL_RESPONSE), 51 | ), 52 | ( 53 | ["tags", "all", "-o", "5"], 54 | "aiolinkding.tag.TagManager.async_get_all", 55 | [], 56 | {"offset": 5}, 57 | TAGS_ALL_RESPONSE, 58 | json.dumps(TAGS_ALL_RESPONSE), 59 | ), 60 | ( 61 | ["tags", "create", "sample-tag"], 62 | "aiolinkding.tag.TagManager.async_create", 63 | ["sample-tag"], 64 | {}, 65 | TAGS_SINGLE_RESPONSE, 66 | json.dumps(TAGS_SINGLE_RESPONSE), 67 | ), 68 | ( 69 | ["tags", "get", "12"], 70 | "aiolinkding.tag.TagManager.async_get_single", 71 | [12], 72 | {}, 73 | TAGS_SINGLE_RESPONSE, 74 | json.dumps(TAGS_SINGLE_RESPONSE), 75 | ), 76 | ], 77 | ) 78 | def test_tag_command_api_calls( # pylint: disable=too-many-positional-arguments 79 | args: list[str], 80 | api_coro_path: str, 81 | api_coro_args: list[int | str], 82 | api_coro_kwargs: dict[str, Any], 83 | api_output: dict[str, Any], 84 | runner: CliRunner, 85 | stdout_output: str, 86 | ) -> None: 87 | """Test various `linkding tags` commands/API calls. 88 | 89 | Args: 90 | args: The arguments to pass to the command. 91 | api_coro_path: The module path to a coroutine function. 92 | api_coro_args: The arguments to pass to the coroutine function. 93 | api_coro_kwargs: The keyword arguments to pass to the coroutine function. 94 | api_output: An API response payload. 95 | runner: A Typer CliRunner object. 96 | stdout_output: The output displayed on stdout. 97 | """ 98 | with patch(api_coro_path, AsyncMock(return_value=api_output)) as mocked_api_call: 99 | result = runner.invoke(APP, args) 100 | mocked_api_call.assert_awaited_with(*api_coro_args, **api_coro_kwargs) 101 | assert stdout_output in result.stdout 102 | -------------------------------------------------------------------------------- /tests/test_user.py: -------------------------------------------------------------------------------- 1 | """Define tests for the user-related operations.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | from typing import Any 7 | from unittest.mock import AsyncMock, patch 8 | 9 | import pytest 10 | from typer.testing import CliRunner 11 | 12 | from linkding_cli.cli import APP 13 | 14 | USER_PROFILE_RESPONSE = { 15 | "theme": "auto", 16 | "bookmark_date_display": "relative", 17 | "bookmark_link_target": "_blank", 18 | "web_archive_integration": "enabled", 19 | "tag_search": "lax", 20 | "enable_sharing": True, 21 | "enable_public_sharing": True, 22 | "enable_favicons": False, 23 | "display_url": False, 24 | "permanent_notes": False, 25 | "search_preferences": {"sort": "title_asc", "shared": "off", "unread": "off"}, 26 | } 27 | 28 | 29 | @pytest.mark.parametrize( 30 | "args,api_coro_path,api_coro_args,api_coro_kwargs,api_output,stdout_output", 31 | [ 32 | ( 33 | ["user", "profile"], 34 | "aiolinkding.user.UserManager.async_get_profile", 35 | [], 36 | {}, 37 | USER_PROFILE_RESPONSE, 38 | json.dumps(USER_PROFILE_RESPONSE), 39 | ), 40 | ], 41 | ) 42 | def test_tag_command_api_calls( # pylint: disable=too-many-positional-arguments 43 | args: list[str], 44 | api_coro_path: str, 45 | api_coro_args: list[int | str], 46 | api_coro_kwargs: dict[str, Any], 47 | api_output: dict[str, Any], 48 | runner: CliRunner, 49 | stdout_output: str, 50 | ) -> None: 51 | """Test various `linkding tags` commands/API calls. 52 | 53 | Args: 54 | args: The arguments to pass to the command. 55 | api_coro_path: The module path to a coroutine function. 56 | api_coro_args: The arguments to pass to the coroutine function. 57 | api_coro_kwargs: The keyword arguments to pass to the coroutine function. 58 | api_output: An API response payload. 59 | runner: A Typer CliRunner object. 60 | stdout_output: The output displayed on stdout. 61 | """ 62 | with patch(api_coro_path, AsyncMock(return_value=api_output)) as mocked_api_call: 63 | result = runner.invoke(APP, args) 64 | mocked_api_call.assert_awaited_with(*api_coro_args, **api_coro_kwargs) 65 | assert stdout_output in result.stdout 66 | --------------------------------------------------------------------------------