├── tests ├── __init__.py ├── test.jpg └── test_ptpimg_uploader.py ├── .gitignore ├── .github └── workflows │ ├── build.yml │ └── pythonpublish.yml ├── setup.py ├── LICENSE ├── CHANGELOG.rst ├── README.rst └── ptpimg_uploader.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info/* 3 | build/* 4 | dist/* 5 | -------------------------------------------------------------------------------- /tests/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theirix/ptpimg-uploader/HEAD/tests/test.jpg -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v5 12 | - name: Set up Python 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: '3.8' 16 | - name: Install dependencies 17 | run: python -m pip install .[dev] 18 | - name: Run tests 19 | run: python setup.py test 20 | - name: Lint with black 21 | run: black --check . tests 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.rst", encoding="utf-8") as desc: 4 | long_description = desc.read() 5 | 6 | setup( 7 | name="ptpimg_uploader", 8 | version="0.13", 9 | author="theirix", 10 | author_email="theirix@gmail.com", 11 | description=("PTPImg uploader, handles local files and URLs, from the commandline"), 12 | long_description=long_description, 13 | long_description_content_type="text/x-rst", 14 | license="BSD", 15 | keywords="image uploader", 16 | url="https://github.com/theirix/ptpimg-uploader", 17 | classifiers=[ 18 | "Development Status :: 4 - Beta", 19 | "Topic :: Utilities", 20 | ], 21 | py_modules=["ptpimg_uploader"], 22 | entry_points={ 23 | "console_scripts": [ 24 | "ptpimg_uploader = ptpimg_uploader:main", 25 | ], 26 | }, 27 | install_requires=[ 28 | "requests", 29 | ], 30 | extras_require={ 31 | "dev": ["requests-mock", "black"], 32 | }, 33 | python_requires=">=3.3", 34 | ) 35 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | # Specifying a GitHub environment is optional, but strongly encouraged 15 | environment: pypi 16 | permissions: 17 | # IMPORTANT: this permission is mandatory for Trusted Publishing 18 | id-token: write 19 | 20 | steps: 21 | - uses: actions/checkout@v5 22 | - name: Set up Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: '3.12' 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install .[dev] 29 | python -m pip install setuptools wheel 30 | - name: Build 31 | run: python setup.py sdist bdist_wheel 32 | - name: Publish 33 | uses: pypa/gh-action-pypi-publish@release/v1 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2017, theirix 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Change Log 3 | ========== 4 | 5 | 0.13 (2025-09-05) 6 | ----------------- 7 | 8 | * Fixup PyPI docs 9 | 10 | 0.12 (2025-09-04) 11 | ----------------- 12 | 13 | * Fixup publishing 14 | 15 | 0.11 (2025-09-04) 16 | ----------------- 17 | 18 | * Fixup publishing 19 | 20 | 0.10 (2025-09-04) 21 | ----------------- 22 | 23 | * Retry network calls (#10) 24 | * Allow to customise ptpimg hostname 25 | * Add tests 26 | * Rework documentation 27 | * Using PyPi trusted publishing 28 | 29 | 0.9 (2023-06-09) 30 | ----------------- 31 | 32 | * Fix problem when omitting clip parameter 33 | 34 | 0.8 (2022-06-07) 35 | ----------------- 36 | 37 | * Use --clip option only if pyperclip installed 38 | 39 | 0.7 (2022-06-06) 40 | ----------------- 41 | 42 | * Added --clip option to read url from clipboard 43 | 44 | 0.6 (2021-05-23) 45 | ----------------- 46 | 47 | * Image order gets changed if urls and files are mixed in input parameter (#5). 48 | 49 | 50 | 0.5 (2020-04-27) 51 | ----------------- 52 | 53 | * Do not require pyperclip to be installed. 54 | 55 | 0.4 (2020-04-27) 56 | ----------------- 57 | 58 | * Add bbcode CLI parameter (#4). 59 | * Bell a terminal on completion. 60 | 61 | 0.3 (2020-02-01) 62 | ----------------- 63 | 64 | * Add ``timeout`` parameter to ``PtpimgUploader`` (#3). 65 | 66 | 0.2 (2020-01-29) 67 | ----------------- 68 | 69 | * Allow to upload images with braces in URL (#2). 70 | 71 | 0.1 (2017-07-05) 72 | ----------------- 73 | 74 | * Initial release. 75 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | ptpimg_uploader 3 | =============== 4 | 5 | .. image:: https://img.shields.io/pypi/v/ptpimg-uploader.svg 6 | :alt: PyPI version 7 | :target: https://pypi.python.org/pypi/ptpimg-uploader 8 | 9 | .. image:: https://github.com/theirix/ptpimg-uploader/workflows/build/badge.svg 10 | :alt: Build Status 11 | :target: https://github.com/theirix/ptpimg-uploader/actions 12 | 13 | Upload image file or image URL to the ptpimg.me image hosting. 14 | 15 | Features 16 | -------- 17 | 18 | * Upload local image files 19 | * Rehost images from image services (e.g., from imgur) 20 | * Copy resulting URL to clipboard 21 | * BBCode formatting support 22 | * Command-line and programmatic usage 23 | 24 | Installation 25 | ------------ 26 | 27 | Using pip (recommended): 28 | 29 | .. code-block:: bash 30 | 31 | pip install ptpimg_uploader 32 | 33 | Using setup.py: 34 | 35 | .. code-block:: bash 36 | 37 | python setup.py install 38 | 39 | Manual Dependencies: 40 | 41 | * Required: ``requests`` package 42 | * Debian/Ubuntu: ``apt-get install python3-requests`` 43 | * Other systems: ``pip3 install requests`` 44 | 45 | * Optional: ``pyperclip`` package for clipboard support 46 | * Install via: ``pip3 install pyperclip`` 47 | 48 | API Key Setup 49 | ------------- 50 | 51 | 1. Login to https://ptpimg.me 52 | 2. Open browser developer tools (View -> Developer -> View Source in Chrome) 53 | 3. Find ``api_key`` in the page source 54 | 4. Copy the hexadecimal string (format: ``xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx``) 55 | 56 | Set your API key using either: 57 | 58 | Environment variable (recommended): 59 | 60 | .. code-block:: bash 61 | 62 | # Add to your ~/.bashrc or ~/.zshenv 63 | export PTPIMG_API_KEY=your-api-key-here 64 | 65 | Or use the command-line option: ``-k`` / ``--api-key`` 66 | 67 | Usage 68 | ----- 69 | 70 | Get help: 71 | 72 | .. code-block:: bash 73 | 74 | ptpimg_uploader -h 75 | 76 | Upload a local image: 77 | 78 | .. code-block:: bash 79 | 80 | ptpimg_uploader ~/seed/mytorrent/folder.jpg 81 | 82 | Rehost from URL: 83 | 84 | .. code-block:: bash 85 | 86 | ptpimg_uploader https://i.imgur.com/eaT6j3X.jpg 87 | 88 | Multiple uploads (mix-and-match files and URLs): 89 | 90 | .. code-block:: bash 91 | 92 | ptpimg_uploader ~/seed/mytorrent/folder.jpg https://i.imgur.com/eaT6j3X.jpg 93 | 94 | Additional command-line options 95 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 96 | 97 | * ``--bbcode``: URLs will be wrapped in BBCode ``[img]`` tags 98 | 99 | .. code-block:: bash 100 | 101 | ptpimg_uploader --bbcode ~/seed/mytorrent/folder.jpg 102 | 103 | * ``--clip``: Place a resulting URL to clipboard (if `pyperclip` package is installed) 104 | 105 | .. code-block:: bash 106 | 107 | ptpimg_uploader --clip ~/seed/mytorrent/folder.jpg 108 | 109 | * ``--nobell``: Disable completion sound. If output is a terminal, a bell will be ringed on completion. 110 | 111 | Programmatic Usage 112 | ------------------ 113 | 114 | The package can be used as a library via the ``upload`` function for programmatic access. 115 | 116 | License 117 | ------- 118 | 119 | BSD 120 | 121 | Acknowledgments 122 | --------------- 123 | 124 | * mjpieters - a great refactoring and Python packaging 125 | * lukacoufyl - fixing image upload order 126 | -------------------------------------------------------------------------------- /tests/test_ptpimg_uploader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | import requests_mock 5 | 6 | from ptpimg_uploader import NotFoundError, PtpimgUploader, UploadFailed, upload 7 | 8 | 9 | class PtptimgUploaderCase(unittest.TestCase): 10 | 11 | def setUp(self): 12 | self.upload_url = "https://ptpimg.me/upload.php" 13 | self.image_url = "https://acme.org/cat.jpg" 14 | self.image_path = os.path.join(os.path.dirname(__file__), "test.jpg") 15 | 16 | self.mock = requests_mock.Mocker() 17 | self.mock.start() 18 | 19 | def tearDown(self): 20 | self.mock.stop() 21 | 22 | def test_instantiate(self): 23 | PtpimgUploader("dummykey") 24 | 25 | def test_upload_file_ok(self): 26 | self.mock.register_uri( 27 | method="POST", 28 | url=self.upload_url, 29 | json=[{"code": "ulkm79", "ext": "jpg"}], 30 | ) 31 | uploader = PtpimgUploader("dummykey") 32 | resp = uploader.upload_file(self.image_path) 33 | self.assertEqual(resp, ["https://ptpimg.me/ulkm79.jpg"]) 34 | 35 | def test_upload_file_missing(self): 36 | self.mock.register_uri( 37 | method="POST", 38 | url=self.upload_url, 39 | json=[{"code": "ulkm79", "ext": "jpg"}], 40 | ) 41 | uploader = PtpimgUploader("dummykey") 42 | with self.assertRaises(NotFoundError): 43 | uploader.upload_file("missing.jpg") 44 | 45 | def test_upload_url_ok(self): 46 | self.mock.register_uri( 47 | method="POST", 48 | url=self.upload_url, 49 | json=[{"code": "ulkm79", "ext": "jpg"}], 50 | ) 51 | self.mock.register_uri( 52 | method="GET", 53 | url=self.image_url, 54 | content=b"dummyjpgimage", 55 | headers={"content-type": "image/jpeg"}, 56 | ) 57 | uploader = PtpimgUploader("dummykey") 58 | resp = uploader.upload_url(self.image_url) 59 | self.assertEqual(resp, ["https://ptpimg.me/ulkm79.jpg"]) 60 | 61 | def test_upload_url_missing(self): 62 | self.mock.register_uri( 63 | method="POST", 64 | url=self.upload_url, 65 | json=[{"code": "ulkm79", "ext": "jpg"}], 66 | ) 67 | self.mock.register_uri( 68 | method="GET", url=self.image_url, content=b"", status_code=404 69 | ) 70 | uploader = PtpimgUploader("dummykey") 71 | with self.assertRaises(NotFoundError): 72 | uploader.upload_url(self.image_url) 73 | 74 | def test_upload_file_ptpimg_error(self): 75 | self.mock.register_uri(method="POST", url=self.upload_url, status_code=400) 76 | uploader = PtpimgUploader("dummykey") 77 | with self.assertRaises(UploadFailed): 78 | uploader.upload_file(self.image_path) 79 | 80 | def test_upload_standalone(self): 81 | self.mock.register_uri( 82 | method="POST", 83 | url=self.upload_url, 84 | json=[{"code": "ulkm79", "ext": "jpg"}], 85 | ) 86 | self.mock.register_uri( 87 | method="GET", 88 | url=self.image_url, 89 | content=b"dummyjpgimage", 90 | headers={"content-type": "image/jpeg"}, 91 | ) 92 | 93 | results = upload("dummykey", [self.image_path, self.image_url]) 94 | self.assertEqual(results, ["https://ptpimg.me/ulkm79.jpg"] * 2) 95 | 96 | 97 | if __name__ == "__main__": 98 | unittest.main() 99 | -------------------------------------------------------------------------------- /ptpimg_uploader.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # pylint: disable=invalid-name 3 | """ 4 | Upload image file or image URL to the ptpimg.me image hosting. 5 | 6 | Usage: 7 | python3 ptpimg-uploader.py image-file.jpg 8 | python3 ptpimg-uploader.py https://i.imgur.com/00000.jpg 9 | python3 ptpimg-uploader.py --clip 10 | """ 11 | 12 | import contextlib 13 | import mimetypes 14 | import os 15 | from io import BytesIO 16 | from sys import stdout 17 | import time 18 | 19 | import requests 20 | 21 | mimetypes.init() 22 | 23 | 24 | class UploadFailed(Exception): 25 | def __str__(self): 26 | msg, *args = self.args 27 | return msg.format(*args) 28 | 29 | 30 | class NotFoundError(ValueError): 31 | """Image at file or URL is not found.""" 32 | 33 | 34 | class PtpimgUploader: 35 | """Upload image or image URL to the ptpimg.me image hosting""" 36 | 37 | def __init__(self, api_key, timeout=None, api_host="https://ptpimg.me"): 38 | self.api_key = api_key 39 | self.timeout = timeout 40 | self.api_host = api_host 41 | 42 | def _handle_result(self, res): 43 | image_url = "{0}/{1}.{2}".format(self.api_host, res["code"], res["ext"]) 44 | return image_url 45 | 46 | def _perform(self, resp): 47 | if resp.status_code == requests.codes.ok: 48 | try: 49 | # print('Successful response', r.json()) 50 | # r.json() is like this: [{'code': 'ulkm79', 'ext': 'jpg'}] 51 | return [self._handle_result(r) for r in resp.json()] 52 | except ValueError as e: 53 | raise UploadFailed( 54 | "Failed decoding body:\n{0}\n{1!r}", e, resp.content 55 | ) from None 56 | else: 57 | raise UploadFailed( 58 | "Failed. Status {0}:\n{1}", resp.status_code, resp.content 59 | ) 60 | 61 | def _send_upload(self, files: dict): 62 | headers = {"referer": "{}/index.php".format(self.api_host)} 63 | data = {"api_key": self.api_key} 64 | service_url = "{}/upload.php".format(self.api_host) 65 | for rem_attempt in reversed(range(5)): 66 | try: 67 | return requests.post( 68 | service_url, headers=headers, data=data, files=files 69 | ) 70 | except requests.RequestException as e: 71 | if rem_attempt == 0: 72 | raise e 73 | time.sleep(1) 74 | return None 75 | 76 | def upload_file(self, filename): 77 | """Upload file using form""" 78 | # The ExitStack closes files for us when the with block exits 79 | with contextlib.ExitStack() as stack: 80 | try: 81 | open_file = stack.enter_context(open(filename, "rb")) 82 | except FileNotFoundError: 83 | raise NotFoundError("File not found {0}".format(filename)) 84 | mime_type, _ = mimetypes.guess_type(filename) 85 | if not mime_type or mime_type.split("/")[0] != "image": 86 | raise ValueError("Unknown image file type {}".format(mime_type)) 87 | 88 | name = os.path.basename(filename) 89 | try: 90 | # until https://github.com/shazow/urllib3/issues/303 is 91 | # resolved, only use the filename if it is Latin-1 safe 92 | e_name = name.encode("latin-1", "replace") 93 | name = e_name.decode("latin-1") 94 | except UnicodeEncodeError: 95 | name = "justfilename" 96 | 97 | files = {"file-upload[]": (name, open_file, mime_type)} 98 | resp = self._send_upload(files=files) 99 | 100 | return self._perform(resp) 101 | 102 | def upload_url(self, url): 103 | """Upload image URL""" 104 | with contextlib.ExitStack() as stack: 105 | for rem_attempt in reversed(range(5)): 106 | try: 107 | resp = requests.get(url, timeout=self.timeout) 108 | except requests.RequestException as e: 109 | if rem_attempt == 0: 110 | raise e 111 | time.sleep(1) 112 | 113 | if resp.status_code != requests.codes.ok: 114 | raise NotFoundError( 115 | "Cannot fetch url {} with error {}".format(url, resp.status_code) 116 | ) 117 | 118 | mime_type = resp.headers.get("content-type") 119 | if not mime_type or mime_type.split("/")[0] != "image": 120 | raise ValueError("Unknown image file type {}".format(mime_type)) 121 | 122 | open_file = stack.enter_context(BytesIO(resp.content)) 123 | 124 | files = {"file-upload[]": ("justfilename", open_file, mime_type)} 125 | resp = self._send_upload(files) 126 | 127 | return self._perform(resp) 128 | 129 | 130 | def _partition(files_or_urls): 131 | file_url_list = [] 132 | for file_or_url in files_or_urls: 133 | if os.path.exists(file_or_url): 134 | file_url_list.append({"type": "file", "path": file_or_url}) 135 | elif file_or_url.startswith("http"): 136 | file_url_list.append({"type": "url", "path": file_or_url}) 137 | else: 138 | raise ValueError( 139 | "Not an existing file or image URL: {}".format(file_or_url) 140 | ) 141 | return file_url_list 142 | 143 | 144 | def upload(api_key, files_or_urls, timeout=None): 145 | uploader = PtpimgUploader(api_key, timeout) 146 | file_url_list = _partition(files_or_urls) 147 | results = [] 148 | if file_url_list: 149 | for file_or_url in file_url_list: 150 | if file_or_url["type"] == "file": 151 | results += uploader.upload_file(file_or_url["path"]) 152 | elif file_or_url["type"] == "url": 153 | results += uploader.upload_url(file_or_url["path"]) 154 | return results 155 | 156 | 157 | def main(): 158 | import argparse 159 | import sys 160 | 161 | try: 162 | import pyperclip 163 | except ImportError: 164 | pyperclip = None 165 | 166 | nargs = "+" 167 | if "--clip" in sys.argv and pyperclip: 168 | nargs = "*" 169 | 170 | parser = argparse.ArgumentParser(description="PTPImg uploader") 171 | parser.add_argument("images", metavar="filename|url", nargs=nargs) 172 | parser.add_argument( 173 | "-k", 174 | "--api-key", 175 | default=os.environ.get("PTPIMG_API_KEY"), 176 | help="PTPImg API key (or set the PTPIMG_API_KEY environment variable)", 177 | ) 178 | if pyperclip is not None: 179 | parser.add_argument( 180 | "-n", 181 | "--dont-copy", 182 | action="store_false", 183 | default=True, 184 | dest="clipboard", 185 | help="Do not copy the resulting URLs to the clipboard", 186 | ) 187 | parser.add_argument( 188 | "--clip", 189 | action="store_true", 190 | default=False, 191 | help="copy from image from clipboard. Image can either " 192 | + "be a path to the image, a url to the image", 193 | ) 194 | parser.add_argument( 195 | "-b", 196 | "--bbcode", 197 | action="store_true", 198 | default=False, 199 | help="Output links in BBCode format (with [img] tags)", 200 | ) 201 | parser.add_argument( 202 | "--nobell", 203 | action="store_true", 204 | default=False, 205 | help="Do not bell in a terminal on completion", 206 | ) 207 | 208 | args = parser.parse_args() 209 | images = args.images 210 | if pyperclip is not None and args.clip: 211 | images.append(pyperclip.paste()) 212 | 213 | if not args.api_key: 214 | parser.error("Please specify an API key") 215 | try: 216 | image_urls = upload(args.api_key, images) 217 | if args.bbcode: 218 | printed_urls = [ 219 | "[img]{}[/img]".format(image_url) for image_url in image_urls 220 | ] 221 | else: 222 | printed_urls = image_urls 223 | print(*printed_urls, sep="\n") 224 | # Copy to clipboard if possible 225 | if getattr(args, "clipboard", False): 226 | pyperclip.copy("\n".join(image_urls)) 227 | # Ring a terminal if we are in terminal and allowed to do this 228 | if not args.nobell and stdout.isatty(): 229 | stdout.write("\a") 230 | stdout.flush() 231 | except (UploadFailed, ValueError) as e: 232 | parser.error(str(e)) 233 | 234 | 235 | if __name__ == "__main__": 236 | main() 237 | --------------------------------------------------------------------------------