├── src ├── cloudflare_images │ ├── __init__.py │ └── main.py └── img │ └── screenshot.png ├── env.example ├── .editorconfig ├── docs └── index.md ├── justfile ├── .pre-commit-config.yaml ├── pyproject.toml ├── .github └── workflows │ └── main.yml ├── LICENSE ├── mkdocs.yml ├── .gitignore └── README.md /src/cloudflare_images/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import CloudflareImagesAPI 2 | -------------------------------------------------------------------------------- /src/img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justmars/cloudflare-images/HEAD/src/img/screenshot.png -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | CF_ACCT_ID=op://dev/cloudflare/acct_id 2 | CF_IMG_TOKEN=op://dev/cloudflare/images/token 3 | CF_IMG_HASH=op://dev/cloudflare/images/hash 4 | 5 | PYPI_TOKEN=op://dev/pypi/credential 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_style = space 9 | indent_size = 2 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [*.py] 14 | indent_size = 4 15 | 16 | [*.{css,html,js,json,sass,scss,vue,yaml,yml}] 17 | indent_style = space 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - navigation 4 | --- 5 | 6 | # Cloudflare Images 7 | 8 | !!! warning "Cloudflare Images is a Paid Service" 9 | 10 | [Pricing](https://developers.cloudflare.com/images/pricing/): 11 | 12 | - $5 per month per 100k images stored 13 | - $1 per month per 100k images delivered 14 | 15 | ::: cloudflare_images.main.CloudflareImagesAPI 16 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | set dotenv-load 2 | 3 | # create .venv 4 | start: 5 | python -m venv .venv && \ 6 | source .venv/bin/activate && \ 7 | python -m pip install -U pip && \ 8 | python -m pip install -U \ 9 | --editable '.[dev]' \ 10 | --require-virtualenv \ 11 | --verbose 12 | 13 | dumpenv: 14 | op inject -i env.example -o .env 15 | 16 | # upload to pypi 17 | publish: 18 | python -m build && \ 19 | python -m twine upload dist/* -u __token__ -p $PYPI_TOKEN 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.11 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v4.6.0 7 | hooks: 8 | - id: check-added-large-files 9 | - id: end-of-file-fixer 10 | - id: trailing-whitespace 11 | - id: name-tests-test 12 | args: ["--django"] 13 | - repo: https://github.com/astral-sh/ruff-pre-commit 14 | # Ruff version. 15 | rev: v0.5.6 16 | hooks: 17 | # Run the linter. 18 | - id: ruff 19 | args: [ --fix ] 20 | # Run the formatter. 21 | - id: ruff-format 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "cloudflare-images" 3 | description = "Wrapper around Cloudflare Images API" 4 | version = "0.1.8" 5 | authors = [ 6 | {name="Marcelino Veloso III", email="hi@mv3.dev" } 7 | ] 8 | readme = "README.md" 9 | requires-python = ">=3.11" 10 | dependencies = [ 11 | "start-cloudflare >= 0.0.5", 12 | "httpx >= 0.24", 13 | ] 14 | 15 | [project.optional-dependencies] 16 | dev = [ 17 | "rich >= 13.3", 18 | "pytest >= 8.3", 19 | "pytest-cov >= 4.1", 20 | "build >= 1.0.3", 21 | "twine >= 4.0.2", 22 | ] 23 | 24 | [tool.pytest.ini_options] 25 | minversion = "8.3" 26 | addopts = "-ra -q --doctest-modules --cov" 27 | # filterwarnings = ["ignore::DeprecationWarning"] see pkg_resources 28 | testpaths = ["src"] 29 | 30 | [tool.ruff] 31 | line-length = 88 32 | 33 | [tool.ruff.lint] 34 | ignore = ["F401", "F403"] 35 | fixable = ["F", "E", "W", "I001"] 36 | select = ["F", "E", "W", "I001"] 37 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Setup Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.11" 17 | - name: Install Dependencies 18 | run: | 19 | pip install pre-commit 20 | pre-commit install-hooks 21 | - name: Lint with pre-commit 22 | run: pre-commit run --all-files 23 | test: 24 | needs: lint 25 | runs-on: ubuntu-latest 26 | strategy: 27 | matrix: 28 | python-version: ['3.11', '3.12'] 29 | steps: 30 | - uses: actions/checkout@v4 31 | - name: Set up Python ${{ matrix.python-version }} 32 | uses: actions/setup-python@v5 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | cache: pip 36 | - name: Install Python Dependencies 37 | run: python -m pip install --editable '.[dev]' 38 | - name: Test with Pytest 39 | run: pytest 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023, Marcelino G. Veloso III 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or 5 | without modification, are permitted provided that the following 6 | conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND 21 | CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, 22 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 23 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 25 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 26 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 27 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF 28 | USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED 29 | AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 30 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 31 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 32 | POSSIBILITY OF SUCH DAMAGE. 33 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: cloudflare-images Docs 2 | site_description: "Wrapper around Cloudflare Images API" 3 | site_url: https://mv3.dev 4 | site_author: Marcelino Veloso III 5 | repo_url: https://github.com/justmars/cloudflare-images 6 | theme: 7 | name: "material" 8 | features: 9 | - content.tabs.link 10 | palette: 11 | - media: "(prefers-color-scheme: light)" 12 | scheme: default 13 | toggle: 14 | icon: material/brightness-7 15 | name: Switch to dark mode 16 | - media: "(prefers-color-scheme: dark)" 17 | scheme: slate 18 | primary: indigo 19 | accent: pink 20 | toggle: 21 | icon: material/brightness-4 22 | name: Switch to light mode 23 | nav: 24 | - index.md 25 | extra: 26 | homepage: https://mv3.dev 27 | social: 28 | - icon: fontawesome/brands/github 29 | link: https://github.com/justmars 30 | name: justmars on Github 31 | - icon: fontawesome/brands/mastodon 32 | link: https://esq.social/@mv 33 | name: mv on Mastodon 34 | copyright: Copyright © Marcelino Veloso III 35 | plugins: 36 | - mkdocstrings: 37 | handlers: 38 | python: 39 | options: 40 | show_root_toc_entry: false 41 | show_category_heading: true 42 | show_source: true 43 | heading_level: 3 44 | - search: 45 | lang: en 46 | markdown_extensions: 47 | - attr_list 48 | - md_in_html 49 | - admonition 50 | - pymdownx.critic 51 | - pymdownx.caret 52 | - pymdownx.keys 53 | - pymdownx.mark 54 | - pymdownx.tilde 55 | - pymdownx.details 56 | - pymdownx.highlight: 57 | anchor_linenums: true 58 | auto_title: true 59 | - pymdownx.inlinehilite 60 | - pymdownx.snippets 61 | - pymdownx.superfences: 62 | custom_fences: 63 | - name: mermaid 64 | class: mermaid 65 | format: !!python/name:pymdownx.superfences.fence_code_format 66 | - pymdownx.emoji: 67 | emoji_index: !!python/name:materialx.emoji.twemoji 68 | emoji_generator: !!python/name:materialx.emoji.to_svg 69 | - pymdownx.tabbed: 70 | alternate_style: true 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .ruff_cache 3 | .vscode 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | 138 | # pytype static type analyzer 139 | .pytype/ 140 | 141 | # Cython debug symbols 142 | cython_debug/ 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cloudflare-images 2 | 3 | ![Github CI](https://github.com/justmars/cloudflare-images/actions/workflows/main.yml/badge.svg) 4 | 5 | Wrapper around Cloudflare Images API, with instructions to create a usable custom Django storage class such wrapper. 6 | 7 | ## Development 8 | 9 | See [documentation](https://justmars.github.io/cloudflare-images). 10 | 11 | 1. Run `just start` 12 | 2. Run `just dumpenv` 13 | 3. Run `pytest` 14 | 15 | Note: `pytest` will work only if **no** `.env` file exists with the included values. See docstrings. 16 | 17 | ## Changes 18 | 19 | ### Dec. 2, 2023 20 | 21 | - Compatibility: python 3.12 22 | - Compatibility: pydantic 2.5 23 | 24 | ### Initial 25 | 26 | - Removed: _Django_ as a dependency 27 | - Added: Instructions to create _Django_ custom storage class 28 | - Added: `.enable_batch()` 29 | - Added: `.list_images()` 30 | - Added: `.get_batch_token()` 31 | - Added: `.get_usage_statistics()` 32 | - Added: `.update_image()` 33 | - Added: `.v2` 34 | - Renamed: `.base_api` to `v1` 35 | - Renamed: `.get()` to `.get_image_details()` 36 | - Renamed: `.post()` to `.upload_image()` 37 | - Renamed: `.delete()` to `.delete_image()` 38 | - Renamed: `.upsert()` to `.delete_then_upload_image()` 39 | - Renamed: `CloudflareImagesAPIv1` to `CloudflareImagesAPI` 40 | 41 | ## Django Instructions 42 | 43 | Starting with `Django` 4.2, add a Custom `Storage` class to the `STORAGES` setting like so: 44 | 45 | ```py 46 | STORAGES = { # django 4.2 and above 47 | "default": { # default 48 | "BACKEND": "django.core.files.storage.FileSystemStorage", 49 | }, 50 | "staticfiles": { # default 51 | "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", 52 | }, 53 | "cloudflare_images": { # add location of custom storage class 54 | "BACKEND": "path.to.storageclass", 55 | }, 56 | } 57 | ``` 58 | 59 | The path to the custom storage class should [resemble](https://docs.djangoproject.com/en/dev/howto/custom-file-storage/#django.core.files.storage._open) the following: 60 | 61 | ```py 62 | from http import HTTPStatus 63 | 64 | import httpx 65 | from django.core.files.base import File 66 | from django.core.files.storage import Storage 67 | from django.utils.deconstruct import deconstructible 68 | 69 | from cloudflare_images import CloudflareImagesAPI 70 | 71 | 72 | @deconstructible 73 | class LimitedStorageCloudflareImages(Storage): 74 | def __init__(self): 75 | super().__init__() 76 | self.api = CloudflareImagesAPI() 77 | 78 | def __repr__(self): 79 | return "" 80 | 81 | def _open(self, name: str, mode="rb") -> File: 82 | return File(self.api.get(img_id=name), name=name) 83 | 84 | def _save(self, name: str, content: bytes) -> str: 85 | res = self.api.delete_then_upload_image(name, content) 86 | return self.api.url(img_id=res.json()["result"]["id"]) 87 | 88 | def get_valid_name(self, name): 89 | return name 90 | 91 | def get_available_name(self, name, max_length=None): 92 | return self.generate_filename(name) 93 | 94 | def generate_filename(self, filename): 95 | return filename 96 | 97 | def delete(self, name) -> httpx.Response: 98 | return self.api.delete(name) 99 | 100 | def exists(self, name: str) -> bool: 101 | res = self.api.get(name) 102 | if res.status_code == HTTPStatus.NOT_FOUND: 103 | return False 104 | elif res.status_code == HTTPStatus.OK: 105 | return True 106 | raise Exception("Image name found but http status code is not OK.") 107 | 108 | def listdir(self, path): 109 | raise NotImplementedError( 110 | "subclasses of Storage must provide a listdir() method" 111 | ) 112 | 113 | def size(self, name: str): 114 | return len(self.api.get(name).content) 115 | 116 | def url(self, name: str): 117 | return self.api.url(name) 118 | 119 | def url_variant(self, name: str, variant: str): 120 | return self.api.url(name, variant) 121 | 122 | def get_accessed_time(self, name): 123 | raise NotImplementedError( 124 | "subclasses of Storage must provide a get_accessed_time() method" 125 | ) 126 | 127 | def get_created_time(self, name): 128 | raise NotImplementedError( 129 | "subclasses of Storage must provide a get_created_time() method" 130 | ) 131 | 132 | def get_modified_time(self, name): 133 | raise NotImplementedError( 134 | "subclasses of Storage must provide a get_modified_time() method" 135 | ) 136 | ``` 137 | 138 | Can then define a [callable](https://docs.djangoproject.com/en/dev/topics/files/#using-a-callable) likeso: 139 | 140 | ```python title="For use in ImageField" 141 | from django.core.files.storage import storages 142 | 143 | 144 | def select_storage(is_remote_env: bool): 145 | return storages["cloudflare_images"] if is_remote_env else storages["default"] 146 | 147 | 148 | class MyModel(models.Model): 149 | my_img = models.ImageField(storage=select_storage) 150 | ``` 151 | 152 | Can also refer to it via: 153 | 154 | ```python title="Invocation" 155 | from django.core.files.storage import storages 156 | cf = storages["cloudflare_images"] 157 | 158 | # assume previous upload done 159 | id = 160 | 161 | # get image url, defaults to 'public' variant 162 | cf.url(id) 163 | 164 | # specified 'avatar' variant, assuming it was created in the Cloudflare Images dashboard / API 165 | cf.url_variant(id, 'avatar') 166 | ``` 167 | -------------------------------------------------------------------------------- /src/cloudflare_images/main.py: -------------------------------------------------------------------------------- 1 | from typing import Self 2 | from urllib.parse import urlencode 3 | 4 | import httpx 5 | from pydantic import Field 6 | from start_cloudflare import CF # type: ignore 7 | 8 | DEFAULT = "imagedelivery.net" 9 | HOST = f"https://{DEFAULT}" 10 | BATCH_HOST = f"https://batch.{DEFAULT}" 11 | V1 = "images/v1" 12 | V2 = "images/v2" 13 | 14 | 15 | class CloudflareImagesAPI(CF): 16 | """ 17 | Need to setup a Cloudflare Images account to use. See Cloudflare Images [docs](https://developers.cloudflare.com/images/cloudflare-images/). 18 | With required variables secured: 19 | 20 | Field in .env | Cloudflare API Credential | Where credential found 21 | :--|:--:|:-- 22 | `CF_ACCT_ID` | Account ID | `https://dash.cloudflare.com//images/images` 23 | `CF_IMG_HASH` | Account Hash | `https://dash.cloudflare.com//images/images` 24 | `CF_IMG_TOKEN` | API Secret | Generate / save via `https://dash.cloudflare.com//profile/api-tokens` 25 | 26 | Add secrets to .env file and use as follows: 27 | 28 | Examples: 29 | ```py title="Example Usage" linenums="1" 30 | >>> cf = CloudflareImagesAPI() # will error out since missing key values 31 | Traceback (most recent call last): 32 | pydantic_core._pydantic_core.ValidationError: 3 validation errors for CloudflareImagesAPI 33 | CF_ACCT_ID 34 | Field required [type=missing, input_value={}, input_type=dict] 35 | For further information visit https://errors.pydantic.dev/2.8/v/missing 36 | CF_IMG_HASH 37 | Field required [type=missing, input_value={}, input_type=dict] 38 | For further information visit https://errors.pydantic.dev/2.8/v/missing 39 | CF_IMG_TOKEN 40 | Field required [type=missing, input_value={}, input_type=dict] 41 | For further information visit https://errors.pydantic.dev/2.8/v/missing 42 | >>> import os 43 | >>> os.environ['CF_ACCT_ID'] = "ABC" 44 | >>> cf = CloudflareImagesAPI() # will error out since still missing other values 45 | Traceback (most recent call last): 46 | pydantic_core._pydantic_core.ValidationError: 2 validation errors for CloudflareImagesAPI 47 | CF_IMG_HASH 48 | Field required [type=missing, input_value={'CF_ACCT_ID': 'ABC'}, input_type=dict] 49 | For further information visit https://errors.pydantic.dev/2.8/v/missing 50 | CF_IMG_TOKEN 51 | Field required [type=missing, input_value={'CF_ACCT_ID': 'ABC'}, input_type=dict] 52 | For further information visit https://errors.pydantic.dev/2.8/v/missing 53 | >>> # we'll add all the values needed 54 | >>> os.environ['CF_IMG_HASH'], os.environ['CF_IMG_TOKEN'] = "DEF", "XYZ" 55 | >>> cf = CloudflareImagesAPI() # no longer errors out 56 | >>> CF.set_bearer_auth(cf.api_token) 57 | {'Authorization': 'Bearer XYZ'} 58 | >>> cf.v1 59 | 'https://api.cloudflare.com/client/v4/accounts/ABC/images/v1' 60 | >>> cf.base_delivery 61 | 'https://imagedelivery.net/DEF' 62 | >>> cf.url('hi-bob', 'w=400,sharpen=3') 63 | 'https://imagedelivery.net/DEF/hi-bob/w=400,sharpen=3' 64 | >>> from pathlib import Path 65 | >>> p = Path().cwd() / "img" / "screenshot.png" 66 | >>> p.exists() # Sample image found in `/img/screenshot.png` 67 | True 68 | >>> import io 69 | >>> img = io.BytesIO(p.read_bytes()) 70 | >>> type(img) 71 | 72 | >>> # Can now use img in `cf.post('sample_id', img)` 73 | ``` 74 | """ # noqa: E501 75 | 76 | account_id: str = Field( 77 | default=..., 78 | repr=False, 79 | title="Cloudflare Account ID", 80 | description="Overrides the base setting by making this mandatory.", 81 | validation_alias="CF_ACCT_ID", 82 | ) 83 | cf_img_hash: str = Field( 84 | default=..., 85 | repr=False, 86 | title="Cloudflare Image Hash", 87 | description="Assigned when you create a Cloudflare Images account", 88 | validation_alias="CF_IMG_HASH", 89 | ) 90 | api_token: str = Field( 91 | default=..., 92 | repr=False, 93 | title="Cloudflare Image API Token", 94 | description="Secure token to perform API operations", 95 | validation_alias="CF_IMG_TOKEN", 96 | ) 97 | client_api_ver: str = Field( 98 | default="v4", 99 | title="Cloudflare Client API Version", 100 | description="Used in the middle of the URL in API requests.", 101 | validation_alias="CLOUDFLARE_CLIENT_API_VERSION", 102 | ) 103 | timeout: int = Field(default=60, validation_alias="CF_IMG_TOKEN_TIMEOUT") 104 | is_batch: bool = Field( 105 | default=False, description="When True, will use a different host." 106 | ) 107 | 108 | @property 109 | def auth(self) -> dict: 110 | """Convenience shortcut to create an authorization header bearing the token.""" 111 | return CF.set_bearer_auth(self.api_token) 112 | 113 | @property 114 | def client(self): 115 | return httpx.Client(timeout=self.timeout) 116 | 117 | @property 118 | def v1(self) -> str: 119 | """Construct endpoint. See [formula](https://developers.cloudflare.com/images/cloudflare-images/api-request/). 120 | 121 | Examples: 122 | >>> import os 123 | >>> os.environ['CF_ACCT_ID'] = "ABC" 124 | >>> os.environ['CF_IMG_HASH'], os.environ['CF_IMG_TOKEN'] = "DEF", "XYZ" 125 | >>> cf = CloudflareImagesAPI() 126 | >>> cf.v1 127 | 'https://api.cloudflare.com/client/v4/accounts/ABC/images/v1' 128 | >>> cf.is_batch = True 129 | >>> cf.v1 130 | 'https://batch.imagedelivery.net/images/v1' 131 | 132 | Returns: 133 | str: URL endpoint to make requests with the Cloudflare-supplied credentials. 134 | """ 135 | if self.is_batch: 136 | # See https://developers.cloudflare.com/images/cloudflare-images/upload-images/images-batch/ 137 | return f"{BATCH_HOST}/{V1}" 138 | return self.add_account_endpoint(f"/{self.account_id}/{V1}") 139 | 140 | @property 141 | def v2(self) -> str: 142 | """See updated [list API endpoint](https://developers.cloudflare.com/api/operations/cloudflare-images-list-images-v2). 143 | 144 | Examples: 145 | >>> import os 146 | >>> os.environ['CF_ACCT_ID'] = "ABC" 147 | >>> os.environ['CF_IMG_HASH'], os.environ['CF_IMG_TOKEN'] = "DEF", "XYZ" 148 | >>> cf = CloudflareImagesAPI() 149 | >>> cf.v2 150 | 'https://api.cloudflare.com/client/v4/accounts/ABC/images/v2' 151 | >>> cf.is_batch = True 152 | >>> cf.v2 153 | 'https://batch.imagedelivery.net/images/v2' 154 | 155 | """ 156 | if self.is_batch: 157 | return f"{BATCH_HOST}/{V2}" 158 | return self.add_account_endpoint(f"/{self.account_id}/{V2}") 159 | 160 | @property 161 | def base_delivery(self): 162 | """Images are served with the following format: `https://imagedelivery.net///` 163 | 164 | This property constructs the first part: `https://imagedelivery.net/` 165 | 166 | See Cloudflare [docs](https://developers.cloudflare.com/images/cloudflare-images/serve-images/). 167 | 168 | Examples: 169 | >>> import os 170 | >>> os.environ['CF_ACCT_ID'] = "ABC" 171 | >>> os.environ['CF_IMG_HASH'], os.environ['CF_IMG_TOKEN'] = "DEF", "XYZ" 172 | >>> cf = CloudflareImagesAPI() 173 | >>> cf.base_delivery 174 | 'https://imagedelivery.net/DEF' 175 | """ # noqa: E501 176 | return "/".join([f"{HOST}", self.cf_img_hash]) 177 | 178 | def url(self, img_id: str, variant: str = "public") -> str: 179 | """Generates url based on the Cloudflare hash of the account. The `variant` is based on 180 | how these are customized on Cloudflare Images. See also flexible variant [docs](https://developers.cloudflare.com/images/cloudflare-images/transform/flexible-variants/) 181 | 182 | Examples: 183 | >>> import os 184 | >>> os.environ['CF_ACCT_ID'] = "ABC" 185 | >>> os.environ['CF_IMG_HASH'], os.environ['CF_IMG_TOKEN'] = "DEF", "XYZ" 186 | >>> cf = CloudflareImagesAPI() 187 | >>> cf.url('sample-img', 'avatar') 188 | 'https://imagedelivery.net/DEF/sample-img/avatar' 189 | 190 | Args: 191 | img_id (str): The uploaded ID 192 | variant (str, optional): The variant created in the Cloudflare Images dashboard. Defaults to "public". 193 | 194 | Returns: 195 | str: URL to display the request `img_id` with `variant`. 196 | """ # noqa: E501 197 | return "/".join([self.base_delivery, img_id, variant]) 198 | 199 | def get_usage_statistics(self) -> httpx.Response: 200 | """Fetch usage statistics details for Cloudflare Images. See [API](https://developers.cloudflare.com/api/operations/cloudflare-images-images-usage-statistics) 201 | 202 | Returns: 203 | httpx.Response: Response containing the counts for `allowed` and `current in the result key 204 | """ # noqa: E501 205 | url = f"{self.v1}/stats" 206 | return self.client.get(url=url, headers=self.auth) 207 | 208 | def get_batch_token(self) -> httpx.Response: 209 | """Get a token to use [Images batch API](https://developers.cloudflare.com/images/cloudflare-images/upload-images/images-batch/) for several requests in sequence bypassing Cloudflare's global API rate limits. 210 | Note that the token has a expiration time indicated in the response. 211 | 212 | Returns: 213 | httpx.Response: Response containing the batch `token` in the result key 214 | """ # noqa: E501 215 | return self.client.get(url=f"{self.v1}/batch_token", headers=self.auth) 216 | 217 | def get_image_details(self, img_id: str, *args, **kwargs) -> httpx.Response: 218 | """Issue httpx GET request to the image found in storage. Assuming request like 219 | `CFImage().get('target-img-id')`, returns a response with metadata: 220 | 221 | Examples: 222 | ```py title="Response object from Cloudflare Images" 223 | >>> # CFImage().get('target-img-id') commented out since hypothetical 224 | b'{ 225 | "result": { 226 | "id": "target-img-id", 227 | "filename": "target-img-id", 228 | "uploaded": "2023-02-20T09:09:41.755Z", 229 | "requireSignedURLs": false, 230 | "variants": [ 231 | "https://imagedelivery.net///public", 232 | "https://imagedelivery.net///cover", 233 | "https://imagedelivery.net///avatar", 234 | "https://imagedelivery.net///uniform" 235 | ] 236 | }, 237 | "success": true, 238 | "errors": [], 239 | "messages": [] 240 | }' 241 | ``` 242 | """ 243 | return self.client.get( 244 | url=f"{self.v1}/{img_id}", headers=self.auth, *args, **kwargs 245 | ) 246 | 247 | def update_image(self, img_id: str, *args, **kwargs) -> httpx.Response: 248 | """Update image access control. On access control change, all copies of the image are purged from cache. 249 | 250 | Issue httpx [PATCH](https://developers.cloudflare.com/api/operations/cloudflare-images-update-image) request to the image. 251 | """ # noqa: E501 252 | return self.client.patch( 253 | url=f"{self.v1}/{img_id}", headers=self.auth, *args, **kwargs 254 | ) 255 | 256 | def delete_image(self, img_id: str, *args, **kwargs) -> httpx.Response: 257 | """Issue httpx [DELETE](https://developers.cloudflare.com/images/cloudflare-images/transform/delete-images/) request to the image.""" # noqa: E501 258 | return self.client.delete( 259 | url=f"{self.v1}/{img_id}", 260 | headers=self.auth, 261 | *args, 262 | **kwargs, 263 | ) 264 | 265 | def upload_image(self, img_id: str, img: bytes, *args, **kwargs) -> httpx.Response: 266 | """Issue httpx [POST](https://developers.cloudflare.com/images/cloudflare-images/upload-images/upload-via-url/) request to upload image.""" # noqa: E501 267 | return self.client.post( 268 | url=self.v1, 269 | headers=self.auth, 270 | data={"id": img_id}, 271 | files={"file": (img_id, img)}, 272 | *args, 273 | **kwargs, 274 | ) 275 | 276 | def delete_then_upload_image(self, img_id: str, img: bytes) -> httpx.Response: 277 | """Ensures a unique id name by first deleting the `img_id` from storage and then 278 | uploading the `img`.""" 279 | self.delete_image(img_id) 280 | return self.upload_image(img_id, img) 281 | 282 | def list_images( 283 | self, 284 | per_page: int = 1000, 285 | sort_order: str = "desc", 286 | continuation_token: str | None = None, 287 | ) -> httpx.Response: 288 | """See [list images API](https://developers.cloudflare.com/api/operations/cloudflare-images-list-images-v2). 289 | 290 | Args: 291 | per_page (int, optional): Number of items per page (10 to 10,000). Defaults to 1000. 292 | sort_order (str, optional): Sorting order by upload time (asc | desc). Defaults to "desc". 293 | continuation_token (str | None, optional): Continuation token for a next page. List images V2 returns continuation_token. Defaults to None. 294 | 295 | Returns: 296 | httpx.Response: Contains top-level fields for `success`, `errors`, `messages` and the `result`. 297 | """ # noqa: E501 298 | if per_page < 10 or per_page > 10000: 299 | raise Exception(f"Improper {per_page=}") 300 | if sort_order not in ["asc", "desc"]: 301 | raise Exception(f"Improper {sort_order=}") 302 | params = {"per_page": per_page, "sort_order": sort_order} 303 | 304 | if continuation_token: 305 | params["continuation_token"] = continuation_token 306 | qs = urlencode(params) 307 | 308 | return self.client.get(url=f"{self.v2}?{qs}", headers=self.auth) 309 | 310 | def create_batch_api(self) -> Self: 311 | """Use the instance to generate a batch token then return a new 312 | API instance where the token is used, e.g.: 313 | 314 | ```py 315 | raw = CloudflareImagesAPI() 316 | api = cf.create_batch_api() 317 | ``` 318 | 319 | Should now be able to use batch.upload_image(), batch.list_images() instead of 320 | the raw.upload_image(), etc. methods. See the [docs](https://developers.cloudflare.com/images/cloudflare-images/upload-images/images-batch/) 321 | """ # noqa: E501 322 | try: 323 | res = self.get_batch_token() 324 | data = res.json() 325 | token = data["result"]["token"] 326 | except Exception as e: 327 | raise Exception(f"Could not generate batch token; {e=}") 328 | return CloudflareImagesAPI(CF_IMG_TOKEN=token, is_batch=True) # type: ignore # noqa: E501 329 | --------------------------------------------------------------------------------