├── .flake8 ├── .github └── workflows │ ├── build-test.yml │ ├── lint.yml │ └── publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── pyproject.toml ├── python └── copykitten │ ├── __init__.py │ ├── _copykitten.pyi │ └── py.typed ├── requirements-dev.txt ├── src └── lib.rs └── tests ├── __init__.py ├── clipboard.py ├── conftest.py └── test_lib.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | extend-ignore = E203,B008 4 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: Build & test 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | paths: 7 | - "**.pyi?" 8 | - "**.rs" 9 | - "Cargo.*" 10 | - "pyproject.toml" 11 | push: 12 | branches: 13 | - "main" 14 | paths: 15 | - "**.pyi?" 16 | - "**.rs" 17 | - "Cargo.*" 18 | - "pyproject.toml" 19 | workflow_call: 20 | inputs: 21 | release: 22 | type: boolean 23 | description: Build for release 24 | default: false 25 | outputs: 26 | run_id: 27 | description: Workflow run ID 28 | value: ${{ jobs.output_run_id.outputs.run_id }} 29 | 30 | env: 31 | # If this workflow is called to build for release, build using 32 | # release profile. 33 | build_profile: ${{ inputs.release && 'release' || 'dev' }} 34 | 35 | jobs: 36 | linux: 37 | name: Build & test on Linux 38 | runs-on: ubuntu-latest 39 | strategy: 40 | matrix: 41 | # If this workflow is called to build for release, build for x64 and ARM64 42 | # processors. However, run tests only for x64, as GH doesn't provide 43 | # ARM64 runners. 44 | target: ${{ inputs.release && fromJSON('["x64", "aarch64"]') || fromJSON('["x64"]') }} 45 | # If this workflow is called to build for release, build using 46 | # only one Python version, which is the lowest ABI version set 47 | # in PyO3 features. 48 | py: ${{ inputs.release && fromJSON('["3.8"]') || fromJSON('["3.8", "3.13"]') }} 49 | env: 50 | DISPLAY: :0 51 | steps: 52 | - uses: actions/checkout@v4 53 | - uses: actions/setup-python@v5 54 | with: 55 | python-version: ${{ matrix.py }} 56 | - name: Install test dependencies 57 | run: sudo apt-get update & sudo apt-get install -y xvfb xclip 58 | - name: Run virtual X11 server 59 | run: Xvfb :0 -screen 0 800x600x16 & 60 | - name: Setup venv 61 | run: python -m venv .venv 62 | - name: Build extension module 63 | uses: PyO3/maturin-action@v1 64 | with: 65 | target: ${{ matrix.target }} 66 | args: --profile ${{ env.build_profile }} --out dist 67 | manylinux: auto 68 | - name: Run tests 69 | if: ${{ matrix.target == 'x64' }} 70 | run: | 71 | source .venv/bin/activate 72 | pip install dist/* 73 | pip install -r requirements-dev.txt 74 | pytest -v 75 | - name: Update build artifact 76 | if: ${{ inputs.release }} 77 | uses: actions/upload-artifact@v4 78 | with: 79 | name: wheels-${{ runner.os }}-${{ matrix.target }} 80 | path: dist 81 | 82 | 83 | windows: 84 | name: Build & test on Windows 85 | runs-on: windows-latest 86 | strategy: 87 | matrix: 88 | # If this workflow is called to build for release, build using 89 | # only one Python version, which is the lowest ABI version set 90 | # in PyO3 features. 91 | py: ${{ inputs.release && fromJSON('["3.8"]') || fromJSON('["3.8", "3.13"]') }} 92 | steps: 93 | - uses: actions/checkout@v4 94 | - uses: actions/setup-python@v5 95 | with: 96 | python-version: ${{ matrix.py }} 97 | - name: Setup venv 98 | run: python -m venv .venv 99 | - name: Build extension module 100 | uses: PyO3/maturin-action@v1 101 | with: 102 | target: x64 103 | args: --profile ${{ env.build_profile }} --out dist 104 | - name: Run tests 105 | shell: powershell 106 | run: | 107 | .venv\Scripts\activate.ps1 108 | pip install dist\$(Get-ChildItem -Name dist\*.whl) 109 | pip install -r requirements-dev.txt 110 | pytest -v 111 | - name: Update build artifact 112 | if: ${{ inputs.release }} 113 | uses: actions/upload-artifact@v4 114 | with: 115 | name: wheels-${{ runner.os }} 116 | path: dist 117 | 118 | macos: 119 | name: Build & test on MacOS 120 | runs-on: macos-12 121 | strategy: 122 | matrix: 123 | # If this workflow is called to build for release, build for x64 and ARM64 124 | # processors. However, run tests only for x64, as GH doesn't provide 125 | # ARM64 runners. 126 | target: ${{ inputs.release && fromJSON('["x64", "aarch64"]') || fromJSON('["x64"]') }} 127 | # If this workflow is called to build for release, build using 128 | # only one Python version, which is the lowest ABI version set 129 | # in PyO3 features. 130 | py: ${{ inputs.release && fromJSON('["3.8"]') || fromJSON('["3.8", "3.13"]') }} 131 | steps: 132 | - uses: actions/checkout@v4 133 | - uses: actions/setup-python@v5 134 | with: 135 | python-version: ${{ matrix.py }} 136 | - name: Setup venv 137 | run: python -m venv .venv 138 | - name: Build extension module 139 | uses: PyO3/maturin-action@v1 140 | with: 141 | target: ${{ matrix.target }} 142 | args: --profile ${{ env.build_profile }} --out dist 143 | - name: Run tests 144 | if: ${{ matrix.target == 'x64' }} 145 | run: | 146 | source .venv/bin/activate 147 | pip install dist/* 148 | pip install -r requirements-dev.txt 149 | pytest -v 150 | - name: Update build artifact 151 | if: ${{ inputs.release }} 152 | uses: actions/upload-artifact@v4 153 | with: 154 | name: wheels-${{ runner.os }}-${{ matrix.target }} 155 | path: dist 156 | 157 | output_run_id: 158 | name: Export workflow run ID to the caller 159 | if: ${{ inputs.release }} 160 | runs-on: ubuntu-latest 161 | env: 162 | RUN_ID: ${{ github.run_id }} 163 | outputs: 164 | run_id: ${{ steps.export.outputs.run_id }} 165 | steps: 166 | - id: export 167 | run: echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT 168 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - "*" 8 | pull_request: 9 | 10 | jobs: 11 | lint: 12 | name: Run linters 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-python@v5 17 | - uses: Klavionik/pre-commit-action@main 18 | with: 19 | extra_args: "--hook-stage manual" 20 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish release 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: 7 | - published 8 | 9 | jobs: 10 | build: 11 | uses: ./.github/workflows/build-test.yml 12 | with: 13 | release: true 14 | 15 | publish: 16 | name: Release 17 | runs-on: ubuntu-latest 18 | needs: [build] 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: actions/download-artifact@v4 22 | with: 23 | pattern: wheels-* 24 | merge-multiple: true 25 | path: dist 26 | github-token: ${{ github.token }} 27 | run-id: ${{ needs.build.outputs.run_id }} 28 | - name: Build sdist 29 | uses: PyO3/maturin-action@v1 30 | with: 31 | command: sdist 32 | args: --out dist 33 | - name: Publish to PyPI 34 | uses: PyO3/maturin-action@v1 35 | env: 36 | MATURIN_REPOSITORY: ${{ secrets.PYPI_INDEX }} 37 | MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} 38 | with: 39 | command: upload 40 | args: --non-interactive --skip-existing dist/* 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | .pytest_cache/ 6 | *.py[cod] 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | .venv/ 14 | env/ 15 | bin/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | include/ 26 | man/ 27 | venv/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | pip-selfcheck.json 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | 45 | # Translations 46 | *.mo 47 | 48 | # Mr Developer 49 | .mr.developer.cfg 50 | .project 51 | .pydevproject 52 | 53 | # Rope 54 | .ropeproject 55 | 56 | # Django stuff: 57 | *.log 58 | *.pot 59 | 60 | .DS_Store 61 | 62 | # Sphinx documentation 63 | docs/_build/ 64 | 65 | # PyCharm 66 | .idea/ 67 | 68 | # VSCode 69 | .vscode/ 70 | 71 | # Pyenv 72 | .python-version 73 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.2.0 6 | hooks: 7 | - id: trailing-whitespace 8 | types: [text] 9 | - id: end-of-file-fixer 10 | types: [text] 11 | 12 | - repo: local 13 | hooks: 14 | - id: cargo-fmt 15 | name: cargo-fmt 16 | stages: 17 | - commit 18 | entry: cargo fmt 19 | language: system 20 | types: [rust] 21 | args: 22 | - "--" 23 | 24 | - id: cargo-fmt-check 25 | name: cargo-fmt-check 26 | stages: 27 | - manual 28 | entry: cargo fmt --check 29 | language: system 30 | types: [rust] 31 | args: 32 | - "--" 33 | 34 | - id: cargo-check 35 | name: cargo-check 36 | entry: cargo check 37 | language: system 38 | types: [rust] 39 | pass_filenames: false 40 | 41 | - id: clippy 42 | name: clippy 43 | entry: cargo clippy 44 | language: system 45 | args: 46 | - "--" 47 | - "-D" 48 | - "warnings" 49 | types: [rust] 50 | pass_filenames: false 51 | 52 | - repo: https://github.com/psf/black 53 | rev: 23.3.0 54 | hooks: 55 | - id: black 56 | stages: 57 | - commit 58 | types: [python] 59 | 60 | - id: black 61 | name: black-check 62 | args: 63 | - --check 64 | - --diff 65 | stages: 66 | - manual 67 | types: [python] 68 | 69 | - repo: https://github.com/pycqa/flake8 70 | rev: 6.0.0 71 | hooks: 72 | - id: flake8 73 | stages: 74 | - commit 75 | - manual 76 | additional_dependencies: [flake8-bugbear] 77 | types: [python] 78 | 79 | - repo: https://github.com/pycqa/isort 80 | rev: 5.12.0 81 | hooks: 82 | - id: isort 83 | stages: 84 | - commit 85 | types: [python] 86 | 87 | - id: isort 88 | name: isort-check 89 | args: 90 | - --check-only 91 | stages: 92 | - manual 93 | types: [python] 94 | 95 | - repo: https://github.com/jendrikseipp/vulture 96 | rev: 'v2.11' 97 | hooks: 98 | - id: vulture 99 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [1.2.3] - 2024-10-20 10 | - Add Python 3.13 PyPI classifier. 11 | 12 | ## [1.2.2] - 2024-10-07 13 | ### Changed 14 | - The package can now be installed using any Python version higher than 3.7. 15 | 16 | ## [1.2.1] - 2024-08-27 17 | ### Fixed 18 | - Initialize the inner clipboard instance lazily to prevent import-time errors in headless environments. 19 | 20 | ## [1.2.0] - 2024-04-29 21 | ### Changed 22 | - Update `arboard` to 3.4.0. 23 | 24 | ## [1.1.1] - 2024-02-20 25 | ### Fixed 26 | - Fix `paste_image` return type. 27 | 28 | ## [1.1.0] - 2024-02-19 29 | ### Added 30 | - Added support for image copying/pasting with `copy_image`/`paste_image` functions. 31 | 32 | ## [1.0.1] - 2024-01-31 33 | - Add more project metadata to be shown on PyPI and some nice badges. 34 | 35 | ## [1.0.0] - 2024-01-31 36 | - First public release. 37 | - Copykitten can `copy`, `paste`, and `clear` the clipboard. 38 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "adler" 7 | version = "1.0.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 10 | 11 | [[package]] 12 | name = "arboard" 13 | version = "3.4.0" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "9fb4009533e8ff8f1450a5bcbc30f4242a1d34442221f72314bea1f5dc9c7f89" 16 | dependencies = [ 17 | "clipboard-win", 18 | "core-graphics", 19 | "image", 20 | "log", 21 | "objc2", 22 | "objc2-app-kit", 23 | "objc2-foundation", 24 | "parking_lot", 25 | "windows-sys 0.48.0", 26 | "x11rb", 27 | ] 28 | 29 | [[package]] 30 | name = "autocfg" 31 | version = "1.1.0" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 34 | 35 | [[package]] 36 | name = "bitflags" 37 | version = "1.3.2" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 40 | 41 | [[package]] 42 | name = "bitflags" 43 | version = "2.4.2" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" 46 | 47 | [[package]] 48 | name = "block2" 49 | version = "0.5.0" 50 | source = "registry+https://github.com/rust-lang/crates.io-index" 51 | checksum = "43ff7d91d3c1d568065b06c899777d1e48dcf76103a672a0adbc238a7f247f1e" 52 | dependencies = [ 53 | "objc2", 54 | ] 55 | 56 | [[package]] 57 | name = "bytemuck" 58 | version = "1.14.3" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "a2ef034f05691a48569bd920a96c81b9d91bbad1ab5ac7c4616c1f6ef36cb79f" 61 | 62 | [[package]] 63 | name = "byteorder" 64 | version = "1.5.0" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 67 | 68 | [[package]] 69 | name = "cfg-if" 70 | version = "1.0.0" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 73 | 74 | [[package]] 75 | name = "clipboard-win" 76 | version = "5.3.1" 77 | source = "registry+https://github.com/rust-lang/crates.io-index" 78 | checksum = "79f4473f5144e20d9aceaf2972478f06ddf687831eafeeb434fbaf0acc4144ad" 79 | dependencies = [ 80 | "error-code", 81 | ] 82 | 83 | [[package]] 84 | name = "copykitten" 85 | version = "1.2.2" 86 | dependencies = [ 87 | "arboard", 88 | "pyo3", 89 | ] 90 | 91 | [[package]] 92 | name = "core-foundation" 93 | version = "0.9.4" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 96 | dependencies = [ 97 | "core-foundation-sys", 98 | "libc", 99 | ] 100 | 101 | [[package]] 102 | name = "core-foundation-sys" 103 | version = "0.8.6" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" 106 | 107 | [[package]] 108 | name = "core-graphics" 109 | version = "0.23.1" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "970a29baf4110c26fedbc7f82107d42c23f7e88e404c4577ed73fe99ff85a212" 112 | dependencies = [ 113 | "bitflags 1.3.2", 114 | "core-foundation", 115 | "core-graphics-types", 116 | "foreign-types", 117 | "libc", 118 | ] 119 | 120 | [[package]] 121 | name = "core-graphics-types" 122 | version = "0.1.3" 123 | source = "registry+https://github.com/rust-lang/crates.io-index" 124 | checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" 125 | dependencies = [ 126 | "bitflags 1.3.2", 127 | "core-foundation", 128 | "libc", 129 | ] 130 | 131 | [[package]] 132 | name = "crc32fast" 133 | version = "1.4.0" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa" 136 | dependencies = [ 137 | "cfg-if", 138 | ] 139 | 140 | [[package]] 141 | name = "errno" 142 | version = "0.3.8" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" 145 | dependencies = [ 146 | "libc", 147 | "windows-sys 0.52.0", 148 | ] 149 | 150 | [[package]] 151 | name = "error-code" 152 | version = "3.2.0" 153 | source = "registry+https://github.com/rust-lang/crates.io-index" 154 | checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b" 155 | 156 | [[package]] 157 | name = "fdeflate" 158 | version = "0.3.4" 159 | source = "registry+https://github.com/rust-lang/crates.io-index" 160 | checksum = "4f9bfee30e4dedf0ab8b422f03af778d9612b63f502710fc500a334ebe2de645" 161 | dependencies = [ 162 | "simd-adler32", 163 | ] 164 | 165 | [[package]] 166 | name = "flate2" 167 | version = "1.0.28" 168 | source = "registry+https://github.com/rust-lang/crates.io-index" 169 | checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" 170 | dependencies = [ 171 | "crc32fast", 172 | "miniz_oxide", 173 | ] 174 | 175 | [[package]] 176 | name = "foreign-types" 177 | version = "0.5.0" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" 180 | dependencies = [ 181 | "foreign-types-macros", 182 | "foreign-types-shared", 183 | ] 184 | 185 | [[package]] 186 | name = "foreign-types-macros" 187 | version = "0.2.3" 188 | source = "registry+https://github.com/rust-lang/crates.io-index" 189 | checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" 190 | dependencies = [ 191 | "proc-macro2", 192 | "quote", 193 | "syn", 194 | ] 195 | 196 | [[package]] 197 | name = "foreign-types-shared" 198 | version = "0.3.1" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" 201 | 202 | [[package]] 203 | name = "gethostname" 204 | version = "0.4.3" 205 | source = "registry+https://github.com/rust-lang/crates.io-index" 206 | checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" 207 | dependencies = [ 208 | "libc", 209 | "windows-targets 0.48.5", 210 | ] 211 | 212 | [[package]] 213 | name = "heck" 214 | version = "0.4.1" 215 | source = "registry+https://github.com/rust-lang/crates.io-index" 216 | checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 217 | 218 | [[package]] 219 | name = "image" 220 | version = "0.25.1" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "fd54d660e773627692c524beaad361aca785a4f9f5730ce91f42aabe5bce3d11" 223 | dependencies = [ 224 | "bytemuck", 225 | "byteorder", 226 | "num-traits", 227 | "png", 228 | "tiff", 229 | ] 230 | 231 | [[package]] 232 | name = "indoc" 233 | version = "2.0.4" 234 | source = "registry+https://github.com/rust-lang/crates.io-index" 235 | checksum = "1e186cfbae8084e513daff4240b4797e342f988cecda4fb6c939150f96315fd8" 236 | 237 | [[package]] 238 | name = "jpeg-decoder" 239 | version = "0.3.1" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" 242 | 243 | [[package]] 244 | name = "libc" 245 | version = "0.2.153" 246 | source = "registry+https://github.com/rust-lang/crates.io-index" 247 | checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" 248 | 249 | [[package]] 250 | name = "linux-raw-sys" 251 | version = "0.4.13" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" 254 | 255 | [[package]] 256 | name = "lock_api" 257 | version = "0.4.11" 258 | source = "registry+https://github.com/rust-lang/crates.io-index" 259 | checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" 260 | dependencies = [ 261 | "autocfg", 262 | "scopeguard", 263 | ] 264 | 265 | [[package]] 266 | name = "log" 267 | version = "0.4.21" 268 | source = "registry+https://github.com/rust-lang/crates.io-index" 269 | checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" 270 | 271 | [[package]] 272 | name = "memoffset" 273 | version = "0.9.0" 274 | source = "registry+https://github.com/rust-lang/crates.io-index" 275 | checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" 276 | dependencies = [ 277 | "autocfg", 278 | ] 279 | 280 | [[package]] 281 | name = "miniz_oxide" 282 | version = "0.7.2" 283 | source = "registry+https://github.com/rust-lang/crates.io-index" 284 | checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" 285 | dependencies = [ 286 | "adler", 287 | "simd-adler32", 288 | ] 289 | 290 | [[package]] 291 | name = "num-traits" 292 | version = "0.2.18" 293 | source = "registry+https://github.com/rust-lang/crates.io-index" 294 | checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" 295 | dependencies = [ 296 | "autocfg", 297 | ] 298 | 299 | [[package]] 300 | name = "objc-sys" 301 | version = "0.3.3" 302 | source = "registry+https://github.com/rust-lang/crates.io-index" 303 | checksum = "da284c198fb9b7b0603f8635185e85fbd5b64ee154b1ed406d489077de2d6d60" 304 | 305 | [[package]] 306 | name = "objc2" 307 | version = "0.5.1" 308 | source = "registry+https://github.com/rust-lang/crates.io-index" 309 | checksum = "b4b25e1034d0e636cd84707ccdaa9f81243d399196b8a773946dcffec0401659" 310 | dependencies = [ 311 | "objc-sys", 312 | "objc2-encode", 313 | ] 314 | 315 | [[package]] 316 | name = "objc2-app-kit" 317 | version = "0.2.0" 318 | source = "registry+https://github.com/rust-lang/crates.io-index" 319 | checksum = "fb79768a710a9a1798848179edb186d1af7e8a8679f369e4b8d201dd2a034047" 320 | dependencies = [ 321 | "block2", 322 | "objc2", 323 | "objc2-core-data", 324 | "objc2-foundation", 325 | ] 326 | 327 | [[package]] 328 | name = "objc2-core-data" 329 | version = "0.2.0" 330 | source = "registry+https://github.com/rust-lang/crates.io-index" 331 | checksum = "6e092bc42eaf30a08844e6a076938c60751225ec81431ab89f5d1ccd9f958d6c" 332 | dependencies = [ 333 | "block2", 334 | "objc2", 335 | "objc2-foundation", 336 | ] 337 | 338 | [[package]] 339 | name = "objc2-encode" 340 | version = "4.0.1" 341 | source = "registry+https://github.com/rust-lang/crates.io-index" 342 | checksum = "88658da63e4cc2c8adb1262902cd6af51094df0488b760d6fd27194269c0950a" 343 | 344 | [[package]] 345 | name = "objc2-foundation" 346 | version = "0.2.0" 347 | source = "registry+https://github.com/rust-lang/crates.io-index" 348 | checksum = "cfaefe14254871ea16c7d88968c0ff14ba554712a20d76421eec52f0a7fb8904" 349 | dependencies = [ 350 | "block2", 351 | "objc2", 352 | ] 353 | 354 | [[package]] 355 | name = "once_cell" 356 | version = "1.19.0" 357 | source = "registry+https://github.com/rust-lang/crates.io-index" 358 | checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" 359 | 360 | [[package]] 361 | name = "parking_lot" 362 | version = "0.12.1" 363 | source = "registry+https://github.com/rust-lang/crates.io-index" 364 | checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" 365 | dependencies = [ 366 | "lock_api", 367 | "parking_lot_core", 368 | ] 369 | 370 | [[package]] 371 | name = "parking_lot_core" 372 | version = "0.9.9" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" 375 | dependencies = [ 376 | "cfg-if", 377 | "libc", 378 | "redox_syscall", 379 | "smallvec", 380 | "windows-targets 0.48.5", 381 | ] 382 | 383 | [[package]] 384 | name = "png" 385 | version = "0.17.13" 386 | source = "registry+https://github.com/rust-lang/crates.io-index" 387 | checksum = "06e4b0d3d1312775e782c86c91a111aa1f910cbb65e1337f9975b5f9a554b5e1" 388 | dependencies = [ 389 | "bitflags 1.3.2", 390 | "crc32fast", 391 | "fdeflate", 392 | "flate2", 393 | "miniz_oxide", 394 | ] 395 | 396 | [[package]] 397 | name = "portable-atomic" 398 | version = "1.6.0" 399 | source = "registry+https://github.com/rust-lang/crates.io-index" 400 | checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0" 401 | 402 | [[package]] 403 | name = "proc-macro2" 404 | version = "1.0.78" 405 | source = "registry+https://github.com/rust-lang/crates.io-index" 406 | checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" 407 | dependencies = [ 408 | "unicode-ident", 409 | ] 410 | 411 | [[package]] 412 | name = "pyo3" 413 | version = "0.20.3" 414 | source = "registry+https://github.com/rust-lang/crates.io-index" 415 | checksum = "53bdbb96d49157e65d45cc287af5f32ffadd5f4761438b527b055fb0d4bb8233" 416 | dependencies = [ 417 | "cfg-if", 418 | "indoc", 419 | "libc", 420 | "memoffset", 421 | "parking_lot", 422 | "portable-atomic", 423 | "pyo3-build-config", 424 | "pyo3-ffi", 425 | "pyo3-macros", 426 | "unindent", 427 | ] 428 | 429 | [[package]] 430 | name = "pyo3-build-config" 431 | version = "0.20.3" 432 | source = "registry+https://github.com/rust-lang/crates.io-index" 433 | checksum = "deaa5745de3f5231ce10517a1f5dd97d53e5a2fd77aa6b5842292085831d48d7" 434 | dependencies = [ 435 | "once_cell", 436 | "target-lexicon", 437 | ] 438 | 439 | [[package]] 440 | name = "pyo3-ffi" 441 | version = "0.20.3" 442 | source = "registry+https://github.com/rust-lang/crates.io-index" 443 | checksum = "62b42531d03e08d4ef1f6e85a2ed422eb678b8cd62b762e53891c05faf0d4afa" 444 | dependencies = [ 445 | "libc", 446 | "pyo3-build-config", 447 | ] 448 | 449 | [[package]] 450 | name = "pyo3-macros" 451 | version = "0.20.3" 452 | source = "registry+https://github.com/rust-lang/crates.io-index" 453 | checksum = "7305c720fa01b8055ec95e484a6eca7a83c841267f0dd5280f0c8b8551d2c158" 454 | dependencies = [ 455 | "proc-macro2", 456 | "pyo3-macros-backend", 457 | "quote", 458 | "syn", 459 | ] 460 | 461 | [[package]] 462 | name = "pyo3-macros-backend" 463 | version = "0.20.3" 464 | source = "registry+https://github.com/rust-lang/crates.io-index" 465 | checksum = "7c7e9b68bb9c3149c5b0cade5d07f953d6d125eb4337723c4ccdb665f1f96185" 466 | dependencies = [ 467 | "heck", 468 | "proc-macro2", 469 | "pyo3-build-config", 470 | "quote", 471 | "syn", 472 | ] 473 | 474 | [[package]] 475 | name = "quote" 476 | version = "1.0.35" 477 | source = "registry+https://github.com/rust-lang/crates.io-index" 478 | checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" 479 | dependencies = [ 480 | "proc-macro2", 481 | ] 482 | 483 | [[package]] 484 | name = "redox_syscall" 485 | version = "0.4.1" 486 | source = "registry+https://github.com/rust-lang/crates.io-index" 487 | checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" 488 | dependencies = [ 489 | "bitflags 1.3.2", 490 | ] 491 | 492 | [[package]] 493 | name = "rustix" 494 | version = "0.38.31" 495 | source = "registry+https://github.com/rust-lang/crates.io-index" 496 | checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949" 497 | dependencies = [ 498 | "bitflags 2.4.2", 499 | "errno", 500 | "libc", 501 | "linux-raw-sys", 502 | "windows-sys 0.52.0", 503 | ] 504 | 505 | [[package]] 506 | name = "scopeguard" 507 | version = "1.2.0" 508 | source = "registry+https://github.com/rust-lang/crates.io-index" 509 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 510 | 511 | [[package]] 512 | name = "simd-adler32" 513 | version = "0.3.7" 514 | source = "registry+https://github.com/rust-lang/crates.io-index" 515 | checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" 516 | 517 | [[package]] 518 | name = "smallvec" 519 | version = "1.13.1" 520 | source = "registry+https://github.com/rust-lang/crates.io-index" 521 | checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" 522 | 523 | [[package]] 524 | name = "syn" 525 | version = "2.0.52" 526 | source = "registry+https://github.com/rust-lang/crates.io-index" 527 | checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07" 528 | dependencies = [ 529 | "proc-macro2", 530 | "quote", 531 | "unicode-ident", 532 | ] 533 | 534 | [[package]] 535 | name = "target-lexicon" 536 | version = "0.12.14" 537 | source = "registry+https://github.com/rust-lang/crates.io-index" 538 | checksum = "e1fc403891a21bcfb7c37834ba66a547a8f402146eba7265b5a6d88059c9ff2f" 539 | 540 | [[package]] 541 | name = "tiff" 542 | version = "0.9.1" 543 | source = "registry+https://github.com/rust-lang/crates.io-index" 544 | checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" 545 | dependencies = [ 546 | "flate2", 547 | "jpeg-decoder", 548 | "weezl", 549 | ] 550 | 551 | [[package]] 552 | name = "unicode-ident" 553 | version = "1.0.12" 554 | source = "registry+https://github.com/rust-lang/crates.io-index" 555 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 556 | 557 | [[package]] 558 | name = "unindent" 559 | version = "0.2.3" 560 | source = "registry+https://github.com/rust-lang/crates.io-index" 561 | checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" 562 | 563 | [[package]] 564 | name = "weezl" 565 | version = "0.1.8" 566 | source = "registry+https://github.com/rust-lang/crates.io-index" 567 | checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" 568 | 569 | [[package]] 570 | name = "windows-sys" 571 | version = "0.48.0" 572 | source = "registry+https://github.com/rust-lang/crates.io-index" 573 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 574 | dependencies = [ 575 | "windows-targets 0.48.5", 576 | ] 577 | 578 | [[package]] 579 | name = "windows-sys" 580 | version = "0.52.0" 581 | source = "registry+https://github.com/rust-lang/crates.io-index" 582 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 583 | dependencies = [ 584 | "windows-targets 0.52.4", 585 | ] 586 | 587 | [[package]] 588 | name = "windows-targets" 589 | version = "0.48.5" 590 | source = "registry+https://github.com/rust-lang/crates.io-index" 591 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 592 | dependencies = [ 593 | "windows_aarch64_gnullvm 0.48.5", 594 | "windows_aarch64_msvc 0.48.5", 595 | "windows_i686_gnu 0.48.5", 596 | "windows_i686_msvc 0.48.5", 597 | "windows_x86_64_gnu 0.48.5", 598 | "windows_x86_64_gnullvm 0.48.5", 599 | "windows_x86_64_msvc 0.48.5", 600 | ] 601 | 602 | [[package]] 603 | name = "windows-targets" 604 | version = "0.52.4" 605 | source = "registry+https://github.com/rust-lang/crates.io-index" 606 | checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" 607 | dependencies = [ 608 | "windows_aarch64_gnullvm 0.52.4", 609 | "windows_aarch64_msvc 0.52.4", 610 | "windows_i686_gnu 0.52.4", 611 | "windows_i686_msvc 0.52.4", 612 | "windows_x86_64_gnu 0.52.4", 613 | "windows_x86_64_gnullvm 0.52.4", 614 | "windows_x86_64_msvc 0.52.4", 615 | ] 616 | 617 | [[package]] 618 | name = "windows_aarch64_gnullvm" 619 | version = "0.48.5" 620 | source = "registry+https://github.com/rust-lang/crates.io-index" 621 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 622 | 623 | [[package]] 624 | name = "windows_aarch64_gnullvm" 625 | version = "0.52.4" 626 | source = "registry+https://github.com/rust-lang/crates.io-index" 627 | checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" 628 | 629 | [[package]] 630 | name = "windows_aarch64_msvc" 631 | version = "0.48.5" 632 | source = "registry+https://github.com/rust-lang/crates.io-index" 633 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 634 | 635 | [[package]] 636 | name = "windows_aarch64_msvc" 637 | version = "0.52.4" 638 | source = "registry+https://github.com/rust-lang/crates.io-index" 639 | checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" 640 | 641 | [[package]] 642 | name = "windows_i686_gnu" 643 | version = "0.48.5" 644 | source = "registry+https://github.com/rust-lang/crates.io-index" 645 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 646 | 647 | [[package]] 648 | name = "windows_i686_gnu" 649 | version = "0.52.4" 650 | source = "registry+https://github.com/rust-lang/crates.io-index" 651 | checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" 652 | 653 | [[package]] 654 | name = "windows_i686_msvc" 655 | version = "0.48.5" 656 | source = "registry+https://github.com/rust-lang/crates.io-index" 657 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 658 | 659 | [[package]] 660 | name = "windows_i686_msvc" 661 | version = "0.52.4" 662 | source = "registry+https://github.com/rust-lang/crates.io-index" 663 | checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" 664 | 665 | [[package]] 666 | name = "windows_x86_64_gnu" 667 | version = "0.48.5" 668 | source = "registry+https://github.com/rust-lang/crates.io-index" 669 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 670 | 671 | [[package]] 672 | name = "windows_x86_64_gnu" 673 | version = "0.52.4" 674 | source = "registry+https://github.com/rust-lang/crates.io-index" 675 | checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" 676 | 677 | [[package]] 678 | name = "windows_x86_64_gnullvm" 679 | version = "0.48.5" 680 | source = "registry+https://github.com/rust-lang/crates.io-index" 681 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 682 | 683 | [[package]] 684 | name = "windows_x86_64_gnullvm" 685 | version = "0.52.4" 686 | source = "registry+https://github.com/rust-lang/crates.io-index" 687 | checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" 688 | 689 | [[package]] 690 | name = "windows_x86_64_msvc" 691 | version = "0.48.5" 692 | source = "registry+https://github.com/rust-lang/crates.io-index" 693 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 694 | 695 | [[package]] 696 | name = "windows_x86_64_msvc" 697 | version = "0.52.4" 698 | source = "registry+https://github.com/rust-lang/crates.io-index" 699 | checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" 700 | 701 | [[package]] 702 | name = "x11rb" 703 | version = "0.13.0" 704 | source = "registry+https://github.com/rust-lang/crates.io-index" 705 | checksum = "f8f25ead8c7e4cba123243a6367da5d3990e0d3affa708ea19dce96356bd9f1a" 706 | dependencies = [ 707 | "gethostname", 708 | "rustix", 709 | "x11rb-protocol", 710 | ] 711 | 712 | [[package]] 713 | name = "x11rb-protocol" 714 | version = "0.13.0" 715 | source = "registry+https://github.com/rust-lang/crates.io-index" 716 | checksum = "e63e71c4b8bd9ffec2c963173a4dc4cbde9ee96961d4fcb4429db9929b606c34" 717 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "copykitten" 3 | version = "1.2.2" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | [lib] 8 | name = "copykitten" 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | arboard = "3.4.0" 13 | pyo3 = { version = "0.20.0", features = ["extension-module", "abi3-py38"] } 14 | 15 | [profile.release] 16 | strip = "symbols" 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Roman Vlasenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # copykitten 2 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 3 | [![PyPI - Version](https://img.shields.io/pypi/v/copykitten)](https://pypi.org/project/copykitten) 4 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/copykitten) 5 | [![PyPI - Downloads](https://img.shields.io/pypi/dm/copykitten)](https://pypistats.org/packages/copykitten) 6 | 7 | 8 | A robust, dependency-free way to use the system clipboard in Python. 9 | 10 | # Installation 11 | `copykitten` supports Python >= 3.8. 12 | 13 | You can install `copykitten` from PyPI using `pip` or any other Python package manager. 14 | 15 | ```sh 16 | pip install copykitten 17 | ``` 18 | 19 | # Usage 20 | ## Text 21 | To copy or paste text content, use `copykitten.copy` and `copykitten.paste` functions. 22 | 23 | ```python 24 | import copykitten 25 | 26 | copykitten.copy("The kitten says meow") 27 | ``` 28 | 29 | ```python 30 | import copykitten 31 | 32 | text = copykitten.paste() 33 | ``` 34 | 35 | ## Image 36 | To copy or paste images, use `copykitten.copy_image` and `copykitten.paste_image` functions. 37 | Working with images is a bit complex, so read further. 38 | 39 | ```python 40 | import copykitten 41 | from PIL import Image 42 | 43 | image = Image.open("image.png") 44 | pixels = image.tobytes() 45 | 46 | copykitten.copy_image(pixels, image.width, image.height) 47 | ``` 48 | 49 | ```python 50 | import copykitten 51 | from PIL import Image 52 | 53 | pixels, width, height = copykitten.paste_image() 54 | image = Image.frombytes(mode="RGBA", size=(width, height), data=pixels) 55 | image.save("image.png") 56 | ``` 57 | 58 | To copy an image to the clipboard, you have to pass three arguments - pixel data, width, and height. 59 | Pixel data must be a `bytes` object containing the raw RGBA value for each pixel. You can easily get it using an imaging 60 | library like [Pillow](https://github.com/python-pillow/Pillow). 61 | 62 | If your image is not of RGBA type (like a typical JPEG, which is RGB), you first have to convert it to RGBA, otherwise 63 | `copy_image` will raise an exception. 64 | 65 | When pasting an image from the clipboard you will receive a 3-tuple of (pixels, width, height). Pixels here are the same 66 | RGBA `bytes` object. Please note that it is not guaranteed that any image copied to the clipboard by another program 67 | will be successfully pasted with `copykitten`. 68 | 69 | You can read more about the [data format](https://docs.rs/arboard/latest/arboard/struct.ImageData.html) and the 70 | [implications](https://docs.rs/arboard/latest/arboard/struct.Clipboard.html#method.get_image) of working with images in 71 | the `arboard` documentation. 72 | 73 | ## Clear 74 | To clear the clipboard, use `copykitten.clear` function. 75 | 76 | ```python 77 | import copykitten 78 | 79 | copykitten.clear() 80 | ``` 81 | 82 | # Rationale 83 | At the time of writing, there are very few Python packages that handle the clipboard. Most of them are simply no longer 84 | maintained (including the most popular solution around the web, [pyperclip](https://github.com/asweigart/pyperclip)). 85 | 86 | They all depend on external command-line tools like xclip/pbcopy or libraries like PyQt/GTK. You have to make sure these 87 | dependencies are installed on the target machine, otherwise they won’t work. 88 | 89 | There are some solutions using the Tkinter library, which comes with the standard Python suite. However, these solutions 90 | are fragile and may leave your app unresponsive. 91 | 92 | Copykitten is a lightweight wrapper around the Rust [arboard](https://github.com/1Password/arboard) library. It comes 93 | with pre-built wheels for Linux (x64, ARM64), macOS (x64, ARM64), and Windows (x64), so you don't have to worry about 94 | anything. 95 | 96 | # What's in a name? 97 | You can’t even imagine, how many Python packages devoted to the clipboard management there are on PyPI! Most of them 98 | are abandoned for a decade, and all the neat obvious names (and even some rather creative ones) are already taken. 99 | So I had no choice, but to invent this tongue-in-cheek name. Also, my wife approved it. 100 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["maturin>=1.3,<2.0"] 3 | build-backend = "maturin" 4 | 5 | [project] 6 | name = "copykitten" 7 | description = "A robust, dependency-free way to use the system clipboard in Python." 8 | authors = [{ name = "Roman Vlasenko", email = "klavionik@gmail.com" }] 9 | readme = "README.md" 10 | homepage = "https://github.com/Klavionik/copykitten" 11 | license = "MIT" 12 | keywords = ["clipboard"] 13 | requires-python = ">=3.8" 14 | classifiers = [ 15 | "Programming Language :: Rust", 16 | "Programming Language :: Python :: Implementation :: CPython", 17 | "Development Status :: 5 - Production/Stable", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: MacOS :: MacOS X", 21 | "Operating System :: Microsoft :: Windows", 22 | "Operating System :: POSIX :: Linux", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Programming Language :: Python :: 3.13", 29 | ] 30 | dynamic = ["version"] 31 | 32 | [tool.black] 33 | line-length = 100 34 | 35 | [tool.isort] 36 | profile = "black" 37 | 38 | [tool.vulture] 39 | paths = ["python", "tests"] 40 | ignore_names = ["read_*", "write_*"] 41 | 42 | [tool.maturin] 43 | python-source = "python" 44 | module-name = "copykitten._copykitten" 45 | -------------------------------------------------------------------------------- /python/copykitten/__init__.py: -------------------------------------------------------------------------------- 1 | from ._copykitten import CopykittenError, clear, copy, copy_image, paste, paste_image 2 | 3 | __all__ = ["copy", "paste", "clear", "copy_image", "paste_image", "CopykittenError"] 4 | -------------------------------------------------------------------------------- /python/copykitten/_copykitten.pyi: -------------------------------------------------------------------------------- 1 | class CopykittenError(Exception): 2 | """ 3 | Raised if anything went wrong during any clipboard operation. 4 | arboard errors are mapped to this exception as well as mutex panics 5 | and clipboard initialization errors in the underlying Rust library. 6 | 7 | More on arboard errors: https://docs.rs/arboard/latest/arboard/enum.Error.html 8 | """ 9 | ... 10 | 11 | 12 | def copy(content: str) -> None: 13 | """ 14 | Copies passed text content into the clipboard. 15 | Content must be a valid UTF-8 string. 16 | 17 | :param content: Text to copy. 18 | :raises CopykittenError: Raised if copying failed. 19 | :raises TypeError: Raised if the content is not a string. 20 | """ 21 | ... 22 | 23 | 24 | def paste() -> str: 25 | """ 26 | Returns the current text content of the clipboard 27 | as a UTF-8 string. 28 | 29 | :return: Clipboard content. 30 | :raises CopykittenError: Raised if fetching clipboard content failed 31 | or the clipboard is empty (on Windows and macOS). 32 | """ 33 | ... 34 | 35 | 36 | def clear() -> None: 37 | """ 38 | Clears the clipboard. Calling `copykitten.paste()` after this 39 | may raise an error on Windows and macOS due to the empty clipboard. 40 | 41 | :raises CopykittenError: Raised if the clear operation failed. 42 | """ 43 | ... 44 | 45 | 46 | def copy_image(content: bytes, width: int, height: int) -> None: 47 | """ 48 | Copies given image data to the clipboard. 49 | 50 | :param content: Raw RGBA image data. 51 | :param width: Image width. 52 | :param height: Image height. 53 | :raises CopykittenError: Raised if the image cannot be copied into the clipboard. 54 | :raises TypeError: Raised if `content` is not bytes or `width`/`height` is not an integer. 55 | """ 56 | ... 57 | 58 | 59 | def paste_image() -> tuple[bytes, int, int]: 60 | """ 61 | Returns image data from the clipboard. 62 | 63 | :raises CopykittenError: Raised if there's no image in the clipboard or the image is of incorrect format. 64 | :return: A 3-tuple of raw RGBA pixels, width, and height. 65 | """ 66 | ... 67 | -------------------------------------------------------------------------------- /python/copykitten/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klavionik/copykitten/f282d106d26bb824c9209ce822a19c92c5f34b87/python/copykitten/py.typed -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pillow==11.0.0 ; python_version >= "3.13" 2 | pillow==10.4.0 ; python_version >= "3.8" and python_version < "3.13" 3 | pytest==8.0.0 4 | pytest-repeat==0.9.3 5 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | extern crate core; 2 | 3 | use pyo3::create_exception; 4 | use pyo3::prelude::*; 5 | use std::borrow::Cow; 6 | use std::sync::{LazyLock, Mutex, MutexGuard}; 7 | 8 | create_exception!(copykitten, CopykittenError, pyo3::exceptions::PyException); 9 | 10 | static CLIPBOARD: LazyLock>> = LazyLock::new(initialize_clipboard); 11 | 12 | fn initialize_clipboard() -> Option> { 13 | let maybe_clipboard = arboard::Clipboard::new().ok(); 14 | maybe_clipboard.map(Mutex::new) 15 | } 16 | 17 | fn raise_exc(text: &'static str) -> PyErr { 18 | CopykittenError::new_err(text) 19 | } 20 | 21 | fn to_exc(err: arboard::Error) -> PyErr { 22 | CopykittenError::new_err(err.to_string()) 23 | } 24 | 25 | fn get_clipboard() -> Result, PyErr> { 26 | let mutex = CLIPBOARD 27 | .as_ref() 28 | .ok_or(raise_exc("Clipboard was never initialized."))?; 29 | 30 | mutex 31 | .lock() 32 | .map_err(|_| raise_exc("Cannot get lock on the clipboard, the lock is poisoned.")) 33 | } 34 | 35 | #[pyfunction] 36 | fn copy(content: &str) -> PyResult<()> { 37 | let mut cb = get_clipboard()?; 38 | 39 | cb.set_text(content).map_err(to_exc)?; 40 | Ok(()) 41 | } 42 | 43 | #[pyfunction] 44 | fn copy_image(content: Cow<[u8]>, width: usize, height: usize) -> PyResult<()> { 45 | let mut cb = get_clipboard()?; 46 | let image = arboard::ImageData { 47 | bytes: content, 48 | width, 49 | height, 50 | }; 51 | 52 | cb.set_image(image).map_err(to_exc)?; 53 | Ok(()) 54 | } 55 | 56 | #[pyfunction] 57 | fn paste() -> PyResult { 58 | let mut cb = get_clipboard()?; 59 | let content = cb.get_text().map_err(to_exc)?; 60 | 61 | Ok(content) 62 | } 63 | 64 | #[pyfunction] 65 | fn paste_image() -> PyResult<(Cow<'static, [u8]>, usize, usize)> { 66 | let mut cb = get_clipboard()?; 67 | let image = cb.get_image().map_err(to_exc)?; 68 | 69 | Ok((image.bytes, image.width, image.height)) 70 | } 71 | 72 | #[pyfunction] 73 | fn clear() -> PyResult<()> { 74 | let mut cb = get_clipboard()?; 75 | 76 | cb.clear().map_err(to_exc) 77 | } 78 | 79 | #[pymodule] 80 | fn _copykitten(py: Python, module: &PyModule) -> PyResult<()> { 81 | module.add("CopykittenError", py.get_type::())?; 82 | module.add_function(wrap_pyfunction!(copy, module)?)?; 83 | module.add_function(wrap_pyfunction!(paste, module)?)?; 84 | module.add_function(wrap_pyfunction!(clear, module)?)?; 85 | module.add_function(wrap_pyfunction!(copy_image, module)?)?; 86 | module.add_function(wrap_pyfunction!(paste_image, module)?)?; 87 | Ok(()) 88 | } 89 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klavionik/copykitten/f282d106d26bb824c9209ce822a19c92c5f34b87/tests/__init__.py -------------------------------------------------------------------------------- /tests/clipboard.py: -------------------------------------------------------------------------------- 1 | import io 2 | import subprocess 3 | import sys 4 | import tempfile 5 | from pathlib import Path 6 | from typing import Callable, Generic, TypeVar, cast 7 | 8 | from PIL import Image 9 | 10 | ReadClipboard = Callable[[], str] 11 | WriteClipboard = Callable[[str], None] 12 | ReadClipboardImage = Callable[[], Image.Image] 13 | WriteClipboardImage = Callable[[Image.Image], None] 14 | 15 | T = TypeVar("T") 16 | 17 | 18 | class Resolver(Generic[T]): 19 | platform_mapping = {"win32": "win", "linux": "linux", "darwin": "macos"} 20 | 21 | def __set_name__(self, _, name): 22 | self.action = name 23 | 24 | def __get__(self, _, __) -> T: 25 | variables = globals() 26 | platform = self.platform_mapping[sys.platform] 27 | function_name = f"{self.action}_{platform}" 28 | 29 | try: 30 | return cast(T, variables[function_name]) 31 | except KeyError: 32 | raise RuntimeError(f"Cannot find suitable clipboard for {sys.platform}.") 33 | 34 | 35 | class Clipboard: 36 | read = Resolver[ReadClipboard]() 37 | write = Resolver[WriteClipboard]() 38 | read_image = Resolver[ReadClipboardImage]() 39 | write_image = Resolver[WriteClipboardImage]() 40 | 41 | 42 | def read_macos() -> str: 43 | return subprocess.check_output("pbpaste").decode().strip() 44 | 45 | 46 | def write_macos(content: str) -> None: 47 | subprocess.run("pbcopy", input=content, text=True) 48 | 49 | 50 | def read_image_macos() -> Image.Image: 51 | data = subprocess.check_output(("osascript", "-e", "get the clipboard as «class PNGf»")) 52 | # On macOS data looks like this: '«data PNGf»\n'. 53 | # So it has to be stripped and converted from hex. 54 | hex_string = data[11:-3].decode() 55 | content = io.BytesIO(bytes.fromhex(hex_string)) 56 | return Image.open(content, formats=["png"]) 57 | 58 | 59 | def write_image_macos(img: Image.Image) -> None: 60 | # AppleScript can't read from stdin, but can read from a file. 61 | with tempfile.NamedTemporaryFile(suffix=".png") as tmp: 62 | img.save(tmp) 63 | cmd = ( 64 | "osascript", 65 | "-e", 66 | "on run args", 67 | "-e", 68 | "set the clipboard to (read POSIX file (first item of args) as JPEG picture)", 69 | "-e", 70 | "end", 71 | tmp.name, 72 | ) 73 | 74 | subprocess.run(cmd, check=True) 75 | 76 | 77 | def read_win() -> str: 78 | return subprocess.check_output(("powershell.exe", "Get-Clipboard")).decode().strip() 79 | 80 | 81 | def write_win(content: str) -> None: 82 | # Set-Clipboard won't read input piped from a Python process. 83 | # Another cmdlet `clip` does read from a pipe, but adds 4 unexpected 84 | # null bytes to the content. 85 | subprocess.check_call(("powershell.exe", "Set-Clipboard", content)) 86 | 87 | 88 | def read_image_win() -> Image.Image: 89 | tmp_dir = tempfile.gettempdir() 90 | tmp_file = Path(tmp_dir) / "pasted_image.png" 91 | subprocess.run( 92 | ( 93 | "powershell.exe", 94 | "Add-Type", 95 | "-Assembly", 96 | "System.Drawing,", 97 | "System.Windows.Forms;", 98 | '[System.Windows.Forms.Clipboard]::GetImage().Save("%s")' % tmp_file, 99 | ), 100 | check=True, 101 | ) 102 | 103 | try: 104 | img = Image.open(tmp_file) 105 | img.load() 106 | finally: 107 | tmp_file.unlink() 108 | 109 | return img 110 | 111 | 112 | def write_image_win(img: Image.Image) -> None: 113 | tmp_dir = tempfile.gettempdir() 114 | tmp_file = Path(tmp_dir) / "copied_image.png" 115 | 116 | try: 117 | img.save(tmp_file) 118 | subprocess.run( 119 | ( 120 | "powershell.exe", 121 | "Add-Type", 122 | "-Assembly", 123 | "System.Drawing,", 124 | "System.Windows.Forms;", 125 | '[System.Windows.Forms.Clipboard]::SetImage([System.Drawing.Image]::FromFile("%s"))' 126 | % tmp_file, 127 | ), 128 | check=True, 129 | ) 130 | finally: 131 | tmp_file.unlink() 132 | 133 | 134 | def read_linux() -> str: 135 | return subprocess.check_output(("xclip", "-sel", "clipboard", "-o")).decode() 136 | 137 | 138 | def write_linux(content: str) -> None: 139 | subprocess.run(("xclip", "-sel", "clipboard", "-i"), input=content, text=True) 140 | 141 | 142 | def read_image_linux() -> Image.Image: 143 | data = subprocess.check_output(("xclip", "-sel", "clipboard", "-o", "-target", "image/png")) 144 | buffer = io.BytesIO(data) 145 | return Image.open(buffer, formats=["png"]) 146 | 147 | 148 | def write_image_linux(img: Image.Image) -> None: 149 | buffer = io.BytesIO() 150 | img.save(buffer, format="png") 151 | subprocess.run( 152 | ("xclip", "-sel", "clipboard", "-i", "-target", "image/png"), 153 | input=buffer.getvalue(), 154 | check=True, 155 | ) 156 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from PIL import Image 3 | 4 | from tests.clipboard import ( 5 | Clipboard, 6 | ReadClipboard, 7 | ReadClipboardImage, 8 | WriteClipboard, 9 | WriteClipboardImage, 10 | ) 11 | 12 | 13 | @pytest.fixture(scope="session") 14 | def test_image() -> Image.Image: 15 | return Image.new(mode="RGBA", size=(10, 10), color="red") 16 | 17 | 18 | @pytest.fixture(scope="session") 19 | def clipboard() -> Clipboard: 20 | return Clipboard() 21 | 22 | 23 | @pytest.fixture(scope="session") 24 | def read_clipboard(clipboard) -> ReadClipboard: 25 | return clipboard.read 26 | 27 | 28 | @pytest.fixture(scope="session") 29 | def write_clipboard(clipboard) -> WriteClipboard: 30 | return clipboard.write 31 | 32 | 33 | @pytest.fixture(scope="session") 34 | def read_clipboard_image(clipboard) -> ReadClipboardImage: 35 | return clipboard.read_image 36 | 37 | 38 | @pytest.fixture(scope="session") 39 | def write_clipboard_image(clipboard) -> WriteClipboardImage: 40 | return clipboard.write_image 41 | -------------------------------------------------------------------------------- /tests/test_lib.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from time import sleep 4 | 5 | import pytest 6 | from PIL import Image 7 | 8 | import copykitten 9 | from tests.clipboard import ( 10 | ReadClipboard, 11 | ReadClipboardImage, 12 | WriteClipboard, 13 | WriteClipboardImage, 14 | ) 15 | 16 | DEFAULT_ITERATIONS = 50 17 | DEFAULT_SLEEP_TIME = 0.1 18 | 19 | 20 | try: 21 | ITERATIONS = int(os.getenv("COPYKITTEN_TEST_ITERATIONS", DEFAULT_ITERATIONS)) 22 | except Exception: 23 | ITERATIONS = DEFAULT_ITERATIONS 24 | 25 | # Why sleep in tests? 26 | # It may take a bit for the OS to finish the clipboard operation, 27 | # so there have to be a short delay before asserting the result. 28 | # Otherwise, tests may randomly fail. 29 | try: 30 | SLEEP_TIME = float(os.getenv("COPYKITTEN_TEST_SLEEP_TIME", DEFAULT_SLEEP_TIME)) 31 | except Exception: 32 | SLEEP_TIME = DEFAULT_SLEEP_TIME 33 | 34 | 35 | @pytest.mark.repeat(ITERATIONS) 36 | def test_copy_text(read_clipboard: ReadClipboard): 37 | copykitten.copy("text") 38 | sleep(SLEEP_TIME) 39 | 40 | actual = read_clipboard() 41 | 42 | assert actual == "text" 43 | 44 | 45 | @pytest.mark.repeat(ITERATIONS) 46 | def test_clear(read_clipboard: ReadClipboard, write_clipboard: WriteClipboard): 47 | write_clipboard("text") 48 | sleep(SLEEP_TIME) 49 | copykitten.clear() 50 | sleep(SLEEP_TIME) 51 | 52 | actual = read_clipboard() 53 | 54 | assert actual == "" 55 | 56 | 57 | @pytest.mark.repeat(ITERATIONS) 58 | def test_paste_text(write_clipboard: WriteClipboard): 59 | write_clipboard("text") 60 | sleep(SLEEP_TIME) 61 | 62 | actual = copykitten.paste() 63 | 64 | assert actual == "text" 65 | 66 | 67 | def test_copy_image(test_image: Image.Image, read_clipboard_image: ReadClipboardImage): 68 | test_image_bytes = test_image.tobytes() 69 | copykitten.copy_image(test_image_bytes, test_image.width, test_image.height) 70 | sleep(SLEEP_TIME) 71 | 72 | pasted_image = read_clipboard_image() 73 | 74 | assert test_image_bytes == pasted_image.tobytes() 75 | 76 | 77 | @pytest.mark.skipif( 78 | sys.platform == "win32", reason="No way to reliably assert result on Windows yet" 79 | ) 80 | def test_paste_image(test_image: Image.Image, write_clipboard_image: WriteClipboardImage): 81 | write_clipboard_image(test_image) 82 | sleep(SLEEP_TIME) 83 | 84 | pasted_image, width, height = copykitten.paste_image() 85 | 86 | assert test_image.tobytes() == pasted_image 87 | assert width == test_image.width 88 | assert height == test_image.height 89 | --------------------------------------------------------------------------------