├── .gitattributes ├── .github └── workflows │ ├── codeql.yml │ ├── deps-review.yml │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierignore ├── .prettierrc.yaml ├── .python-version ├── .renovaterc.json ├── .taplo.toml ├── .typos.toml ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── PKGBUILD ├── README.md ├── cliff.toml ├── docs ├── changelog.md ├── index.md ├── reference-cli.md └── scripts │ └── gen_ref_nav.py ├── mkdocs.yml ├── poetry.lock ├── pyproject.toml ├── src └── images_upload_cli │ ├── __init__.py │ ├── __main__.py │ ├── _cli.py │ ├── image.py │ ├── logger.py │ ├── main.py │ ├── upload.py │ └── util.py └── tests ├── __init__.py ├── conftest.py ├── data ├── .env.sample ├── DejaVuSerif.ttf └── pic.png ├── mock.py ├── test_bm.py ├── test_cli.py ├── test_image.py ├── test_logger.py ├── test_main.py ├── test_upload.py └── test_util.py /.gitattributes: -------------------------------------------------------------------------------- 1 | .vscode/*.json linguist-language=jsonc 2 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | permissions: 10 | security-events: write 11 | 12 | jobs: 13 | codeql: 14 | name: CodeQL analyze 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Initialize CodeQL 20 | uses: github/codeql-action/init@v3 21 | with: 22 | languages: python 23 | 24 | - name: Perform CodeQL analysis 25 | uses: github/codeql-action/analyze@v3 26 | -------------------------------------------------------------------------------- /.github/workflows/deps-review.yml: -------------------------------------------------------------------------------- 1 | name: Deps Review 2 | 3 | on: 4 | pull_request: 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | deps-review: 11 | name: Deps Review 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Dependency Review 17 | uses: actions/dependency-review-action@v4 18 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: 7 | - "v*" 8 | pull_request: 9 | branches: [main] 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | lint: 16 | name: Lint code 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Install poetry 22 | run: pipx install poetry 23 | 24 | - uses: actions/setup-python@v5 25 | with: 26 | cache: poetry 27 | 28 | - name: Install deps 29 | run: poetry install 30 | 31 | - name: Lint code 32 | run: poetry run poe lint 33 | 34 | tests: 35 | name: Tests 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | os: [ubuntu-latest] 40 | python-version: ["3.10", "3.11", "3.12", "3.13"] 41 | include: 42 | - os: macos-latest 43 | python-version: "3.13" 44 | - os: windows-latest 45 | python-version: "3.13" 46 | runs-on: ${{ matrix.os }} 47 | steps: 48 | - uses: actions/checkout@v4 49 | 50 | - name: Install poetry 51 | run: pipx install poetry 52 | 53 | - uses: actions/setup-python@v5 54 | with: 55 | python-version: ${{ matrix.python-version }} 56 | cache: poetry 57 | 58 | - name: Install deps 59 | run: poetry install 60 | 61 | - name: Run tests 62 | run: poetry run poe test 63 | 64 | - name: Upload coverage to Codecov 65 | if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' 66 | uses: codecov/codecov-action@4fe8c5f003fae66aa5ebb77cfd3e7bfbbda0b6b0 # v3.1.5 67 | 68 | pypi-deploy: 69 | name: Release to PyPI 70 | if: github.ref_type == 'tag' 71 | needs: [lint, tests] 72 | environment: pypi 73 | permissions: 74 | id-token: write 75 | runs-on: ubuntu-latest 76 | steps: 77 | - uses: actions/checkout@v4 78 | 79 | - name: Install poetry 80 | run: | 81 | pipx install poetry 82 | pipx inject poetry poetry-dynamic-versioning[plugin] 83 | 84 | - uses: actions/setup-python@v5 85 | 86 | - name: Build package 87 | run: poetry build 88 | 89 | - name: Publish package to PyPI 90 | uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 91 | 92 | aur-deploy: 93 | name: Release to AUR 94 | if: true && !contains(github.ref, 'alpha') && !contains(github.ref, 'beta') 95 | needs: [pypi-deploy] 96 | environment: 97 | name: aur 98 | url: https://aur.archlinux.org/packages/python-images-upload-cli 99 | runs-on: ubuntu-latest 100 | steps: 101 | - uses: actions/checkout@v4 102 | 103 | - name: Wait for PyPI update 104 | run: sleep 10 105 | 106 | - name: Update version in PKGBUILD 107 | run: | 108 | sed "s|^pkgver=.*$|pkgver=\"${GITHUB_REF_NAME#v}\"|" -i PKGBUILD 109 | 110 | - name: Deploy PKGBUILD to the Arch User Repository 111 | uses: ksxgithub/github-actions-deploy-aur@2ac5a4c1d7035885d46b10e3193393be8460b6f1 # v4.1.1 112 | with: 113 | pkgname: python-images-upload-cli 114 | pkgbuild: ./PKGBUILD 115 | commit_username: DeadNews 116 | commit_email: deadnewsgit@gmail.com 117 | ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} 118 | commit_message: Upstream release ${{ github.ref_name }} 119 | updpkgsums: true 120 | 121 | github-deploy: 122 | name: Release to GitHub 123 | if: github.ref_type == 'tag' 124 | needs: [lint, tests] 125 | environment: github-releases 126 | permissions: 127 | contents: write 128 | env: 129 | CHANGELOG: https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md 130 | PRERELEASE: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }} 131 | runs-on: ubuntu-latest 132 | steps: 133 | - uses: actions/checkout@v4 134 | 135 | - name: Create GitHub Release 136 | run: | 137 | gh release create ${{ github.ref_name }} \ 138 | --title ${{ github.ref_name }} \ 139 | --notes="See [the CHANGELOG](${{ env.CHANGELOG }}) for more details." \ 140 | --draft=${{ env.PRERELEASE }} \ 141 | --prerelease=${{ env.PRERELEASE }} 142 | env: 143 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 144 | 145 | build-win: 146 | name: Windows Build 147 | if: github.ref_type == 'tag' 148 | needs: [github-deploy] 149 | environment: github-releases 150 | permissions: 151 | contents: write 152 | env: 153 | ASSET: ${{ github.event.repository.name }}_${{ github.ref_name }}_windows_amd64.zip 154 | BINARY: ${{ github.event.repository.name }}.exe 155 | NUITKA_CACHE_DIR: /cache/nuitka 156 | PYTHON_VERSION: "3.13" 157 | runs-on: windows-latest 158 | steps: 159 | - uses: actions/checkout@v4 160 | 161 | - name: Install poetry 162 | run: pipx install poetry 163 | 164 | - uses: actions/setup-python@v5 165 | with: 166 | python-version: ${{ env.PYTHON_VERSION }} 167 | cache: poetry 168 | 169 | - name: Install deps 170 | run: poetry install 171 | 172 | - name: Cache nuitka 173 | uses: actions/cache@v4 174 | with: 175 | key: ${{ runner.os }}-nuitka 176 | path: ${{ env.NUITKA_CACHE_DIR }} 177 | 178 | - name: Build binaries 179 | run: poetry run poe nuitka --output-file ${{ env.BINARY }} 180 | 181 | - name: Archive binaries 182 | run: poetry run python -m zipfile --create ${{ env.ASSET }} dist/${{ env.BINARY }} 183 | 184 | - name: Upload binaries to Release 185 | run: gh release upload ${{ github.ref_name }} ${{ env.ASSET }} 186 | env: 187 | GITHUB_TOKEN: ${{ github.token }} 188 | 189 | docs-build: 190 | name: Build docs 191 | if: github.ref_type == 'tag' 192 | needs: [lint, tests] 193 | runs-on: ubuntu-latest 194 | steps: 195 | - uses: actions/checkout@v4 196 | 197 | - name: Install poetry 198 | run: pipx install poetry 199 | 200 | - uses: actions/setup-python@v5 201 | with: 202 | cache: poetry 203 | 204 | - name: Install deps 205 | run: poetry install 206 | 207 | - name: Build docs 208 | run: poetry run mkdocs build 209 | 210 | - name: Setup Pages 211 | uses: actions/configure-pages@v5 212 | 213 | - name: Upload artifact 214 | uses: actions/upload-pages-artifact@v3 215 | with: 216 | path: site 217 | 218 | docs-deploy: 219 | name: Deploy docs 220 | if: true && !contains(github.ref, 'alpha') 221 | needs: [docs-build] 222 | environment: 223 | name: github-pages 224 | url: ${{ steps.deployment.outputs.page_url }} 225 | permissions: 226 | id-token: write 227 | pages: write 228 | runs-on: ubuntu-latest 229 | steps: 230 | - name: Deploy to GitHub Pages 231 | id: deployment 232 | uses: actions/deploy-pages@v4 233 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | .benchmarks/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # ruff 157 | .ruff_cache/ 158 | 159 | # vscode 160 | .vscode/* 161 | !.vscode/settings.json 162 | !.vscode/tasks.json 163 | !.vscode/launch.json 164 | !.vscode/extensions.json 165 | !.vscode/*.code-snippets 166 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autofix_commit_msg: "chore: auto fixes from `pre-commit` hooks" 3 | autoupdate_commit_msg: "chore(pre-commit): autoupdate" 4 | autoupdate_schedule: quarterly 5 | 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v5.0.0 9 | hooks: 10 | - id: check-added-large-files 11 | - id: check-case-conflict 12 | - id: check-merge-conflict 13 | - id: check-toml 14 | - id: check-yaml 15 | - id: detect-private-key 16 | - id: end-of-file-fixer 17 | - id: trailing-whitespace 18 | - id: mixed-line-ending 19 | args: [--fix=lf] 20 | 21 | - repo: https://github.com/pre-commit/mirrors-prettier 22 | rev: v3.1.0 23 | hooks: 24 | - id: prettier 25 | 26 | - repo: https://github.com/crate-ci/typos 27 | rev: v1.32.0 28 | hooks: 29 | - id: typos 30 | 31 | - repo: https://github.com/python-jsonschema/check-jsonschema 32 | rev: 0.33.0 33 | hooks: 34 | - id: check-github-workflows 35 | - id: check-renovate 36 | 37 | - repo: https://github.com/rhysd/actionlint 38 | rev: v1.7.7 39 | hooks: 40 | - id: actionlint 41 | 42 | - repo: https://github.com/mrtazz/checkmake 43 | rev: 0.2.2 44 | hooks: 45 | - id: checkmake 46 | 47 | - repo: https://github.com/astral-sh/ruff-pre-commit 48 | rev: v0.11.8 49 | hooks: 50 | - id: ruff-format 51 | - id: ruff 52 | args: [--fix] 53 | 54 | - repo: https://github.com/pre-commit/mirrors-mypy 55 | rev: v1.15.0 56 | hooks: 57 | - id: mypy 58 | 59 | - repo: https://github.com/python-poetry/poetry 60 | rev: 2.1.2 61 | hooks: 62 | - id: poetry-check 63 | args: [--lock] 64 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | docs/reference-cli.md 2 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | printWidth: 80 2 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /.renovaterc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended", "github>DeadNews/renovate-config"], 4 | "packageRules": [ 5 | { 6 | "addLabels": ["main"], 7 | "description": "Group httpx and pytest-httpx in one PR", 8 | "groupName": "httpx and pytest-httpx", 9 | "matchManagers": ["poetry"], 10 | "matchPackageNames": ["httpx", "pytest-httpx"] 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.taplo.toml: -------------------------------------------------------------------------------- 1 | [formatting] 2 | allowed_blank_lines = 1 3 | -------------------------------------------------------------------------------- /.typos.toml: -------------------------------------------------------------------------------- 1 | [files] 2 | extend-exclude = ["CHANGELOG.md"] 3 | 4 | [default] 5 | extend-ignore-re = [ 6 | "[a-zA-Z]{0,3}", # ignore short words 7 | ] 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "ms-pyright.pyright", 5 | "ms-python.mypy-type-checker", 6 | "ms-python.python", 7 | "tamasfe.even-better-toml" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.defaultFormatter": "charliermarsh.ruff", 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll": "explicit", 7 | "source.organizeImports": "explicit" 8 | } 9 | }, 10 | "python.testing.pytestEnabled": true 11 | } 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [3.0.5](https://github.com/DeadNews/images-upload-cli/compare/v3.0.4...v3.0.5) - 2024-12-15 4 | 5 | ### 🧹 Chores 6 | 7 | - _(config)_ migrate config .renovaterc.json ([#257](https://github.com/DeadNews/images-upload-cli/issues/257)) - ([c08f711](https://github.com/DeadNews/images-upload-cli/commit/c08f711911c91fff61bf65fd602961871b793db4)) 8 | 9 | ### ⚙️ CI/CD 10 | 11 | - _(github)_ update `build-win` to `python:3.13` ([#270](https://github.com/DeadNews/images-upload-cli/issues/270)) - ([9ad2853](https://github.com/DeadNews/images-upload-cli/commit/9ad285390263b1f55e4ebf62e4161e76a7131a25)) 12 | - _(renovate)_ group `httpx` and `pytest-httpx` in one PR - ([b3fa5d5](https://github.com/DeadNews/images-upload-cli/commit/b3fa5d5459acd47c50e46d4cc08e25110b5c0fc0)) 13 | 14 | ### ⬆️ Dependencies 15 | 16 | - _(deps)_ update httpx and pytest-httpx ([#269](https://github.com/DeadNews/images-upload-cli/issues/269)) - ([83324f7](https://github.com/DeadNews/images-upload-cli/commit/83324f7620ff7581a3f50453b4aef4ea723cb07a)) 17 | - _(deps)_ update dependency loguru to v0.7.3 ([#264](https://github.com/DeadNews/images-upload-cli/issues/264)) - ([8713c43](https://github.com/DeadNews/images-upload-cli/commit/8713c430c4ef27ebadc412214a177416164c7050)) 18 | - _(deps)_ update dependency rich to v13.9.4 ([#256](https://github.com/DeadNews/images-upload-cli/issues/256)) - ([c014852](https://github.com/DeadNews/images-upload-cli/commit/c014852bf9e01bfe2b26250f8b9885077d7e7451)) 19 | - _(deps)_ update dependency rich to v13.9.3 ([#254](https://github.com/DeadNews/images-upload-cli/issues/254)) - ([8cba2ed](https://github.com/DeadNews/images-upload-cli/commit/8cba2edd268605f155499e38506f53a46385b863)) 20 | - _(deps)_ update dependency pillow to v11 ([#252](https://github.com/DeadNews/images-upload-cli/issues/252)) - ([2c7aeb0](https://github.com/DeadNews/images-upload-cli/commit/2c7aeb0b7c6a913c8f8efe87ad1e0070f27d30cb)) 21 | 22 | ## [3.0.4](https://github.com/DeadNews/images-upload-cli/compare/v3.0.3...v3.0.4) - 2024-10-11 23 | 24 | ### 🐛 Bug fixes 25 | 26 | - now `pixeldrain` require an `api key` ([#249](https://github.com/DeadNews/images-upload-cli/issues/249)) - ([4ff3f6d](https://github.com/DeadNews/images-upload-cli/commit/4ff3f6d86e7f7e96c33d462bc24fa962e3712373)) 27 | 28 | ### 📚 Documentation 29 | 30 | - _(changelog)_ update `git-cliff` config - ([f7f987d](https://github.com/DeadNews/images-upload-cli/commit/f7f987dffc23bb783fd842c3dc7ecba80f5cefa3)) 31 | 32 | ### 🧪 Testing 33 | 34 | - fix deprecations ([#248](https://github.com/DeadNews/images-upload-cli/issues/248)) - ([4958b5f](https://github.com/DeadNews/images-upload-cli/commit/4958b5f0bd44c325e2d8d0944919edc68eb9e3bf)) 35 | 36 | ### 🧹 Chores 37 | 38 | - _(typos)_ ignore short words - ([7b84695](https://github.com/DeadNews/images-upload-cli/commit/7b846953521c9271a1d1121c30610fc9a19cd1d3)) 39 | - remove `httpx` timeout - ([7a33752](https://github.com/DeadNews/images-upload-cli/commit/7a337526376c0aee683dc67b0b7baed4f54e268a)) 40 | 41 | ### ⚙️ CI/CD 42 | 43 | - _(github)_ add `python:3.13` to tests matrix ([#247](https://github.com/DeadNews/images-upload-cli/issues/247)) - ([0fbbe48](https://github.com/DeadNews/images-upload-cli/commit/0fbbe48179740d962f1c301ca8eaa29ed27b8e8a)) 44 | - _(github)_ update `build-win` to python `3.12` ([#223](https://github.com/DeadNews/images-upload-cli/issues/223)) - ([526910a](https://github.com/DeadNews/images-upload-cli/commit/526910ad8a7e3949e7fdc09ff179affe5a59d698)) 45 | - _(github)_ update `aur-deploy` job - ([0738a55](https://github.com/DeadNews/images-upload-cli/commit/0738a552717bead992ded73eb07d1b57a6ac5e68)) 46 | 47 | ### ⬆️ Dependencies 48 | 49 | - _(deps)_ update dependency rich to v13.9.2 ([#240](https://github.com/DeadNews/images-upload-cli/issues/240)) - ([cdd8537](https://github.com/DeadNews/images-upload-cli/commit/cdd853738a62abf87c72bccaceb0b9af883cfea2)) 50 | - _(deps)_ update dependency rich to v13.8.1 ([#233](https://github.com/DeadNews/images-upload-cli/issues/233)) - ([81edda6](https://github.com/DeadNews/images-upload-cli/commit/81edda6d271f6fbb8e4b1e1b963c4d7a688e170c)) 51 | - _(deps)_ update dependency rich to v13.8.0 ([#232](https://github.com/DeadNews/images-upload-cli/issues/232)) - ([97568df](https://github.com/DeadNews/images-upload-cli/commit/97568dfa6f075ec49ad492d5a93dc66f1a7904de)) 52 | - _(deps)_ update dependency httpx to v0.27.2 ([#231](https://github.com/DeadNews/images-upload-cli/issues/231)) - ([1e75d47](https://github.com/DeadNews/images-upload-cli/commit/1e75d47af71aaa77b3ce0375bc857f3933d9d9cd)) 53 | - _(deps)_ update dependency pillow to v10.4.0 ([#218](https://github.com/DeadNews/images-upload-cli/issues/218)) - ([48a45f4](https://github.com/DeadNews/images-upload-cli/commit/48a45f4a2eaacf9b7cbfa7ec59dff9001af8ad05)) 54 | - _(deps)_ update dependency pyperclip to v1.9.0 ([#211](https://github.com/DeadNews/images-upload-cli/issues/211)) - ([d39dc81](https://github.com/DeadNews/images-upload-cli/commit/d39dc810cfbdc5babba38487b69a675a2ddd905f)) 55 | 56 | ## [3.0.3](https://github.com/DeadNews/images-upload-cli/compare/v3.0.2...v3.0.3) - 2024-04-08 57 | 58 | ### 🧹 Chores 59 | 60 | - _(makefile)_ update `release` command ([#198](https://github.com/DeadNews/images-upload-cli/issues/198)) - ([953207d](https://github.com/DeadNews/images-upload-cli/commit/953207dfa6f490f05e71e22a42068e9716b4d842)) 61 | 62 | ### ⚙️ CI/CD 63 | 64 | - _(github)_ update `build-win` job ([#197](https://github.com/DeadNews/images-upload-cli/issues/197)) - ([d1eedc7](https://github.com/DeadNews/images-upload-cli/commit/d1eedc72f1d9d3701935568526b34134c2d381d8)) 65 | - _(github)_ update aur release job ([#196](https://github.com/DeadNews/images-upload-cli/issues/196)) - ([4e2e87c](https://github.com/DeadNews/images-upload-cli/commit/4e2e87c8564dced1c3bd61da1c1274c7643edd1a)) 66 | 67 | ## [3.0.2](https://github.com/DeadNews/images-upload-cli/compare/v3.0.1...v3.0.2) - 2024-04-03 68 | 69 | ### 🐛 Bug fixes 70 | 71 | - update deprecated name for `pillow 10.3.0` compatibility ([#189](https://github.com/DeadNews/images-upload-cli/issues/189)) - ([c0c5897](https://github.com/DeadNews/images-upload-cli/commit/c0c5897ad27c22c80ee7e2e7dbe7a6eaf6f3f4b5)) 72 | 73 | ### 📚 Documentation 74 | 75 | - _(changelog)_ add `git-cliff` ([#186](https://github.com/DeadNews/images-upload-cli/issues/186)) - ([64b44d4](https://github.com/DeadNews/images-upload-cli/commit/64b44d4cb1baa36679c6708702dfc63810385e14)) 76 | - _(mkdocs)_ add ([#184](https://github.com/DeadNews/images-upload-cli/issues/184)) - ([cd2fbf0](https://github.com/DeadNews/images-upload-cli/commit/cd2fbf0cd8de48db713c89dbd43c11d6a9400896)) 77 | - _(readme)_ add badges - ([8912d71](https://github.com/DeadNews/images-upload-cli/commit/8912d71b9a2a60090f072d666901e0b7abcd5144)) 78 | 79 | ### 🧹 Chores 80 | 81 | - update linting tasks in `makefile` and `poe` - ([e01404a](https://github.com/DeadNews/images-upload-cli/commit/e01404aad59b559f7d148fa3fed520b2e4a78942)) 82 | 83 | ### ⬆️ Dependencies 84 | 85 | - _(deps)_ update dependency pillow to v10.3.0 ([#190](https://github.com/DeadNews/images-upload-cli/issues/190)) - ([df49044](https://github.com/DeadNews/images-upload-cli/commit/df490441833f37ac17777e984015f9af4245c6e8)) 86 | - _(deps)_ update dependency rich to v13.7.1 ([#179](https://github.com/DeadNews/images-upload-cli/issues/179)) - ([9191acc](https://github.com/DeadNews/images-upload-cli/commit/9191acca8ff27f32e16afc4ae38360f73a9644ca)) 87 | 88 | ## [3.0.1](https://github.com/DeadNews/images-upload-cli/compare/v2.0.1...v3.0.1) - 2024-02-22 89 | 90 | ### 🚀 Features 91 | 92 | - add logger and error handling ([#175](https://github.com/DeadNews/images-upload-cli/issues/175)) - ([15678ee](https://github.com/DeadNews/images-upload-cli/commit/15678ee29bb848663d093407405bb496c85a4759)) 93 | - add `anhmoe` image hosting ([#174](https://github.com/DeadNews/images-upload-cli/issues/174)) - ([c1f401b](https://github.com/DeadNews/images-upload-cli/commit/c1f401b8f0e9d7dda089a912d8f4cacd03a54864)) 94 | - update public accessible objects of that module ([#171](https://github.com/DeadNews/images-upload-cli/issues/171)) - ([99b81de](https://github.com/DeadNews/images-upload-cli/commit/99b81de5e9bb7d31a2301908c4de44de17789ba2)) 95 | 96 | ### 🐛 Bug fixes 97 | 98 | - [**breaking**] remove the `-c/-C` shortcut from the `clipboard` cli option ([#177](https://github.com/DeadNews/images-upload-cli/issues/177)) - ([0aafcce](https://github.com/DeadNews/images-upload-cli/commit/0aafcce7e63c0e5fdd35d9184b7d7bae185f4a53)) 99 | 100 | ### 📚 Documentation 101 | 102 | - update docstrings ([#176](https://github.com/DeadNews/images-upload-cli/issues/176)) - ([1f5b20d](https://github.com/DeadNews/images-upload-cli/commit/1f5b20dfa0ddf2065efbd21456fcd5a1c1f4b9a0)) 103 | 104 | ### 🎨 Styling 105 | 106 | - update `ruff` settings ([#162](https://github.com/DeadNews/images-upload-cli/issues/162)) - ([ca58de3](https://github.com/DeadNews/images-upload-cli/commit/ca58de3b98400bf586d06f03b6b55f6d7503a400)) 107 | 108 | ### 🧪 Testing 109 | 110 | - update tests ([#122](https://github.com/DeadNews/images-upload-cli/issues/122)) - ([6226443](https://github.com/DeadNews/images-upload-cli/commit/622644371147c16b5e872bdd9a06bf523cd749b4)) 111 | 112 | ### 🧹 Chores 113 | 114 | - replace `black` with `ruff` - ([46cf164](https://github.com/DeadNews/images-upload-cli/commit/46cf1644ee9e6d48b0b96305746da937a2365069)) 115 | - update docstrings ([#132](https://github.com/DeadNews/images-upload-cli/issues/132)) - ([44fe8e6](https://github.com/DeadNews/images-upload-cli/commit/44fe8e603682a4efdaccf030bfd68f56e65d55cf)) 116 | - specify python `target-version` - ([794a622](https://github.com/DeadNews/images-upload-cli/commit/794a622befd3d9c9e300057b0c8f088aa375c7b0)) 117 | 118 | ### ⚙️ CI/CD 119 | 120 | - _(pre-commit)_ add `checkmake` hook - ([85c19cb](https://github.com/DeadNews/images-upload-cli/commit/85c19cbd6b0e22cc7e5b192f62967581887c33a5)) 121 | - _(pre-commit)_ add `actionlint` hook - ([8a2ceb1](https://github.com/DeadNews/images-upload-cli/commit/8a2ceb140ffb94485a029d96f16e98b8de262e54)) 122 | - _(pre-commit)_ use `black` mirror - ([90444aa](https://github.com/DeadNews/images-upload-cli/commit/90444aa8b25c8e8b34f6c2d1db72ba934facceb4)) 123 | - build a `windows` executable using the `nuitka` compiler ([#167](https://github.com/DeadNews/images-upload-cli/issues/167)) - ([a28be07](https://github.com/DeadNews/images-upload-cli/commit/a28be079833a80cdfae5eb6bbb0d941647c5bc13)) 124 | - add `python 3.12` to tests matrix ([#138](https://github.com/DeadNews/images-upload-cli/issues/138)) - ([904be1b](https://github.com/DeadNews/images-upload-cli/commit/904be1b03d5faa6f09bb226efd655e412eaa6408)) 125 | - use `environment` for `aur` deploy - ([92df766](https://github.com/DeadNews/images-upload-cli/commit/92df76614c1860959d34f60d07903b3f258a6835)) 126 | - disable `codeql` on `schedule` - ([25fd100](https://github.com/DeadNews/images-upload-cli/commit/25fd10032f0b57c129c72bb98b19bbaf92c4ea18)) 127 | 128 | ### ⬆️ Dependencies 129 | 130 | - _(deps)_ update dependency python-dotenv to v1.0.1 ([#161](https://github.com/DeadNews/images-upload-cli/issues/161)) - ([f7f57f3](https://github.com/DeadNews/images-upload-cli/commit/f7f57f3006d81aa1fb42a365fcd97318a80d732b)) 131 | - _(deps)_ update dependencies ([#158](https://github.com/DeadNews/images-upload-cli/issues/158)) - ([ac8078d](https://github.com/DeadNews/images-upload-cli/commit/ac8078d41bfb28e33934e3d81666dbd8bb33078b)) 132 | - _(deps)_ update dependency pillow to v10.2.0 ([#157](https://github.com/DeadNews/images-upload-cli/issues/157)) - ([8073735](https://github.com/DeadNews/images-upload-cli/commit/8073735d0515ff31ca50ec50928337c3efc4f4fe)) 133 | - _(deps)_ update dependency httpx to v0.25.2 ([#148](https://github.com/DeadNews/images-upload-cli/issues/148)) - ([df8ef71](https://github.com/DeadNews/images-upload-cli/commit/df8ef7137f62f65f136d8d54219a45d4d5465749)) 134 | - _(deps)_ update dependency httpx to v0.25.1 ([#144](https://github.com/DeadNews/images-upload-cli/issues/144)) - ([12639f7](https://github.com/DeadNews/images-upload-cli/commit/12639f7d75270a1d69c8f02f8105583775231c5a)) 135 | - _(deps)_ update dependency pillow to v10.1.0 ([#143](https://github.com/DeadNews/images-upload-cli/issues/143)) - ([48ca32f](https://github.com/DeadNews/images-upload-cli/commit/48ca32fc39738d56d4746a5dc69e03f44a117a77)) 136 | - _(deps)_ update dependency pillow to v10.0.1 ([#133](https://github.com/DeadNews/images-upload-cli/issues/133)) - ([a0123a4](https://github.com/DeadNews/images-upload-cli/commit/a0123a49c055539c9d6190d417671da02d7bfb75)) 137 | - _(deps)_ update dependency click to v8.1.7 ([#127](https://github.com/DeadNews/images-upload-cli/issues/127)) - ([a5da102](https://github.com/DeadNews/images-upload-cli/commit/a5da1029c3087685464448bed15605ee6fd9d5d0)) 138 | - _(deps)_ update dependency click to v8.1.6 ([#123](https://github.com/DeadNews/images-upload-cli/issues/123)) - ([2a28aea](https://github.com/DeadNews/images-upload-cli/commit/2a28aea40fd6d4e33ad1b773cf5d60c3433940f2)) 139 | - _(deps)_ update dependency click to v8.1.5 ([#119](https://github.com/DeadNews/images-upload-cli/issues/119)) - ([ebbb719](https://github.com/DeadNews/images-upload-cli/commit/ebbb719e61b92dd3d79ecd0af4c71efe743ea922)) 140 | 141 | ## [2.0.1](https://github.com/DeadNews/images-upload-cli/compare/v2.0.0...v2.0.1) - 2023-07-11 142 | 143 | ### 🚀 Features 144 | 145 | - call `get_font` only once ([#117](https://github.com/DeadNews/images-upload-cli/issues/117)) - ([2be7eca](https://github.com/DeadNews/images-upload-cli/commit/2be7eca2a0a3fb2584be2e2f472e5b13649f9c06)) 146 | 147 | ### 🧪 Testing 148 | 149 | - rename `.env.sample` - ([73c59c5](https://github.com/DeadNews/images-upload-cli/commit/73c59c50fb1b927b071981ea1065ac14cd335fe0)) 150 | 151 | ### ⬆️ Dependencies 152 | 153 | - _(deps)_ update dependency pillow to v10 ([#113](https://github.com/DeadNews/images-upload-cli/issues/113)) - ([6bd957f](https://github.com/DeadNews/images-upload-cli/commit/6bd957f6ed7f54758c21a6631cf611d17e542efe)) 154 | 155 | ## [2.0.0](https://github.com/DeadNews/images-upload-cli/compare/v1.1.3...v2.0.0) - 2023-06-24 156 | 157 | ### 🚀 Features 158 | 159 | - simplify cli - ([c01c6c5](https://github.com/DeadNews/images-upload-cli/commit/c01c6c53db06999009bbbfda7009f81ee2d4af07)) 160 | - use `asyncio` ([#106](https://github.com/DeadNews/images-upload-cli/issues/106)) - ([d6966de](https://github.com/DeadNews/images-upload-cli/commit/d6966deac152c974a9d0e73c3674859877e76dcc)) 161 | - rename `get_env` func - ([b5fea88](https://github.com/DeadNews/images-upload-cli/commit/b5fea88d7cb929340411b25afc9d7cbbb7ebfd70)) 162 | 163 | ### 📚 Documentation 164 | 165 | - fix `workflow` name - ([1dd9115](https://github.com/DeadNews/images-upload-cli/commit/1dd91159420a93b877ab3881b5164952756ff9c4)) 166 | 167 | ### 🧪 Testing 168 | 169 | - use `pragma: no cover` ([#110](https://github.com/DeadNews/images-upload-cli/issues/110)) - ([c47567a](https://github.com/DeadNews/images-upload-cli/commit/c47567a3eccf2f17bfae1d252854b39c336d5f44)) 170 | 171 | ### 🧹 Chores 172 | 173 | - rename poetry `group` - ([98852e1](https://github.com/DeadNews/images-upload-cli/commit/98852e134d1810ed3d12cbccc7ede5ceae6c78a4)) 174 | 175 | ### ⚙️ CI/CD 176 | 177 | - _(pre-commit)_ add `typos` hook - ([892b6ce](https://github.com/DeadNews/images-upload-cli/commit/892b6cebf0c2cd047467cf6220f4d3d91266ece4)) 178 | - _(renovate)_ use shared config - ([9fe50ad](https://github.com/DeadNews/images-upload-cli/commit/9fe50ad0c2bf4fdcc69d33bb719144a0ab683dbe)) 179 | - use `digest pinning` - ([d9bc707](https://github.com/DeadNews/images-upload-cli/commit/d9bc707990478082e22c0bcc5f10d3aa2575f6f9)) 180 | - rename `deps-review` - ([aafd1fe](https://github.com/DeadNews/images-upload-cli/commit/aafd1fe60484a4ae3f6b7cd91cf68ecc8fc23c1a)) 181 | - update `workflows` ([#98](https://github.com/DeadNews/images-upload-cli/issues/98)) - ([342e77c](https://github.com/DeadNews/images-upload-cli/commit/342e77cba5d31ce778eea876ba44485f12062f6b)) 182 | 183 | ### ⬆️ Dependencies 184 | 185 | - _(deps)_ update dependency requests to v2.31.0 ([#95](https://github.com/DeadNews/images-upload-cli/issues/95)) - ([3b302dc](https://github.com/DeadNews/images-upload-cli/commit/3b302dc67172fc0e3cf62d82d37ad09204cf8e86)) 186 | 187 | ## [1.1.3](https://github.com/DeadNews/images-upload-cli/compare/v1.1.1...v1.1.3) - 2023-05-08 188 | 189 | ### 👷 Build 190 | 191 | - update `PKGBUILD` ([#86](https://github.com/DeadNews/images-upload-cli/issues/86)) - ([864d257](https://github.com/DeadNews/images-upload-cli/commit/864d257202732bdd31af81fe0c705cae5e00f3d2)) 192 | 193 | ### ⚙️ CI/CD 194 | 195 | - fix deploy to `aur` - ([571b763](https://github.com/DeadNews/images-upload-cli/commit/571b7639a01b227f82748ffb6468af88d2c9b89d)) 196 | 197 | ## [1.1.1](https://github.com/DeadNews/images-upload-cli/commits/v1.1.1) - 2023-05-07 198 | 199 | ### 🚀 Features 200 | 201 | - dev pr ([#77](https://github.com/DeadNews/images-upload-cli/issues/77)) - ([9b3e7a6](https://github.com/DeadNews/images-upload-cli/commit/9b3e7a68e21d343e03634eff7e1ac55b8448d276)) 202 | 203 | ### 🐛 Bug fixes 204 | 205 | - _(build)_ enable `poetry-dynamic-versioning` - ([5007861](https://github.com/DeadNews/images-upload-cli/commit/50078619083700a8f8fc0765ac05c272f08cf3a3)) 206 | 207 | ### ⬆️ Dependencies 208 | 209 | - _(deps)_ update dependency requests to v2.30.0 - ([6d3932b](https://github.com/DeadNews/images-upload-cli/commit/6d3932b28a81d9ee0db85cc1ad8e54c05376a658)) 210 | 211 | 212 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 DeadNews 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all clean default install lock update checks pc test docs run 2 | 3 | default: checks 4 | 5 | install: 6 | pre-commit install 7 | poetry install --sync 8 | 9 | lock: 10 | poetry lock --no-update 11 | 12 | update: 13 | poetry up --latest 14 | 15 | checks: pc install lint test 16 | pc: 17 | pre-commit run -a 18 | lint: 19 | poetry run poe lint 20 | test: 21 | poetry run poe test 22 | 23 | docs: 24 | poetry run mkdocs serve 25 | 26 | bumped: 27 | git cliff --bumped-version 28 | 29 | # make release-tag_name 30 | # make release-$(git cliff --bumped-version)-alpha.0 31 | release-%: checks 32 | git cliff -o CHANGELOG.md --tag $* 33 | pre-commit run --files CHANGELOG.md || pre-commit run --files CHANGELOG.md 34 | git add CHANGELOG.md 35 | git commit -m "chore(release): prepare for $*" 36 | git push 37 | git tag -a $* -m "chore(release): $*" 38 | git push origin $* 39 | git tag --verify $* 40 | -------------------------------------------------------------------------------- /PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: DeadNews 2 | 3 | pkgbase="python-images-upload-cli" 4 | pkgname=("python-images-upload-cli") 5 | _name="images_upload_cli" 6 | pkgver="dynamically updated by ci/cd" 7 | pkgrel=1 8 | pkgdesc="Upload images via APIs" 9 | url="https://github.com/DeadNews/images-upload-cli" 10 | depends=( 11 | "python" 12 | "python-click" 13 | "python-dotenv" 14 | "python-httpx" 15 | "python-loguru" 16 | "python-pillow" 17 | "python-pyperclip" 18 | "python-rich" 19 | ) 20 | makedepends=( 21 | "python-installer" 22 | ) 23 | optdepends=( 24 | "libnotify: sending desktop notifications" 25 | ) 26 | license=("MIT") 27 | arch=("any") 28 | source=("https://files.pythonhosted.org/packages/py3/${_name::1}/${_name}/${_name}-$pkgver-py3-none-any.whl") 29 | sha256sums=("dynamically updated by ci/cd") 30 | 31 | package() { 32 | python -m installer --destdir="${pkgdir}" "${_name}-$pkgver-py3-none-any.whl" 33 | } 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # images-upload-cli 2 | 3 | > Upload images via APIs 4 | 5 | [![PyPI: Version](https://img.shields.io/pypi/v/images-upload-cli?logo=pypi&logoColor=white)](https://pypi.org/project/images-upload-cli) 6 | [![AUR: version](https://img.shields.io/aur/version/python-images-upload-cli?logo=archlinux&logoColor=white)](https://aur.archlinux.org/packages/python-images-upload-cli) 7 | [![GitHub: Release](https://img.shields.io/github/v/release/deadnews/images-upload-cli?logo=github&logoColor=white)](https://github.com/deadnews/images-upload-cli/releases/latest) 8 | [![Documentation](https://img.shields.io/badge/documentation-gray.svg?logo=materialformkdocs&logoColor=white)](https://deadnews.github.io/images-upload-cli) 9 | [![CI: pre-commit](https://results.pre-commit.ci/badge/github/DeadNews/images-upload-cli/main.svg)](https://results.pre-commit.ci/latest/github/deadnews/images-upload-cli/main) 10 | [![CI: Main](https://img.shields.io/github/actions/workflow/status/deadnews/images-upload-cli/main.yml?branch=main&logo=github&logoColor=white&label=main)](https://github.com/deadnews/images-upload-cli/actions/workflows/main.yml) 11 | [![CI: Coverage](https://img.shields.io/codecov/c/github/deadnews/images-upload-cli?token=OCZDZIYPMC&logo=codecov&logoColor=white)](https://app.codecov.io/gh/deadnews/images-upload-cli) 12 | 13 | **[Installation](#installation)** • **[Hostings](#hostings)** • **[Usage](#usage)** • **[Env Variables](#env-variables)** 14 | 15 | ## Installation 16 | 17 | PyPI 18 | 19 | ```sh 20 | pipx install images-upload-cli 21 | # or 22 | pip install images-upload-cli 23 | ``` 24 | 25 | AUR 26 | 27 | ```sh 28 | yay -S python-images-upload-cli 29 | ``` 30 | 31 | Windows executable is attached to the GitHub release. 32 | 33 | ## Hostings 34 | 35 | | host | key required | return example | 36 | | :------------------------------------ | :----------: | :--------------------------------------------------- | 37 | | [anhmoe](https://anh.moe/) | - | `https://cdn.anh.moe/c/{id}.png` | 38 | | [beeimg](https://beeimg.com/) | - | `https://beeimg.com/images/{id}.png` | 39 | | [catbox](https://catbox.moe/) | - | `https://files.catbox.moe/{id}` | 40 | | [fastpic](https://fastpic.org/) | - | `https://i120.fastpic.org/big/2022/0730/d9/{id}.png` | 41 | | [filecoffee](https://file.coffee/) | - | `https://file.coffee/u/{id}.png` | 42 | | [freeimage](https://freeimage.host/) | - | `https://iili.io/{id}.png` | 43 | | [gyazo](https://gyazo.com/) | + | `https://i.gyazo.com/{id}.png` | 44 | | [imageban](https://imageban.ru/) | + | `https://i2.imageban.ru/out/2022/07/30/{id}.png` | 45 | | [imagebin](https://imagebin.ca/) | - | `https://ibin.co/{id}.png` | 46 | | [imgbb](https://imgbb.com/) | + | `https://i.ibb.co/{id}/image.png` | 47 | | [imgchest](https://imgchest.com/) | + | `https://cdn.imgchest.com/files/{id}.png` | 48 | | [imgur](https://imgur.com/) | - | `https://i.imgur.com/{id}.png` | 49 | | [lensdump](https://lensdump.com/) | + | `https://i.lensdump.com/i/{id}.png` | 50 | | [pixeldrain](https://pixeldrain.com/) | + | `https://pixeldrain.com/api/file/{id}` | 51 | | [pixhost](https://pixhost.to/) | - | `https://img75.pixhost.to/images/69/{id}_img.png` | 52 | | [ptpimg](https://ptpimg.me/) | + | `https://ptpimg.me/{id}.png` | 53 | | [smms](https://sm.ms/) | + | `https://s2.loli.net/2022/07/30/{id}.png` | 54 | | [sxcu](https://sxcu.net/) | - | `https://sxcu.net/{id}.png` | 55 | | [telegraph](https://telegra.ph/) | - | `https://telegra.ph/file/{id}.png` | 56 | | [thumbsnap](https://thumbsnap.com/) | + | `https://thumbsnap.com/i/{id}.png` | 57 | | [tixte](https://tixte.com/) | + | `https://{domain}.tixte.co/r/{id}.png` | 58 | | [up2sha](https://up2sha.re/) | + | `https://up2sha.re/media/raw/{id}.png` | 59 | | [uplio](https://upl.io/) | + | `https://upl.io/i/{id}.png` | 60 | | [uploadcare](https://uploadcare.com/) | + | `https://ucarecdn.com/{id}/img.png` | 61 | | [vgy](https://vgy.me/) | + | `https://i.vgy.me/{id}.png` | 62 | 63 | ## Usage 64 | 65 | [CLI Reference](https://deadnews.github.io/images-upload-cli/reference-cli/) 66 | 67 | ```sh 68 | Usage: images-upload-cli [OPTIONS] IMAGES... 69 | 70 | Upload images via APIs. 71 | 72 | Options: 73 | -h, --hosting [anhmoe|beeimg|catbox|fastpic|filecoffee|freeimage|gyazo|imageban|imagebin|imgbb|imgchest|imgur|lensdump|pixeldrain|pixhost|ptpimg|smms|sxcu|telegraph|thumbsnap|tixte|up2sha|uplio|uploadcare|vgy] 74 | [default: imgur] 75 | -f, --format [plain|bbcode|html|markdown] 76 | The format of the links to be generated. [default: plain] 77 | -t, --thumbnail Create captioned thumbnails. By default, in bbcode format. 78 | -n, --notify Send desktop notification on completion. Required libnotify. 79 | --clipboard / --no-clipboard Copy the result to the clipboard. [default: clipboard] 80 | --env-file FILE The path to the environment file. Takes precedence over the default config file. 81 | --log-level [DEBUG|INFO|WARNING|ERROR|CRITICAL] 82 | Use DEBUG to show debug logs. Use CRITICAL to suppress all logs. [default: INFO] 83 | --version Show the version and exit. 84 | --help Show this message and exit. 85 | ``` 86 | 87 | ## Env variables 88 | 89 | ```ini 90 | CAPTION_FONT= # The default font is system dependent. 91 | 92 | FREEIMAGE_KEY= 93 | GYAZO_TOKEN= 94 | IMAGEBAN_TOKEN= 95 | IMGBB_KEY= 96 | IMGCHEST_KEY= 97 | IMGUR_CLIENT_ID= 98 | LENSDUMP_KEY= 99 | PIXELDRAIN_KEY= 100 | PTPIMG_KEY= 101 | SMMS_KEY= 102 | THUMBSNAP_KEY= 103 | TIXTE_KEY= 104 | UP2SHA_KEY= 105 | UPLIO_KEY= 106 | UPLOADCARE_KEY= 107 | VGY_KEY= 108 | ``` 109 | 110 | You can set these in environment variables, or in `.env` file: 111 | 112 | - Unix: `~/.config/images-upload-cli/.env` 113 | - MacOS: `~/Library/Application Support/images-upload-cli/.env` 114 | - Windows: `C:\Users\\AppData\Roaming\images-upload-cli\.env` 115 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ configuration file 2 | # https://git-cliff.org/docs/configuration 3 | 4 | [changelog] 5 | header = """ 6 | # Changelog\n 7 | """ 8 | postprocessors = [ 9 | { pattern = '', replace = "https://github.com/DeadNews/images-upload-cli" }, 10 | ] 11 | body = """ 12 | {%- macro remote_url() -%} 13 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 14 | {%- endmacro -%} 15 | 16 | {% macro print_commit(commit) -%} 17 | - {% if commit.scope %}_({{ commit.scope }})_ {% endif %}\ 18 | {% if commit.breaking %}[**breaking**] {% endif %}\ 19 | {{ commit.message }} - \ 20 | ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\ 21 | {% endmacro -%} 22 | 23 | {% if version %}\ 24 | {% if previous.version %}\ 25 | ## [{{ version | trim_start_matches(pat="v") }}]\ 26 | ({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }} 27 | {% else %}\ 28 | ## [{{ version | trim_start_matches(pat="v") }}]\ 29 | ({{ self::remote_url() }}/commits/{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }} 30 | {% endif %}\ 31 | {% else %}\ 32 | ## [unreleased] 33 | {% endif %}\ 34 | 35 | {% for group, commits in commits | group_by(attribute="group") %} 36 | ### {{ group | striptags | trim | upper_first }} 37 | {% for commit in commits 38 | | filter(attribute="scope") 39 | | sort(attribute="scope") %} 40 | {{ self::print_commit(commit=commit) }} 41 | {%- endfor -%} 42 | {% raw %}\n{% endraw %}\ 43 | {%- for commit in commits %} 44 | {%- if not commit.scope -%} 45 | {{ self::print_commit(commit=commit) }} 46 | {% endif -%} 47 | {% endfor -%} 48 | {% endfor %}\n 49 | """ 50 | footer = """ 51 | 52 | """ 53 | trim = true 54 | 55 | [git] 56 | conventional_commits = true # parse the commits based on https://www.conventionalcommits.org 57 | filter_unconventional = true # filter out the commits that are not conventional 58 | split_commits = false # process each line of a commit as an individual commit 59 | commit_preprocessors = [ 60 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))" }, # Replace issue numbers 61 | { pattern = " +", replace = " " }, # Remove multiple whitespaces 62 | ] 63 | commit_parsers = [ 64 | { message = "^(ci|build|chore)\\(deps.*\\)", skip = true }, 65 | { message = "^chore\\(release\\)", skip = true }, 66 | { message = "^fix\\(deps.*\\)", group = "⬆️ Dependencies" }, 67 | { message = "^feat", group = "🚀 Features" }, 68 | { message = "^fix", group = "🐛 Bug fixes" }, 69 | { message = "^refactor", group = "🚜 Refactor" }, 70 | { message = "^doc", group = "📚 Documentation" }, 71 | { message = "^perf", group = "⚡ Performance" }, 72 | { message = "^style", group = "🎨 Styling" }, 73 | { message = "^test", group = "🧪 Testing" }, 74 | { message = "^chore", group = "🧹 Chores" }, 75 | { body = ".*security", group = "🛡️ Security" }, 76 | { message = "^build", group = "👷 Build" }, 77 | { message = "^ci", group = "⚙️ CI/CD" }, 78 | { message = "^revert", group = "◀️ Revert" }, 79 | ] 80 | protect_breaking_commits = true 81 | filter_commits = false # filter out the commits that are not matched by commit parsers 82 | tag_pattern = "v[0-9].*" # regex for matching git tags 83 | skip_tags = "" # drop commits from the changelog 84 | ignore_tags = "rc|beta|alpha" # include ignored commits into the next tag 85 | topo_order = false # sort the tags topologically 86 | sort_commits = "newest" # sort the commits inside sections by oldest/newest order 87 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | --8<-- "CHANGELOG.md" 2 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --8<-- "README.md" 2 | -------------------------------------------------------------------------------- /docs/reference-cli.md: -------------------------------------------------------------------------------- 1 | ::: mkdocs-click 2 | :prog_name: images-upload-cli 3 | :module: images_upload_cli._cli 4 | :command: cli 5 | :style: table 6 | :remove_ascii_art: True 7 | :list_subcommands: True 8 | -------------------------------------------------------------------------------- /docs/scripts/gen_ref_nav.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Generate the code reference pages and navigation.""" 3 | 4 | from pathlib import Path 5 | 6 | import mkdocs_gen_files 7 | from mkdocs_gen_files.nav import Nav 8 | 9 | pkg_name = "images_upload_cli" 10 | nav = Nav() 11 | mod_symbol = '' 12 | 13 | root = Path(__file__).parent.parent.parent 14 | src = root / "src" 15 | mods = sorted(src.rglob("*.py"), key=lambda p: (len(p.parts), p)) 16 | 17 | for path in mods: 18 | module_path = path.relative_to(src).with_suffix("") 19 | doc_path = path.relative_to(src / pkg_name).with_suffix(".md") 20 | full_doc_path = Path("reference", doc_path) 21 | 22 | parts = tuple(module_path.parts) 23 | 24 | if parts[-1] == "__init__": 25 | parts = parts[:-1] 26 | doc_path = doc_path.with_name("index.md") 27 | full_doc_path = full_doc_path.with_name("index.md") 28 | elif parts[-1].startswith("_"): 29 | continue 30 | 31 | nav_parts = [f"{mod_symbol} {part}" for part in parts] 32 | nav[tuple(nav_parts)] = doc_path.as_posix() 33 | 34 | with mkdocs_gen_files.open(full_doc_path, "w") as fd: 35 | ident = ".".join(parts) 36 | fd.write(f"::: {ident}") 37 | 38 | mkdocs_gen_files.set_edit_path(full_doc_path, ".." / path.relative_to(root)) 39 | 40 | with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: 41 | nav_file.writelines(nav.build_literate_nav()) 42 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: images-upload-cli 2 | repo_url: https://github.com/deadnews/images-upload-cli 3 | edit_uri: blob/main/docs/ 4 | 5 | nav: 6 | - Overview: index.md 7 | - Changelog: changelog.md 8 | - API reference: reference/ # defer to gen-files + literate-nav 9 | - CLI Reference: reference-cli.md 10 | 11 | watch: 12 | - src 13 | - README.md 14 | - CHANGELOG.md 15 | 16 | theme: 17 | name: material 18 | palette: 19 | - media: "(prefers-color-scheme)" 20 | toggle: 21 | name: Switch to light mode 22 | icon: material/brightness-auto 23 | - media: "(prefers-color-scheme: light)" 24 | scheme: default 25 | toggle: 26 | name: Switch to dark mode 27 | icon: material/brightness-7 28 | - media: "(prefers-color-scheme: dark)" 29 | scheme: slate 30 | toggle: 31 | name: Switch to system preference 32 | icon: material/brightness-4 33 | features: 34 | - content.action.edit 35 | - content.action.view 36 | - content.code.annotate 37 | - content.code.copy 38 | - content.code.select 39 | - content.tooltips 40 | - navigation.expand 41 | - navigation.footer 42 | - navigation.indexes 43 | - navigation.instant 44 | - navigation.sections 45 | - navigation.tabs 46 | - navigation.top 47 | - navigation.tracking 48 | - search.highlight 49 | - search.share 50 | - search.suggest 51 | - toc.follow 52 | 53 | markdown_extensions: 54 | - attr_list 55 | - mkdocs-click 56 | - pymdownx.snippets 57 | - pymdownx.superfences 58 | - toc: 59 | permalink: true 60 | 61 | plugins: 62 | - search 63 | - gen-files: 64 | scripts: 65 | - docs/scripts/gen_ref_nav.py 66 | - literate-nav: 67 | nav_file: SUMMARY.md 68 | - mkdocstrings: 69 | handlers: 70 | python: 71 | paths: [src] 72 | options: 73 | docstring_options: 74 | ignore_init_summary: true 75 | docstring_section_style: table 76 | docstring_style: google 77 | filters: ["!^_"] 78 | heading_level: 1 79 | inherited_members: true 80 | members_order: source 81 | merge_init_into_class: true 82 | separate_signature: true 83 | show_root_full_path: false 84 | show_root_heading: true 85 | show_signature_annotations: true 86 | show_submodules: true 87 | show_symbol_type_heading: true 88 | show_symbol_type_toc: true 89 | signature_crossrefs: true 90 | summary: true 91 | import: 92 | - https://docs.python.org/3/objects.inv 93 | - https://pillow.readthedocs.io/en/stable/objects.inv 94 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"] 3 | build-backend = "poetry_dynamic_versioning.backend" 4 | 5 | [tool.poetry] 6 | name = "images-upload-cli" 7 | version = "0.0.0" 8 | description = "Upload images via APIs" 9 | authors = ["DeadNews "] 10 | license = "MIT" 11 | readme = "README.md" 12 | homepage = "https://github.com/DeadNews/images-upload-cli" 13 | repository = "https://github.com/DeadNews/images-upload-cli" 14 | documentation = "https://deadnews.github.io/images-upload-cli" 15 | keywords = ["cli", "imgur", "image-upload", "upload-images", "upload-pictures"] 16 | classifiers = ["Environment :: Console", "Operating System :: OS Independent"] 17 | 18 | [tool.poetry.scripts] 19 | images-upload-cli = "images_upload_cli._cli:cli" 20 | 21 | [tool.poetry.dependencies] 22 | python = "^3.10" 23 | click = "^8.1.7" 24 | httpx = "^0.28.0" 25 | loguru = "^0.7.2" 26 | pillow = "^11.0.0" 27 | pyperclip = "^1.9.0" 28 | python-dotenv = "^1.0.1" 29 | rich = "^14.0.0" 30 | 31 | [tool.poetry.group.lint.dependencies] 32 | mypy = "^1.15.0" 33 | poethepoet = "^0.33.1" 34 | pyright = "^1.1.398" 35 | ruff = "^0.11.4" 36 | 37 | [tool.poetry.group.test.dependencies] 38 | pytest = "^8.3.5" 39 | pytest-asyncio = "^0.26.0" 40 | pytest-benchmark = "^5.1.0" 41 | pytest-cov = "^6.1.0" 42 | pytest-httpx = "^0.35.0" 43 | pytest-mock = "^3.14.0" 44 | logot = "^1.3.0" 45 | 46 | [tool.poetry.group.docs.dependencies] 47 | mkdocs = "^1.6.1" 48 | mkdocs-click = "^0.8.1" 49 | mkdocs-gen-files = "^0.5.0" 50 | mkdocs-literate-nav = "^0.6.2" 51 | mkdocs-material = "^9.6.11" 52 | mkdocstrings = "^0.29.1" 53 | mkdocstrings-python = "^1.16.10" 54 | 55 | [tool.poetry.group.build.dependencies] 56 | nuitka = { version = "^2.6.9", platform = "win32" } 57 | 58 | [tool.poetry-dynamic-versioning] 59 | enable = true 60 | vcs = "git" 61 | style = "semver" 62 | 63 | [tool.poe.tasks] 64 | mypy = "mypy ." 65 | pyright = "pyright ." 66 | ruff = "ruff check ." 67 | ruff-fmt = "ruff format ." 68 | lint.sequence = ["ruff", "ruff-fmt", "mypy", "pyright"] 69 | 70 | [tool.poe.tasks.nuitka] 71 | cmd = """ 72 | python -m nuitka 73 | --assume-yes-for-downloads 74 | --onefile 75 | --output-dir=dist 76 | --output-file=${outfile} 77 | --script-name=src/images_upload_cli/__main__.py 78 | """ 79 | 80 | [tool.poe.tasks.nuitka.args.outfile] 81 | options = ["--output-file"] 82 | default = "images-upload-cli.exe" 83 | help = "Output file name." 84 | 85 | [tool.poe.tasks.test] 86 | cmd = "pytest -m 'not (online or benchmark)'" 87 | 88 | [tool.poe.tasks.benchmark] 89 | cmd = "pytest -m 'benchmark and not online' --benchmark-autosave --benchmark-compare" 90 | 91 | [tool.poe.tasks.benchmark-online] 92 | cmd = "pytest -m 'benchmark and online' --benchmark-autosave --benchmark-compare" 93 | 94 | [tool.pytest.ini_options] 95 | addopts = "--verbose --cov=./src --cov-report=term --cov-report=xml" 96 | asyncio_default_fixture_loop_scope = "function" 97 | testpaths = ["tests"] 98 | markers = [ 99 | "benchmark: Run benchmarks", 100 | "online: Run tests that require internet connection", 101 | ] 102 | 103 | [tool.coverage.report] 104 | exclude_lines = [ 105 | "# pragma: no cover", 106 | "if __name__ == .__main__.:", 107 | "if TYPE_CHECKING:", 108 | ] 109 | 110 | [tool.mypy] 111 | disallow_untyped_defs = true 112 | follow_imports = "normal" 113 | ignore_missing_imports = true 114 | show_column_numbers = true 115 | show_error_codes = true 116 | warn_unused_ignores = true 117 | 118 | [[tool.mypy.overrides]] 119 | module = ["tests.*"] 120 | disallow_untyped_defs = false 121 | 122 | [tool.pyright] 123 | include = ["src"] 124 | ignore = ["tests"] 125 | typeCheckingMode = "standard" 126 | 127 | [tool.ruff] 128 | line-length = 99 129 | target-version = "py310" # Until Poetry v2 130 | 131 | [tool.ruff.format] 132 | line-ending = "lf" 133 | 134 | [tool.ruff.lint] 135 | select = ["ALL"] 136 | ignore = [ 137 | "COM812", # Trailing comma missing 138 | "FBT001", # Boolean positional arg in function definition 139 | "FBT002", # Boolean default value in function definition 140 | "ISC001", # Checks for implicitly concatenated strings on a single line 141 | "PLR0913", # Too many arguments to function call 142 | ] 143 | 144 | [tool.ruff.lint.per-file-ignores] 145 | "__init__.py" = ["F401"] 146 | "tests/*" = ["ANN", "D", "E501", "PLC1901", "PLR2004", "S"] 147 | 148 | [tool.ruff.lint.flake8-tidy-imports] 149 | ban-relative-imports = "all" 150 | 151 | [tool.ruff.lint.pycodestyle] 152 | max-doc-length = 129 153 | max-line-length = 129 154 | 155 | [tool.ruff.lint.pydocstyle] 156 | convention = "google" 157 | -------------------------------------------------------------------------------- /src/images_upload_cli/__init__.py: -------------------------------------------------------------------------------- 1 | """Public accessible objects of that module.""" 2 | 3 | from images_upload_cli import image, util 4 | from images_upload_cli.main import format_link, upload_images 5 | from images_upload_cli.upload import HOSTINGS, UPLOAD 6 | -------------------------------------------------------------------------------- /src/images_upload_cli/__main__.py: -------------------------------------------------------------------------------- 1 | """Entrypoint for cli, enables execution with `python -m images_upload_cli`.""" 2 | 3 | from images_upload_cli._cli import cli 4 | 5 | if __name__ == "__main__": 6 | cli() 7 | -------------------------------------------------------------------------------- /src/images_upload_cli/_cli.py: -------------------------------------------------------------------------------- 1 | """Entrypoint for cli.""" 2 | 3 | import asyncio 4 | import sys 5 | from pathlib import Path 6 | 7 | import click 8 | from dotenv import load_dotenv 9 | from pyperclip import copy 10 | 11 | from images_upload_cli.logger import setup_logger 12 | from images_upload_cli.main import format_link, upload_images 13 | from images_upload_cli.upload import HOSTINGS, UPLOAD 14 | from images_upload_cli.util import get_config_path, notify_send 15 | 16 | 17 | @click.command(context_settings={"max_content_width": 120, "show_default": True}) 18 | @click.argument( 19 | "images", 20 | nargs=-1, 21 | required=True, 22 | type=click.Path(exists=True, dir_okay=False, path_type=Path), 23 | ) 24 | @click.option("-h", "--hosting", type=click.Choice(HOSTINGS), default="imgur") 25 | @click.option( 26 | "-f", 27 | "--format", 28 | "fmt", 29 | type=click.Choice(("plain", "bbcode", "html", "markdown")), 30 | default="plain", 31 | help="The format of the links to be generated.", 32 | ) 33 | @click.option( 34 | "-t", 35 | "--thumbnail", 36 | is_flag=True, 37 | help="Create captioned thumbnails. By default, in bbcode format.", 38 | ) 39 | @click.option( 40 | "-n", 41 | "--notify", 42 | is_flag=True, 43 | help="Send desktop notification on completion. Required libnotify.", 44 | ) 45 | @click.option( 46 | "--clipboard/--no-clipboard", 47 | is_flag=True, 48 | default=True, 49 | help="Copy the result to the clipboard.", 50 | ) 51 | @click.option( 52 | "--env-file", 53 | type=click.Path(exists=True, dir_okay=False, path_type=Path), 54 | help="The path to the environment file. Takes precedence over the default config file.", 55 | ) 56 | @click.option( 57 | "--log-level", 58 | type=click.Choice(("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")), 59 | default="INFO", 60 | help="Use DEBUG to show debug logs. Use CRITICAL to suppress all logs.", 61 | ) 62 | @click.version_option() 63 | def cli( 64 | images: tuple[Path], 65 | hosting: str, 66 | fmt: str, 67 | thumbnail: bool, 68 | notify: bool, 69 | clipboard: bool, 70 | env_file: Path, 71 | log_level: str, 72 | ) -> None: 73 | """Upload images via APIs.""" 74 | """ 75 | Upload images to the specified hosting service, format links, and print. 76 | Optionally copy links to clipboard and send desktop notification. 77 | 78 | Args: 79 | images: The paths to the images to upload. 80 | hosting: The hosting service to use for uploading the images. 81 | fmt: The format to use for generating the links to the uploaded images. 82 | thumbnail: Whether thumbnail images should be generated for the uploaded images. 83 | notify: Whether to send desktop notification on completion. 84 | clipboard: Whether to copy the image links to the clipboard. 85 | env_file: The path to the environment file. 86 | log_level: The log level to use for the logger. 87 | """ 88 | # Set up logger. 89 | error_handler = setup_logger(log_level=log_level) 90 | # Load environment variables. 91 | load_dotenv(dotenv_path=env_file or get_config_path()) 92 | 93 | # Upload images. 94 | links = asyncio.run( 95 | upload_images(upload_func=UPLOAD[hosting], images=images, thumbnail=thumbnail) 96 | ) 97 | # If links are available, format and print them. 98 | # If thumbnail is enabled and fmt is plain, change fmt to bbcode. 99 | if links: 100 | if thumbnail and fmt == "plain": 101 | fmt = "bbcode" 102 | formatted_links = format_link(links, fmt) 103 | 104 | click.echo(formatted_links) 105 | if clipboard: 106 | copy(formatted_links) 107 | if notify: 108 | notify_send(formatted_links) 109 | 110 | if error_handler.has_error_occurred(): 111 | sys.exit(1) 112 | -------------------------------------------------------------------------------- /src/images_upload_cli/image.py: -------------------------------------------------------------------------------- 1 | """Image processing and manipulation.""" 2 | 3 | from io import BytesIO 4 | from os import getenv 5 | 6 | from PIL import Image, ImageDraw, ImageFont 7 | 8 | from images_upload_cli.util import GetEnvError, get_config_path, human_size 9 | 10 | 11 | def get_img_ext(img: bytes) -> str: 12 | """Get the extension of an image from a byte string. 13 | 14 | Args: 15 | img: A byte string representing an image. 16 | 17 | Returns: 18 | The extension of the image file. 19 | """ 20 | with BytesIO(img) as f: 21 | ext = Image.open(f).format 22 | return "" if ext is None else ext.lower() 23 | 24 | 25 | def get_font(size: int = 14) -> ImageFont.FreeTypeFont: 26 | """Get font for thumbnail captions. 27 | 28 | Args: 29 | size: The size of the font. Defaults to 14. 30 | 31 | Returns: 32 | ImageFont.FreeTypeFont: Represents the font. 33 | """ 34 | if font_name := getenv("CAPTION_FONT"): 35 | return ImageFont.truetype(font_name, size=size) 36 | 37 | default_fonts = [ 38 | "Helvetica", 39 | "NotoSerif-Regular", 40 | "Menlo", 41 | "DejaVuSerif", 42 | "arial", 43 | ] 44 | return search_font(fonts=default_fonts, size=size) 45 | 46 | 47 | def search_font(fonts: list[str], size: int = 14) -> ImageFont.FreeTypeFont: 48 | """Attempt to retrieve a TTF font from the system. 49 | 50 | Args: 51 | fonts: A list of font names to search for. 52 | size: The font size. Defaults to 14. 53 | 54 | Returns: 55 | ImageFont.FreeTypeFont: Represents the font. 56 | 57 | Raises: 58 | GetEnvError: If none of the default fonts are found. 59 | """ 60 | for font_name in fonts: 61 | try: 62 | return ImageFont.truetype(font_name, size=size) 63 | except OSError: # noqa: PERF203 64 | continue 65 | 66 | msg = ( 67 | f"None of the fonts were found: {fonts}.\n" 68 | f"Please setup CAPTION_FONT in environment variables or in '{get_config_path()}'.", 69 | ) 70 | raise GetEnvError(msg) 71 | 72 | 73 | def make_thumbnail( 74 | img: bytes, 75 | font: ImageFont.FreeTypeFont, 76 | size: tuple[int, int] = (300, 300), 77 | ) -> bytes: 78 | """Generate thumbnail for the image. 79 | 80 | Args: 81 | img: The input image in bytes format. 82 | font: The font to be used for the text caption. 83 | size: The desired size of the thumbnail image. Defaults to (300, 300). 84 | 85 | Returns: 86 | The modified image in bytes format. 87 | """ 88 | # Open the input image and create a copy in RGB format. 89 | im = Image.open(BytesIO(img)) 90 | pw = im.copy() 91 | 92 | if pw.mode != "RGB": 93 | pw = pw.convert("RGB") 94 | 95 | # Resize the image to the desired size using Lanczos resampling. 96 | pw.thumbnail(size=size, resample=Image.Resampling.LANCZOS) 97 | 98 | # Create a blank image for the text 99 | pw_with_line = Image.new( 100 | mode="RGB", 101 | size=(pw.width, pw.height + 16), 102 | color=(255, 255, 255), 103 | ) 104 | pw_with_line.paste(pw, box=(0, 0)) 105 | 106 | # Get the file size of the input image. 107 | fsize = human_size(len(img)) 108 | 109 | # Draw the text caption 110 | d = ImageDraw.Draw(pw_with_line) 111 | d.text( 112 | xy=(pw.width / 5, pw.height), 113 | text=f"{im.width}x{im.height} ({im.format}) [{fsize}]", 114 | font=font, 115 | fill=(0, 0, 0), 116 | ) 117 | 118 | # Save the modified image as a JPEG file in bytes format. 119 | buffer = BytesIO() 120 | pw_with_line.save( 121 | buffer, 122 | format="JPEG", 123 | quality=95, 124 | optimize=True, 125 | progressive=True, 126 | ) 127 | 128 | return buffer.getvalue() 129 | -------------------------------------------------------------------------------- /src/images_upload_cli/logger.py: -------------------------------------------------------------------------------- 1 | """Logger configuration.""" 2 | 3 | import logging 4 | 5 | from loguru import logger 6 | from rich.logging import RichHandler 7 | 8 | 9 | class ErrorHandler(logging.StreamHandler): 10 | """Custom error handler for logging.""" 11 | 12 | def __init__(self: "ErrorHandler") -> None: 13 | """Init.""" 14 | super().__init__() 15 | self.error_occurred = False 16 | 17 | def emit(self: "ErrorHandler", record: logging.LogRecord) -> None: 18 | """Emit a record.""" 19 | if record.levelno >= logging.ERROR: 20 | self.error_occurred = True 21 | 22 | def has_error_occurred(self: "ErrorHandler") -> bool: 23 | """Check if an error has occurred.""" 24 | return self.error_occurred 25 | 26 | 27 | def setup_logger(log_level: str) -> ErrorHandler: 28 | """Configure logger. 29 | 30 | Args: 31 | log_level: The log level to set for the logger. 32 | 33 | Returns: 34 | ErrorHandler: The error handler associated with the logger. 35 | """ 36 | logger.remove() 37 | # Console handler 38 | logger.add( 39 | sink=RichHandler(log_time_format="[%X]", rich_tracebacks=True), 40 | level=log_level, 41 | format=lambda _: "{message}", 42 | ) 43 | # Error handler 44 | error_handler = ErrorHandler() 45 | logger.add(sink=error_handler, level="ERROR") 46 | 47 | return error_handler 48 | -------------------------------------------------------------------------------- /src/images_upload_cli/main.py: -------------------------------------------------------------------------------- 1 | """Main logic for the images-upload-cli package.""" 2 | 3 | from collections.abc import Awaitable, Callable, Sequence 4 | from pathlib import Path 5 | 6 | from httpx import AsyncClient 7 | 8 | from images_upload_cli.image import get_font, make_thumbnail 9 | 10 | 11 | async def upload_images( 12 | upload_func: Callable[[AsyncClient, bytes], Awaitable[str]], 13 | images: tuple[Path], 14 | thumbnail: bool, 15 | ) -> Sequence[tuple[str, str] | tuple[str, None]]: 16 | """Upload images using the specified upload function and optionally generate thumbnails. 17 | 18 | Args: 19 | upload_func: The function used to upload the images. 20 | images: The paths of the images to be uploaded. 21 | thumbnail: Indicates whether to generate thumbnails for the images. 22 | 23 | Returns: 24 | The links to the uploaded images and their corresponding thumbnails. 25 | The thumbnail link will be `None` if generation is disabled. 26 | """ 27 | links = [] 28 | 29 | if thumbnail: 30 | font = get_font() 31 | 32 | async with AsyncClient() as client: 33 | for img_path in images: 34 | img = img_path.read_bytes() 35 | 36 | img_link = await upload_func(client, img) 37 | # If the upload fails, skip the current image and proceed with the next one. 38 | if not img_link: 39 | continue 40 | 41 | if thumbnail: 42 | thumb = make_thumbnail(img, font) # pyright: ignore[reportPossiblyUnboundVariable] 43 | thumb_link = await upload_func(client, thumb) 44 | # If the upload fails, skip the current image and proceed with the next one. 45 | if not thumb_link: 46 | continue 47 | else: 48 | thumb_link = None 49 | 50 | links.append((img_link, thumb_link)) 51 | 52 | return links 53 | 54 | 55 | def format_link(links: Sequence[tuple[str, str] | tuple[str, None]], fmt: str) -> str: 56 | """Format the image links based on the specified format. 57 | 58 | Args: 59 | links: The image links and their corresponding thumbnails. 60 | fmt: The format to use for formatting the links. Valid options are "plain", "bbcode", "html", and "markdown". 61 | 62 | Returns: 63 | The formatted image links as a string. 64 | """ 65 | if fmt == "plain": 66 | return " ".join([img_link for img_link, _ in links]) 67 | 68 | if fmt == "bbcode": 69 | return " ".join( 70 | f"[img]{img_link}[/img]" 71 | if thumb_link is None 72 | else f"[url={img_link}][img]{thumb_link}[/img][/url]" 73 | for img_link, thumb_link in links 74 | ) 75 | 76 | if fmt == "html": 77 | return " ".join( 78 | f'image' 79 | if thumb_link is None 80 | else f'thumb' 81 | for img_link, thumb_link in links 82 | ) 83 | 84 | if fmt == "markdown": 85 | return " ".join( 86 | f"![image]({img_link})" 87 | if thumb_link is None 88 | else f"[![thumb]({thumb_link})]({img_link})" 89 | for img_link, thumb_link in links 90 | ) 91 | 92 | return "" 93 | -------------------------------------------------------------------------------- /src/images_upload_cli/upload.py: -------------------------------------------------------------------------------- 1 | """Upload images to various hosting services.""" 2 | 3 | from collections.abc import Awaitable, Callable 4 | from os import getenv 5 | from re import search 6 | from urllib.parse import urlparse 7 | 8 | from httpx import AsyncClient, BasicAuth 9 | from loguru import logger 10 | 11 | from images_upload_cli.image import get_img_ext 12 | from images_upload_cli.util import get_env, log_on_error 13 | 14 | 15 | @logger.catch(default="") 16 | async def anhmoe_upload(client: AsyncClient, img: bytes) -> str: 17 | """Uploads an image to the `anh.mo`. 18 | 19 | Args: 20 | client: The async HTTP client used to make the API request. 21 | img: The image data to be uploaded. 22 | 23 | Returns: 24 | The URL of the uploaded image, or an empty string if the upload failed. 25 | """ 26 | key = "anh.moe_public_api" 27 | 28 | response = await client.post( 29 | url="https://anh.moe/api/1/upload", 30 | data={"key": key}, 31 | files={"source": img}, 32 | ) 33 | if response.is_error: 34 | log_on_error(response) 35 | return "" 36 | 37 | return response.json()["image"]["url"] 38 | 39 | 40 | @logger.catch(default="") 41 | async def beeimg_upload(client: AsyncClient, img: bytes) -> str: 42 | """Uploads an image to the `beeimg.com`. 43 | 44 | Args: 45 | client: The async HTTP client used to make the API request. 46 | img: The image data to be uploaded. 47 | 48 | Returns: 49 | The URL of the uploaded image, or an empty string if the upload failed. 50 | """ 51 | ext = get_img_ext(img) 52 | name = f"img.{ext}" 53 | content_type = f"image/{ext}" 54 | 55 | response = await client.post( 56 | url="https://beeimg.com/api/upload/file/json/", 57 | files={"file": (name, img, content_type)}, 58 | ) 59 | if response.is_error: 60 | log_on_error(response) 61 | return "" 62 | 63 | return f"https:{response.json()['files']['url']}" 64 | 65 | 66 | @logger.catch(default="") 67 | async def catbox_upload(client: AsyncClient, img: bytes) -> str: 68 | """Uploads an image to the `catbox.moe`. 69 | 70 | Args: 71 | client: The async HTTP client used to make the API request. 72 | img: The image data to be uploaded. 73 | 74 | Returns: 75 | The URL of the uploaded image, or an empty string if the upload failed. 76 | """ 77 | response = await client.post( 78 | url="https://catbox.moe/user/api.php", 79 | data={"reqtype": "fileupload"}, 80 | files={"fileToUpload": img}, 81 | ) 82 | if response.is_error: 83 | log_on_error(response) 84 | return "" 85 | 86 | return response.text 87 | 88 | 89 | @logger.catch(default="") 90 | async def fastpic_upload(client: AsyncClient, img: bytes) -> str: 91 | """Uploads an image to the `fastpic.org`. 92 | 93 | Args: 94 | client: The async HTTP client used to make the API request. 95 | img: The image data to be uploaded. 96 | 97 | Returns: 98 | The URL of the uploaded image, or an empty string if the upload failed. 99 | """ 100 | response = await client.post( 101 | url="https://fastpic.org/upload?api=1", 102 | data={ 103 | "method": "file", 104 | "check_thumb": "no", 105 | "uploading": "1", 106 | }, 107 | files={"file1": img}, 108 | ) 109 | if response.is_error: 110 | log_on_error(response) 111 | return "" 112 | 113 | match = search(r"(.+?)", response.text) 114 | if match is None: 115 | logger.error(f"Image link not found in '{response.url}' response.") 116 | logger.debug(f"Response text:\n{response.text}") 117 | return "" 118 | 119 | return match[1].strip() 120 | 121 | 122 | @logger.catch(default="") 123 | async def filecoffee_upload(client: AsyncClient, img: bytes) -> str: 124 | """Uploads an image to the `file.coffee`. 125 | 126 | Args: 127 | client: The async HTTP client used to make the API request. 128 | img: The image data to be uploaded. 129 | 130 | Returns: 131 | The URL of the uploaded image, or an empty string if the upload failed. 132 | """ 133 | response = await client.post( 134 | url="https://file.coffee/api/file/upload", 135 | files={"file": img}, 136 | ) 137 | if response.is_error: 138 | log_on_error(response) 139 | return "" 140 | 141 | return response.json()["url"] 142 | 143 | 144 | @logger.catch(default="") 145 | async def freeimage_upload(client: AsyncClient, img: bytes) -> str: 146 | """Uploads an image to the `freeimage.host`. 147 | 148 | Args: 149 | client: The async HTTP client used to make the API request. 150 | img: The image data to be uploaded. 151 | 152 | Returns: 153 | The URL of the uploaded image, or an empty string if the upload failed. 154 | """ 155 | key = get_env("FREEIMAGE_KEY") 156 | 157 | response = await client.post( 158 | url="https://freeimage.host/api/1/upload", 159 | data={"key": key}, 160 | files={"source": img}, 161 | ) 162 | if response.is_error: 163 | log_on_error(response) 164 | return "" 165 | 166 | return response.json()["image"]["url"] 167 | 168 | 169 | @logger.catch(default="") 170 | async def gyazo_upload(client: AsyncClient, img: bytes) -> str: 171 | """Uploads an image to the `gyazo.com`. 172 | 173 | Args: 174 | client: The async HTTP client used to make the API request. 175 | img: The image data to be uploaded. 176 | 177 | Returns: 178 | The URL of the uploaded image, or an empty string if the upload failed. 179 | """ 180 | key = get_env("GYAZO_TOKEN") 181 | 182 | response = await client.post( 183 | url=f"https://upload.gyazo.com/api/upload?access_token={key}", 184 | files={"imagedata": img}, 185 | ) 186 | if response.is_error: 187 | log_on_error(response) 188 | return "" 189 | 190 | return response.json()["url"] 191 | 192 | 193 | @logger.catch(default="") 194 | async def imageban_upload(client: AsyncClient, img: bytes) -> str: 195 | """Uploads an image to the `imageban.ru`. 196 | 197 | Args: 198 | client: The async HTTP client used to make the API request. 199 | img: The image data to be uploaded. 200 | 201 | Returns: 202 | The URL of the uploaded image, or an empty string if the upload failed. 203 | """ 204 | token = get_env("IMAGEBAN_TOKEN") 205 | 206 | response = await client.post( 207 | url="https://api.imageban.ru/v1", 208 | headers={"Authorization": f"TOKEN {token}"}, 209 | files={"image": img}, 210 | ) 211 | if response.is_error: 212 | log_on_error(response) 213 | return "" 214 | 215 | return response.json()["data"]["link"] 216 | 217 | 218 | @logger.catch(default="") 219 | async def imagebin_upload(client: AsyncClient, img: bytes) -> str: 220 | """Uploads an image to the `imagebin.ca`. 221 | 222 | Args: 223 | client: The async HTTP client used to make the API request. 224 | img: The image data to be uploaded. 225 | 226 | Returns: 227 | The URL of the uploaded image, or an empty string if the upload failed. 228 | """ 229 | response = await client.post( 230 | url="https://imagebin.ca/upload.php", 231 | files={"file": img}, 232 | ) 233 | if response.is_error: 234 | log_on_error(response) 235 | return "" 236 | 237 | match = search(r"url:(.+?)$", response.text) 238 | if match is None: 239 | logger.error(f"Image link not found in '{response.url}' response.") 240 | logger.debug(f"Response text:\n{response.text}") 241 | return "" 242 | 243 | return match[1].strip() 244 | 245 | 246 | @logger.catch(default="") 247 | async def imgbb_upload(client: AsyncClient, img: bytes) -> str: 248 | """Uploads an image to the `imgbb.com`. 249 | 250 | Args: 251 | client: The async HTTP client used to make the API request. 252 | img: The image data to be uploaded. 253 | 254 | Returns: 255 | The URL of the uploaded image, or an empty string if the upload failed. 256 | """ 257 | key = get_env("IMGBB_KEY") 258 | 259 | response = await client.post( 260 | url="https://api.imgbb.com/1/upload", 261 | data={"key": key}, 262 | files={"image": img}, 263 | ) 264 | if response.is_error: 265 | log_on_error(response) 266 | return "" 267 | 268 | return response.json()["data"]["url"] 269 | 270 | 271 | @logger.catch(default="") 272 | async def imgchest_upload(client: AsyncClient, img: bytes) -> str: 273 | """Uploads an image to the `imgchest.com`. 274 | 275 | Args: 276 | client: The async HTTP client used to make the API request. 277 | img: The image data to be uploaded. 278 | 279 | Returns: 280 | The URL of the uploaded image, or an empty string if the upload failed. 281 | """ 282 | key = get_env("IMGCHEST_KEY") 283 | name = f"img.{get_img_ext(img)}" 284 | 285 | response = await client.post( 286 | url="https://api.imgchest.com/v1/post", 287 | headers={"Authorization": f"Bearer {key}"}, 288 | files={"images[]": (name, img)}, 289 | ) 290 | if response.is_error: 291 | log_on_error(response) 292 | return "" 293 | 294 | return response.json()["data"]["images"][0]["link"] 295 | 296 | 297 | @logger.catch(default="") 298 | async def imgur_upload(client: AsyncClient, img: bytes) -> str: 299 | """Uploads an image to the `imgur.com`. 300 | 301 | Args: 302 | client: The async HTTP client used to make the API request. 303 | img: The image data to be uploaded. 304 | 305 | Returns: 306 | The URL of the uploaded image, or an empty string if the upload failed. 307 | """ 308 | client_id = getenv("IMGUR_CLIENT_ID", "dd32dd3c6aaa9a0") 309 | 310 | response = await client.post( 311 | url="https://api.imgur.com/3/image", 312 | headers={"Authorization": f"Client-ID {client_id}"}, 313 | files={"image": img}, 314 | ) 315 | if response.is_error: 316 | log_on_error(response) 317 | return "" 318 | 319 | return response.json()["data"]["link"] 320 | 321 | 322 | @logger.catch(default="") 323 | async def lensdump_upload(client: AsyncClient, img: bytes) -> str: 324 | """Uploads an image to the `lensdump.com`. 325 | 326 | Args: 327 | client: The async HTTP client used to make the API request. 328 | img: The image data to be uploaded. 329 | 330 | Returns: 331 | The URL of the uploaded image, or an empty string if the upload failed. 332 | """ 333 | key = get_env("LENSDUMP_KEY") 334 | 335 | response = await client.post( 336 | url="https://lensdump.com/api/1/upload", 337 | data={"key": key}, 338 | files={"source": img}, 339 | ) 340 | if response.is_error: 341 | log_on_error(response) 342 | return "" 343 | 344 | return response.json()["image"]["url"] 345 | 346 | 347 | @logger.catch(default="") 348 | async def pixeldrain_upload(client: AsyncClient, img: bytes) -> str: 349 | """Uploads an image to the `pixeldrain.com`. 350 | 351 | Args: 352 | client: The async HTTP client used to make the API request. 353 | img: The image data to be uploaded. 354 | 355 | Returns: 356 | The URL of the uploaded image, or an empty string if the upload failed. 357 | """ 358 | key = get_env("PIXELDRAIN_KEY") 359 | 360 | response = await client.post( 361 | url="https://pixeldrain.com/api/file", 362 | auth=BasicAuth(username="", password=key), 363 | files={"file": img}, 364 | ) 365 | if response.is_error: 366 | log_on_error(response) 367 | return "" 368 | 369 | return f"https://pixeldrain.com/api/file/{response.json()['id']}" 370 | 371 | 372 | @logger.catch(default="") 373 | async def pixhost_upload(client: AsyncClient, img: bytes) -> str: 374 | """Uploads an image to the `pixhost.to`. 375 | 376 | Args: 377 | client: The async HTTP client used to make the API request. 378 | img: The image data to be uploaded. 379 | 380 | Returns: 381 | The URL of the uploaded image, or an empty string if the upload failed. 382 | """ 383 | response = await client.post( 384 | url="https://api.pixhost.to/images", 385 | data={"content_type": 0}, 386 | files={"img": img}, 387 | ) 388 | if response.is_error: 389 | log_on_error(response) 390 | return "" 391 | 392 | show_url = response.json()["show_url"] 393 | 394 | # Get direct link. 395 | get_resp = await client.get(show_url) 396 | u = urlparse(show_url) 397 | match = search( 398 | rf"({u.scheme}://(.+?){u.netloc}/images/{u.path.removeprefix('/show/')})", 399 | get_resp.text, 400 | ) 401 | image_link = None if match is None else match[0].strip() 402 | 403 | return show_url if image_link is None else image_link 404 | 405 | 406 | @logger.catch(default="") 407 | async def ptpimg_upload(client: AsyncClient, img: bytes) -> str: 408 | """Uploads an image to the `ptpimg.me`. 409 | 410 | Args: 411 | client: The async HTTP client used to make the API request. 412 | img: The image data to be uploaded. 413 | 414 | Returns: 415 | The URL of the uploaded image, or an empty string if the upload failed. 416 | """ 417 | key = get_env("PTPIMG_KEY") 418 | 419 | response = await client.post( 420 | url="https://ptpimg.me/upload.php", 421 | data={"api_key": key}, 422 | files={"file-upload[0]": img}, 423 | ) 424 | if response.is_error: 425 | log_on_error(response) 426 | return "" 427 | 428 | return f"https://ptpimg.me/{response.json()[0]['code']}.{response.json()[0]['ext']}" 429 | 430 | 431 | @logger.catch(default="") 432 | async def smms_upload(client: AsyncClient, img: bytes) -> str: 433 | """Uploads an image to the `sm.ms`. 434 | 435 | Args: 436 | client: The async HTTP client used to make the API request. 437 | img: The image data to be uploaded. 438 | 439 | Returns: 440 | The URL of the uploaded image, or an empty string if the upload failed. 441 | """ 442 | key = get_env("SMMS_KEY") 443 | 444 | response = await client.post( 445 | url="https://sm.ms/api/v2/upload", 446 | headers={"Authorization": key}, 447 | files={"smfile": img}, 448 | ) 449 | if response.is_error: 450 | log_on_error(response) 451 | return "" 452 | 453 | json = response.json() 454 | 455 | return json["images"] if json["code"] == "image_repeated" else json["data"]["url"] 456 | 457 | 458 | @logger.catch(default="") 459 | async def sxcu_upload(client: AsyncClient, img: bytes) -> str: 460 | """Uploads an image to the `sxcu.net`. 461 | 462 | Args: 463 | client: The async HTTP client used to make the API request. 464 | img: The image data to be uploaded. 465 | 466 | Returns: 467 | The URL of the uploaded image, or an empty string if the upload failed. 468 | """ 469 | response = await client.post( 470 | url="https://sxcu.net/api/files/create", 471 | headers={"user-agent": "python-https/1.0.0"}, 472 | files={"file": img}, 473 | ) 474 | if response.is_error: 475 | log_on_error(response) 476 | return "" 477 | 478 | return f"{response.json()['url']}.{get_img_ext(img)}" 479 | 480 | 481 | @logger.catch(default="") 482 | async def telegraph_upload(client: AsyncClient, img: bytes) -> str: 483 | """Uploads an image to the `telegra.ph`. 484 | 485 | Args: 486 | client: The async HTTP client used to make the API request. 487 | img: The image data to be uploaded. 488 | 489 | Returns: 490 | The URL of the uploaded image, or an empty string if the upload failed. 491 | """ 492 | response = await client.post( 493 | url="https://telegra.ph/upload", 494 | files={"file": img}, 495 | ) 496 | if response.is_error: 497 | log_on_error(response) 498 | return "" 499 | 500 | return f"https://telegra.ph{response.json()[0]['src']}" 501 | 502 | 503 | @logger.catch(default="") 504 | async def thumbsnap_upload(client: AsyncClient, img: bytes) -> str: 505 | """Uploads an image to the `thumbsnap.com`. 506 | 507 | Args: 508 | client: The async HTTP client used to make the API request. 509 | img: The image data to be uploaded. 510 | 511 | Returns: 512 | The URL of the uploaded image, or an empty string if the upload failed. 513 | """ 514 | key = get_env("THUMBSNAP_KEY") 515 | 516 | response = await client.post( 517 | url="https://thumbsnap.com/api/upload", 518 | data={"key": key}, 519 | files={"media": img}, 520 | ) 521 | if response.is_error: 522 | log_on_error(response) 523 | return "" 524 | 525 | return response.json()["data"]["media"] 526 | 527 | 528 | @logger.catch(default="") 529 | async def tixte_upload(client: AsyncClient, img: bytes) -> str: 530 | """Uploads an image to the `tixte.com`. 531 | 532 | Args: 533 | client: The async HTTP client used to make the API request. 534 | img: The image data to be uploaded. 535 | 536 | Returns: 537 | The URL of the uploaded image, or an empty string if the upload failed. 538 | """ 539 | key = get_env("TIXTE_KEY") 540 | name = f"img.{get_img_ext(img)}" 541 | 542 | response = await client.post( 543 | url="https://api.tixte.com/v1/upload", 544 | headers={"Authorization": key}, 545 | data={"payload_json": '{"random":true}'}, 546 | files={"file": (name, img)}, 547 | ) 548 | if response.is_error: 549 | log_on_error(response) 550 | return "" 551 | 552 | return response.json()["data"]["direct_url"] 553 | 554 | 555 | @logger.catch(default="") 556 | async def up2sha_upload(client: AsyncClient, img: bytes) -> str: 557 | """Uploads an image to the `up2sha.re`. 558 | 559 | Args: 560 | client: The async HTTP client used to make the API request. 561 | img: The image data to be uploaded. 562 | 563 | Returns: 564 | The URL of the uploaded image, or an empty string if the upload failed. 565 | """ 566 | key = get_env("UP2SHA_KEY") 567 | ext = get_img_ext(img) 568 | name = f"img.{ext}" 569 | 570 | response = await client.post( 571 | url="https://api.up2sha.re/files", 572 | headers={"X-Api-Key": key}, 573 | files={"file": (name, img)}, 574 | ) 575 | if response.is_error: 576 | log_on_error(response) 577 | return "" 578 | 579 | return f"{response.json()['public_url'].replace('file?f=', 'media/raw/')}.{ext}" 580 | 581 | 582 | @logger.catch(default="") 583 | async def uplio_upload(client: AsyncClient, img: bytes) -> str: 584 | """Uploads an image to the `upl.io`. 585 | 586 | Args: 587 | client: The async HTTP client used to make the API request. 588 | img: The image data to be uploaded. 589 | 590 | Returns: 591 | The URL of the uploaded image, or an empty string if the upload failed. 592 | """ 593 | key = get_env("UPLIO_KEY") 594 | ext = get_img_ext(img) 595 | name = f"img.{ext}" 596 | 597 | response = await client.post( 598 | url="https://upl.io", 599 | data={"key": key}, 600 | files={"file": (name, img)}, 601 | ) 602 | if response.is_error: 603 | log_on_error(response) 604 | return "" 605 | 606 | host, uid = response.text.rsplit("/", 1) 607 | return f"{host}/i/{uid}.{ext}" 608 | 609 | 610 | @logger.catch(default="") 611 | async def uploadcare_upload(client: AsyncClient, img: bytes) -> str: 612 | """Uploads an image to the `uploadcare.com`. 613 | 614 | Args: 615 | client: The async HTTP client used to make the API request. 616 | img: The image data to be uploaded. 617 | 618 | Returns: 619 | The URL of the uploaded image, or an empty string if the upload failed. 620 | """ 621 | key = get_env("UPLOADCARE_KEY") 622 | name = f"img.{get_img_ext(img)}" 623 | 624 | response = await client.post( 625 | url="https://upload.uploadcare.com/base/", 626 | data={ 627 | "UPLOADCARE_PUB_KEY": key, 628 | "UPLOADCARE_STORE": "1", 629 | }, 630 | files={"filename": (name, img)}, 631 | ) 632 | if response.is_error: 633 | log_on_error(response) 634 | return "" 635 | 636 | return f"https://ucarecdn.com/{response.json()['filename']}/{name}" 637 | 638 | 639 | @logger.catch(default="") 640 | async def vgy_upload(client: AsyncClient, img: bytes) -> str: 641 | """Uploads an image to the `vgy.me`. 642 | 643 | Args: 644 | client: The async HTTP client used to make the API request. 645 | img: The image data to be uploaded. 646 | 647 | Returns: 648 | The URL of the uploaded image, or an empty string if the upload failed. 649 | """ 650 | key = get_env("VGY_KEY") 651 | name = f"img.{get_img_ext(img)}" 652 | 653 | response = await client.post( 654 | url="https://vgy.me/upload", 655 | data={"userkey": key}, 656 | files={"file[]": (name, img)}, 657 | ) 658 | if response.is_error: 659 | log_on_error(response) 660 | return "" 661 | 662 | return response.json()["image"] 663 | 664 | 665 | UPLOAD: dict[str, Callable[[AsyncClient, bytes], Awaitable[str]]] = { 666 | "anhmoe": anhmoe_upload, 667 | "beeimg": beeimg_upload, 668 | "catbox": catbox_upload, 669 | "fastpic": fastpic_upload, 670 | "filecoffee": filecoffee_upload, 671 | "freeimage": freeimage_upload, 672 | "gyazo": gyazo_upload, 673 | "imageban": imageban_upload, 674 | "imagebin": imagebin_upload, 675 | "imgbb": imgbb_upload, 676 | "imgchest": imgchest_upload, 677 | "imgur": imgur_upload, 678 | "lensdump": lensdump_upload, 679 | "pixeldrain": pixeldrain_upload, 680 | "pixhost": pixhost_upload, 681 | "ptpimg": ptpimg_upload, 682 | "smms": smms_upload, 683 | "sxcu": sxcu_upload, 684 | "telegraph": telegraph_upload, 685 | "thumbsnap": thumbsnap_upload, 686 | "tixte": tixte_upload, 687 | "up2sha": up2sha_upload, 688 | "uplio": uplio_upload, 689 | "uploadcare": uploadcare_upload, 690 | "vgy": vgy_upload, 691 | } 692 | 693 | HOSTINGS = tuple(UPLOAD.keys()) 694 | -------------------------------------------------------------------------------- /src/images_upload_cli/util.py: -------------------------------------------------------------------------------- 1 | """Utility functions for the package.""" 2 | 3 | from os import getenv 4 | from pathlib import Path 5 | from shutil import which 6 | from subprocess import Popen 7 | 8 | import click 9 | from httpx import Response 10 | from loguru import logger 11 | 12 | 13 | class GetEnvError(Exception): 14 | """Exception raised when an environment variable is not found.""" 15 | 16 | 17 | def get_config_path() -> Path: 18 | """Get the path to the app config file. 19 | 20 | Returns: 21 | The path to the app config file. 22 | """ 23 | app_dir = click.get_app_dir("images-upload-cli") 24 | return Path(app_dir) / ".env" 25 | 26 | 27 | def get_env(variable: str) -> str: 28 | """Get the value of an environment variable. 29 | 30 | Args: 31 | variable: The name of the environment variable to retrieve. 32 | 33 | Returns: 34 | The value of the environment variable, if found. 35 | 36 | Raises: 37 | GetEnvError: If the environment variable is not found. 38 | """ 39 | if value := getenv(variable): 40 | return value 41 | 42 | msg = f"Please setup {variable} in environment variables or in '{get_config_path()}'." 43 | raise GetEnvError(msg) 44 | 45 | 46 | def human_size(num: float, suffix: str = "B") -> str: 47 | """Convert bytes to human-readable format. 48 | 49 | Args: 50 | num: The number of bytes to be converted. 51 | suffix: The suffix to be appended to the converted size. 52 | 53 | Returns: 54 | The human-readable size with the appropriate unit and suffix. 55 | """ 56 | units = ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"] 57 | round_num = 1024.0 58 | 59 | for unit in units: 60 | if abs(num) < round_num: 61 | return f"{num:3.1f} {unit}{suffix}" 62 | num /= round_num 63 | 64 | return f"{num:.1f} Yi{suffix}" 65 | 66 | 67 | def notify_send(text_to_print: str) -> None: 68 | """Send desktop notifications via libnotify. 69 | 70 | Args: 71 | text_to_print: The text to be displayed in the desktop notification. 72 | """ 73 | if notify_send := which("notify-send"): 74 | Popen([notify_send, "-a", "images-upload-cli", text_to_print]) # noqa: S603 75 | 76 | 77 | def log_on_error(response: Response) -> None: 78 | """Logs an error message based on the HTTP response. 79 | 80 | Args: 81 | response: The HTTP response object. 82 | """ 83 | status_class = response.status_code // 100 84 | error_types = { 85 | 1: "Informational response", 86 | 3: "Redirect response", 87 | 4: "Client error", 88 | 5: "Server error", 89 | } 90 | error_type = error_types.get(status_class, "Invalid status code") 91 | 92 | logger.error( 93 | f"{error_type} '{response.status_code} {response.reason_phrase}' for url '{response.url}'." 94 | ) 95 | logger.debug(f"Response text:\n{response.text}") 96 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeadNews/images-upload-cli/389a2af09e0d946a90a2c861b1371c51e46ca11c/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Shared fixtures.""" 2 | 3 | from collections.abc import Callable 4 | from pathlib import Path 5 | 6 | import pytest 7 | from logot.loguru import LoguruCapturer 8 | 9 | 10 | @pytest.fixture 11 | def img() -> bytes: 12 | return Path("tests/data/pic.png").read_bytes() 13 | 14 | 15 | @pytest.fixture(scope="session") 16 | def logot_capturer() -> Callable[[], LoguruCapturer]: 17 | return LoguruCapturer 18 | -------------------------------------------------------------------------------- /tests/data/.env.sample: -------------------------------------------------------------------------------- 1 | FREEIMAGE_KEY=key 2 | GYAZO_TOKEN=key 3 | IMAGEBAN_TOKEN=key 4 | IMGBB_KEY=key 5 | IMGCHEST_KEY=key 6 | IMGUR_CLIENT_ID=key 7 | LENSDUMP_KEY=key 8 | PIXELDRAIN_KEY=key 9 | PTPIMG_KEY=key 10 | SMMS_KEY=key 11 | THUMBSNAP_KEY=key 12 | TIXTE_KEY=key 13 | UP2SHA_KEY=key 14 | UPLIO_KEY=key 15 | UPLOADCARE_KEY=key 16 | VGY_KEY=key 17 | -------------------------------------------------------------------------------- /tests/data/DejaVuSerif.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeadNews/images-upload-cli/389a2af09e0d946a90a2c861b1371c51e46ca11c/tests/data/DejaVuSerif.ttf -------------------------------------------------------------------------------- /tests/data/pic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeadNews/images-upload-cli/389a2af09e0d946a90a2c861b1371c51e46ca11c/tests/data/pic.png -------------------------------------------------------------------------------- /tests/mock.py: -------------------------------------------------------------------------------- 1 | """Mock data.""" 2 | 3 | anhmoe = """{"status_code":200,"success":{"message":"image uploaded","code":200},"image":{"name":"Lyw3o","extension":"png","size":91,"width":100,"height":100,"date":"2024-01-01 00:00:00","date_gmt":"2024-01-01 00:00:00","title":null,"description":null,"nsfw":0,"storage_mode":"direct","md5":"45927ce6c3a6ba2e48a260328dc57d3d","source_md5":null,"original_filename":"upload","original_exifdata":null,"views":0,"category_id":null,"chain":5,"thumb_size":301,"medium_size":0,"expiration_date_gmt":null,"likes":0,"is_animated":0,"is_approved":1,"is_360":0,"file":{"resource":{"type":"url"}},"id_encoded":"Lyw3o","filename":"Lyw3o.png","mime":"image\\/png","url":"https:\\/\\/cdn.anh.moe\\/c\\/Lyw3o.png","ratio":1,"size_formatted":"91 B","url_viewer":"https:\\/\\/anh.moe\\/view\\/Lyw3o","path_viewer":"\\/view\\/Lyw3o","url_short":"https:\\/\\/anh.moe\\/view\\/Lyw3o","image":{"filename":"Lyw3o.png","name":"Lyw3o","mime":"image\\/png","extension":"png","url":"https:\\/\\/cdn.anh.moe\\/c\\/Lyw3o.png","size":91},"thumb":{"filename":"Lyw3o.th.png","name":"Lyw3o.th","mime":"image\\/png","extension":"png","url":"https:\\/\\/cdn.anh.moe\\/c\\/Lyw3o.th.png","size":301},"display_url":"https:\\/\\/cdn.anh.moe\\/c\\/Lyw3o.png","display_width":100,"display_height":100,"views_label":"l\u01b0\u1ee3t xem","likes_label":"th\u00edch","how_long_ago":"m\u1edbi \u0111\u00e2y","date_fixed_peer":"2024-01-01 00:00:00","title_truncated":"","title_truncated_html":"","is_use_loader":false,"delete_url":"https:\\/\\/anh.moe\\/view\\/Lyw3o\\/delete\\/e2ace748e2e1f9ca82ad47ee194dc6fdd0b6c440a55e9419"},"status_txt":"OK"}""" 4 | beeimg = """{"files":{"name":"x8078479702","size":"","url":"\\/\\/beeimg.com\\/images\\/x80784797021.png","thumbnail_url":"\\/\\/i.beeimg.com\\/images\\/thumb\\/x80784797021-xs.png","view_url":"\\/\\/beeimg.com\\/view\\/x8078479702\\/","delete_url":"N\\/A","delete_type":"DELETE","status":"Duplicate","code":"200"}}""" 5 | catbox = """https://files.catbox.moe/4yt1tj""" 6 | fastpic = """ 7 | 8 | https://i122.fastpic.org/big/2023/0621/ba/4cde7fe843ecf35688167399d5b269ba.png 9 | 4cde7fe843ecf35688167399d5b269ba 10 | ykBMZJeE6p 11 | ok 12 | 13 | https://fastpic.org/view/122/2023/0621/4cde7fe843ecf35688167399d5b269ba.png.html 14 | https://fastpic.org/view/122/2023/0621/4cde7fe843ecf35688167399d5b269ba.png.html 15 | https://i122.fastpic.org/thumb/2023/0621/ba/4cde7fe843ecf35688167399d5b269ba.jpeg 16 | https://fastpic.org/session/2023/0621/ykBMZJeE6p.html 17 | """ 18 | filecoffee = """{"success":true,"message":"File successfully uploaded","file":"u3s_hXaFjVQ5bWm0NqSfp.png","size":91,"filename":"u/u3s_hXaFjVQ5bWm0NqSfp.png","url":"https://file.coffee/u/u3s_hXaFjVQ5bWm0NqSfp.png","mime":"image/png","md5":"45927ce6c3a6ba2e48a260328dc57d3d"}""" 19 | freeimage = """{"status_code":200,"success":{"message":"image uploaded","code":200},"image":{"name":"upload","extension":"png","width":100,"height":100,"size":91,"time":1687346353,"expiration":0,"adult":8,"status":0,"cloud":0,"vision":1,"likes":0,"description":null,"original_exifdata":null,"original_filename":"upload","views_html":0,"views_hotlink":0,"access_html":0,"access_hotlink":0,"file":{"resource":{"chain":{"image":"https:\\/\\/iili.io\\/HPTxvWu.png","thumb":"https:\\/\\/iili.io\\/HPTxvWu.th.png"},"chain_code":{"image":"HPTxvWu","thumb":"HPTxvWu"}}},"is_animated":0,"nsfw":0,"id_encoded":"HPTxvWu","ratio":1,"size_formatted":"91 B","filename":"HPTxvWu.png","url":"https:\\/\\/iili.io\\/HPTxvWu.png","url_short":"https:\\/\\/freeimage.host\\/","url_seo":"https:\\/\\/freeimage.host\\/i\\/upload.HPTxvWu","url_viewer":"https:\\/\\/freeimage.host\\/i\\/HPTxvWu","url_viewer_preview":"https:\\/\\/freeimage.host\\/","url_viewer_thumb":"https:\\/\\/freeimage.host\\/","image":{"filename":"HPTxvWu.png","name":"HPTxvWu","mime":"image\\/png","extension":"png","url":"https:\\/\\/iili.io\\/HPTxvWu.png","size":91},"thumb":{"filename":"HPTxvWu.th.png","name":"HPTxvWu.th","mime":"image\\/png","extension":"png","url":"https:\\/\\/iili.io\\/HPTxvWu.th.png"},"display_url":"https:\\/\\/iili.io\\/HPTxvWu.png","display_width":100,"display_height":100,"views_label":"views","likes_label":"likes","how_long_ago":"5 hours ago","date_fixed_peer":"2023-06-21 11:19:13","title":"upload","title_truncated":"upload","title_truncated_html":"upload","is_use_loader":false},"status_txt":"OK"}""" 20 | gyazo = """{"type":"png","thumb_url":"https://thumb.gyazo.com/thumb/200/eyJhbGciOiJIUzI1NiJ9.eyJpbWciOiJfOWU5YzJiOTA1ZTM4NTYwMWE1NjQ4NmViOGFiYzVmMjEifQ.UEnPXn6-dbc4G74aogPzfbStPQ0V5hlHf5ghhZOdA1c-png.jpg","created_at":"2023-02-24T08:50:18+0000","image_id":"45927ce6c3a6ba2e48a260328dc57d3d","permalink_url":"https://gyazo.com/45927ce6c3a6ba2e48a260328dc57d3d","url":"https://i.gyazo.com/45927ce6c3a6ba2e48a260328dc57d3d.png"}""" 21 | imageban = """{"data":{"date":"2023.06.21","name":"55dd05177f9f8a35875447f0f0755e67.png","server":"i5.imageban.ru","img_name":"image.png","size":"91","res":"100x100","link":"https:\\/\\/i5.imageban.ru\\/out\\/2023\\/06\\/21\\/55dd05177f9f8a35875447f0f0755e67.png","short_link":"http:\\/\\/ibn.im\\/4i374hK","deletehash":"kdhi4WKT9gEH9rl1ob92H1o19M5hWF0p"},"success":true,"status":200}""" 22 | imagebin = """status:7QknLCJgP5iN 23 | url:https://ibin.co/7QknLCJgP5iN.png""" 24 | imgbb = """{"data":{"id":"bs96JVz","title":"upload","url_viewer":"https:\\/\\/ibb.co\\/bs96JVz","url":"https:\\/\\/i.ibb.co\\/rxrQsh4\\/upload.png","display_url":"https:\\/\\/i.ibb.co\\/rxrQsh4\\/upload.png","width":100,"height":100,"size":91,"time":1687346358,"expiration":0,"image":{"filename":"upload.png","name":"upload","mime":"image\\/png","extension":"png","url":"https:\\/\\/i.ibb.co\\/rxrQsh4\\/upload.png"},"thumb":{"filename":"upload.png","name":"upload","mime":"image\\/png","extension":"png","url":"https:\\/\\/i.ibb.co\\/bs96JVz\\/upload.png"},"delete_url":"https:\\/\\/ibb.co\\/bs96JVz\\/8940c1ed55dd806e8b9de248b3707288"},"success":true,"status":200}""" 25 | imgchest = """{"data":{"id":"qe4g522b7j2","title":null,"username":"lF2LP3","privacy":"hidden","report_status":1,"views":0,"nsfw":0,"image_count":1,"created":"2023-06-21T17:22:57.000000Z","delete_url":"https:\\/\\/api.imgchest.com\\/p\\/7b49jh6ggj7w8\\/delete","images":[{"id":"3yrgcr3jpp4","description":null,"link":"https:\\/\\/cdn.imgchest.com\\/files\\/3yrgcr3jpp4.png","position":1,"created":"2023-06-21T17:22:57.000000Z","original_name":"img.png"}]}}""" 26 | imgur = """{"data":{"id":"SKAxiks","title":null,"description":null,"datetime":1687366735,"type":"image\\/png","animated":false,"width":100,"height":100,"size":91,"views":0,"bandwidth":0,"vote":null,"favorite":false,"nsfw":null,"section":null,"account_url":null,"account_id":0,"is_ad":false,"in_most_viral":false,"has_sound":false,"tags":[],"ad_type":0,"ad_url":"","edited":"0","in_gallery":false,"deletehash":"1NV90wWpO691n05","name":"","link":"https:\\/\\/i.imgur.com\\/SKAxiks.png"},"success":true,"status":200}""" 27 | lensdump = """{"status_code":200,"success":{"message":"image uploaded","code":200},"image":{"name":"CJkLoa","extension":"png","size":91,"width":100,"height":100,"date":"2023-06-21 13:23:30","date_gmt":"2023-06-21 17:23:30","title":null,"description":null,"nsfw":0,"storage_mode":"direct","md5":"45927ce6c3a6ba2e48a260328dc57d3d","original_filename":"upload","original_exifdata":null,"views":0,"category_id":null,"chain":5,"thumb_size":292,"medium_size":0,"expiration_date_gmt":null,"likes":0,"is_animated":0,"source_md5":null,"is_approved":1,"is_360":0,"file":{"resource":{"type":"url"}},"id_encoded":"CJkLoa","filename":"CJkLoa.png","mime":"image\\/png","url":"https:\\/\\/i.lensdump.com\\/i\\/CJkLoa.png","ratio":1,"size_formatted":"91 B","url_viewer":"https:\\/\\/lensdump.com\\/i\\/CJkLoa","path_viewer":"\\/i\\/CJkLoa","url_short":"https:\\/\\/lensdump.com\\/i\\/CJkLoa","image":{"filename":"CJkLoa.png","name":"CJkLoa","mime":"image\\/png","extension":"png","url":"https:\\/\\/i.lensdump.com\\/i\\/CJkLoa.png","size":91},"thumb":{"filename":"CJkLoa.th.png","name":"CJkLoa.th","mime":"image\\/png","extension":"png","url":"https:\\/\\/i.lensdump.com\\/i\\/CJkLoa.th.png","size":292},"display_url":"https:\\/\\/i.lensdump.com\\/i\\/CJkLoa.png","display_width":100,"display_height":100,"views_label":"views","likes_label":"likes","how_long_ago":"moments ago","date_fixed_peer":"2023-06-21 17:23:30","title_truncated":"","title_truncated_html":"","is_use_loader":false,"delete_url":"https:\\/\\/lensdump.com\\/i\\/CJkLoa\\/delete\\/916e9c2b82ee44cc2f8cdff393bf6167f017ad8b3c2aca1b"},"status_txt":"OK"}""" 28 | pixeldrain = """{"success":true,"id":"y61mKtGN"}""" 29 | pixhost = """{"name":"upload.png","show_url":"https:\\/\\/pixhost.to\\/show\\/865\\/361824612_upload.png","th_url":"https:\\/\\/t87.pixhost.to\\/thumbs\\/865\\/361824612_upload.png"}""" 30 | ptpimg = """[ 31 | { 32 | "code": "8i531v", 33 | "ext": "png" 34 | } 35 | ]""" 36 | smms = """{"success":true,"code":"success","message":"Upload success.","data":{"file_id":0,"width":100,"height":100,"filename":"upload","storename":"Bqv9EmdelxcM2XN.png","size":91,"path":"\\/2023\\/06\\/22\\/Bqv9EmdelxcM2XN.png","hash":"LgImaVk5s4UBujtdfeRzCQ9DX6","url":"https:\\/\\/s2.loli.net\\/2023\\/06\\/22\\/Bqv9EmdelxcM2XN.png","delete":"https:\\/\\/sm.ms\\/delete\\/LgImaVk5s4UBujtdfeRzCQ9DX6","page":"https:\\/\\/sm.ms\\/image\\/Bqv9EmdelxcM2XN"},"RequestId":"21A70D33-EFFC-4540-8C2A-8BF36131C122"}""" 37 | sxcu = """{ 38 | "id": "66iFnGoQ6", 39 | "url": "https://sxcu.net/66iFnGoQ6", 40 | "del_url": "https://sxcu.net/api/files/delete/66iFnGoQ6/dd6d902a-ebaa-453a-a794-458596a7badf", 41 | "thumb": "https://sxcu.net/t/66iFnGoQ6.png" 42 | }""" 43 | telegraph = """[{"src":"\\/file\\/994610b961b722e9fbd9c.png"}]""" 44 | thumbsnap = """{"data":{"id":"GfQWVNwT","url":"https://thumbsnap.com/GfQWVNwT","media":"https://thumbsnap.com/i/GfQWVNwT.png","thumb":"https://thumbsnap.com/t/GfQWVNwT.jpg","width":100,"height":100},"success":true,"status":200}""" 45 | tixte = """{"success":true,"size":91,"data":{"id":"lj5zsvnkwa0","name":"lj5zsvnkwa0","region":"us-east-1","filename":"lj5zsvnkwa0.png","extension":"png","domain":"cdx.tixte.co","type":1,"expiration":null,"permissions":[{"user":{"id":"0e0f89b4229244ff8b229d20e1e84852","username":"CdX"},"access_level":3}],"url":"https://cdx.tixte.co/lj5zsvnkwa0.png","direct_url":"https://cdx.tixte.co/r/lj5zsvnkwa0.png","deletion_url":"https://api.tixte.com/v1/users/@me/uploads/del/lj5zsvnkwa0?auth=e2ceedcd-2b46-4ded-b241-e6d310db6aac","message":"File uploaded successfully"}}""" 46 | up2sha = """{"id":116031,"public_url":"https:\\/\\/up2sha.re\\/file?f=CozTXz35lv1Lk0o7NC","download_url":"https:\\/\\/up2sha.re\\/files\\/CozTXz35lv1Lk0o7NC\\/download"}""" 47 | uplio = """https://upl.io/0w25y7""" 48 | uploadcare = """{"filename":"7eb40c10-fc9b-42a2-a086-e9eef5bafb9c"}""" 49 | vgy = """{"error":false,"size":91,"filename":"3Kyfvf","ext":"png","url":"https://vgy.me/u/3Kyfvf","image":"https://i.vgy.me/3Kyfvf.png","delete":"https://vgy.me/delete/357619c3-aab3-4a22-84ba-03ebb601efbf"}""" 50 | 51 | 52 | RESPONSE: dict[str, tuple[str, str]] = { 53 | "anhmoe": (anhmoe, "https://cdn.anh.moe/c/Lyw3o.png"), 54 | "beeimg": (beeimg, "https://beeimg.com/images/x80784797021.png"), 55 | "catbox": (catbox, "https://files.catbox.moe/4yt1tj"), 56 | "fastpic": ( 57 | fastpic, 58 | "https://i122.fastpic.org/big/2023/0621/ba/4cde7fe843ecf35688167399d5b269ba.png", 59 | ), 60 | "filecoffee": (filecoffee, "https://file.coffee/u/u3s_hXaFjVQ5bWm0NqSfp.png"), 61 | "freeimage": (freeimage, "https://iili.io/HPTxvWu.png"), 62 | "gyazo": (gyazo, "https://i.gyazo.com/45927ce6c3a6ba2e48a260328dc57d3d.png"), 63 | "imageban": ( 64 | imageban, 65 | "https://i5.imageban.ru/out/2023/06/21/55dd05177f9f8a35875447f0f0755e67.png", 66 | ), 67 | "imagebin": (imagebin, "https://ibin.co/7QknLCJgP5iN.png"), 68 | "imgbb": (imgbb, "https://i.ibb.co/rxrQsh4/upload.png"), 69 | "imgchest": (imgchest, "https://cdn.imgchest.com/files/3yrgcr3jpp4.png"), 70 | "imgur": (imgur, "https://i.imgur.com/SKAxiks.png"), 71 | "lensdump": (lensdump, "https://i.lensdump.com/i/CJkLoa.png"), 72 | "pixeldrain": (pixeldrain, "https://pixeldrain.com/api/file/y61mKtGN"), 73 | "pixhost": (pixhost, "https://pixhost.to/show/865/361824612_upload.png"), # show_url 74 | "ptpimg": (ptpimg, "https://ptpimg.me/8i531v.png"), 75 | "smms": (smms, "https://s2.loli.net/2023/06/22/Bqv9EmdelxcM2XN.png"), 76 | "sxcu": (sxcu, "https://sxcu.net/66iFnGoQ6.png"), 77 | "telegraph": (telegraph, "https://telegra.ph/file/994610b961b722e9fbd9c.png"), 78 | "thumbsnap": (thumbsnap, "https://thumbsnap.com/i/GfQWVNwT.png"), 79 | "tixte": (tixte, "https://cdx.tixte.co/r/lj5zsvnkwa0.png"), 80 | "up2sha": (up2sha, "https://up2sha.re/media/raw/CozTXz35lv1Lk0o7NC.png"), 81 | "uplio": (uplio, "https://upl.io/i/0w25y7.png"), 82 | "uploadcare": ( 83 | uploadcare, 84 | "https://ucarecdn.com/7eb40c10-fc9b-42a2-a086-e9eef5bafb9c/img.png", 85 | ), 86 | "vgy": (vgy, "https://i.vgy.me/3Kyfvf.png"), 87 | } 88 | 89 | MOCK_HOSTINGS = tuple(RESPONSE.keys()) 90 | -------------------------------------------------------------------------------- /tests/test_bm.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from click.testing import CliRunner, Result 3 | from dotenv import load_dotenv 4 | from pytest_benchmark.fixture import BenchmarkFixture 5 | from pytest_httpx import HTTPXMock 6 | 7 | from images_upload_cli.__main__ import cli 8 | from tests.mock import uploadcare 9 | 10 | 11 | @pytest.fixture 12 | def runner(): 13 | return CliRunner() 14 | 15 | 16 | @pytest.mark.online 17 | @pytest.mark.benchmark(max_time=2) 18 | def test_bm_cli_online(benchmark: BenchmarkFixture, runner: CliRunner): 19 | """Benchmark test for the cli function. 20 | 21 | Measures the execution time of the `cli` function using the `pytest_benchmark` library. 22 | 23 | Args: 24 | benchmark: A fixture provided by the `pytest_benchmark` library for benchmarking tests. 25 | runner: An instance of the `CliRunner` class from the `click.testing` module. 26 | """ 27 | 28 | @benchmark 29 | def result() -> Result: 30 | """Measure the execution time of the cli function.""" 31 | args = [ 32 | "-h", 33 | "uploadcare", 34 | "tests/data/pic.png", 35 | "tests/data/pic.png", 36 | "tests/data/pic.png", 37 | "tests/data/pic.png", 38 | "tests/data/pic.png", 39 | "tests/data/pic.png", 40 | ] 41 | return runner.invoke(cli, args) 42 | 43 | assert result.exit_code == 0 44 | 45 | 46 | @pytest.mark.benchmark(max_time=2) 47 | def test_bm_cli(benchmark: BenchmarkFixture, runner: CliRunner, httpx_mock: HTTPXMock): 48 | """Benchmark test for the cli function. 49 | 50 | Measures the execution time of the `cli` function using the `pytest_benchmark` library and a mock HTTP response. 51 | 52 | Args: 53 | benchmark: A fixture provided by the `pytest_benchmark` library for benchmarking tests. 54 | runner: An instance of the `CliRunner` class from the `click.testing` module. 55 | httpx_mock: A fixture provided by the `pytest_httpx` library for mocking HTTP responses. 56 | """ 57 | # Mock the response 58 | httpx_mock.add_response(text=uploadcare) 59 | 60 | # Load environment variables 61 | load_dotenv(dotenv_path="tests/data/.env.sample") 62 | 63 | @benchmark 64 | def result() -> Result: 65 | """Measure the execution time of the cli function.""" 66 | args = [ 67 | "-h", 68 | "uploadcare", 69 | "tests/data/pic.png", 70 | "tests/data/pic.png", 71 | "tests/data/pic.png", 72 | "tests/data/pic.png", 73 | "tests/data/pic.png", 74 | "tests/data/pic.png", 75 | ] 76 | return runner.invoke(cli, args) 77 | 78 | # Assert the exit code of the result. 79 | assert result.exit_code == 0 80 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from click.testing import CliRunner 3 | from pytest_httpx import HTTPXMock 4 | from pytest_mock import MockerFixture 5 | 6 | from images_upload_cli.__main__ import cli 7 | from images_upload_cli.upload import HOSTINGS 8 | from tests.mock import MOCK_HOSTINGS, RESPONSE 9 | 10 | 11 | @pytest.fixture 12 | def runner(): 13 | return CliRunner() 14 | 15 | 16 | def test_cli_help(runner: CliRunner) -> None: 17 | """Test the cli function with the provided arguments.""" 18 | args = ["--help"] 19 | assert runner.invoke(cli=cli, args=args).exit_code == 0 20 | 21 | 22 | def test_cli_error(runner: CliRunner) -> None: 23 | """Test the cli function with the provided arguments.""" 24 | args = ["tests/data/nonexistent", "-C", "-h", "nonexistent"] 25 | assert runner.invoke(cli=cli, args=args).exit_code == 2 26 | 27 | 28 | @pytest.mark.parametrize( 29 | ("hosting", "mock_text", "mock_link"), 30 | [ 31 | pytest.param(hosting, RESPONSE[hosting][0], RESPONSE[hosting][1], id=hosting) 32 | for hosting in MOCK_HOSTINGS 33 | ], 34 | ) 35 | @pytest.mark.parametrize( 36 | "thumbnail", 37 | [pytest.param(False, id="default"), pytest.param(True, id="thumbnail")], 38 | ) 39 | @pytest.mark.httpx_mock(can_send_already_matched_responses=True) 40 | def test_cli( 41 | runner: CliRunner, 42 | httpx_mock: HTTPXMock, 43 | mocker: MockerFixture, 44 | hosting: str, 45 | mock_text: str, 46 | mock_link: str, 47 | thumbnail: bool, 48 | ) -> None: 49 | """Test the cli function with different hosting services. 50 | 51 | Args: 52 | runner: An instance of CliRunner used to invoke the cli function. 53 | httpx_mock: An instance of HTTPXMock used to mock the HTTP responses. 54 | mocker: An instance of MockerFixture used for mocking. 55 | hosting: The hosting service to use for image upload. 56 | mock_text: The mock response text to be returned by the HTTPXMock. 57 | mock_link: The expected link to be returned by the cli function. 58 | thumbnail: Flag indicating whether to generate a thumbnail link. 59 | """ 60 | # Mock response. 61 | httpx_mock.add_response(text=mock_text) 62 | # Mock functions. 63 | mock_copy = mocker.patch("images_upload_cli._cli.copy", return_value=None) 64 | mock_notify_send = mocker.patch("images_upload_cli._cli.notify_send", return_value=None) 65 | # Mock image extension to be matched with mock_link. 66 | mocker.patch("images_upload_cli.upload.get_img_ext", return_value="png") 67 | 68 | # Thumbnail link. 69 | mock_link_thumb = f"[url={mock_link}][img]{mock_link}[/img][/url]" 70 | 71 | # Invoke the cli function. 72 | args = [ 73 | "tests/data/pic.png", 74 | "--env-file", 75 | "tests/data/.env.sample", 76 | "--notify", 77 | "-h", 78 | hosting, 79 | ] 80 | if thumbnail: 81 | args.append("--thumbnail") 82 | 83 | result = runner.invoke(cli=cli, args=args) 84 | 85 | # Assert the result. 86 | assert result.exit_code == 0 87 | 88 | if thumbnail: 89 | assert result.output.strip() == mock_link_thumb 90 | mock_copy.assert_called_once_with(mock_link_thumb) 91 | mock_notify_send.assert_called_once_with(mock_link_thumb) 92 | else: 93 | assert result.output.strip() == mock_link 94 | mock_copy.assert_called_once_with(mock_link) 95 | mock_notify_send.assert_called_once_with(mock_link) 96 | 97 | 98 | @pytest.mark.online 99 | @pytest.mark.parametrize("hosting", HOSTINGS) 100 | def test_cli_online(runner: CliRunner, hosting: str) -> None: 101 | """ 102 | Test the cli function with different hosting services. Online. 103 | 104 | Args: 105 | runner: An instance of CliRunner used to invoke the cli function. 106 | hosting: The hosting service to be tested. 107 | """ 108 | args = ["tests/data/pic.png", "--no-clipboard", "-h", hosting] 109 | assert runner.invoke(cli=cli, args=args).exit_code == 0 110 | -------------------------------------------------------------------------------- /tests/test_image.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | import pytest 4 | from PIL import Image, ImageFont 5 | from pytest_mock import MockerFixture 6 | 7 | from images_upload_cli.image import get_font, get_img_ext, make_thumbnail, search_font 8 | from images_upload_cli.util import GetEnvError 9 | 10 | 11 | @pytest.fixture 12 | def font_name() -> str: 13 | return "tests/data/DejaVuSerif.ttf" 14 | 15 | 16 | def test_make_thumbnail(font_name: str): 17 | # Create a sample image 18 | image = Image.new("RGBA", (600, 600)) 19 | image_bytes = BytesIO() 20 | image.save(image_bytes, format="PNG") 21 | image_bytes.seek(0) 22 | 23 | # Create a sample font 24 | font = ImageFont.truetype(font_name, size=12) 25 | 26 | # Call the make_thumbnail function 27 | thumbnail = make_thumbnail(image_bytes.read(), font, size=(300, 300)) 28 | 29 | # Check if the thumbnail has the desired size and format 30 | thumbnail_image = Image.open(BytesIO(thumbnail)) 31 | assert thumbnail_image.size == (300, 300 + 16) 32 | assert thumbnail_image.format == "JPEG" 33 | 34 | 35 | def test_get_img_ext(img: bytes) -> None: 36 | assert get_img_ext(img) == "png" 37 | 38 | 39 | def test_get_font() -> None: 40 | font = get_font() 41 | assert isinstance(font, ImageFont.FreeTypeFont) 42 | 43 | 44 | def test_get_font_custom_size() -> None: 45 | size = 16 46 | font = get_font(size=size) 47 | assert isinstance(font, ImageFont.FreeTypeFont) 48 | assert font.size == size 49 | 50 | 51 | def test_get_font_from_env(mocker: MockerFixture, font_name: str) -> None: 52 | variable = "CAPTION_FONT" 53 | value = font_name 54 | mocker.patch.dict("os.environ", {variable: value}) 55 | font = get_font() 56 | assert isinstance(font, ImageFont.FreeTypeFont) 57 | assert font.path == font_name 58 | 59 | 60 | def test_search_font(font_name: str): 61 | fonts = [font_name] 62 | font = search_font(fonts) 63 | assert isinstance(font, ImageFont.FreeTypeFont) 64 | assert font.path == font_name 65 | 66 | 67 | def test_search_font_not_found(): 68 | fonts = ["NonExistentFont1", "NonExistentFont2"] 69 | with pytest.raises(GetEnvError): 70 | search_font(fonts) 71 | -------------------------------------------------------------------------------- /tests/test_logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from loguru import logger 4 | 5 | from images_upload_cli.logger import ErrorHandler, setup_logger 6 | 7 | 8 | def test_errorhandler_emit(): 9 | handler = ErrorHandler() 10 | record = logging.LogRecord( 11 | "test", logging.ERROR, "test_logger.py", 10, "Error message", None, None 12 | ) 13 | handler.emit(record) 14 | assert handler.has_error_occurred() is True 15 | 16 | 17 | def test_errorhandler_emit_no_error(): 18 | handler = ErrorHandler() 19 | record = logging.LogRecord( 20 | "test", logging.INFO, "test_logger.py", 10, "Info message", None, None 21 | ) 22 | handler.emit(record) 23 | assert handler.has_error_occurred() is False 24 | 25 | 26 | def test_setup_logger(): 27 | log_level = "DEBUG" 28 | error_handler = setup_logger(log_level) 29 | 30 | assert isinstance(error_handler, ErrorHandler) 31 | assert logger.level(log_level).name == log_level 32 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from pytest_httpx import HTTPXMock 5 | 6 | from images_upload_cli.main import format_link, upload_images 7 | from images_upload_cli.upload import UPLOAD 8 | from tests.mock import RESPONSE 9 | 10 | 11 | @pytest.mark.asyncio 12 | @pytest.mark.parametrize( 13 | "thumbnail", 14 | [pytest.param(False, id="default"), pytest.param(True, id="thumbnail")], 15 | ) 16 | @pytest.mark.httpx_mock(can_send_already_matched_responses=True) 17 | async def test_upload_images_coroutine(httpx_mock: HTTPXMock, thumbnail: bool) -> None: 18 | """Test the upload_images coroutine. 19 | 20 | Args: 21 | httpx_mock: An instance of the HTTPXMock class used for mocking HTTP responses. 22 | thumbnail: A boolean flag indicating whether to generate thumbnail images for the uploaded images. 23 | 24 | Raises: 25 | AssertionError: If the returned link is not equal to the expected mock_link. 26 | """ 27 | images = (Path("tests/data/pic.png"),) 28 | hosting = "imgur" 29 | mock_text = RESPONSE[hosting][0] 30 | mock_link = RESPONSE[hosting][1] 31 | 32 | # Mock the response 33 | httpx_mock.add_response(text=mock_text) 34 | 35 | # Upload the image 36 | result = await upload_images( 37 | upload_func=UPLOAD[hosting], 38 | images=images, 39 | thumbnail=thumbnail, 40 | ) 41 | 42 | if thumbnail: 43 | assert result == [(mock_link, mock_link)] 44 | else: 45 | assert result == [(mock_link, None)] 46 | 47 | 48 | @pytest.mark.asyncio 49 | @pytest.mark.parametrize( 50 | "thumbnail", 51 | [pytest.param(False, id="default"), pytest.param(True, id="thumbnail")], 52 | ) 53 | async def test_upload_images_upload_failure(httpx_mock: HTTPXMock, thumbnail: bool) -> None: 54 | """Test the upload_images coroutine when the upload fails for an image. 55 | 56 | Args: 57 | httpx_mock: An instance of the HTTPXMock class used for mocking HTTP responses. 58 | """ 59 | images = (Path("tests/data/pic.png"),) 60 | hosting = "imgur" 61 | 62 | # Mock the response 63 | httpx_mock.add_response(text="Upload failed.", status_code=500) 64 | 65 | # Upload the image 66 | result = await upload_images( 67 | upload_func=UPLOAD[hosting], 68 | images=images, 69 | thumbnail=thumbnail, 70 | ) 71 | 72 | assert result == [] 73 | 74 | 75 | def test_format_link_plain(): 76 | links = [("https://example.com/image1.jpg", None), ("https://example.com/image2.jpg", None)] 77 | fmt = "plain" 78 | expected_output = "https://example.com/image1.jpg https://example.com/image2.jpg" 79 | assert format_link(links, fmt) == expected_output 80 | 81 | 82 | def test_format_link_bbcode(): 83 | links = [ 84 | ("https://example.com/image1.jpg", "https://example.com/thumb1.jpg"), 85 | ("https://example.com/image2.jpg", None), 86 | ] 87 | fmt = "bbcode" 88 | expected_output = "[url=https://example.com/image1.jpg][img]https://example.com/thumb1.jpg[/img][/url] [img]https://example.com/image2.jpg[/img]" 89 | 90 | assert format_link(links, fmt) == expected_output 91 | 92 | 93 | def test_format_link_html(): 94 | links = [ 95 | ("https://example.com/image1.jpg", None), 96 | ("https://example.com/image2.jpg", "https://example.com/thumb1.jpg"), 97 | ] 98 | fmt = "html" 99 | expected_output = 'image thumb' 100 | assert format_link(links, fmt) == expected_output 101 | 102 | 103 | def test_format_link_markdown(): 104 | links = [ 105 | ("https://example.com/image1.jpg", None), 106 | ("https://example.com/image2.jpg", "https://example.com/thumb1.jpg"), 107 | ] 108 | fmt = "markdown" 109 | expected_output = "![image](https://example.com/image1.jpg) [![thumb](https://example.com/thumb1.jpg)](https://example.com/image2.jpg)" 110 | assert format_link(links, fmt) == expected_output 111 | 112 | 113 | def test_format_link_invalid_format(): 114 | links = [("https://example.com/image1.jpg", None), ("https://example.com/image2.jpg", None)] 115 | fmt = "invalid_format" 116 | expected_output = "" 117 | assert format_link(links, fmt) == expected_output 118 | -------------------------------------------------------------------------------- /tests/test_upload.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from dotenv import load_dotenv 3 | from httpx import AsyncClient 4 | from logot import Logot, logged 5 | from pytest_httpx import HTTPXMock 6 | 7 | from images_upload_cli.upload import UPLOAD 8 | from tests.mock import MOCK_HOSTINGS, RESPONSE 9 | 10 | 11 | @pytest.mark.asyncio 12 | @pytest.mark.parametrize( 13 | ("hosting", "mock_text", "mock_link"), 14 | [ 15 | pytest.param(hosting, RESPONSE[hosting][0], RESPONSE[hosting][1], id=hosting) 16 | for hosting in MOCK_HOSTINGS 17 | ], 18 | ) 19 | @pytest.mark.httpx_mock(can_send_already_matched_responses=True) 20 | async def test_upload_funcs( 21 | httpx_mock: HTTPXMock, 22 | hosting: str, 23 | mock_text: str, 24 | mock_link: str, 25 | img: bytes, 26 | ) -> None: 27 | """Test the image upload functionality of different hosting services. 28 | 29 | Args: 30 | httpx_mock: An instance of the HTTPXMock class used for mocking HTTP responses. 31 | hosting: A string representing the hosting service to test. 32 | mock_text: A string representing the mock response text. 33 | mock_link: A string representing the expected link after image upload. 34 | img: Bytes of the image to be uploaded. 35 | 36 | Raises: 37 | AssertionError: If the returned link is not equal to the expected mock_link. 38 | """ 39 | # Mock the response 40 | httpx_mock.add_response(text=mock_text) 41 | 42 | # Load environment variables 43 | load_dotenv(dotenv_path="tests/data/.env.sample") 44 | 45 | # Upload the image 46 | async with AsyncClient() as client: 47 | upload_func = UPLOAD[hosting] 48 | link = await upload_func(client, img) 49 | assert link == mock_link 50 | 51 | 52 | @pytest.mark.asyncio 53 | @pytest.mark.parametrize("hosting", MOCK_HOSTINGS) 54 | async def test_upload_funcs_error( 55 | httpx_mock: HTTPXMock, 56 | logot: Logot, 57 | hosting: str, 58 | img: bytes, 59 | ) -> None: 60 | """Test the image upload functionality of different hosting services when an error occurs. 61 | 62 | Args: 63 | httpx_mock: An instance of the HTTPXMock class used for mocking HTTP responses. 64 | logot: An instance of the Logot class used for logging. 65 | hosting: A string representing the hosting service to test. 66 | img: Bytes of the image to be uploaded. 67 | 68 | Raises: 69 | AssertionError: If the returned result is not empty. 70 | """ 71 | # Mock the response 72 | httpx_mock.add_response(text="Upload failed.", status_code=500) 73 | 74 | # Load environment variables 75 | load_dotenv(dotenv_path="tests/data/.env.sample") 76 | 77 | # Upload the image 78 | async with AsyncClient() as client: 79 | upload_func = UPLOAD[hosting] 80 | result = await upload_func(client, img) 81 | 82 | # Assert the result is empty 83 | assert result == "" 84 | 85 | # Assert the log messages 86 | await logot.await_for(logged.error("Server error '500 Internal Server Error' for url '%s'.")) 87 | await logot.await_for(logged.debug("Response text:\nUpload failed.")) 88 | 89 | 90 | @pytest.mark.asyncio 91 | @pytest.mark.parametrize("hosting", ["fastpic", "imagebin"]) 92 | async def test_upload_funcs_not_found( 93 | httpx_mock: HTTPXMock, 94 | logot: Logot, 95 | hosting: str, 96 | img: bytes, 97 | ) -> None: 98 | """Test the error handling of image upload functionality for specific hosting services. 99 | 100 | Args: 101 | httpx_mock: An instance of the HTTPXMock class used for mocking HTTP responses. 102 | logot: An instance of the Logot class used for logging. 103 | hosting: A string representing the hosting service to test. 104 | img: Bytes of the image to be uploaded. 105 | 106 | Raises: 107 | AssertionError: If the result is not empty. 108 | """ 109 | # Mock the response 110 | httpx_mock.add_response(text="Response without the url.") 111 | 112 | # Upload the image 113 | async with AsyncClient() as client: 114 | upload_func = UPLOAD[hosting] 115 | result = await upload_func(client, img) 116 | 117 | # Assert the result is empty 118 | assert result == "" 119 | 120 | # Assert the log messages 121 | await logot.await_for(logged.error("Image link not found in '%s' response.")) 122 | await logot.await_for(logged.debug("Response text:\nResponse without the url.")) 123 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from httpx import codes, post 5 | from logot import Logot, logged 6 | from pytest_httpx import HTTPXMock 7 | from pytest_mock import MockerFixture 8 | 9 | from images_upload_cli.util import ( 10 | GetEnvError, 11 | get_config_path, 12 | get_env, 13 | human_size, 14 | log_on_error, 15 | notify_send, 16 | ) 17 | 18 | 19 | @pytest.mark.parametrize( 20 | ("test_arg", "expected"), 21 | [ 22 | (1, "1.0 B"), 23 | (300, "300.0 B"), 24 | (3000, "2.9 KiB"), 25 | (3000000, "2.9 MiB"), 26 | (1024, "1.0 KiB"), 27 | (10**26 * 30, "2481.5 YiB"), 28 | ], 29 | ) 30 | def test_human_size(test_arg: int, expected: str) -> None: 31 | """Test the human_size function. 32 | 33 | Args: 34 | test_arg: The number of bytes to be converted. 35 | expected: The expected human-readable size with the appropriate unit and suffix. 36 | 37 | Raises: 38 | AssertionError: If the output of calling human_size with (negation of) test_arg is not equal to (negation of) expected. 39 | """ 40 | assert human_size(test_arg) == expected 41 | 42 | args_with_negative = -test_arg 43 | assert human_size(args_with_negative) == f"-{expected}" 44 | 45 | 46 | def test_get_config_path(mocker: MockerFixture): 47 | """Test the get_config_path function.""" 48 | # Mock the click.get_app_dir function to return a custom app directory 49 | custom_app_dir = "/custom/app/dir" 50 | click_get_app_dir_mock = mocker.patch("click.get_app_dir", return_value=custom_app_dir) 51 | 52 | # Call the get_config_path function 53 | result = get_config_path() 54 | 55 | # Check if the click.get_app_dir function was called with the correct argument 56 | click_get_app_dir_mock.assert_called_once_with("images-upload-cli") 57 | 58 | # Check if the result is the expected path 59 | expected_path = Path(custom_app_dir) / ".env" 60 | assert result == expected_path 61 | 62 | 63 | def test_get_env_existing_variable(mocker: MockerFixture): 64 | """Test the get_env function with an existing environment variable.""" 65 | variable = "TEST_VARIABLE" 66 | value = "test_value" 67 | mocker.patch.dict("os.environ", {variable: value}) 68 | 69 | assert get_env(variable) == value 70 | 71 | 72 | def test_get_env_non_existing_variable(mocker: MockerFixture): 73 | """Test the get_env function with a non-existing environment variable.""" 74 | variable = "NON_EXISTING_VARIABLE" 75 | mocker.patch.dict("os.environ", clear=True) 76 | 77 | with pytest.raises(GetEnvError): 78 | get_env(variable) 79 | 80 | 81 | def test_notify_send_with_notify_send_installed(mocker: MockerFixture): 82 | """Test the notify_send function when notify-send is installed.""" 83 | which_mock = mocker.patch("images_upload_cli.util.which", return_value="notify-send") 84 | popen_mock = mocker.patch("images_upload_cli.util.Popen") 85 | 86 | notify_send("Test notification") 87 | 88 | # Check if the which function was called with the correct argument 89 | which_mock.assert_called_once_with("notify-send") 90 | 91 | # Check if the Popen function was called with the correct arguments 92 | popen_mock.assert_called_once_with( 93 | ["notify-send", "-a", "images-upload-cli", "Test notification"] 94 | ) 95 | 96 | 97 | def test_notify_send_with_notify_send_not_installed(mocker: MockerFixture): 98 | """Test the notify_send function when notify-send is not installed.""" 99 | which_mock = mocker.patch("images_upload_cli.util.which", return_value=None) 100 | popen_mock = mocker.patch("images_upload_cli.util.Popen") 101 | 102 | notify_send("Test notification") 103 | 104 | # Check if the which function was called with the correct argument 105 | which_mock.assert_called_once_with("notify-send") 106 | 107 | # Check if the Popen function was not called 108 | popen_mock.assert_not_called() 109 | 110 | 111 | def test_log_on_error(httpx_mock: HTTPXMock, logot: Logot): 112 | """Test the log_on_error function when a client error occurs. 113 | 114 | Args: 115 | httpx_mock: The HTTPXMock object for mocking HTTP requests. 116 | logot: The Logot object for logging. 117 | """ 118 | # Mock the response 119 | httpx_mock.add_response(status_code=codes.NOT_FOUND, text="Page not found") 120 | 121 | response = post(url="https://example.com") 122 | log_on_error(response) 123 | 124 | # Assert the log messages 125 | logot.assert_logged( 126 | logged.error("Client error '404 Not Found' for url 'https://example.com'.") 127 | ) 128 | logot.assert_logged(logged.debug("Response text:\nPage not found")) 129 | --------------------------------------------------------------------------------