├── .coveragerc ├── .editorconfig ├── .git-blame-ignore-revs ├── .github └── workflows │ ├── pypi-publish.yml │ └── tests.yml ├── .gitignore ├── AUTHORS ├── CHANGES.rst ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── conf.py ├── index.rst └── requirements.txt ├── flask_iiif ├── __init__.py ├── api.py ├── cache │ ├── __init__.py │ ├── cache.py │ ├── redis.py │ └── simple.py ├── config.py ├── decorators.py ├── errors.py ├── restful.py ├── signals.py └── utils.py ├── pyproject.toml ├── run-tests.sh ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── helpers.py ├── test_context_template.py ├── test_ext.py ├── test_image_redis_cache.py ├── test_image_simple_cache.py ├── test_multimedia_image_api.py ├── test_restful_api.py ├── test_restful_api_signals.py └── test_restful_api_with_redis.py /.coveragerc: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-IIIF 2 | # Copyright (C) 2014 CERN. 3 | # 4 | # Flask-IIIF is free software; you can redistribute it and/or modify 5 | # it under the terms of the Revised BSD License; see LICENSE file for 6 | # more details. 7 | 8 | [run] 9 | source = flask_iiif 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | charset = utf-8 9 | 10 | # RST files (used by sphinx) 11 | [*.rst] 12 | indent_size = 4 13 | 14 | # CSS, HTML, JS, JSON, YML 15 | [*.{css,html,js,json,yml}] 16 | indent_size = 2 17 | 18 | # Matches the exact files either package.json or .github/workflows/*.yml 19 | [{package.json, .github/workflows/*.yml}] 20 | indent_size = 2 21 | 22 | # Dockerfile 23 | [Dockerfile] 24 | indent_size = 4 25 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | 8315456c176b57deec3849ee9d5a9d64ab0c41f5 2 | 09dd96830d84e1f2efe05072de0b327368b3f685 3 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Invenio. 4 | # Copyright (C) 2020 CERN. 5 | # 6 | # Invenio is free software; you can redistribute it and/or modify it 7 | # under the terms of the MIT License; see LICENSE file for more details 8 | 9 | name: Publish 10 | 11 | on: 12 | push: 13 | tags: 14 | - v* 15 | 16 | jobs: 17 | Publish: 18 | runs-on: ubuntu-20.04 19 | 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v2 23 | 24 | - name: Set up Python 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: 3.8 28 | 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install setuptools wheel babel 33 | 34 | - name: Build package 35 | run: python setup.py sdist bdist_wheel 36 | 37 | - name: Publish on PyPI 38 | uses: pypa/gh-action-pypi-publish@v1.3.1 39 | with: 40 | user: __token__ 41 | # The token is provided by the inveniosoftware organization 42 | password: ${{ secrets.pypi_token }} 43 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Invenio. 4 | # Copyright (C) 2020-2024 CERN. 5 | # Copyright (C) 2022 Graz University of Technology. 6 | # 7 | # Invenio is free software; you can redistribute it and/or modify it 8 | # under the terms of the MIT License; see LICENSE file for more details. 9 | 10 | name: CI 11 | 12 | on: 13 | push: 14 | branches: 15 | - master 16 | pull_request: 17 | branches: 18 | - master 19 | schedule: 20 | # * is a special character in YAML so you have to quote this string 21 | - cron: '0 3 * * 6' 22 | workflow_dispatch: 23 | inputs: 24 | reason: 25 | description: 'Reason' 26 | required: false 27 | default: 'Manual trigger' 28 | 29 | jobs: 30 | Tests: 31 | runs-on: ubuntu-24.04 32 | strategy: 33 | matrix: 34 | python-version: ["3.9", "3.12"] 35 | requirements-level: [pypi] 36 | cache-service: [redis] 37 | 38 | env: 39 | CACHE: ${{ matrix.cache-service }} 40 | EXTRAS: tests 41 | steps: 42 | - name: Checkout 43 | uses: actions/checkout@v4 44 | 45 | - name: Set up Python ${{ matrix.python-version }} 46 | uses: actions/setup-python@v5 47 | with: 48 | python-version: ${{ matrix.python-version }} 49 | cache: pip 50 | cache-dependency-path: setup.cfg 51 | 52 | - name: Install dependencies 53 | run: | 54 | pip install ".[$EXTRAS]" 55 | pip freeze 56 | docker version 57 | 58 | - name: Run tests 59 | run: ./run-tests.sh 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | bin/ 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | .eggs/ 23 | 24 | # Installer logs 25 | pip-log.txt 26 | pip-delete-this-directory.txt 27 | 28 | # Unit test / coverage reports 29 | .tox/ 30 | .coverage 31 | .cache 32 | nosetests.xml 33 | coverage.xml 34 | 35 | # Translations 36 | *.mo 37 | 38 | # Mr Developer 39 | .mr.developer.cfg 40 | .project 41 | .pydevproject 42 | 43 | # Rope 44 | .ropeproject 45 | 46 | # Django stuff: 47 | *.log 48 | *.pot 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # IDE 54 | .idea 55 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Authors 2 | ======= 3 | 4 | Flask-IIIF was originally developed for use in 5 | `Invenio `_ digital library software. 6 | 7 | Contributors 8 | ^^^^^^^^^^^^ 9 | 10 | - Alexander Ioannidis 11 | - Chiara Bigarella 12 | - Dan Granville 13 | - Georgios Lignos 14 | - Harris Tzovanakis 15 | - Jan Okraska 16 | - Jiri Kuncar 17 | - Karolina Przerwa 18 | - Lars Holm Nielsen 19 | - Leonardo Rossi 20 | - Nicola Tarocco 21 | - Orestis Melkonian 22 | - Pawel Zembrzuski 23 | - Rokas Maciulaitis 24 | - Tibor Simko 25 | - Øystein Blixhavn 26 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | Version 1.2.0 (released 2024-12-12) 5 | 6 | - fix: docs reference target not found 7 | - setup: remove werkzeug pin 8 | 9 | Version v1.1.1 (released 2024-11-05) 10 | 11 | - setup: remove werkzeug pin 12 | 13 | Version v1.1.0 (released 2024-08-26) 14 | 15 | - resize: added upscaling params for h & w 16 | 17 | Here you can see the full list of changes between each Flask-IIIF 18 | release. 19 | 20 | Version 1.0.0 (released 2023-10-27) 21 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 22 | 23 | - api: fix resize of greyscale source images 24 | - bump flask to >=2.0, pin Werkzeug <3.0 25 | - fix deprecated use of ``attachment_filename`` 26 | 27 | Version 0.6.3 (released 2022-07-08) 28 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 29 | 30 | - remove custom resizing of GIF 31 | 32 | Version 0.6.2 (released 2021-12-09) 33 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 34 | 35 | - Removes encoding of key, due to incompatibility with python3 36 | - Makes temp folder location regarding the generation of gif files configurable 37 | - Removes upper pinning of Werkzeug 38 | - Closes image after usage to avoid leaking memory during api requests 39 | - Migrates CI to gh-actions 40 | - Updates copyright and contributors 41 | 42 | Version 0.6.1 (released 2020-03-19) 43 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 44 | 45 | - Added missing ``app`` argument for the ``flask_iiif.cache.ImageCache`` 46 | constructor. 47 | 48 | Version 0.6.0 (released 2020-03-13) 49 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 50 | 51 | - Removes support for Python 2.7 52 | - Image API specification fixes 53 | - Support both ``gray`` and ``grey`` as valid qualities. 54 | - Rotations are now performed clock-wise. 55 | - No padding added to resized images. 56 | - Better support for image extension conversions (``.tif/.tiff``, ``.jp2``). 57 | - Pillow bumped to v4.0 58 | - Introduced ``IIIF_CACHE_IGNORE_ERRORS`` config variable to allow ignoring 59 | cache access exceptions. 60 | - Changed ``current_iiif.cache`` from a callable function to a Werkzeug 61 | ``cached_property``. 62 | 63 | Version 0.5.3 (released 2019-11-21) 64 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 65 | 66 | - Adds Last-Lodified and If-Modified-Since to imageapi 67 | - Removes warning message for LocalProxy 68 | - Fixes werkzeug deprecation warning 69 | 70 | Version 0.5.2 (released 2019-07-25) 71 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 72 | 73 | - Sets Redis cache prefix 74 | - Fixes cache control headers 75 | 76 | Version 0.5.1 (released 2019-05-23) 77 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 78 | 79 | - Fixes syntax error in documentation 80 | - Fixes import sorting 81 | 82 | Version 0.5.0 (released 2018-05-18) 83 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 84 | + Fixes 85 | 86 | - wrong ratio calculation for best fit 87 | 88 | + New features 89 | 90 | - adds black background to requested best fit thumbnail or gif 91 | if the image does not cover the whole window of requested size 92 | 93 | 94 | Version 0.4.0 (released 2018-04-17) 95 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 96 | 97 | - Fixes unicode filename issues. 98 | 99 | - Changes default resampling algorithm to BICUBIC for better image quality. 100 | 101 | - Adds support for _external, _scheme etc parameters for iiif_image_url. 102 | 103 | 104 | Version 0.3.2 (released 2018-04-09) 105 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 106 | 107 | + Security 108 | 109 | - Fixed missing API protection on image metadata endpoint. 110 | 111 | Version 0.3.1 (released 2017-08-18) 112 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 113 | 114 | - Deployment changes. 115 | 116 | Version 0.3.0 (released 2017-08-17) 117 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 118 | 119 | + New features 120 | 121 | - Adds TIFF image support to the default config. 122 | 123 | - Adds proper GIF resize. 124 | 125 | - Adds optional Redis cache. 126 | 127 | + Notes 128 | 129 | - Minimum Pillow version is update to 3.4. 130 | 131 | Version 0.2.0 (released 2015-05-22) 132 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 133 | 134 | + Incompatible changes 135 | 136 | - Removes `uuid_to_path_handler` callback. 137 | 138 | - Updates error classes names (MultimediaImageResizeError and 139 | MultimediaImageCropError). 140 | 141 | + New features 142 | 143 | - Adds image information request endpoint `/info.json` which 144 | contains available metadata for the image, such as the full height 145 | and width, and the functionality available for the image, such as 146 | the formats in which it may be retrieved, and the IIIF profile 147 | used. 148 | 149 | - Adds new signals to REST API that permits to have access before 150 | and after process of the request as well as after the validation 151 | of IIIF. 152 | 153 | - Adds a configurable decorator to the REST API which can be 154 | configure with the `api_decorator_handler`. 155 | 156 | - Adds the `uuid_to_image_opener_handler` which can handle both 157 | `fullpath` and `bytestream` as source. 158 | 159 | + Improved features 160 | 161 | - Improves the initialisation of the REST API by adding a 162 | possibility to override the default API prefix 163 | `/api/multimedia/image/`. 164 | 165 | - Adds better testing cases and increases the overall test 166 | efficiency. 167 | 168 | + Notes 169 | 170 | - The decorator can be used to restrict access to the REST API. 171 | 172 | Version 0.1.0 (released 2015-04-28) 173 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 174 | 175 | - Initial public release. 176 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Bug reports, feature requests, and other contributions are welcome. 5 | If you find a demonstrable problem that is caused by the code of this 6 | library, please: 7 | 8 | 1. Search for `already reported problems 9 | `_. 10 | 2. Check if the issue has been fixed or is still reproducible on the 11 | latest `master` branch. 12 | 3. Create an issue with **a test case**. 13 | 14 | If you create a feature branch, you can run the tests to ensure everything is 15 | operating correctly: 16 | 17 | .. code-block:: console 18 | 19 | $ ./run-tests.sh 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Flask-IIIF is free software; you can redistribute it and/or modify it 2 | under the terms of the Revised BSD License quoted below. 3 | 4 | Copyright (C) 2014-2020 CERN. 5 | Copyright (C) 2020 data-futures. 6 | 7 | All rights reserved. 8 | 9 | Redistribution and use in source and binary forms, with or without 10 | modification, are permitted provided that the following conditions are 11 | met: 12 | 13 | * Redistributions of source code must retain the above copyright 14 | notice, this list of conditions and the following disclaimer. 15 | 16 | * Redistributions in binary form must reproduce the above copyright 17 | notice, this list of conditions and the following disclaimer in the 18 | documentation and/or other materials provided with the distribution. 19 | 20 | * Neither the name of the copyright holder nor the names of its 21 | contributors may be used to endorse or promote products derived from 22 | this software without specific prior written permission. 23 | 24 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 25 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 26 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 27 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 28 | HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 29 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 30 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS 31 | OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 32 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR 33 | TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE 34 | USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH 35 | DAMAGE. 36 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014-2024 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | 10 | include *.py *.sh 11 | include .coveragerc .travis.yml pytest.ini run-tests.sh 12 | include .editorconfig 13 | include README.rst CHANGES.rst CONTRIBUTING.rst AUTHORS LICENSE MANIFEST.in 14 | include docs/*.rst docs/*.py docs/Makefile docs/requirements.txt 15 | include tests/*.py 16 | recursive-include flask_iiif *.py 17 | recursive-include .github/workflows *.yml 18 | include .git-blame-ignore-revs 19 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Flask-IIIF 3 | ============ 4 | 5 | .. image:: https://github.com/inveniosoftware/flask-iiif/workflows/CI/badge.svg 6 | :target: https://github.com/inveniosoftware/flask-iiif/actions 7 | 8 | .. image:: https://img.shields.io/coveralls/inveniosoftware/flask-iiif.svg 9 | :target: https://coveralls.io/r/inveniosoftware/flask-iiif 10 | 11 | .. image:: https://img.shields.io/github/tag/inveniosoftware/flask-iiif.svg 12 | :target: https://github.com/inveniosoftware/flask-iiif/releases 13 | 14 | .. image:: https://img.shields.io/pypi/dm/flask-iiif.svg 15 | :target: https://pypi.python.org/pypi/flask-iiif 16 | 17 | .. image:: https://img.shields.io/github/license/inveniosoftware/flask-iiif.svg 18 | :target: https://github.com/inveniosoftware/flask-iiif/blob/master/LICENSE 19 | 20 | About 21 | ===== 22 | 23 | Flask-IIIF is a Flask extension permitting easy integration with the 24 | International Image Interoperability Framework (IIIF) API standards. 25 | 26 | Installation 27 | ============ 28 | 29 | Flask-IIIF is on PyPI so all you need is: :: 30 | 31 | pip install Flask-IIIF 32 | 33 | Documentation 34 | ============= 35 | 36 | Documentation is readable at http://flask-iiif.readthedocs.io or can be 37 | built using Sphinx: :: 38 | 39 | git submodule init 40 | git submodule update 41 | pip install Sphinx 42 | python setup.py build_sphinx 43 | 44 | Testing 45 | ======= 46 | Running the test suite is as simple as: :: 47 | 48 | python setup.py test 49 | 50 | or, to also show code coverage: :: 51 | 52 | ./run-tests.sh 53 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | 10 | # You can set these variables from the command line. 11 | SPHINXOPTS = 12 | SPHINXBUILD = sphinx-build 13 | PAPER = 14 | BUILDDIR = _build 15 | 16 | # User-friendly check for sphinx-build 17 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 18 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 19 | endif 20 | 21 | # Internal variables. 22 | PAPEROPT_a4 = -D latex_paper_size=a4 23 | PAPEROPT_letter = -D latex_paper_size=letter 24 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 25 | # the i18n builder cannot share the environment and doctrees with the others 26 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 27 | 28 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 29 | 30 | help: 31 | @echo "Please use \`make ' where is one of" 32 | @echo " html to make standalone HTML files" 33 | @echo " dirhtml to make HTML files named index.html in directories" 34 | @echo " singlehtml to make a single large HTML file" 35 | @echo " pickle to make pickle files" 36 | @echo " json to make JSON files" 37 | @echo " htmlhelp to make HTML files and a HTML help project" 38 | @echo " qthelp to make HTML files and a qthelp project" 39 | @echo " devhelp to make HTML files and a Devhelp project" 40 | @echo " epub to make an epub" 41 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 42 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 43 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 44 | @echo " text to make text files" 45 | @echo " man to make manual pages" 46 | @echo " texinfo to make Texinfo files" 47 | @echo " info to make Texinfo files and run them through makeinfo" 48 | @echo " gettext to make PO message catalogs" 49 | @echo " changes to make an overview of all changed/added/deprecated items" 50 | @echo " xml to make Docutils-native XML files" 51 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 52 | @echo " linkcheck to check all external links for integrity" 53 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 54 | 55 | clean: 56 | rm -rf $(BUILDDIR)/* 57 | 58 | html: 59 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 60 | @echo 61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 62 | 63 | coverage: 64 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 65 | @echo 66 | @echo "Build finished. The coverage pages are in $(BUILDDIR)/coverage/python.txt." 67 | 68 | dirhtml: 69 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 70 | @echo 71 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 72 | 73 | singlehtml: 74 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 75 | @echo 76 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 77 | 78 | pickle: 79 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 80 | @echo 81 | @echo "Build finished; now you can process the pickle files." 82 | 83 | json: 84 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 85 | @echo 86 | @echo "Build finished; now you can process the JSON files." 87 | 88 | htmlhelp: 89 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 90 | @echo 91 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 92 | ".hhp project file in $(BUILDDIR)/htmlhelp." 93 | 94 | qthelp: 95 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 96 | @echo 97 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 98 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 99 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Flask-IIIF.qhcp" 100 | @echo "To view the help file:" 101 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Flask-IIIF.qhc" 102 | 103 | devhelp: 104 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 105 | @echo 106 | @echo "Build finished." 107 | @echo "To view the help file:" 108 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Flask-IIIF" 109 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Flask-IIIF" 110 | @echo "# devhelp" 111 | 112 | epub: 113 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 114 | @echo 115 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 116 | 117 | latex: 118 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 119 | @echo 120 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 121 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 122 | "(use \`make latexpdf' here to do that automatically)." 123 | 124 | latexpdf: 125 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 126 | @echo "Running LaTeX files through pdflatex..." 127 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 128 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 129 | 130 | latexpdfja: 131 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 132 | @echo "Running LaTeX files through platex and dvipdfmx..." 133 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 134 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 135 | 136 | text: 137 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 138 | @echo 139 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 140 | 141 | man: 142 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 143 | @echo 144 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 145 | 146 | texinfo: 147 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 148 | @echo 149 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 150 | @echo "Run \`make' in that directory to run these through makeinfo" \ 151 | "(use \`make info' here to do that automatically)." 152 | 153 | info: 154 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 155 | @echo "Running Texinfo files through makeinfo..." 156 | make -C $(BUILDDIR)/texinfo info 157 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 158 | 159 | gettext: 160 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 161 | @echo 162 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 163 | 164 | changes: 165 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 166 | @echo 167 | @echo "The overview file is in $(BUILDDIR)/changes." 168 | 169 | linkcheck: 170 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 171 | @echo 172 | @echo "Link check complete; look for any errors in the above output " \ 173 | "or in $(BUILDDIR)/linkcheck/output.txt." 174 | 175 | doctest: 176 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 177 | @echo "Testing of doctests in the sources finished, look at the " \ 178 | "results in $(BUILDDIR)/doctest/output.txt." 179 | 180 | xml: 181 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 182 | @echo 183 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 184 | 185 | pseudoxml: 186 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 187 | @echo 188 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 189 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014-2020 CERN. 5 | # Copyright (C) 2022-2024 Graz University of Technology. 6 | # 7 | # Flask-IIIF is free software; you can redistribute it and/or modify 8 | # it under the terms of the Revised BSD License; see LICENSE file for 9 | # more details. 10 | 11 | """Sphinx configuration.""" 12 | 13 | from __future__ import print_function 14 | 15 | from flask_iiif import __version__ 16 | 17 | # -- General configuration ------------------------------------------------ 18 | 19 | # If your documentation needs a minimal Sphinx version, state it here. 20 | # needs_sphinx = '1.0' 21 | 22 | # Do not warn on external images. 23 | suppress_warnings = ["image.nonlocal_uri"] 24 | 25 | # Add any Sphinx extension module names here, as strings. They can be 26 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 27 | # ones. 28 | extensions = [ 29 | "sphinx.ext.autodoc", 30 | "sphinx.ext.coverage", 31 | "sphinx.ext.doctest", 32 | "sphinx.ext.intersphinx", 33 | "sphinx.ext.viewcode", 34 | ] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ["_templates"] 38 | 39 | # The suffix(es) of source filenames. 40 | # You can specify multiple suffix as a list of string: 41 | # source_suffix = ['.rst', '.md'] 42 | source_suffix = ".rst" 43 | 44 | # The encoding of source files. 45 | # source_encoding = 'utf-8-sig' 46 | 47 | # The master toctree document. 48 | master_doc = "index" 49 | 50 | # General information about the project. 51 | project = "Flask-IIIF" 52 | copyright = "2014-2020, CERN" 53 | author = "CERN" 54 | 55 | # The version info for the project you're documenting, acts as replacement for 56 | # |version| and |release|, also used in various other places throughout the 57 | # built documents. 58 | # 59 | # The short X.Y version. 60 | 61 | # The full version, including alpha/beta/rc tags. 62 | release = __version__ 63 | 64 | # The language for content autogenerated by Sphinx. Refer to documentation 65 | # for a list of supported languages. 66 | # 67 | # This is also used if you do content translation via gettext catalogs. 68 | # Usually you set "language" from the command line for these cases. 69 | language = "en" 70 | 71 | # There are two options for replacing |today|: either, you set today to some 72 | # non-false value, then it is used: 73 | # today = '' 74 | # Else, today_fmt is used as the format for a strftime call. 75 | # today_fmt = '%B %d, %Y' 76 | 77 | # List of patterns, relative to source directory, that match files and 78 | # directories to ignore when looking for source files. 79 | exclude_patterns = [] 80 | 81 | # The reST default role (used for this markup: `text`) to use for all 82 | # documents. 83 | # default_role = None 84 | 85 | # If true, '()' will be appended to :func: etc. cross-reference text. 86 | # add_function_parentheses = True 87 | 88 | # If true, the current module name will be prepended to all description 89 | # unit titles (such as .. function::). 90 | # add_module_names = True 91 | 92 | # If true, sectionauthor and moduleauthor directives will be shown in the 93 | # output. They are ignored by default. 94 | # show_authors = False 95 | 96 | # The name of the Pygments (syntax highlighting) style to use. 97 | pygments_style = "sphinx" 98 | 99 | # A list of ignored prefixes for module index sorting. 100 | # modindex_common_prefix = [] 101 | 102 | # If true, keep warnings as "system message" paragraphs in the built documents. 103 | # keep_warnings = False 104 | 105 | # If true, `todo` and `todoList` produce output, else they produce nothing. 106 | todo_include_todos = False 107 | 108 | 109 | # -- Options for HTML output ---------------------------------------------- 110 | html_theme = "alabaster" 111 | 112 | html_theme_options = { 113 | "description": "Flask-IIIF extension provides easy IIIF API standard integration.", 114 | "github_user": "inveniosoftware", 115 | "github_repo": "flask-iiif", 116 | "github_button": False, 117 | "github_banner": True, 118 | "show_powered_by": False, 119 | "extra_nav_links": { 120 | "flask-iiif@GitHub": "https://github.com/inveniosoftware/flask-iiif", 121 | "flask-iiif@PyPI": "https://pypi.python.org/pypi/flask-iiif/", 122 | }, 123 | } 124 | 125 | # The theme to use for HTML and HTML Help pages. See the documentation for 126 | # a list of builtin themes. 127 | 128 | # Theme options are theme-specific and customize the look and feel of a theme 129 | # further. For a list of options available for each theme, see the 130 | # documentation. 131 | # html_theme_options = {} 132 | 133 | # Add any paths that contain custom themes here, relative to this directory. 134 | # html_theme_path = [] 135 | 136 | # The name for this set of Sphinx documents. If None, it defaults to 137 | # " v documentation". 138 | # html_title = None 139 | 140 | # A shorter title for the navigation bar. Default is the same as html_title. 141 | # html_short_title = None 142 | 143 | # The name of an image file (relative to this directory) to place at the top 144 | # of the sidebar. 145 | # html_logo = None 146 | 147 | # The name of an image file (within the static path) to use as favicon of the 148 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 149 | # pixels large. 150 | # html_favicon = None 151 | 152 | # Add any paths that contain custom static files (such as style sheets) here, 153 | # relative to this directory. They are copied after the builtin static files, 154 | # so a file named "default.css" will overwrite the builtin "default.css". 155 | # html_static_path = ['_static'] 156 | 157 | # Add any extra paths that contain custom files (such as robots.txt or 158 | # .htaccess) here, relative to this directory. These files are copied 159 | # directly to the root of the documentation. 160 | # html_extra_path = [] 161 | 162 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 163 | # using the given strftime format. 164 | # html_last_updated_fmt = '%b %d, %Y' 165 | 166 | # If true, SmartyPants will be used to convert quotes and dashes to 167 | # typographically correct entities. 168 | # html_use_smartypants = True 169 | 170 | # Custom sidebar templates, maps document names to template names. 171 | html_sidebars = { 172 | "**": [ 173 | "about.html", 174 | "navigation.html", 175 | "relations.html", 176 | "searchbox.html", 177 | "donate.html", 178 | ] 179 | } 180 | 181 | # Additional templates that should be rendered to pages, maps page names to 182 | # template names. 183 | # html_additional_pages = {} 184 | 185 | # If false, no module index is generated. 186 | # html_domain_indices = True 187 | 188 | # If false, no index is generated. 189 | # html_use_index = True 190 | 191 | # If true, the index is split into individual pages for each letter. 192 | # html_split_index = False 193 | 194 | # If true, links to the reST sources are added to the pages. 195 | # html_show_sourcelink = True 196 | 197 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 198 | # html_show_sphinx = True 199 | 200 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 201 | # html_show_copyright = True 202 | 203 | # If true, an OpenSearch description file will be output, and all pages will 204 | # contain a tag referring to it. The value of this option must be the 205 | # base URL from which the finished HTML is served. 206 | # html_use_opensearch = '' 207 | 208 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 209 | # html_file_suffix = None 210 | 211 | # Language to be used for generating the HTML full-text search index. 212 | # Sphinx supports the following languages: 213 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 214 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 215 | # html_search_language = 'en' 216 | 217 | # A dictionary with options for the search language support, empty by default. 218 | # Now only 'ja' uses this config value 219 | # html_search_options = {'type': 'default'} 220 | 221 | # The name of a javascript file (relative to the configuration directory) that 222 | # implements a search results scorer. If empty, the default will be used. 223 | # html_search_scorer = 'scorer.js' 224 | 225 | # Output file base name for HTML help builder. 226 | htmlhelp_basename = "flask-iiif_namedoc" 227 | 228 | # -- Options for LaTeX output --------------------------------------------- 229 | 230 | latex_elements = { 231 | # The paper size ('letterpaper' or 'a4paper'). 232 | #'papersize': 'letterpaper', 233 | # The font size ('10pt', '11pt' or '12pt'). 234 | #'pointsize': '10pt', 235 | # Additional stuff for the LaTeX preamble. 236 | #'preamble': '', 237 | # Latex figure (float) alignment 238 | #'figure_align': 'htbp', 239 | } 240 | 241 | # Grouping the document tree into LaTeX files. List of tuples 242 | # (source start file, target name, title, 243 | # author, documentclass [howto, manual, or own class]). 244 | latex_documents = [ 245 | (master_doc, "Flask-IIIF.tex", "Flask-IIIF Documentation", "CERN", "manual"), 246 | ] 247 | 248 | # The name of an image file (relative to this directory) to place at the top of 249 | # the title page. 250 | # latex_logo = None 251 | 252 | # For "manual" documents, if this is true, then toplevel headings are parts, 253 | # not chapters. 254 | # latex_use_parts = False 255 | 256 | # If true, show page references after internal links. 257 | # latex_show_pagerefs = False 258 | 259 | # If true, show URL addresses after external links. 260 | # latex_show_urls = False 261 | 262 | # Documents to append as an appendix to all manuals. 263 | # latex_appendices = [] 264 | 265 | # If false, no module index is generated. 266 | # latex_domain_indices = True 267 | 268 | 269 | # -- Options for manual page output --------------------------------------- 270 | 271 | # One entry per manual page. List of tuples 272 | # (source start file, name, description, authors, manual section). 273 | man_pages = [(master_doc, "flask-iiif", "Flask-IIIF Documentation", [author], 1)] 274 | 275 | # If true, show URL addresses after external links. 276 | # man_show_urls = False 277 | 278 | 279 | # -- Options for Texinfo output ------------------------------------------- 280 | 281 | # Grouping the document tree into Texinfo files. List of tuples 282 | # (source start file, target name, title, author, 283 | # dir menu entry, description, category) 284 | texinfo_documents = [ 285 | ( 286 | master_doc, 287 | "Flask-IIIF", 288 | "Flask-IIIF Documentation", 289 | author, 290 | "flask-iiif", 291 | "Flask-IIIF extension provides easy IIIF API standard integration.", 292 | "Miscellaneous", 293 | ), 294 | ] 295 | 296 | # Documents to append as an appendix to all manuals. 297 | # texinfo_appendices = [] 298 | 299 | # If false, no module index is generated. 300 | # texinfo_domain_indices = True 301 | 302 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 303 | # texinfo_show_urls = 'footnote' 304 | 305 | # If true, do not generate a @detailmenu in the "Top" node's menu. 306 | # texinfo_no_detailmenu = False 307 | 308 | # Example configuration for intersphinx: refer to the Python standard library. 309 | intersphinx_mapping = { 310 | "flask": ("https://flask.readthedocs.io/", None), 311 | "werkzeug": ("https://werkzeug.readthedocs.io/", None), 312 | "python": ("https://docs.python.org/2.7/", None), 313 | "PIL": ("https://pillow.readthedocs.io/en/latest/", None), 314 | } 315 | 316 | # Autodoc configuraton. 317 | autoclass_content = "both" 318 | 319 | nitpick_ignore = [ 320 | ("py:class", "type"), 321 | ("py:class", "t.ClassVar"), 322 | ("py:class", "t.Optional"), 323 | ("py:class", "t.Collection"), 324 | ] 325 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Flask-IIIF 3 | ============ 4 | .. currentmodule:: flask_iiif 5 | 6 | .. image:: https://img.shields.io/travis/inveniosoftware/flask-iiif.svg 7 | :target: https://travis-ci.org/inveniosoftware/flask-iiif 8 | 9 | .. image:: https://img.shields.io/coveralls/inveniosoftware/flask-iiif.svg 10 | :target: https://coveralls.io/r/inveniosoftware/flask-iiif 11 | 12 | .. image:: https://img.shields.io/github/tag/inveniosoftware/flask-iiif.svg 13 | :target: https://github.com/inveniosoftware/flask-iiif/releases 14 | 15 | .. image:: https://img.shields.io/pypi/dm/flask-iiif.svg 16 | :target: https://pypi.python.org/pypi/flask-iiif 17 | 18 | .. image:: https://img.shields.io/github/license/inveniosoftware/flask-iiif.svg 19 | :target: https://github.com/inveniosoftware/flask-iiif/blob/master/LICENSE 20 | 21 | Flask-IIIF is a Flask extension permitting easy integration with the 22 | International Image Interoperability Framework (IIIF) API standards. 23 | 24 | Contents 25 | -------- 26 | 27 | .. contents:: 28 | :local: 29 | :backlinks: none 30 | 31 | 32 | Installation 33 | ============ 34 | 35 | Flask-IIIF is on PyPI so all you need is : 36 | 37 | .. code-block:: console 38 | 39 | $ pip install flask-iiif 40 | 41 | The development version can be downloaded from `its page at GitHub 42 | `_. 43 | 44 | .. code-block:: console 45 | 46 | $ git clone https://github.com/inveniosoftware/flask-iiif.git 47 | $ cd flask-iiif 48 | $ python setup.py develop 49 | $ ./run-tests.sh 50 | 51 | Requirements 52 | ^^^^^^^^^^^^ 53 | 54 | Flask-IIIF has the following dependencies: 55 | 56 | * `Flask `_ 57 | * `blinker `_ 58 | * `six `_ 59 | 60 | Flask-IIIF requires Python version 2.6, 2.7 or 3.3+ 61 | 62 | 63 | Quickstart 64 | ========== 65 | 66 | This part of the documentation will show you how to get started in using 67 | Flask-IIIF with Flask. 68 | 69 | This guide assumes that you have successfully installed Flask-IIIF and 70 | that you have a working understanding of Flask framework. If not, 71 | please follow the installation steps and read about Flask at 72 | http://flask.pocoo.org/docs/. 73 | 74 | 75 | A Minimal Example 76 | ^^^^^^^^^^^^^^^^^ 77 | 78 | A minimal Flask-IIIF usage example looks like this. 79 | 80 | First, let's create the application and initialise the extension: 81 | 82 | .. code-block:: python 83 | 84 | from flask import Flask, session, redirect 85 | from flask_iiif import IIIF 86 | app = Flask("myapp") 87 | ext = IIIF(app=app) 88 | 89 | 90 | Second, let's create *Flask-RESTful* ``api`` instance and register image 91 | resource. 92 | 93 | .. code-block:: python 94 | 95 | from flask_restful import Api 96 | api = Api(app=app) 97 | ext.init_restful(api) 98 | 99 | 100 | Configuration 101 | ============= 102 | 103 | .. automodule:: flask_iiif.config 104 | :members: 105 | 106 | API 107 | === 108 | 109 | This documentation section is automatically generated from Flask-IIIF 110 | source code. 111 | 112 | Flask-IIIF 113 | ^^^^^^^^^^ 114 | 115 | .. automodule:: flask_iiif.api 116 | :members: 117 | 118 | Cache 119 | ^^^^^ 120 | 121 | .. automodule:: flask_iiif.cache.cache 122 | :members: 123 | 124 | .. automodule:: flask_iiif.cache.redis 125 | :members: 126 | 127 | .. automodule:: flask_iiif.cache.simple 128 | :members: 129 | 130 | RESTful 131 | ^^^^^^^ 132 | 133 | .. automodule:: flask_iiif.restful 134 | :members: 135 | 136 | .. include:: ../CHANGES.rst 137 | 138 | .. include:: ../CONTRIBUTING.rst 139 | 140 | License 141 | ======= 142 | 143 | .. include:: ../LICENSE 144 | 145 | In applying this license, CERN does not waive the privileges and immunities 146 | granted to it by virtue of its status as an Intergovernmental Organization or 147 | submit itself to any jurisdiction. 148 | 149 | .. include:: ../AUTHORS 150 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | -e .[docs,tests] 2 | -------------------------------------------------------------------------------- /flask_iiif/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014, 2015, 2016, 2017 CERN. 5 | # Copyright (C) 2024 Graz University of Technology. 6 | # 7 | # Flask-IIIF is free software; you can redistribute it and/or modify 8 | # it under the terms of the Revised BSD License; see LICENSE file for 9 | # more details. 10 | 11 | """Multimedia and IIIF Image APIs. 12 | 13 | Flask-IIIF is initialized like this: 14 | 15 | Initialization of the extension: 16 | 17 | >>> from flask import Flask 18 | >>> from flask_iiif import IIIF 19 | >>> app = Flask('myapp') 20 | >>> ext = IIIF(app=app) 21 | 22 | or alternatively using the factory pattern: 23 | 24 | >>> app = Flask('myapp') 25 | >>> ext = IIIF() 26 | >>> ext.init_app(app) 27 | """ 28 | 29 | from __future__ import absolute_import 30 | 31 | from urllib.parse import urljoin as url_join 32 | 33 | from flask import current_app 34 | from six import string_types 35 | from werkzeug.utils import cached_property, import_string 36 | 37 | from . import config 38 | from .cache.cache import ImageCache 39 | from .utils import iiif_image_url 40 | 41 | 42 | class IIIF(object): 43 | """Flask extension implementation.""" 44 | 45 | def __init__(self, app=None): 46 | """Initialize login callback.""" 47 | self.uuid_to_image_opener = None 48 | self.api_decorator_callback = None 49 | if app is not None: 50 | self.init_app(app) 51 | 52 | @cached_property 53 | def cache(self): 54 | """Return the cache handler. 55 | 56 | .. note:: 57 | 58 | You can create your own cache handler by change the 59 | :py:attr:`~flask_iiif.config.IIIF_CACHE_HANDLER`. More infos 60 | could be found in :py:mod:`~flask_iiif.cache.cache`. 61 | """ 62 | handler = current_app.config["IIIF_CACHE_HANDLER"] 63 | if isinstance(handler, string_types): 64 | handler = import_string(handler) 65 | if callable(handler): 66 | handler = handler(self.app) 67 | assert isinstance(handler, ImageCache) 68 | return handler 69 | 70 | def init_app(self, app): 71 | """Initialize a Flask application.""" 72 | self.app = app 73 | # Follow the Flask guidelines on usage of app.extensions 74 | if not hasattr(app, "extensions"): 75 | app.extensions = {} 76 | if "iiif" in app.extensions: 77 | raise RuntimeError("Flask application already initialized") 78 | app.extensions["iiif"] = self 79 | 80 | # Set default configuration 81 | for k in dir(config): 82 | if k.startswith("IIIF_"): 83 | self.app.config.setdefault(k, getattr(config, k)) 84 | 85 | # Register context processors 86 | if hasattr(app, "add_template_global"): 87 | app.add_template_global(iiif_image_url) 88 | else: 89 | ctx = dict(iiif_image_url=iiif_image_url) 90 | app.context_processor(lambda: ctx) 91 | 92 | def init_restful(self, api, prefix="/api/multimedia/image/"): 93 | """Set up the urls. 94 | 95 | :param str prefix: the url perfix 96 | 97 | .. note:: 98 | 99 | In IIIF Image API the Image Request URI Syntax must following 100 | 101 | ``{scheme}://{server}{/prefix}/{identifier}/ 102 | {region}/{size}/{rotation}/{quality}.{format}`` 103 | 104 | pattern, the default prefix is ``/api/multimedia/image`` but 105 | this can be changes by changing the ``prefix`` paremeter. The 106 | ``prefix`` MUST always start and end with `/` 107 | 108 | .. seealso:: 109 | `IIIF IMAGE API URI Syntax 110 | ` 111 | """ 112 | from .restful import IIIFImageAPI, IIIFImageBase, IIIFImageInfo 113 | 114 | if not prefix.startswith("/") or not prefix.endswith("/"): 115 | raise RuntimeError("The `prefix` must always start and end with `/`") 116 | 117 | api.add_resource( 118 | IIIFImageAPI, 119 | url_join( 120 | prefix, 121 | ( 122 | "//" 123 | "///" 124 | "." 125 | ), 126 | ), 127 | ) 128 | 129 | api.add_resource( 130 | IIIFImageInfo, url_join(prefix, "//info.json") 131 | ) 132 | api.add_resource( 133 | IIIFImageBase, url_join(prefix, "/") 134 | ) 135 | 136 | def uuid_to_image_opener_handler(self, callback): 137 | """Set the callback for the ``uuid`` to ``image`` convertion. 138 | 139 | .. note: 140 | 141 | The supported file type is either ``fullpath`` or ``bytestream`` 142 | object anything else will raise 143 | :class:`~flask_iiif.errors.MultimediaImageNotFound` 144 | 145 | .. code-block:: python 146 | 147 | def uuid_to_path(uuid): 148 | # do something magical 149 | 150 | iiif.uuid_to_image_opener_handler(uuid_to_path) 151 | """ 152 | self.uuid_to_image_opener = callback 153 | 154 | def api_decorator_handler(self, callback): 155 | """Protect API handler. 156 | 157 | .. code-block:: python 158 | 159 | def protect_api(): 160 | return 161 | iiif.api_decorator_handler(protect_api) 162 | 163 | .. note:: 164 | 165 | The API would be always decorated with ``api_decorator_handler``. 166 | If is not defined, it would just pass. 167 | """ 168 | self.api_decorator_callback = callback 169 | 170 | 171 | __version__ = "1.2.0" 172 | 173 | __all__ = ( 174 | "IIIF", 175 | "__version__", 176 | ) 177 | -------------------------------------------------------------------------------- /flask_iiif/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014, 2015, 2016, 2017 CERN. 5 | # Copyright (C) 2020 data-futures. 6 | # 7 | # Flask-IIIF is free software; you can redistribute it and/or modify 8 | # it under the terms of the Revised BSD License; see LICENSE file for 9 | # more details. 10 | 11 | """Multimedia Image API.""" 12 | 13 | import itertools 14 | import math 15 | import os 16 | import re 17 | 18 | from flask import current_app 19 | from PIL import Image 20 | from six import BytesIO, string_types 21 | from werkzeug.utils import import_string 22 | 23 | from .errors import ( 24 | IIIFValidatorError, 25 | MultimediaImageCropError, 26 | MultimediaImageFormatError, 27 | MultimediaImageNotFound, 28 | MultimediaImageQualityError, 29 | MultimediaImageResizeError, 30 | MultimediaImageRotateError, 31 | ) 32 | 33 | 34 | class MultimediaObject(object): 35 | """The Multimedia Object.""" 36 | 37 | 38 | class MultimediaImage(MultimediaObject): 39 | r"""Multimedia Image API. 40 | 41 | Initializes an image api with IIIF standards. You can: 42 | 43 | * Resize :func:`resize`. 44 | * Crop :func:`crop`. 45 | * Rotate :func:`rotate`. 46 | * Change image quality :func:`quality`. 47 | 48 | Example of editing an image and saving it to disk: 49 | 50 | .. code-block:: python 51 | 52 | from flask_iiif.api import MultimediaImage 53 | 54 | image = IIIFImageAPIWrapper.from_file(path) 55 | # Rotate the image 56 | image.rotate(90) 57 | # Resize the image 58 | image.resize('300,200') 59 | # Crop the image 60 | image.crop('20,20,400,300') 61 | # Make the image black and white 62 | image.quality('grey') 63 | # Finaly save it to /tmp 64 | image.save('/tmp') 65 | 66 | Example of serving the modified image over http: 67 | 68 | .. code-block:: python 69 | 70 | from flask import current_app, Blueprint 71 | from flask_iiif.api import MultimediaImage 72 | 73 | @blueprint.route('/serve//') 74 | def serve_thumbnail(uuid, size): 75 | \"\"\"Serve the image thumbnail. 76 | 77 | :param uuid: The document uuid. 78 | :param size: The desired image size. 79 | \"\"\" 80 | # Initialize the image with the uuid 81 | path = current_app.extensions['iiif'].uuid_to_path(uuid) 82 | image = IIIFImageAPIWrapper.from_file(path) 83 | # Resize it 84 | image.resize(size) 85 | # Serve it 86 | return send_file(image.serve(), mimetype='image/jpeg') 87 | """ 88 | 89 | def __init__(self, image): 90 | """Initialize the image.""" 91 | self.image = image 92 | 93 | @classmethod 94 | def from_file(cls, path): 95 | """Return the image object from the given path. 96 | 97 | :param str path: The absolute path of the file 98 | :returns: a :class:`~flask_iiif.api.MultimediaImage` 99 | instance 100 | """ 101 | if not os.path.exists(path): 102 | raise MultimediaImageNotFound( 103 | "The requested image {0} not found".format(path) 104 | ) 105 | 106 | image = Image.open(path) 107 | return cls(image) 108 | 109 | @classmethod 110 | def from_string(cls, source): 111 | """Create an :class:`~flask_iiif.api.MultimediaImage` instance. 112 | 113 | :param str source: the image string 114 | :type source: `BytesIO` object 115 | :returns: a :class:`~flask_iiif.api.MultimediaImage` 116 | instance 117 | """ 118 | image = Image.open(source) 119 | return cls(image) 120 | 121 | def resize(self, dimensions, resample=None): 122 | """Resize the image. 123 | 124 | :param str dimensions: The dimensions to resize the image 125 | :param resample: The algorithm to be used. Use Pillow default if not supplied. 126 | :type resample: :py:mod:`PIL.Image` algorithm 127 | 128 | .. note:: 129 | 130 | * `dimensions` must be one of the following: 131 | 132 | * 'w,': The exact width, height will be calculated. 133 | * ',h': The exact height, width will be calculated. 134 | * 'pct:n': Image percentage scale. 135 | * 'w,h': The exact width and height. 136 | * '!w,h': Best fit for the given width and height. 137 | 138 | """ 139 | real_width, real_height = self.image.size 140 | point_x, point_y = 0, 0 141 | 142 | # Check if it is `pct:` 143 | if dimensions.startswith("pct:"): 144 | percent = float(dimensions.split(":")[1]) * 0.01 145 | if percent < 0: 146 | raise MultimediaImageResizeError( 147 | ( 148 | "Image percentage could not be negative, {0} has been" " given" 149 | ).format(percent) 150 | ) 151 | 152 | width = max(1, int(real_width * percent)) 153 | height = max(1, int(real_height * percent)) 154 | 155 | # Check if it is `,h` or `^,h`, 156 | # the '^' case is handled the same way as just height being present 157 | elif dimensions.startswith((",", "^,")): 158 | if dimensions.startswith(","): # Handle `,h` 159 | height = int(dimensions[1:]) 160 | else: # Handle `^,h` 161 | height = int(dimensions[2:]) 162 | 163 | # find the ratio 164 | ratio = self.reduce_by(height, real_height) 165 | # calculate width (minimum 1) 166 | width = max(1, int(real_width * ratio)) 167 | 168 | # Check if it is `!w,h` 169 | elif dimensions.startswith("!"): 170 | point_x, point_y = map(int, dimensions[1:].split(",")) 171 | # find the ratio 172 | ratio_x = self.reduce_by(point_x, real_width) 173 | ratio_y = self.reduce_by(point_y, real_height) 174 | # take the min 175 | ratio = min(ratio_x, ratio_y) 176 | # calculate the dimensions 177 | width = max(1, int(real_width * ratio)) 178 | height = max(1, int(real_height * ratio)) 179 | 180 | # Check if it is `w,` or `^w,` 181 | # the '^' case is handled the same way as just width being present 182 | elif dimensions.endswith(",") or ( 183 | (dimensions.startswith("^") and dimensions.endswith(",")) 184 | ): 185 | if dimensions.endswith(",") and not dimensions.startswith( 186 | "^" 187 | ): # Handle `w,` 188 | width = int(dimensions[:-1]) 189 | else: # Handle `^w,` 190 | width = int(dimensions[1:-1]) 191 | 192 | # find the ratio 193 | ratio = self.reduce_by(width, real_width) 194 | # calculate the height 195 | height = max(1, int(real_height * ratio)) 196 | 197 | # Normal mode `w,h` 198 | else: 199 | try: 200 | width, height = map(int, dimensions.split(",")) 201 | except ValueError: 202 | raise MultimediaImageResizeError( 203 | "The request must contain width,height sequence" 204 | ) 205 | 206 | # If a dimension is missing throw error 207 | if any( 208 | (dimension <= 0 and dimension is not None) for dimension in (width, height) 209 | ): 210 | raise MultimediaImageResizeError( 211 | ( 212 | "Width and height cannot be zero or negative, {0},{1} has" 213 | " been given" 214 | ).format(width, height) 215 | ) 216 | 217 | arguments = dict(size=(width, height)) 218 | if resample: 219 | arguments["resample"] = resample 220 | self.image = self.image.resize(**arguments) 221 | 222 | def crop(self, coordinates): 223 | """Crop the image. 224 | 225 | :param str coordinates: The coordinates to crop the image 226 | 227 | .. note:: 228 | 229 | * `coordinates` must have the following pattern: 230 | 231 | * 'x,y,w,h': in pixels. 232 | * 'pct:x,y,w,h': percentage. 233 | 234 | """ 235 | # Get image full dimensions 236 | real_width, real_height = self.image.size 237 | real_dimensions = itertools.cycle((real_width, real_height)) 238 | 239 | dimensions = [] 240 | percentage = False 241 | if coordinates.startswith("pct:"): 242 | for coordinate in coordinates.split(":")[1].split(","): 243 | dimensions.append(float(coordinate)) 244 | percentage = True 245 | else: 246 | for coordinate in coordinates.split(","): 247 | dimensions.append(int(coordinate)) 248 | 249 | # First check if it has 4 coordinates x,y,w,h 250 | dimensions_length = len(dimensions) 251 | if dimensions_length != 4: 252 | raise MultimediaImageCropError( 253 | "Must have 4 dimensions {0} has been given".format(dimensions_length) 254 | ) 255 | 256 | # Make sure that there is not any negative dimension 257 | if any(coordinate < 0 for coordinate in dimensions): 258 | raise MultimediaImageCropError( 259 | "Dimensions cannot be negative {0} has been given".format(dimensions) 260 | ) 261 | 262 | if percentage: 263 | if any(coordinate > 100.0 for coordinate in dimensions): 264 | raise MultimediaImageCropError( 265 | "Dimensions could not be grater than 100%" 266 | ) 267 | 268 | # Calculate the dimensions 269 | start_x, start_y, width, height = [ 270 | int( 271 | math.floor( 272 | self.percent_to_number(dimension) * next(real_dimensions) 273 | ) 274 | ) 275 | for dimension in dimensions 276 | ] 277 | else: 278 | start_x, start_y, width, height = dimensions 279 | 280 | # Check if any of the requested axis is outside of image borders 281 | if any(axis > next(real_dimensions) for axis in (start_x, start_y)): 282 | raise MultimediaImageCropError( 283 | "Outside of image borders {0},{1}".format(real_width, real_height) 284 | ) 285 | 286 | # Calculate the final dimensions 287 | max_x = start_x + width 288 | max_y = start_y + height 289 | # Check if the final width is bigger than the the real image width 290 | if max_x > real_width: 291 | max_x = real_width 292 | 293 | # Check if the final height is bigger than the the real image height 294 | if max_y > real_height: 295 | max_y = real_height 296 | 297 | self.image = self.image.crop((start_x, start_y, max_x, max_y)) 298 | 299 | def rotate(self, degrees, mirror=False): 300 | """Rotate the image clockwise by given degrees. 301 | 302 | :param float degrees: The degrees, should be in range of [0, 360] 303 | :param bool mirror: Flip image from left to right 304 | """ 305 | # PIL wants to do anti-clockwise rotation so swap these around 306 | transforms = { 307 | "90": Image.ROTATE_270, 308 | "180": Image.ROTATE_180, 309 | "270": Image.ROTATE_90, 310 | "mirror": Image.FLIP_LEFT_RIGHT, 311 | } 312 | 313 | # Check if we have the right degrees 314 | if not 0.0 <= float(degrees) <= 360.0: 315 | raise MultimediaImageRotateError( 316 | "Degrees must be between 0 and 360, {0} has been given".format(degrees) 317 | ) 318 | 319 | # mirror must be applied before rotation 320 | if mirror: 321 | self.image = self.image.transpose(transforms.get("mirror")) 322 | 323 | if str(degrees) in transforms.keys(): 324 | self.image = self.image.transpose(transforms.get(str(degrees))) 325 | else: 326 | # transparent background if degrees not multiple of 90 327 | self.image = self.image.convert("RGBA") 328 | self.image = self.image.rotate(float(degrees), expand=1) 329 | 330 | def quality(self, quality): 331 | """Change the image format. 332 | 333 | :param str quality: The image quality should be in (default, grey, 334 | bitonal, color) 335 | 336 | .. note:: 337 | 338 | The library supports transformations between each supported 339 | mode and the "L" and "RGB" modes. To convert between other 340 | modes, you may have to use an intermediate image (typically 341 | an "RGB" image). 342 | 343 | """ 344 | qualities = current_app.config["IIIF_QUALITIES"] 345 | if quality not in qualities: 346 | raise MultimediaImageQualityError( 347 | ( 348 | "{0} is not supported, please select one of the" 349 | " valid qualities: {1}" 350 | ).format(quality, qualities) 351 | ) 352 | 353 | qualities_by_code = zip(qualities, current_app.config["IIIF_CONVERTERS"]) 354 | 355 | if quality not in ("default", "color"): 356 | # Convert image to RGB read the note 357 | if self.image.mode != "RGBA": 358 | self.image = self.image.convert("RGBA") 359 | 360 | code = [ 361 | quality_code[1] 362 | for quality_code in qualities_by_code 363 | if quality_code[0] == quality 364 | ][0] 365 | 366 | self.image = self.image.convert(code) 367 | 368 | def size(self): 369 | """Return the current image size. 370 | 371 | :return: the image size 372 | """ 373 | return self.image.size 374 | 375 | def save(self, path, image_format="jpeg", quality=90): 376 | """Store the image to the specific path. 377 | 378 | :param str path: absolute path 379 | :param str image_format: (gif, jpeg, pdf, png, tif) 380 | :param int quality: The image quality; [1, 100] 381 | 382 | .. note:: 383 | 384 | `image_format` = jpg will not be recognized by :py:mod:`PIL.Image` 385 | and it will be changed to jpeg. 386 | 387 | """ 388 | # transform `image_format` is lower case and not equals to jpg 389 | cleaned_image_format = self._prepare_for_output(image_format) 390 | self.image.save(path, cleaned_image_format, quality=quality) 391 | 392 | def serve(self, image_format="png", quality=90): 393 | """Return a BytesIO object to easily serve it thought HTTTP. 394 | 395 | :param str image_format: (gif, jpeg, pdf, png, tif) 396 | :param int quality: The image quality; [1, 100] 397 | 398 | .. note:: 399 | 400 | `image_format` = jpg will not be recognized by 401 | :py:mod:`PIL.Image` and it will be changed to jpeg. 402 | 403 | """ 404 | image_buffer = BytesIO() 405 | # transform `image_format` is lower case and not equals to jpg 406 | cleaned_image_format = self._prepare_for_output(image_format) 407 | save_kwargs = dict(quality=quality) 408 | 409 | if self.image.format == "GIF": 410 | save_kwargs.update(save_all=True) 411 | 412 | self.image.save(image_buffer, cleaned_image_format, **save_kwargs) 413 | image_buffer.seek(0) 414 | 415 | return image_buffer 416 | 417 | def _prepare_for_output(self, requested_format): 418 | """Help validate output format. 419 | 420 | :param str requested_format: The image output format 421 | 422 | .. note:: 423 | 424 | pdf and jpeg format can't be saved as `RBGA` so image needs to be 425 | converted to `RGB` mode. 426 | 427 | """ 428 | pil_map = current_app.config["IIIF_FORMATS_PIL_MAP"] 429 | pil_keys = pil_map.keys() 430 | format_keys = current_app.config["IIIF_FORMATS"].keys() 431 | 432 | if requested_format not in format_keys or requested_format not in pil_keys: 433 | raise MultimediaImageFormatError( 434 | ( 435 | "{0} is not supported, please select one of the valid" 436 | " formats: {1}" 437 | ).format(requested_format, format_keys) 438 | ) 439 | else: 440 | image_format = pil_map[requested_format] 441 | 442 | # If the the `requested_format` is pdf or jpeg force mode to RGB 443 | if image_format in ("pdf", "jpeg"): 444 | self.image = self.image.convert("RGB") 445 | 446 | return image_format 447 | 448 | @staticmethod 449 | def reduce_by(nominally, dominator): 450 | """Calculate the ratio.""" 451 | return float(nominally) / float(dominator) 452 | 453 | @staticmethod 454 | def percent_to_number(number): 455 | """Calculate the percentage.""" 456 | return float(number) / 100.0 457 | 458 | 459 | class IIIFImageAPIWrapper(MultimediaImage): 460 | """IIIF Image API Wrapper.""" 461 | 462 | @staticmethod 463 | def validate_api(**kwargs): 464 | """Validate IIIF Image API. 465 | 466 | Example to validate the IIIF API: 467 | 468 | .. code:: python 469 | 470 | from flask_iiif.api import IIIFImageAPIWrapper 471 | 472 | IIIFImageAPIWrapper.validate_api( 473 | version=version, 474 | region=region, 475 | size=size, 476 | rotation=rotation, 477 | quality=quality, 478 | image_format=image_format 479 | ) 480 | 481 | .. note:: 482 | 483 | If the version is not specified it will fallback to version 2.0. 484 | 485 | """ 486 | # Get the api version 487 | version = kwargs.get("version", "v2") 488 | # Get the validations and ignore cases 489 | cases = current_app.config["IIIF_VALIDATIONS"].get(version) 490 | for key in cases.keys(): 491 | # If the parameter don't match with iiif casess 492 | if not re.search(cases.get(key, {}).get("validate", ""), kwargs.get(key)): 493 | raise IIIFValidatorError( 494 | ("value: `{0}` for parameter: `{1}` is not supported").format( 495 | kwargs.get(key), key 496 | ) 497 | ) 498 | 499 | def apply_api(self, **kwargs): 500 | """Apply the IIIF API to the image. 501 | 502 | Example to apply the IIIF API: 503 | 504 | .. code:: python 505 | 506 | from flask_iiif.api import IIIFImageAPIWrapper 507 | 508 | image = IIIFImageAPIWrapper.from_file(path) 509 | 510 | image.apply_api( 511 | version=version, 512 | region=region, 513 | size=size, 514 | rotation=rotation, 515 | quality=quality 516 | ) 517 | 518 | .. note:: 519 | 520 | * If the version is not specified it will fallback to version 2.0. 521 | * Please note the :func:`validate_api` should be run before 522 | :func:`apply_api`. 523 | 524 | """ 525 | # Get the api version 526 | version = kwargs.get("version", "v2") 527 | # Get the validations and ignore cases 528 | cases = current_app.config["IIIF_VALIDATIONS"].get(version) 529 | # Set the apply order 530 | order = "region", "size", "rotation", "quality" 531 | # Set the functions to be applied 532 | tools = { 533 | "region": self.apply_region, 534 | "size": self.apply_size, 535 | "rotation": self.apply_rotate, 536 | "quality": self.apply_quality, 537 | } 538 | 539 | for key in order: 540 | # Ignore if has the ignore value for the specific key 541 | if kwargs.get(key) != cases.get(key, {}).get("ignore"): 542 | tools.get(key)(kwargs.get(key)) 543 | 544 | def apply_region(self, value): 545 | """IIIF apply crop. 546 | 547 | Apply :func:`~flask_iiif.api.MultimediaImage.crop`. 548 | """ 549 | self.crop(value) 550 | 551 | def apply_size(self, value): 552 | """IIIF apply resize. 553 | 554 | Apply :func:`~flask_iiif.api.MultimediaImage.resize`. 555 | """ 556 | self.resize(value) 557 | 558 | def apply_rotate(self, value): 559 | """IIIF apply rotate. 560 | 561 | Apply :func:`~flask_iiif.api.MultimediaImage.rotate`. 562 | 563 | .. note:: 564 | PIL rotates anti-clockwise, IIIF specifies clockwise 565 | 566 | """ 567 | mirror = False 568 | degrees = value 569 | if value.startswith("!"): 570 | mirror = True 571 | degrees = value[1:] 572 | self.rotate(360 - float(degrees), mirror=mirror) 573 | 574 | def apply_quality(self, value): 575 | """IIIF apply quality. 576 | 577 | Apply :func:`~flask_iiif.api.MultimediaImage.quality`. 578 | """ 579 | self.quality(value) 580 | 581 | @classmethod 582 | def open_image(cls, source): 583 | """Create an :class:`~flask_iiif.api.MultimediaImage` instance. 584 | 585 | :param str source: The image image string 586 | :type source: `BytesIO` object 587 | :param str source_type: the type of ``data`` 588 | :returns: a :class:`~flask_iiif.api.MultimediaImage` 589 | instance 590 | """ 591 | try: 592 | image = Image.open(source) 593 | except (AttributeError, IOError): 594 | raise MultimediaImageNotFound("The requested image cannot be opened") 595 | return cls(image) 596 | 597 | def close_image(self): 598 | """Close an image file descriptor.""" 599 | self.image.close() 600 | -------------------------------------------------------------------------------- /flask_iiif/cache/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | 10 | """Cache implementations.""" 11 | -------------------------------------------------------------------------------- /flask_iiif/cache/cache.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014-2020 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | 10 | """Abstract simple cache definition. 11 | 12 | All cache adaptors must at least implement 13 | :func:`~flask_iiif.cache.cache.ImageCache.get` and 14 | :func:`~flask_iiif.cache.cache.ImageCache.set` methods. 15 | """ 16 | 17 | from flask import current_app 18 | from werkzeug.utils import cached_property 19 | 20 | 21 | class ImageCache(object): 22 | """Abstract cache layer.""" 23 | 24 | def __init__(self, app=None): 25 | """Initialize the cache.""" 26 | 27 | @cached_property 28 | def timeout(self): 29 | """Return default timeout from config.""" 30 | return current_app.config["IIIF_CACHE_TIME"] 31 | 32 | def get(self, key): 33 | """Return the key value. 34 | 35 | :param key: the object's key 36 | """ 37 | 38 | def set(self, key, value, timeout=None): 39 | """Cache the object. 40 | 41 | :param key: the object's key 42 | :param value: the stored object 43 | :type value: :class:`StringIO.StringIO` object 44 | :param timeout: the cache timeout in seconds 45 | """ 46 | 47 | def get_last_modification(self, key): 48 | """Get last modification of cached file. 49 | 50 | :param key: the file object's key 51 | """ 52 | 53 | def set_last_modification(self, key, last_modification=None, timeout=None): 54 | """Set last modification of cached file. 55 | 56 | :param key: the file object's key 57 | :param last_modification: Last modification date of 58 | file represented by the key 59 | :type last_modification: datetime.datetime 60 | :param timeout: the cache timeout in seconds 61 | """ 62 | 63 | def _last_modification_key_name(self, key): 64 | """Generate key for last_modification entry of specified key. 65 | 66 | :param key: the file object's key 67 | """ 68 | return "last_modification::%s" % key 69 | 70 | def delete(self, key): 71 | """Delete the specific key.""" 72 | 73 | def flush(self): 74 | """Flush the cache.""" 75 | 76 | def __call__(self, app=None): 77 | """Backwards-compatibility method returning ``self``.""" 78 | return self 79 | -------------------------------------------------------------------------------- /flask_iiif/cache/redis.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2016, 2017 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | 10 | """Implements a Redis cache.""" 11 | 12 | from __future__ import absolute_import 13 | 14 | from datetime import datetime 15 | 16 | from cachelib.redis import RedisCache 17 | from flask import current_app 18 | from redis import StrictRedis 19 | 20 | from .cache import ImageCache 21 | 22 | 23 | class ImageRedisCache(ImageCache): 24 | """Redis image cache.""" 25 | 26 | def __init__(self, app=None): 27 | """Initialize the cache.""" 28 | super(ImageRedisCache, self).__init__(app=app) 29 | app = app or current_app 30 | redis_url = app.config["IIIF_CACHE_REDIS_URL"] 31 | prefix = app.config.get("IIIF_CACHE_REDIS_PREFIX", "iiif") 32 | self.cache = RedisCache(host=StrictRedis.from_url(redis_url), key_prefix=prefix) 33 | 34 | def get(self, key): 35 | """Return the key value. 36 | 37 | :param key: the object's key 38 | :return: the stored object 39 | :rtype: `BytesIO` object 40 | """ 41 | return self.cache.get(key) 42 | 43 | def set(self, key, value, timeout=None): 44 | """Cache the object. 45 | 46 | :param key: the object's key 47 | :param value: the stored object 48 | :type value: `BytesIO` object 49 | :param timeout: the cache timeout in seconds 50 | """ 51 | timeout = timeout or self.timeout 52 | self.cache.set(key, value, timeout=timeout) 53 | self.set_last_modification(key, timeout=timeout) 54 | 55 | def get_last_modification(self, key): 56 | """Get last modification of cached file. 57 | 58 | :param key: the file object's key 59 | """ 60 | return self.get(self._last_modification_key_name(key)) 61 | 62 | def set_last_modification(self, key, last_modification=None, timeout=None): 63 | """Set last modification of cached file. 64 | 65 | :param key: the file object's key 66 | :param last_modification: Last modification date of 67 | file represented by the key 68 | :type last_modification: datetime.datetime 69 | :param timeout: the cache timeout in seconds 70 | """ 71 | if not last_modification: 72 | last_modification = datetime.utcnow().replace(microsecond=0) 73 | timeout = timeout or self.timeout 74 | self.cache.set( 75 | self._last_modification_key_name(key), last_modification, timeout 76 | ) 77 | 78 | def delete(self, key): 79 | """Delete the specific key.""" 80 | self.cache.delete(key) 81 | self.cache.delete(self._last_modification_key_name(key)) 82 | 83 | def flush(self): 84 | """Flush the cache.""" 85 | self.cache.clear() 86 | -------------------------------------------------------------------------------- /flask_iiif/cache/simple.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014, 2015, 2016, 2017 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | 10 | """Implement a simple cache.""" 11 | 12 | from __future__ import absolute_import 13 | 14 | from datetime import datetime 15 | 16 | from cachelib.simple import SimpleCache 17 | 18 | from .cache import ImageCache 19 | 20 | 21 | class ImageSimpleCache(ImageCache): 22 | """Simple image cache.""" 23 | 24 | def __init__(self, app=None): 25 | """Initialize the cache.""" 26 | super(ImageSimpleCache, self).__init__(app=app) 27 | self.cache = SimpleCache() 28 | 29 | def get(self, key): 30 | """Return the key value. 31 | 32 | :param key: the object's key 33 | :return: the stored object 34 | :rtype: `BytesIO` object 35 | """ 36 | return self.cache.get(key) 37 | 38 | def set(self, key, value, timeout=None): 39 | """Cache the object. 40 | 41 | :param key: the object's key 42 | :param value: the stored object 43 | :type value: `BytesIO` object 44 | :param timeout: the cache timeout in seconds 45 | """ 46 | timeout = timeout or self.timeout 47 | self.cache.set(key, value, timeout) 48 | self.set_last_modification(key, timeout=timeout) 49 | 50 | def get_last_modification(self, key): 51 | """Get last modification of cached file. 52 | 53 | :param key: the file object's key 54 | """ 55 | return self.get(self._last_modification_key_name(key)) 56 | 57 | def set_last_modification(self, key, last_modification=None, timeout=None): 58 | """Set last modification of cached file. 59 | 60 | :param key: the file object's key 61 | :param last_modification: Last modification date of 62 | file represented by the key 63 | :type last_modification: datetime.datetime 64 | :param timeout: the cache timeout in seconds 65 | """ 66 | if not last_modification: 67 | last_modification = datetime.utcnow().replace(microsecond=0) 68 | timeout = timeout or self.timeout 69 | self.cache.set( 70 | self._last_modification_key_name(key), last_modification, timeout 71 | ) 72 | 73 | def delete(self, key): 74 | """Delete the specific key.""" 75 | self.cache.delete(key) 76 | self.cache.delete(self._last_modification_key_name(key)) 77 | 78 | def flush(self): 79 | """Flush the cache.""" 80 | self.cache.clear() 81 | -------------------------------------------------------------------------------- /flask_iiif/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014, 2015, 2016, 2017 CERN. 5 | # Copyright (C) 2020 data-futures. 6 | # 7 | # Flask-IIIF is free software; you can redistribute it and/or modify 8 | # it under the terms of the Revised BSD License; see LICENSE file for 9 | # more details. 10 | 11 | """IIIF configuration. 12 | 13 | .. py:data:: IIIF_CACHE_HANDLER 14 | 15 | Add the preferred cache adaptor. 16 | 17 | .. seealso:: :py:class:`~flask_iiif.cache.cache.ImageCache` 18 | 19 | .. py:data:: IIIF_CACHE_REDIS_PREFIX 20 | 21 | Sets prefix for redis keys, default: `iiif` 22 | 23 | .. py:data:: IIIF_CACHE_TIME 24 | 25 | How much time the image would be cached. 26 | 27 | .. py:data:: IIIF_QUALITIES 28 | 29 | The supported image qualities. 30 | 31 | .. seealso:: 32 | 33 | `IIIF Image API 34 | `_ 35 | 36 | .. py:data:: IIIF_CONVERTERS 37 | 38 | The supported image converters. 39 | 40 | .. py:data:: IIIF_FORMATS 41 | 42 | The supported image formats with their MIME type. 43 | 44 | .. py:data:: IIIF_VALIDATIONS 45 | 46 | The IIIF Image API validation. 47 | 48 | .. seealso:: 49 | 50 | `IIIF Image API v1 51 | `_ and 52 | `IIIF Image API v2 53 | `_ 54 | 55 | .. py:data:: IIIF_API_INFO_RESPONSE_SKELETON 56 | 57 | Information request document for the image. 58 | 59 | .. seealso:: 60 | `IIIF Image API v1 Information request 61 | `_ and 62 | `IIIF Image API v2 Information request 63 | `_ 64 | 65 | """ 66 | # Cache handler 67 | IIIF_CACHE_HANDLER = "flask_iiif.cache.simple:ImageSimpleCache" 68 | 69 | # Cache duration 70 | # 60 seconds * 60 minutes (1 hour) * 24 (24 hours) * 2 (2 days) = 172800 secs 71 | # 60 seconds * 60 (1 hour) * 24 (1 day) * 2 (2 days) 72 | IIIF_CACHE_TIME = 60 * 60 * 24 * 2 73 | 74 | # Redis URL Cache 75 | IIIF_CACHE_REDIS_URL = "redis://localhost:6379/0" 76 | 77 | # Supported qualities 78 | IIIF_QUALITIES = ("default", "gray", "grey", "bitonal", "color", "native") 79 | # Suported coverters 80 | IIIF_CONVERTERS = "", "L", "L", "1", "", "" 81 | 82 | # Supported multimedia image API formats 83 | IIIF_FORMATS = { 84 | "gif": "image/gif", 85 | "jp2": "image/jp2", 86 | "jpeg": "image/jpeg", 87 | "jpg": "image/jpeg", 88 | "pdf": "application/pdf", 89 | "png": "image/png", 90 | "tif": "image/tiff", 91 | "tiff": "image/tiff", 92 | } 93 | 94 | IIIF_FORMATS_PIL_MAP = { 95 | "gif": "gif", 96 | "jp2": "jpeg2000", 97 | "jpeg": "jpeg", 98 | "jpg": "jpeg", 99 | "pdf": "pdf", 100 | "png": "png", 101 | "tif": "tiff", 102 | "tiff": "tiff", 103 | } 104 | 105 | # Regular expressions to validate each parameter 106 | IIIF_VALIDATIONS = { 107 | "v1": { 108 | "region": { 109 | "ignore": "full", 110 | "validate": r"(^full|(pct:)?([\d.]+,){3}([\d.]+))", 111 | }, 112 | "size": { 113 | "ignore": "full", 114 | "validate": ( 115 | r"(^full|[\d.]+,|,[\d.]+|pct:[\d.]+|[\d.]+,[\d.]+|![\d.]+,[\d.]+)" 116 | ), 117 | }, 118 | "rotation": {"ignore": "0", "validate": r"^[\d.]+$"}, 119 | "quality": {"ignore": "default", "validate": r"(native|color|gr[ae]y|bitonal)"}, 120 | "image_format": {"ignore": "", "validate": r"(gif|jp2|jpe?g|pdf|png|tiff?)"}, 121 | }, 122 | "v2": { 123 | "region": { 124 | "ignore": "full", 125 | "validate": r"(^full|(pct:)?([\d.]+,){3}([\d.]+))", 126 | }, 127 | "size": { 128 | "ignore": "full", 129 | "validate": ( 130 | r"(^full|[\d.]+,|,[\d.]+|pct:[\d.]+|[\d.]+,[\d.]+|![\d.]+,[\d.]+)" 131 | ), 132 | }, 133 | "rotation": {"ignore": "0", "validate": r"^!?[\d.]+$"}, 134 | "quality": { 135 | "ignore": "default", 136 | "validate": r"(default|color|gr[ae]y|bitonal)", 137 | }, 138 | "image_format": {"ignore": "", "validate": r"(gif|jp2|jpe?g|pdf|png|tiff?)"}, 139 | }, 140 | } 141 | 142 | # Qualities per image mode 143 | IIIF_MODE = { 144 | "1": ["default", "bitonal"], 145 | "L": ["default", "gray", "grey", "bitonal"], 146 | "P": ["default", "gray", "grey", "bitonal"], 147 | "RGB": ["default", "color", "gray", "grey", "bitonal"], 148 | "RGBA": ["default", "color", "gray", "grey", "bitonal"], 149 | "CMYK": ["default", "color", "gray", "grey", "bitonal"], 150 | "YCbCr": ["default", "color", "gray", "grey", "bitonal"], 151 | "I": ["default", "color", "gray", "grey", "bitonal"], 152 | "F": ["default", "color", "gray", "grey", "bitonal"], 153 | } 154 | 155 | # API Info 156 | IIIF_API_INFO_RESPONSE_SKELETON = { 157 | "v1": { 158 | "@context": ("http://library.stanford.edu/iiif/image-api/1.1/context.json"), 159 | "@id": "", 160 | "width": "", 161 | "height": "", 162 | "profile": ( 163 | "http://library.stanford.edu/iiif/image-api/compliance.html#level1" 164 | ), 165 | "tile_width": 256, 166 | "tile_height": 256, 167 | "scale_factors": [1, 2, 4, 8, 16, 32, 64], 168 | }, 169 | "v2": { 170 | "@context": "http://iiif.io/api/image/2/context.json", 171 | "@id": "", 172 | "protocol": "http://iiif.io/api/image", 173 | "width": "", 174 | "height": "", 175 | "tiles": [{"width": 256, "scaleFactors": [1, 2, 4, 8, 16, 32, 64]}], 176 | "profile": ["http://iiif.io/api/image/2/level2.json"], 177 | }, 178 | } 179 | 180 | # Raise errors during interactions with the cache. 181 | IIIF_CACHE_IGNORE_ERRORS = False 182 | 183 | IIIF_GIF_TEMP_FOLDER_PATH = "/tmp" 184 | -------------------------------------------------------------------------------- /flask_iiif/decorators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2015, 2016 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | 10 | """Flask-IIIF decorators.""" 11 | 12 | from functools import wraps 13 | 14 | from flask import current_app 15 | from flask_restful import abort 16 | from werkzeug.local import LocalProxy 17 | 18 | from .errors import ( 19 | IIIFValidatorError, 20 | MultimediaError, 21 | MultimediaImageCropError, 22 | MultimediaImageFormatError, 23 | MultimediaImageNotFound, 24 | MultimediaImageQualityError, 25 | MultimediaImageResizeError, 26 | MultimediaImageRotateError, 27 | ) 28 | 29 | __all__ = ( 30 | "api_decorator", 31 | "error_handler", 32 | ) 33 | 34 | current_iiif = LocalProxy(lambda: current_app.extensions["iiif"]) 35 | 36 | 37 | def error_handler(f): 38 | """Error handler.""" 39 | 40 | @wraps(f) 41 | def inner(*args, **kwargs): 42 | """Wrap the errors.""" 43 | try: 44 | return f(*args, **kwargs) 45 | except ( 46 | MultimediaImageCropError, 47 | MultimediaImageResizeError, 48 | MultimediaImageFormatError, 49 | MultimediaImageRotateError, 50 | MultimediaImageQualityError, 51 | ) as error: 52 | abort(500, message=error.message, code=500) 53 | except IIIFValidatorError as error: 54 | abort(400, message=error.message, code=400) 55 | except (MultimediaError, MultimediaImageNotFound) as error: 56 | abort(error.code, message=error.message, code=error.code) 57 | 58 | return inner 59 | 60 | 61 | def api_decorator(f): 62 | """Decorate API method.""" 63 | 64 | @wraps(f) 65 | def inner(*args, **kwargs): 66 | if current_iiif.api_decorator_callback: 67 | current_iiif.api_decorator_callback(*args, **kwargs) 68 | return f(*args, **kwargs) 69 | 70 | return inner 71 | -------------------------------------------------------------------------------- /flask_iiif/errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014, 2015 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | 10 | """Multimedia error.""" 11 | 12 | 13 | class MultimediaError(Exception): 14 | """General multimedia exception.""" 15 | 16 | def __init__(self, message=None, code=None): 17 | """Init the error handler.""" 18 | super(MultimediaError, self).__init__() 19 | self.message = message or self.__class__.__name__ 20 | self.code = code or 500 21 | 22 | def __str__(self): 23 | """Error message.""" 24 | return repr( 25 | "Error message: {message}. Error code: {code}".format( 26 | message=self.message, code=self.code 27 | ) 28 | ) 29 | 30 | 31 | class MultimediaImageNotFound(MultimediaError): 32 | """Image not found error.""" 33 | 34 | def __init__(self, message=None): 35 | """Init with status code 404.""" 36 | super(MultimediaImageNotFound, self).__init__(message, code=404) 37 | 38 | 39 | class MultimediaImageCropError(MultimediaError): 40 | """Image on crop error.""" 41 | 42 | 43 | class MultimediaImageResizeError(MultimediaError): 44 | """Image resize error.""" 45 | 46 | 47 | class MultimediaImageRotateError(MultimediaError): 48 | """Image rotate error.""" 49 | 50 | 51 | class MultimediaImageQualityError(MultimediaError): 52 | """Image quality error.""" 53 | 54 | 55 | class MultimediaImageFormatError(MultimediaError): 56 | """Image format error.""" 57 | 58 | 59 | class IIIFValidatorError(MultimediaError): 60 | """IIIF API validator error.""" 61 | -------------------------------------------------------------------------------- /flask_iiif/restful.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014, 2015, 2016, 2017 CERN. 5 | # Copyright (C) 2020 data-futures. 6 | # 7 | # Flask-IIIF is free software; you can redistribute it and/or modify 8 | # it under the terms of the Revised BSD License; see LICENSE file for 9 | # more details. 10 | 11 | """Multimedia IIIF Image API.""" 12 | import datetime 13 | from email.utils import parsedate 14 | from io import BytesIO 15 | 16 | from flask import Response, current_app, jsonify, redirect, request, send_file, url_for 17 | from flask_restful import Resource 18 | from flask_restful.utils import cors 19 | from werkzeug.local import LocalProxy 20 | from werkzeug.utils import secure_filename 21 | 22 | from .api import IIIFImageAPIWrapper 23 | from .decorators import api_decorator, error_handler 24 | from .signals import ( 25 | iiif_after_info_request, 26 | iiif_after_process_request, 27 | iiif_before_info_request, 28 | iiif_before_process_request, 29 | ) 30 | from .utils import should_cache 31 | 32 | current_iiif = LocalProxy(lambda: current_app.extensions["iiif"]) 33 | 34 | 35 | class IIIFImageBase(Resource): 36 | """IIIF Image Base.""" 37 | 38 | def get(self, version, uuid): 39 | """Get IIIF Image Base. 40 | 41 | .. note:: 42 | 43 | It will redirect to ``iiifimageinfo`` endpoint with status code 44 | 303. 45 | """ 46 | return redirect(url_for("iiifimageinfo", version=version, uuid=uuid), code=303) 47 | 48 | 49 | class IIIFImageInfo(Resource): 50 | """IIIF Image Info.""" 51 | 52 | method_decorators = [ 53 | error_handler, 54 | api_decorator, 55 | ] 56 | 57 | @cors.crossdomain(origin="*", methods="GET") 58 | def get(self, version, uuid): 59 | """Get IIIF Image Info.""" 60 | # Trigger event before proccess the api request 61 | iiif_before_info_request.send(self, version=version, uuid=uuid) 62 | 63 | # build the image key 64 | key = "iiif:info:{0}/{1}".format(version, uuid) 65 | 66 | # Check if its cached 67 | try: 68 | cached = current_iiif.cache.get(key) 69 | except Exception: 70 | if current_app.config.get("IIIF_CACHE_IGNORE_ERRORS", False): 71 | cached = None 72 | else: 73 | raise 74 | 75 | # If the image size is cached loaded from cache 76 | if cached: 77 | width, height = map(int, cached.split(",")) 78 | else: 79 | data = current_iiif.uuid_to_image_opener(uuid) 80 | image = IIIFImageAPIWrapper.open_image(data) 81 | width, height = image.size() 82 | image.close_image() 83 | if should_cache(request.args): 84 | try: 85 | current_iiif.cache.set(key, "{0},{1}".format(width, height)) 86 | except Exception: 87 | if not current_app.config.get("IIIF_CACHE_IGNORE_ERRORS", False): 88 | raise 89 | 90 | data = current_app.config["IIIF_API_INFO_RESPONSE_SKELETON"][version] 91 | 92 | base_uri = url_for("iiifimagebase", uuid=uuid, version=version, _external=True) 93 | data["@id"] = base_uri 94 | data["width"] = width 95 | data["height"] = height 96 | 97 | # Trigger event after proccess the api request 98 | iiif_after_info_request.send(self, **data) 99 | 100 | resp = jsonify(data) 101 | if "application/ld+json" in request.headers.get("Accept", ""): 102 | resp.mimetype = "application/ld+json" 103 | return resp 104 | 105 | 106 | class IIIFImageAPI(Resource): 107 | """IIIF API Implementation. 108 | 109 | .. note:: 110 | 111 | * IIF IMAGE API v1.0 112 | * For more infos please visit . 113 | * IIIF Image API v2.0 114 | * For more infos please visit . 115 | * The API works only for GET requests 116 | * The image process must follow strictly the following workflow: 117 | 118 | * Region 119 | * Size 120 | * Rotation 121 | * Quality 122 | * Format 123 | 124 | """ 125 | 126 | method_decorators = [ 127 | error_handler, 128 | api_decorator, 129 | ] 130 | 131 | def get(self, version, uuid, region, size, rotation, quality, image_format): 132 | """Run IIIF Image API workflow.""" 133 | api_parameters = dict( 134 | version=version, 135 | uuid=uuid, 136 | region=region, 137 | size=size, 138 | rotation=rotation, 139 | quality=quality, 140 | image_format=image_format, 141 | ) 142 | # Trigger event before proccess the api request 143 | iiif_before_process_request.send(self, **api_parameters) 144 | 145 | # Validate IIIF parameters 146 | IIIFImageAPIWrapper.validate_api(**api_parameters) 147 | 148 | # build the image key 149 | key = "iiif:{0}/{1}/{2}/{3}/{4}.{5}".format( 150 | uuid, region, size, quality, rotation, image_format 151 | ) 152 | 153 | # Check if its cached 154 | try: 155 | cached = current_iiif.cache.get(key) 156 | except Exception: 157 | if current_app.config.get("IIIF_CACHE_IGNORE_ERRORS", False): 158 | cached = None 159 | else: 160 | raise 161 | 162 | # If the image is cached loaded from cache 163 | if cached: 164 | to_serve = BytesIO(cached) 165 | to_serve.seek(0) 166 | # Otherwise create the image 167 | else: 168 | data = current_iiif.uuid_to_image_opener(uuid) 169 | image = IIIFImageAPIWrapper.open_image(data) 170 | 171 | image.apply_api( 172 | version=version, 173 | region=region, 174 | size=size, 175 | rotation=rotation, 176 | quality=quality, 177 | ) 178 | 179 | # prepare image to be serve 180 | to_serve = image.serve(image_format=image_format) 181 | image.close_image() 182 | if should_cache(request.args): 183 | try: 184 | current_iiif.cache.set(key, to_serve.getvalue()) 185 | except Exception: 186 | if not current_app.config.get("IIIF_CACHE_IGNORE_ERRORS", False): 187 | raise 188 | 189 | try: 190 | last_modified = current_iiif.cache.get_last_modification(key) 191 | except Exception: 192 | if not current_app.config.get("IIIF_CACHE_IGNORE_ERRORS", False): 193 | raise 194 | last_modified = None 195 | 196 | # decide the mime_type from the requested image_format 197 | mimetype = current_app.config["IIIF_FORMATS"].get(image_format, "image/jpeg") 198 | # Built the after request parameters 199 | api_after_request_parameters = dict(mimetype=mimetype, image=to_serve) 200 | 201 | # Trigger event after proccess the api request 202 | iiif_after_process_request.send(self, **api_after_request_parameters) 203 | send_file_kwargs = {"mimetype": mimetype} 204 | # last_modified is not supported before flask 0.12 205 | additional_headers = [] 206 | if last_modified: 207 | send_file_kwargs.update(last_modified=last_modified) 208 | 209 | if "dl" in request.args: 210 | filename = secure_filename(request.args.get("dl", "")) 211 | if filename.lower() in {"", "1", "true"}: 212 | filename = "{0}-{1}-{2}-{3}-{4}.{5}".format( 213 | uuid, region, size, quality, rotation, image_format 214 | ) 215 | send_file_kwargs.update( 216 | as_attachment=True, 217 | download_name=secure_filename(filename), 218 | ) 219 | if_modified_since_raw = request.headers.get("If-Modified-Since") 220 | if if_modified_since_raw: 221 | if_modified_since = datetime.datetime(*parsedate(if_modified_since_raw)[:6]) 222 | if if_modified_since and if_modified_since >= last_modified: 223 | return Response(status=304) 224 | response = send_file(to_serve, **send_file_kwargs) 225 | if additional_headers: 226 | response.headers.extend(additional_headers) 227 | return response 228 | -------------------------------------------------------------------------------- /flask_iiif/signals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2015, 2016, 2017 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | 10 | """Signals triggered on Flask-IIIF API request. 11 | 12 | .. note:: 13 | 14 | The signals are triggered before process, after the validation and after 15 | the process of the request. 16 | 17 | .. code-block:: python 18 | 19 | from flask import current_app 20 | 21 | from flask_iiif.signals import ( 22 | iiif_after_process_request, iiif_before_process_request 23 | ) 24 | 25 | def on_before_process_request( 26 | current_object_state, version='', uuid='', region='', size='', 27 | rotate='', quality='', image_format=''): 28 | # Do something before process the request 29 | 30 | iiif_before_process_request.connect_via(on_before_process_request) 31 | 32 | def on_after_process_request( 33 | current_object_state, mimetype='', image=''): 34 | # Do something after proccess the request 35 | 36 | iiif_after_process_request.connect(on_after_process_request) 37 | 38 | """ 39 | 40 | from blinker import Namespace 41 | 42 | _signals = Namespace() 43 | 44 | # Before request 45 | iiif_before_process_request = _signals.signal("iiif-before-process-request") 46 | # After request 47 | iiif_after_process_request = _signals.signal("iiif-after-process-request") 48 | # Before info.json request 49 | iiif_before_info_request = _signals.signal("iiif-before-info-request") 50 | # After info.json request 51 | iiif_after_info_request = _signals.signal("iiif-after-info-request") 52 | -------------------------------------------------------------------------------- /flask_iiif/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014, 2015, 2016 CERN. 5 | # Copyright (C) 2020 data-futures. 6 | # 7 | # Flask-IIIF is free software; you can redistribute it and/or modify 8 | # it under the terms of the Revised BSD License; see LICENSE file for 9 | # more details. 10 | 11 | """Flask-IIIF utilities.""" 12 | import datetime 13 | import shutil 14 | import tempfile 15 | from email.utils import formatdate 16 | from os.path import dirname, join 17 | 18 | from flask import abort, current_app, url_for 19 | from PIL import Image, ImageSequence 20 | 21 | __all__ = ("iiif_image_url",) 22 | 23 | 24 | def iiif_image_url(**kwargs): 25 | """Generate a `IIIF API` image url. 26 | 27 | :returns: `IIIF API` image url 28 | :rtype: str 29 | 30 | .. code:: html 31 | 32 | Title 36 | 37 | .. note:: 38 | 39 | If any IIIF parameter missing it will fall back to default: 40 | * `image_format = png` 41 | * `quality = default` 42 | * `region = full` 43 | * `rotation = 0` 44 | * `size = full` 45 | * `version = v2` 46 | """ 47 | try: 48 | assert kwargs.get("uuid") is not None 49 | except AssertionError: 50 | abort(404) 51 | else: 52 | url_for_args = {k: v for k, v in kwargs.items() if k.startswith("_")} 53 | 54 | return url_for( 55 | "iiifimageapi", 56 | image_format=kwargs.get("image_format", "png"), 57 | quality=kwargs.get("quality", "default"), 58 | region=kwargs.get("region", "full"), 59 | rotation=kwargs.get("rotation", 0), 60 | size=kwargs.get("size", "full"), 61 | uuid=kwargs.get("uuid"), 62 | version=kwargs.get("version", "v2"), 63 | **url_for_args 64 | ) 65 | 66 | 67 | def should_cache(request_args): 68 | """Check the request args for cache-control specifications. 69 | 70 | :param request_args: flask request args 71 | """ 72 | if "cache-control" in request_args and request_args["cache-control"] in [ 73 | "no-cache", 74 | "no-store", 75 | ]: 76 | return False 77 | return True 78 | 79 | 80 | def datetime_to_float(date): 81 | """Convert datetime to string accepted by browsers as per RFC 2822.""" 82 | epoch = datetime.datetime.utcfromtimestamp(0) 83 | total_seconds = (date - epoch).total_seconds() 84 | return formatdate(total_seconds, usegmt=True) 85 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel", "babel>2.8"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | -------------------------------------------------------------------------------- /run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # -*- coding: utf-8 -*- 3 | # 4 | # This file is part of Invenio. 5 | # Copyright (C) 2014-2024 CERN. 6 | # Copyright (C) 2022 Graz University of Technology. 7 | # 8 | # Invenio is free software; you can redistribute it and/or modify it 9 | # under the terms of the MIT License; see LICENSE file for more details. 10 | 11 | # Quit on errors 12 | set -o errexit 13 | 14 | # Quit on unbound symbols 15 | set -o nounset 16 | 17 | # Always bring down docker services 18 | function cleanup() { 19 | eval "$(docker-services-cli down --env)" 20 | } 21 | trap cleanup EXIT 22 | 23 | python -m check_manifest 24 | # TODO: We've temporarily removed the -W flag because of unresolvable warnings 25 | python -m sphinx.cmd.build -qnN docs docs/_build/html 26 | eval "$(docker-services-cli up --cache ${CACHE:-redis} --env)" 27 | python -m pytest 28 | tests_exit_code=$? 29 | exit "$tests_exit_code" 30 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014 CERN. 5 | # Copyright (C) 2022-2024 Graz University of Technology. 6 | # 7 | # Flask-IIIF is free software; you can redistribute it and/or modify 8 | # it under the terms of the Revised BSD License; see LICENSE file for 9 | # more details. 10 | 11 | [metadata] 12 | name = flask-iiif 13 | version = attr: flask_iiif.__version__ 14 | description = "Flask-IIIF extension provides easy IIIF API standard integration." 15 | long_description = file: README.rst, CHANGES.rst 16 | license = BSD 17 | author = Invenio collaboration 18 | author_email = info@inveniosoftware.org 19 | platforms = any 20 | url = http://github.com/inveniosoftware/flask-iiif/ 21 | classifiers = 22 | Development Status :: 5 - Production/Stable 23 | 24 | [options] 25 | include_package_data = True 26 | packages = find: 27 | python_requires = >=3.7 28 | zip_safe = False 29 | install_requires = 30 | blinker>=1.4 31 | cachelib>=0.1 32 | Flask>=2.0 33 | Flask-RESTful>=0.3.7 34 | pillow>=7.0 35 | six>=1.7.2 36 | 37 | [options.extras_require] 38 | tests = 39 | pytest-black-ng>=0.4.0 40 | flask-testing>=0.6.0 41 | pytest-invenio>=1.4.0 42 | sphinx>=4.5 43 | redis>=3.5 44 | 45 | [build_sphinx] 46 | source-dir = docs/ 47 | build-dir = docs/_build 48 | all_files = 1 49 | 50 | [bdist_wheel] 51 | universal = 1 52 | 53 | [isort] 54 | profile=black 55 | 56 | [check-manifest] 57 | ignore = 58 | *-requirements.txt 59 | 60 | [tool:pytest] 61 | addopts = --black --isort --pydocstyle --doctest-glob="*.rst" --doctest-modules --cov=flask_iiif --cov-report=term-missing 62 | testpaths = tests flask_iiif 63 | filterwarnings = ignore::pytest.PytestDeprecationWarning 64 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014, 2015, 2016, 2017 CERN. 5 | # Copyright (C) 2020 data-futures. 6 | # Copyright (C) 2022 Graz University of Technology. 7 | # 8 | # Flask-IIIF is free software; you can redistribute it and/or modify 9 | # it under the terms of the Revised BSD License; see LICENSE file for 10 | # more details. 11 | 12 | """Flask-IIIF extension provides easy IIIF API standard integration.""" 13 | 14 | from setuptools import setup 15 | 16 | setup() 17 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | r"""Flask-IIIF tests.""" 10 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014, 2015 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | 10 | """Flask-IIIF test helpers.""" 11 | 12 | from contextlib import contextmanager 13 | from io import BytesIO 14 | 15 | from flask import Flask, abort, url_for 16 | from flask_testing import TestCase 17 | 18 | 19 | class IIIFTestCase(TestCase): 20 | """IIIF REST test case.""" 21 | 22 | def create_app(self): 23 | """Create the app.""" 24 | from flask_restful import Api 25 | 26 | from flask_iiif import IIIF 27 | from flask_iiif.cache.simple import ImageSimpleCache 28 | 29 | app = Flask(__name__) 30 | app.config["DEBUG"] = True 31 | app.config["TESTING"] = True 32 | app.config["SERVER_NAME"] = "shield.worker.node.1" 33 | app.config["SITE_URL"] = "http://shield.worker.node.1" 34 | app.config["IIIF_CACHE_HANDLER"] = ImageSimpleCache() 35 | app.config["PRESERVE_CONTEXT_ON_EXCEPTION"] = False 36 | app.logger.disabled = True 37 | 38 | api = Api(app=app) 39 | 40 | iiif = IIIF(app=app) 41 | 42 | iiif.uuid_to_image_opener_handler(self.create_image) 43 | 44 | def api_decorator_test(*args, **kwargs): 45 | if "decorator" in kwargs.get("uuid"): 46 | abort(403) 47 | 48 | iiif.api_decorator_handler(api_decorator_test) 49 | 50 | iiif.init_restful(api) 51 | return app 52 | 53 | def get(self, *args, **kwargs): 54 | """Simulate a GET request.""" 55 | return self.make_request(self.client.get, *args, **kwargs) 56 | 57 | def post(self, *args, **kwargs): 58 | """Simulate a POST request.""" 59 | return self.make_request(self.client.post, *args, **kwargs) 60 | 61 | def put(self, *args, **kwargs): 62 | """Simulate a PUT request.""" 63 | return self.make_request(self.client.put, *args, **kwargs) 64 | 65 | def delete(self, *args, **kwargs): 66 | """Simulate a DELETE request.""" 67 | return self.make_request(self.client.delete, *args, **kwargs) 68 | 69 | def patch(self, *args, **kwargs): 70 | """Simulate a PATCH request.""" 71 | return self.make_request(self.client.patch, *args, **kwargs) 72 | 73 | def make_request(self, client_func, endpoint, urlargs=None, *args, **kwargs): 74 | """Simulate a request.""" 75 | url = url_for(endpoint, **(urlargs or {})) 76 | response = client_func( 77 | url, base_url=self.app.config["SITE_URL"], *args, **kwargs 78 | ) 79 | return response 80 | 81 | def create_image(self, uuid): 82 | """Create a test image.""" 83 | if uuid.startswith("valid"): 84 | from PIL import Image 85 | 86 | tmp_file = BytesIO() 87 | # create a new image 88 | image = Image.new("RGBA", (1280, 1024), (255, 0, 0, 0)) 89 | image.save(tmp_file, "png") 90 | tmp_file.seek(0) 91 | return tmp_file 92 | return "" 93 | 94 | 95 | class IIIFTestCaseWithRedis(IIIFTestCase): 96 | """IIIF REST test case with Redis cache.""" 97 | 98 | def create_app(self): 99 | """Create the app.""" 100 | from flask_restful import Api 101 | 102 | from flask_iiif import IIIF 103 | from flask_iiif.cache.redis import ImageRedisCache 104 | 105 | app = Flask(__name__) 106 | app.config["DEBUG"] = True 107 | app.config["TESTING"] = True 108 | app.config["SERVER_NAME"] = "shield.worker.node.1" 109 | app.config["SITE_URL"] = "http://shield.worker.node.1" 110 | app.config["IIIF_CACHE_REDIS_URL"] = "redis://localhost:6379/0" 111 | app.config["IIIF_CACHE_HANDLER"] = ImageRedisCache(app) 112 | app.config["PRESERVE_CONTEXT_ON_EXCEPTION"] = False 113 | app.logger.disabled = True 114 | 115 | api = Api(app=app) 116 | 117 | iiif = IIIF(app=app) 118 | 119 | iiif.uuid_to_image_opener_handler(self.create_image) 120 | 121 | def api_decorator_test(*args, **kwargs): 122 | if "decorator" in kwargs.get("uuid"): 123 | abort(403) 124 | 125 | iiif.api_decorator_handler(api_decorator_test) 126 | 127 | iiif.init_restful(api) 128 | return app 129 | 130 | 131 | @contextmanager 132 | def signal_listener(signal): 133 | """Context Manager that listen to signals. 134 | 135 | .. note:: 136 | 137 | Checks if any signal were fired and returns the passed arguments. 138 | 139 | .. code-block:: python 140 | 141 | from somesignals import signal 142 | 143 | with signal_listener(signal) as listener: 144 | fire_the_signal() 145 | results = listener.assert_signal() 146 | # Assert the results here 147 | 148 | """ 149 | 150 | class _Signal(object): 151 | def __init__(self): 152 | self.heard = [] 153 | 154 | def add(self, *args, **kwargs): 155 | """Keep the signals.""" 156 | self.heard.append((args, kwargs)) 157 | 158 | def assert_signal(self): 159 | """Check signal.""" 160 | if len(self.heard) == 0: 161 | raise AssertionError("No signals.") 162 | return self.heard[0][0], self.heard[0][1] 163 | 164 | signals = _Signal() 165 | signal.connect(signals.add) 166 | 167 | try: 168 | yield signals 169 | finally: 170 | signal.disconnect(signals.add) 171 | -------------------------------------------------------------------------------- /tests/test_context_template.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2015-2024 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | 10 | """Context Template Tests.""" 11 | 12 | from .helpers import IIIFTestCase 13 | 14 | 15 | class TestContextTemplates(IIIFTestCase): 16 | """Context templates test case.""" 17 | 18 | def test_context_tempalte_iiif_image_url(self): 19 | """Test context template.""" 20 | from werkzeug.exceptions import NotFound 21 | 22 | from flask_iiif.utils import iiif_image_url 23 | 24 | with self.app.app_context(): 25 | image_url_default = iiif_image_url(uuid="test-ünicode") 26 | image_url_default_answer = ( 27 | "/api/multimedia/image/v2/" "test-%C3%BCnicode/full/full/0/default.png" 28 | ) 29 | self.assertEqual(image_url_default_answer, image_url_default) 30 | image_url_custom_answer = ( 31 | "/api/multimedia/image/v1/" 32 | "test-%C3%BCnicode/full/full/180/default.jpg" 33 | ) 34 | image_url_custom = iiif_image_url( 35 | uuid="test-ünicode", image_format="jpg", rotation=180, version="v1" 36 | ) 37 | self.assertEqual(image_url_custom_answer, image_url_custom) 38 | self.assertRaises(NotFound, iiif_image_url, size="200,") 39 | -------------------------------------------------------------------------------- /tests/test_ext.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014, 2015 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | 10 | """Flask-IIIF extension test.""" 11 | 12 | from __future__ import absolute_import 13 | 14 | from unittest import TestCase 15 | 16 | from flask import Flask 17 | 18 | from flask_iiif import IIIF 19 | from flask_iiif import config as default_config 20 | 21 | 22 | class TestIIIF(TestCase): 23 | """Test extension creation.""" 24 | 25 | def setUp(self): 26 | """Setup up.""" 27 | app = Flask(__name__) 28 | app.config["DEBUG"] = True 29 | app.config["TESTING"] = True 30 | app.logger.disabled = True 31 | self.app = app 32 | 33 | def test_version(self): 34 | """Assert that version number can be parsed.""" 35 | from distutils.version import LooseVersion 36 | 37 | from flask_iiif import __version__ 38 | 39 | LooseVersion(__version__) 40 | 41 | def test_creation(self): 42 | """Test extension creation.""" 43 | assert "iiif" not in self.app.extensions 44 | IIIF(app=self.app) 45 | assert isinstance(self.app.extensions["iiif"], IIIF) 46 | 47 | def test_creation_old_flask(self): 48 | """Simulate old Flask (pre 0.9).""" 49 | del self.app.extensions 50 | IIIF(app=self.app) 51 | assert isinstance(self.app.extensions["iiif"], IIIF) 52 | 53 | def test_creation_init(self): 54 | """Test extension creation init.""" 55 | assert "iiif" not in self.app.extensions 56 | r = IIIF() 57 | r.init_app(app=self.app) 58 | assert isinstance(self.app.extensions["iiif"], IIIF) 59 | 60 | def test_double_creation(self): 61 | """Test extension double creation.""" 62 | IIIF(app=self.app) 63 | self.assertRaises(RuntimeError, IIIF, app=self.app) 64 | 65 | def test_default_config(self): 66 | """Test extension default configuration.""" 67 | IIIF(app=self.app) 68 | for k in dir(default_config): 69 | if k.startswith("IIIF_"): 70 | assert self.app.config.get(k) == getattr(default_config, k) 71 | -------------------------------------------------------------------------------- /tests/test_image_redis_cache.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2017-2024 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | 10 | """Image Redis Cache Tests.""" 11 | 12 | from __future__ import absolute_import 13 | 14 | from six import BytesIO 15 | 16 | from .helpers import IIIFTestCase 17 | 18 | 19 | class TestImageRedisCache(IIIFTestCase): 20 | """Multimedia Image Redis Cache test case.""" 21 | 22 | def setUp(self): 23 | """Run before the test.""" 24 | # Create a redis cache object 25 | from PIL import Image 26 | 27 | from flask_iiif.cache.redis import ImageRedisCache 28 | 29 | # Initialize the cache object 30 | self.cache = ImageRedisCache() 31 | # Create an image in memory 32 | tmp_file = BytesIO() 33 | # create a new image 34 | image = Image.new("RGBA", (1280, 1024), (255, 0, 0, 0)) 35 | image.save(tmp_file, "png") 36 | # Store the image 37 | tmp_file.seek(0) 38 | self.image_file = tmp_file 39 | 40 | def test_set_and_get_function(self): 41 | """Test cache set and get functions.""" 42 | # Seek position 43 | self.image_file.seek(0) 44 | # Add image to cache 45 | self.cache.set("image_1", self.image_file.getvalue()) 46 | # Get image from cache 47 | image_string = self.cache.get("image_1") 48 | # test if the cache image is equal to the real 49 | self.assertEqual(image_string, self.image_file.getvalue()) 50 | 51 | def test_image_recreation(self): 52 | """Test the image recreation from cache.""" 53 | from flask_iiif.api import MultimediaImage 54 | 55 | # Seek position 56 | self.image_file.seek(0) 57 | # Add the image to cache 58 | self.cache.set("image_2", self.image_file.getvalue()) 59 | # Get image from cache 60 | image_string = self.cache.get("image_2") 61 | # Create a ByteIO object 62 | cached_image = BytesIO(image_string) 63 | # Seek object to the right position 64 | cached_image.seek(0) 65 | # Create an image object form the stored string 66 | image = MultimediaImage.from_string(cached_image) 67 | # Check if the image is still the same 68 | self.assertEqual(str(image.size()), str((1280, 1024))) 69 | 70 | def test_cache_deletion(self): 71 | """Test cache delete function.""" 72 | self.cache.set("foo", "bar") 73 | self.assertEqual(self.cache.get("foo"), "bar") 74 | self.cache.delete("foo") 75 | self.assertEqual(self.cache.get("foo"), None) 76 | 77 | def test_cache_flush(self): 78 | """Test cache flush function.""" 79 | self.cache.set("foo_1", "bar") 80 | self.cache.set("foo_2", "bar") 81 | self.cache.set("foo_3", "bar") 82 | for i in [1, 2, 3]: 83 | self.assertEqual(self.cache.get("foo_{0}".format(i)), "bar") 84 | self.cache.flush() 85 | for i in [1, 2, 3]: 86 | self.assertEqual(self.cache.get("foo_{0}".format(i)), None) 87 | 88 | def test_default_prefix_for_redis(self): 89 | """Test default redis prefix""" 90 | # Test default prefix for redis keys (when nothing set in config) 91 | self.assertEqual(self.cache.cache.key_prefix, "iiif") 92 | 93 | def test_redis_prefix_set_properly(self): 94 | """Test if ImageRedisCache properly sets redis prefix""" 95 | from flask import current_app 96 | 97 | from flask_iiif.cache.redis import ImageRedisCache 98 | 99 | # Store old prefix 100 | old_prefix = current_app.config.get("IIIF_CACHE_REDIS_PREFIX") 101 | # Set new prefix in config 102 | current_app.config["IIIF_CACHE_REDIS_PREFIX"] = "TEST_PREFIX" 103 | # Create new ImageRedisCache which should read new prefix from config 104 | tmp_redis_cache = ImageRedisCache() 105 | # Check prefix set in ImageRedisCache object 106 | self.assertEqual(tmp_redis_cache.cache.key_prefix, "TEST_PREFIX") 107 | # Restore old prefix in config 108 | current_app.config["IIIF_CACHE_REDIS_PREFIX"] = old_prefix 109 | 110 | def test_removing_keys_removes_only_ones_with_prefix(self): 111 | """Test if ImageRedisCache properly removes only keys with it's prefix""" 112 | from flask import current_app 113 | 114 | from flask_iiif.cache.redis import ImageRedisCache 115 | 116 | # Create few keys with default prefix 117 | self.cache.set("key_1", "value_1") 118 | self.cache.set("key_2", "value_2") 119 | self.cache.set("key_3", "value_3") 120 | 121 | # Create RedisCache with different prefix 122 | old_prefix = current_app.config.get("IIIF_CACHE_REDIS_PREFIX") 123 | current_app.config["IIIF_CACHE_REDIS_PREFIX"] = "TEST_PREFIX" 124 | tmp_redis_cache = ImageRedisCache() 125 | current_app.config["IIIF_CACHE_REDIS_PREFIX"] = old_prefix 126 | 127 | # Create few keys with different prefix 128 | tmp_redis_cache.set("key_1", "value_4") 129 | tmp_redis_cache.set("key_2", "value_5") 130 | tmp_redis_cache.set("key_3", "value_6") 131 | 132 | # Remove all keys with default prefix 133 | self.cache.flush() 134 | 135 | # Check if keys from second prefix are still in redis 136 | self.assertEqual(tmp_redis_cache.get("key_1"), "value_4") 137 | self.assertEqual(tmp_redis_cache.get("key_2"), "value_5") 138 | self.assertEqual(tmp_redis_cache.get("key_3"), "value_6") 139 | -------------------------------------------------------------------------------- /tests/test_image_simple_cache.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014, 2015 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | 10 | """Image Simple Cache Tests.""" 11 | 12 | from __future__ import absolute_import 13 | 14 | from io import BytesIO 15 | 16 | from .helpers import IIIFTestCase 17 | 18 | 19 | class TestImageSimpleCache(IIIFTestCase): 20 | """Multimedia Image Simple Cache test case.""" 21 | 22 | def setUp(self): 23 | """Run before the test.""" 24 | # Create a simple cache object 25 | from PIL import Image 26 | 27 | from flask_iiif.cache.simple import ImageSimpleCache 28 | 29 | # Initialize the cache object 30 | self.cache = ImageSimpleCache() 31 | # Create an image in memory 32 | tmp_file = BytesIO() 33 | # create a new image 34 | image = Image.new("RGBA", (1280, 1024), (255, 0, 0, 0)) 35 | image.save(tmp_file, "png") 36 | # Store the image 37 | tmp_file.seek(0) 38 | self.image_file = tmp_file 39 | 40 | def test_set_and_get_function(self): 41 | """Test cache set and get functions.""" 42 | # Seek position 43 | self.image_file.seek(0) 44 | # Add image to cache 45 | self.cache.set("image_1", self.image_file.getvalue()) 46 | # Get image from cache 47 | image_string = self.cache.get("image_1") 48 | # test if the cache image is equal to the real 49 | self.assertEqual(image_string, self.image_file.getvalue()) 50 | 51 | def test_image_recreation(self): 52 | """Test the image recreation from cache.""" 53 | from flask_iiif.api import MultimediaImage 54 | 55 | # Seek position 56 | self.image_file.seek(0) 57 | # Add the image to cache 58 | self.cache.set("image_2", self.image_file.getvalue()) 59 | # Get image from cache 60 | image_string = self.cache.get("image_2") 61 | # Create a ByteIO object 62 | cached_image = BytesIO(image_string) 63 | # Seek object to the right position 64 | cached_image.seek(0) 65 | # Create an image object form the stored string 66 | image = MultimediaImage.from_string(cached_image) 67 | # Check if the image is still the same 68 | self.assertEqual(str(image.size()), str((1280, 1024))) 69 | 70 | def test_cache_deletion(self): 71 | """Test cache delete function.""" 72 | self.cache.set("foo", "bar") 73 | self.assertEqual(self.cache.get("foo"), "bar") 74 | self.cache.delete("foo") 75 | self.assertEqual(self.cache.get("foo"), None) 76 | 77 | def test_cache_flush(self): 78 | """Test cache flush function.""" 79 | self.cache.set("foo_1", "bar") 80 | self.cache.set("foo_2", "bar") 81 | self.cache.set("foo_3", "bar") 82 | for i in [1, 2, 3]: 83 | self.assertEqual(self.cache.get("foo_{0}".format(i)), "bar") 84 | self.cache.flush() 85 | for i in [1, 2, 3]: 86 | self.assertEqual(self.cache.get("foo_{0}".format(i)), None) 87 | -------------------------------------------------------------------------------- /tests/test_multimedia_image_api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2014, 2015, 2016 CERN. 5 | # Copyright (C) 2020 data-futures. 6 | # 7 | # Flask-IIIF is free software; you can redistribute it and/or modify 8 | # it under the terms of the Revised BSD License; see LICENSE file for 9 | # more details. 10 | 11 | """Multimedia Image API Tests.""" 12 | 13 | from io import BytesIO 14 | 15 | import pytest 16 | from PIL import Image 17 | 18 | from flask_iiif.api import MultimediaImage 19 | 20 | from .helpers import IIIFTestCase 21 | 22 | 23 | class TestMultimediaAPI(IIIFTestCase): 24 | """Multimedia Image API test case.""" 25 | 26 | @pytest.mark.parametrize( 27 | "width, height", 28 | [(1280, 1024), (100, 100), (1024, 1280), (200, 100), (1280, 720)], # portrait 29 | ) 30 | def setUp(self, width=1280, height=1024): 31 | """Run before the test.""" 32 | # Create an image in memory 33 | 34 | tmp_file = BytesIO() 35 | # create a new image 36 | image = Image.new("RGBA", (width, height), (255, 0, 0, 0)) 37 | image.save(tmp_file, "png") 38 | self.width, self.height = width, height 39 | 40 | # Initialize it for our object and create and instance for 41 | # each test 42 | tmp_file.seek(0) 43 | self.image_resize = MultimediaImage.from_string(tmp_file) 44 | tmp_file.seek(0) 45 | self.image_crop = MultimediaImage.from_string(tmp_file) 46 | tmp_file.seek(0) 47 | self.image_rotate = MultimediaImage.from_string(tmp_file) 48 | tmp_file.seek(0) 49 | self.image_errors = MultimediaImage.from_string(tmp_file) 50 | tmp_file.seek(0) 51 | self.image_convert = MultimediaImage.from_string(tmp_file) 52 | tmp_file.seek(0) 53 | self.image_save = MultimediaImage.from_string(tmp_file) 54 | 55 | # NOT RGBA 56 | tmp_file = BytesIO() 57 | # create a new image 58 | image = Image.new("RGB", (width, height), (255, 0, 0, 0)) 59 | image.save(tmp_file, "jpeg") 60 | tmp_file.seek(0) 61 | self.image_not_rgba = MultimediaImage.from_string(tmp_file) 62 | 63 | # Image in P Mode 64 | tmp_file = BytesIO() 65 | image = Image.new("P", (width, height)) 66 | image.save(tmp_file, "gif") 67 | tmp_file.seek(0) 68 | self.image_p_mode = MultimediaImage.from_string(tmp_file) 69 | 70 | # TIFF Image 71 | tmp_file = BytesIO() 72 | image = Image.new("RGBA", (width, height), (255, 0, 0, 0)) 73 | image.save(tmp_file, "tiff") 74 | tmp_file.seek(0) 75 | self.image_tiff = MultimediaImage.from_string(tmp_file) 76 | 77 | def test_image_resize(self): 78 | """Test image resize function.""" 79 | # Test image size before 80 | self.assertEqual(str(self.image_resize.size()), str((self.width, self.height))) 81 | 82 | # Resize.image_resize to 720,680 83 | self.image_resize.resize("720,680") 84 | self.assertEqual(str(self.image_resize.size()), str((720, 680))) 85 | 86 | # Resize.image_resize to 300, 87 | self.image_resize.resize("300,") 88 | self.assertEqual(str(self.image_resize.size()), str((300, 283))) 89 | 90 | # Resize.image_resize to ,300 91 | self.image_resize.resize(",300") 92 | self.assertEqual(str(self.image_resize.size()), str((318, 300))) 93 | 94 | # Resize.image_resize to pct:90 95 | self.image_resize.resize("pct:90") 96 | self.assertEqual(str(self.image_resize.size()), str((286, 270))) 97 | 98 | # Resize.image_resize to !100,100 99 | self.image_resize.resize("!100,100") 100 | self.assertEqual(str(self.image_resize.size()), str((100, 94))) 101 | 102 | def test_errors(self): 103 | """Test errors.""" 104 | from flask_iiif.errors import ( 105 | MultimediaImageCropError, 106 | MultimediaImageFormatError, 107 | MultimediaImageNotFound, 108 | MultimediaImageQualityError, 109 | MultimediaImageResizeError, 110 | ) 111 | 112 | # Test resize errors 113 | self.assertRaises( 114 | MultimediaImageResizeError, self.image_errors.resize, "pct:-12222" 115 | ) 116 | self.assertRaises(MultimediaImageResizeError, self.image_errors.resize, "2") 117 | self.assertRaises( 118 | MultimediaImageResizeError, self.image_errors.resize, "-22,100" 119 | ) 120 | # Test crop errors 121 | self.assertRaises( 122 | MultimediaImageCropError, self.image_errors.crop, "22,100,222" 123 | ) 124 | self.assertRaises( 125 | MultimediaImageCropError, self.image_errors.crop, "-22,100,222,323" 126 | ) 127 | self.assertRaises( 128 | MultimediaImageCropError, self.image_errors.crop, "pct:222,100,222,323" 129 | ) 130 | self.assertRaises( 131 | MultimediaImageCropError, self.image_errors.crop, "2000,2000,2000,2000" 132 | ) 133 | self.assertRaises( 134 | MultimediaImageNotFound, self.image_errors.from_file, "unicorn" 135 | ) 136 | with self.app.app_context(): 137 | self.assertRaises( 138 | MultimediaImageQualityError, 139 | self.image_errors.quality, 140 | "pct:222,100,222,323", 141 | ) 142 | self.assertRaises( 143 | MultimediaImageFormatError, 144 | self.image_errors._prepare_for_output, 145 | "pct:222,100,222,323", 146 | ) 147 | 148 | def test_image_crop(self): 149 | """Test the crop function.""" 150 | # Crop image x,y,w,h 151 | self.image_crop.crop("20,20,400,300") 152 | self.assertEqual(str(self.image_crop.size()), str((400, 300))) 153 | 154 | # Crop image pct:x,y,w,h 155 | self.image_crop.crop("pct:20,20,40,30") 156 | self.assertEqual(str(self.image_crop.size()), str((160, 90))) 157 | 158 | # Check if exeeds image borders 159 | self.image_crop.crop("10,10,160,90") 160 | self.assertEqual(str(self.image_crop.size()), str((150, 80))) 161 | 162 | def test_image_rotate(self): 163 | """Test image rotate function.""" 164 | self.image_rotate.rotate(90) 165 | self.assertEqual(str(self.image_rotate.size()), str((1024, 1280))) 166 | 167 | self.image_rotate.rotate(120) 168 | self.assertEqual(str(self.image_rotate.size()), str((1622, 1528))) 169 | 170 | def test_image_mode(self): 171 | """Test image mode.""" 172 | self.image_not_rgba.quality("grey") 173 | self.assertEqual(self.image_not_rgba.image.mode, "L") 174 | 175 | def test_image_incompatible_modes(self): 176 | """Test P-incompatible image to RGB auto-convert.""" 177 | tmp_file = BytesIO() 178 | self.image_p_mode.save(tmp_file) 179 | self.assertEqual(self.image_p_mode.image.mode, "RGB") 180 | 181 | def test_image_saving(self): 182 | """Test image saving.""" 183 | tmp_file = BytesIO() 184 | compare_file = BytesIO() 185 | self.assertEqual(tmp_file.getvalue(), compare_file.getvalue()) 186 | self.image_save.save(tmp_file) 187 | self.assertNotEqual(str(tmp_file.getvalue()), compare_file.getvalue()) 188 | 189 | def test_image_tiff_support(self): 190 | """Test TIFF image support.""" 191 | self.assertEqual(self.image_tiff.image.format, "TIFF") 192 | -------------------------------------------------------------------------------- /tests/test_restful_api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2015-2024 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | 10 | """Test REST API.""" 11 | 12 | from io import BytesIO 13 | from unittest.mock import patch 14 | 15 | from flask import url_for 16 | from PIL import Image 17 | from werkzeug.utils import secure_filename 18 | 19 | from .helpers import IIIFTestCase 20 | 21 | 22 | class TestRestAPI(IIIFTestCase): 23 | """Test signals and decorators.""" 24 | 25 | def test_api_base(self): 26 | """Test API Base.""" 27 | data = dict(uuid="valid:id", version="v2") 28 | get_the_response = self.get("iiifimagebase", urlargs=data) 29 | self.assertEqual(get_the_response.status_code, 303) 30 | 31 | def test_api_info(self): 32 | """Test API Info not found case.""" 33 | from flask import jsonify 34 | 35 | id_v1 = url_for( 36 | "iiifimagebase", uuid="valid:id-üni", version="v1", _external=True 37 | ) 38 | id_v2 = url_for( 39 | "iiifimagebase", uuid="valid:id-üni", version="v2", _external=True 40 | ) 41 | 42 | expected = { 43 | "v1": { 44 | "@context": ( 45 | "http://library.stanford.edu/iiif/" "image-api/1.1/context.json" 46 | ), 47 | "@id": id_v1, 48 | "width": 1280, 49 | "height": 1024, 50 | "profile": ( 51 | "http://library.stanford.edu/iiif/image-api/compliance" 52 | ".html#level1" 53 | ), 54 | "tile_width": 256, 55 | "tile_height": 256, 56 | "scale_factors": [1, 2, 4, 8, 16, 32, 64], 57 | }, 58 | "v2": { 59 | "@context": "http://iiif.io/api/image/2/context.json", 60 | "@id": id_v2, 61 | "protocol": "http://iiif.io/api/image", 62 | "width": 1280, 63 | "height": 1024, 64 | "tiles": [{"width": 256, "scaleFactors": [1, 2, 4, 8, 16, 32, 64]}], 65 | "profile": ["http://iiif.io/api/image/2/level2.json"], 66 | }, 67 | } 68 | get_the_response = self.get( 69 | "iiifimageinfo", 70 | urlargs=dict( 71 | uuid="valid:id-üni", 72 | version="v2", 73 | ), 74 | ) 75 | self.assert200(get_the_response) 76 | get_the_response = self.get( 77 | "iiifimageinfo", 78 | urlargs=dict( 79 | uuid="valid:id-üni", 80 | version="v2", 81 | ), 82 | ) 83 | self.assertEqual(jsonify(expected.get("v2")).data, get_the_response.data) 84 | 85 | get_the_response = self.get( 86 | "iiifimageinfo", 87 | urlargs=dict( 88 | uuid="valid:id-üni", 89 | version="v1", 90 | ), 91 | ) 92 | self.assert200(get_the_response) 93 | self.assertEqual(jsonify(expected.get("v1")).data, get_the_response.data) 94 | 95 | def test_api_info_not_found(self): 96 | """Test API Info.""" 97 | get_the_response = self.get( 98 | "iiifimageinfo", 99 | urlargs=dict( 100 | uuid="notfound", 101 | version="v2", 102 | ), 103 | ) 104 | self.assert404(get_the_response) 105 | 106 | def test_api_not_found(self): 107 | """Test API not found case.""" 108 | get_the_response = self.get( 109 | "iiifimageapi", 110 | urlargs=dict( 111 | uuid="notfound", 112 | version="v2", 113 | region="full", 114 | size="full", 115 | rotation="0", 116 | quality="default", 117 | image_format="png", 118 | ), 119 | ) 120 | self.assert404(get_the_response) 121 | 122 | def test_api_internal_server_error(self): 123 | """Test API internal server error case.""" 124 | get_the_response = self.get( 125 | "iiifimageapi", 126 | urlargs=dict( 127 | uuid="valid:id-üni", 128 | version="v2", 129 | region="full", 130 | size="full", 131 | rotation="2220", 132 | quality="default", 133 | image_format="png", 134 | ), 135 | ) 136 | self.assert500(get_the_response) 137 | 138 | def test_api_iiif_validation_error(self): 139 | """Test API iiif validation case.""" 140 | get_the_response = self.get( 141 | "iiifimageapi", 142 | urlargs=dict( 143 | uuid="valid:id-üni", 144 | version="v1", 145 | region="200", 146 | size="full", 147 | rotation="2220", 148 | quality="default", 149 | image_format="png", 150 | ), 151 | ) 152 | self.assert400(get_the_response) 153 | 154 | def test_api_stream_image(self): 155 | """Test API stream image.""" 156 | tmp_file = BytesIO() 157 | # create a new image 158 | image = Image.new("RGBA", (1280, 1024), (255, 0, 0, 0)) 159 | image.save(tmp_file, "png") 160 | tmp_file.seek(0) 161 | get_the_response = self.get( 162 | "iiifimageapi", 163 | urlargs=dict( 164 | uuid="valid:id-üni", 165 | version="v2", 166 | region="full", 167 | size="full", 168 | rotation="0", 169 | quality="default", 170 | image_format="png", 171 | ), 172 | ) 173 | # Check if returns `Last-Modified` key in headers 174 | # required for `If-Modified-Since` 175 | self.assertTrue("Last-Modified" in get_the_response.headers) 176 | 177 | last_modified = get_the_response.headers["Last-Modified"] 178 | 179 | self.assertEqual(get_the_response.data, tmp_file.getvalue()) 180 | 181 | # Test `If-Modified-Since` recognized properly 182 | get_the_response = self.get( 183 | "iiifimageapi", 184 | urlargs=dict( 185 | uuid="valid:id-üni", 186 | version="v2", 187 | region="full", 188 | size="full", 189 | rotation="0", 190 | quality="default", 191 | image_format="png", 192 | ), 193 | headers={"If-Modified-Since": last_modified}, 194 | ) 195 | 196 | self.assertEqual(get_the_response.status_code, 304) 197 | 198 | urlargs = dict( 199 | uuid="valid:id-üni", 200 | version="v2", 201 | region="200,200,200,200", 202 | size="300,300", 203 | rotation="!50", 204 | quality="color", 205 | image_format="pdf", 206 | ) 207 | 208 | get_the_response = self.get( 209 | "iiifimageapi", 210 | urlargs=urlargs, 211 | ) 212 | self.assert200(get_the_response) 213 | 214 | default_name = "{name}-200200200200-300300-color-50.pdf".format( 215 | name=secure_filename(urlargs["uuid"]) 216 | ) 217 | for dl, name in ( 218 | ("", default_name), 219 | ("1", default_name), 220 | ("foo.pdf", "foo.pdf"), 221 | ): 222 | urlargs["dl"] = dl 223 | get_the_response = self.get( 224 | "iiifimageapi", 225 | urlargs=urlargs, 226 | ) 227 | self.assert200(get_the_response) 228 | self.assertEqual( 229 | get_the_response.headers["Content-Disposition"], 230 | "attachment; filename={name}".format(name=name), 231 | ) 232 | 233 | def test_api_decorator(self): 234 | """Test API decorator.""" 235 | get_the_response = self.get( 236 | "iiifimageapi", 237 | urlargs=dict( 238 | uuid="valid:decorator:id", 239 | version="v2", 240 | region="full", 241 | size="full", 242 | rotation="0", 243 | quality="default", 244 | image_format="png", 245 | ), 246 | ) 247 | self.assert403(get_the_response) 248 | 249 | def test_api_abort_all_methods_except_get(self): 250 | """Abort all methods but GET.""" 251 | data = dict( 252 | uuid="valid:id-üni", 253 | version="v2", 254 | region="full", 255 | size="full", 256 | rotation="0", 257 | quality="default", 258 | image_format="png", 259 | ) 260 | get_the_response = self.post("iiifimageapi", urlargs=data) 261 | self.assert405(get_the_response) 262 | 263 | get_the_response = self.put("iiifimageapi", urlargs=data) 264 | self.assert405(get_the_response) 265 | 266 | get_the_response = self.delete("iiifimageapi", urlargs=data) 267 | self.assert405(get_the_response) 268 | 269 | get_the_response = self.patch("iiifimageapi", urlargs=data) 270 | self.assert405(get_the_response) 271 | 272 | def test_api_cache_control(self): 273 | """Test cache-control headers""" 274 | 275 | urlargs = dict( 276 | uuid="valid:id-üni", 277 | version="v2", 278 | region="200,200,200,200", 279 | size="300,300", 280 | rotation="!50", 281 | quality="color", 282 | image_format="pdf", 283 | ) 284 | 285 | key = "iiif:{0}/{1}/{2}/{3}/{4}.{5}".format( 286 | urlargs["uuid"], 287 | urlargs["region"], 288 | urlargs["size"], 289 | urlargs["quality"], 290 | urlargs["rotation"], 291 | urlargs["image_format"], 292 | ) 293 | 294 | cache = self.app.config["IIIF_CACHE_HANDLER"].cache 295 | 296 | get_the_response = self.get( 297 | "iiifimageapi", 298 | urlargs=urlargs, 299 | ) 300 | 301 | self.assertFalse("cache-control" in urlargs) 302 | 303 | self.assert200(get_the_response) 304 | 305 | self.assertTrue(cache.get(key)) 306 | 307 | cache.clear() 308 | 309 | for cache_control, name in (("no-cache", "foo.pdf"), ("no-store", "foo.pdf")): 310 | urlargs["cache-control"] = cache_control 311 | 312 | get_the_response = self.get( 313 | "iiifimageapi", 314 | urlargs=urlargs, 315 | ) 316 | 317 | self.assert200(get_the_response) 318 | 319 | self.assertFalse(cache.get(key)) 320 | 321 | cache.clear() 322 | 323 | for cache_control, name in (("public", "foo.pdf"), ("no-transform", "foo.pdf")): 324 | urlargs["cache-control"] = cache_control 325 | 326 | get_the_response = self.get( 327 | "iiifimageapi", 328 | urlargs=urlargs, 329 | ) 330 | 331 | self.assert200(get_the_response) 332 | 333 | self.assertTrue(cache.get(key)) 334 | 335 | cache.clear() 336 | 337 | def test_cache_ignore_errors(self): 338 | """Test if cache retrieval errors are ignored when configured.""" 339 | from flask import current_app 340 | 341 | info_args = dict(uuid="valid:id", version="v2") 342 | api_args = dict( 343 | uuid="valid:id", 344 | version="v2", 345 | region="full", 346 | size="full", 347 | rotation="0", 348 | quality="default", 349 | image_format="png", 350 | ) 351 | 352 | cache = self.app.config["IIIF_CACHE_HANDLER"].cache 353 | with patch.object(cache, "get", side_effect=Exception("test fail")): 354 | # Without ignoring errors 355 | self.assertRaisesRegex( 356 | Exception, "test fail", self.get, "iiifimageinfo", urlargs=info_args 357 | ) 358 | self.assertRaisesRegex( 359 | Exception, "test fail", self.get, "iiifimageapi", urlargs=api_args 360 | ) 361 | 362 | # Ignore errors 363 | old_value = current_app.config.get("IIIF_CACHE_IGNORE_ERRORS") 364 | current_app.config["IIIF_CACHE_IGNORE_ERRORS"] = True 365 | 366 | resp = self.get("iiifimageinfo", urlargs=info_args) 367 | self.assert200(resp) 368 | resp = self.get("iiifimageapi", urlargs=api_args) 369 | self.assert200(resp) 370 | 371 | current_app.config["IIIF_CACHE_REDIS_PREFIX"] = old_value 372 | -------------------------------------------------------------------------------- /tests/test_restful_api_signals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2015, 2017 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | 10 | """Test REST API signals.""" 11 | import json 12 | 13 | from .helpers import IIIFTestCase, signal_listener 14 | 15 | 16 | class TestRestAPISignals(IIIFTestCase): 17 | """Test REST API signals.""" 18 | 19 | def test_api_signals(self): 20 | """Test API signals.""" 21 | from flask_iiif.signals import ( 22 | iiif_after_info_request, 23 | iiif_after_process_request, 24 | iiif_before_info_request, 25 | iiif_before_process_request, 26 | ) 27 | 28 | data = dict( 29 | uuid="valid:id", 30 | version="v2", 31 | region="full", 32 | size="full", 33 | rotation="0", 34 | quality="default", 35 | image_format="png", 36 | ) 37 | 38 | infodata = dict(version="v2", uuid="valid:id") 39 | 40 | with signal_listener(iiif_before_process_request) as listener: 41 | self.get("iiifimageapi", urlargs=data) 42 | results = listener.assert_signal() 43 | self.assertEqual(results[1], data) 44 | 45 | with signal_listener(iiif_after_process_request) as listener: 46 | response = self.get("iiifimageapi", urlargs=data) 47 | results = listener.assert_signal() 48 | self.assertEqual( 49 | (results[1].get("mimetype"), results[1].get("image").getvalue()), 50 | ("image/png", response.data), 51 | ) 52 | 53 | with signal_listener(iiif_before_info_request) as listener: 54 | response = self.get("iiifimageinfo", urlargs=infodata) 55 | results = listener.assert_signal() 56 | self.assertEqual(results[1], infodata) 57 | 58 | with signal_listener(iiif_after_info_request) as listener: 59 | response = self.get("iiifimageinfo", urlargs=infodata) 60 | results = listener.assert_signal() 61 | self.assertEqual(results[1], json.loads(response.get_data(as_text=True))) 62 | -------------------------------------------------------------------------------- /tests/test_restful_api_with_redis.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Flask-IIIF 4 | # Copyright (C) 2015-2024 CERN. 5 | # 6 | # Flask-IIIF is free software; you can redistribute it and/or modify 7 | # it under the terms of the Revised BSD License; see LICENSE file for 8 | # more details. 9 | 10 | """Test REST API.""" 11 | 12 | from io import BytesIO 13 | from unittest.mock import patch 14 | 15 | from flask import url_for 16 | from PIL import Image 17 | from werkzeug.utils import secure_filename 18 | 19 | from .helpers import IIIFTestCaseWithRedis 20 | 21 | 22 | class TestRestAPI(IIIFTestCaseWithRedis): 23 | """Test signals and decorators.""" 24 | 25 | def test_api_base(self): 26 | """Test API Base.""" 27 | data = dict(uuid="valid:id", version="v2") 28 | get_the_response = self.get("iiifimagebase", urlargs=data) 29 | self.assertEqual(get_the_response.status_code, 303) 30 | 31 | def test_api_info(self): 32 | """Test API Info not found case.""" 33 | from flask import jsonify 34 | 35 | id_v1 = url_for( 36 | "iiifimagebase", uuid="valid:id-üni", version="v1", _external=True 37 | ) 38 | id_v2 = url_for( 39 | "iiifimagebase", uuid="valid:id-üni", version="v2", _external=True 40 | ) 41 | 42 | expected = { 43 | "v1": { 44 | "@context": ( 45 | "http://library.stanford.edu/iiif/" "image-api/1.1/context.json" 46 | ), 47 | "@id": id_v1, 48 | "width": 1280, 49 | "height": 1024, 50 | "profile": ( 51 | "http://library.stanford.edu/iiif/image-api/compliance" 52 | ".html#level1" 53 | ), 54 | "tile_width": 256, 55 | "tile_height": 256, 56 | "scale_factors": [1, 2, 4, 8, 16, 32, 64], 57 | }, 58 | "v2": { 59 | "@context": "http://iiif.io/api/image/2/context.json", 60 | "@id": id_v2, 61 | "protocol": "http://iiif.io/api/image", 62 | "width": 1280, 63 | "height": 1024, 64 | "tiles": [{"width": 256, "scaleFactors": [1, 2, 4, 8, 16, 32, 64]}], 65 | "profile": ["http://iiif.io/api/image/2/level2.json"], 66 | }, 67 | } 68 | get_the_response = self.get( 69 | "iiifimageinfo", 70 | urlargs=dict( 71 | uuid="valid:id-üni", 72 | version="v2", 73 | ), 74 | ) 75 | self.assert200(get_the_response) 76 | get_the_response = self.get( 77 | "iiifimageinfo", 78 | urlargs=dict( 79 | uuid="valid:id-üni", 80 | version="v2", 81 | ), 82 | ) 83 | self.assertEqual(jsonify(expected.get("v2")).data, get_the_response.data) 84 | 85 | get_the_response = self.get( 86 | "iiifimageinfo", 87 | urlargs=dict( 88 | uuid="valid:id-üni", 89 | version="v1", 90 | ), 91 | ) 92 | self.assert200(get_the_response) 93 | self.assertEqual(jsonify(expected.get("v1")).data, get_the_response.data) 94 | 95 | def test_api_info_not_found(self): 96 | """Test API Info.""" 97 | get_the_response = self.get( 98 | "iiifimageinfo", 99 | urlargs=dict( 100 | uuid="notfound", 101 | version="v2", 102 | ), 103 | ) 104 | self.assert404(get_the_response) 105 | 106 | def test_api_not_found(self): 107 | """Test API not found case.""" 108 | get_the_response = self.get( 109 | "iiifimageapi", 110 | urlargs=dict( 111 | uuid="notfound", 112 | version="v2", 113 | region="full", 114 | size="full", 115 | rotation="0", 116 | quality="default", 117 | image_format="png", 118 | ), 119 | ) 120 | self.assert404(get_the_response) 121 | 122 | def test_api_internal_server_error(self): 123 | """Test API internal server error case.""" 124 | get_the_response = self.get( 125 | "iiifimageapi", 126 | urlargs=dict( 127 | uuid="valid:id-üni", 128 | version="v2", 129 | region="full", 130 | size="full", 131 | rotation="2220", 132 | quality="default", 133 | image_format="png", 134 | ), 135 | ) 136 | self.assert500(get_the_response) 137 | 138 | def test_api_iiif_validation_error(self): 139 | """Test API iiif validation case.""" 140 | get_the_response = self.get( 141 | "iiifimageapi", 142 | urlargs=dict( 143 | uuid="valid:id-üni", 144 | version="v1", 145 | region="200", 146 | size="full", 147 | rotation="2220", 148 | quality="default", 149 | image_format="png", 150 | ), 151 | ) 152 | self.assert400(get_the_response) 153 | 154 | def test_api_stream_image(self): 155 | """Test API stream image.""" 156 | tmp_file = BytesIO() 157 | # create a new image 158 | image = Image.new("RGBA", (1280, 1024), (255, 0, 0, 0)) 159 | image.save(tmp_file, "png") 160 | tmp_file.seek(0) 161 | get_the_response = self.get( 162 | "iiifimageapi", 163 | urlargs=dict( 164 | uuid="valid:id-üni", 165 | version="v2", 166 | region="full", 167 | size="full", 168 | rotation="0", 169 | quality="default", 170 | image_format="png", 171 | ), 172 | ) 173 | # Check if returns `Last-Modified` key in headers 174 | # required for `If-Modified-Since` 175 | self.assertTrue("Last-Modified" in get_the_response.headers) 176 | 177 | last_modified = get_the_response.headers["Last-Modified"] 178 | 179 | self.assertEqual(get_the_response.data, tmp_file.getvalue()) 180 | 181 | # Test `If-Modified-Since` recognized properly 182 | get_the_response = self.get( 183 | "iiifimageapi", 184 | urlargs=dict( 185 | uuid="valid:id-üni", 186 | version="v2", 187 | region="full", 188 | size="full", 189 | rotation="0", 190 | quality="default", 191 | image_format="png", 192 | ), 193 | headers={"If-Modified-Since": last_modified}, 194 | ) 195 | 196 | self.assertEqual(get_the_response.status_code, 304) 197 | 198 | urlargs = dict( 199 | uuid="valid:id-üni", 200 | version="v2", 201 | region="200,200,200,200", 202 | size="300,300", 203 | rotation="!50", 204 | quality="color", 205 | image_format="pdf", 206 | ) 207 | 208 | get_the_response = self.get( 209 | "iiifimageapi", 210 | urlargs=urlargs, 211 | ) 212 | self.assert200(get_the_response) 213 | 214 | default_name = "{name}-200200200200-300300-color-50.pdf".format( 215 | name=secure_filename(urlargs["uuid"]) 216 | ) 217 | for dl, name in ( 218 | ("", default_name), 219 | ("1", default_name), 220 | ("foo.pdf", "foo.pdf"), 221 | ): 222 | urlargs["dl"] = dl 223 | get_the_response = self.get( 224 | "iiifimageapi", 225 | urlargs=urlargs, 226 | ) 227 | self.assert200(get_the_response) 228 | self.assertEqual( 229 | get_the_response.headers["Content-Disposition"], 230 | "attachment; filename={name}".format(name=name), 231 | ) 232 | 233 | def test_api_decorator(self): 234 | """Test API decorator.""" 235 | get_the_response = self.get( 236 | "iiifimageapi", 237 | urlargs=dict( 238 | uuid="valid:decorator:id", 239 | version="v2", 240 | region="full", 241 | size="full", 242 | rotation="0", 243 | quality="default", 244 | image_format="png", 245 | ), 246 | ) 247 | self.assert403(get_the_response) 248 | 249 | def test_api_abort_all_methods_except_get(self): 250 | """Abort all methods but GET.""" 251 | data = dict( 252 | uuid="valid:id-üni", 253 | version="v2", 254 | region="full", 255 | size="full", 256 | rotation="0", 257 | quality="default", 258 | image_format="png", 259 | ) 260 | get_the_response = self.post("iiifimageapi", urlargs=data) 261 | self.assert405(get_the_response) 262 | 263 | get_the_response = self.put("iiifimageapi", urlargs=data) 264 | self.assert405(get_the_response) 265 | 266 | get_the_response = self.delete("iiifimageapi", urlargs=data) 267 | self.assert405(get_the_response) 268 | 269 | get_the_response = self.patch("iiifimageapi", urlargs=data) 270 | self.assert405(get_the_response) 271 | 272 | def test_api_cache_control(self): 273 | """Test cache-control headers""" 274 | 275 | urlargs = dict( 276 | uuid="valid:id-üni", 277 | version="v2", 278 | region="200,200,200,200", 279 | size="300,300", 280 | rotation="!50", 281 | quality="color", 282 | image_format="pdf", 283 | ) 284 | 285 | key = "iiif:{0}/{1}/{2}/{3}/{4}.{5}".format( 286 | urlargs["uuid"], 287 | urlargs["region"], 288 | urlargs["size"], 289 | urlargs["quality"], 290 | urlargs["rotation"], 291 | urlargs["image_format"], 292 | ) 293 | 294 | cache = self.app.config["IIIF_CACHE_HANDLER"].cache 295 | 296 | get_the_response = self.get( 297 | "iiifimageapi", 298 | urlargs=urlargs, 299 | ) 300 | 301 | self.assertFalse("cache-control" in urlargs) 302 | 303 | self.assert200(get_the_response) 304 | 305 | self.assertTrue(cache.get(key)) 306 | 307 | cache.clear() 308 | 309 | for cache_control, name in (("no-cache", "foo.pdf"), ("no-store", "foo.pdf")): 310 | urlargs["cache-control"] = cache_control 311 | 312 | get_the_response = self.get( 313 | "iiifimageapi", 314 | urlargs=urlargs, 315 | ) 316 | 317 | self.assert200(get_the_response) 318 | 319 | self.assertFalse(cache.get(key)) 320 | 321 | cache.clear() 322 | 323 | for cache_control, name in (("public", "foo.pdf"), ("no-transform", "foo.pdf")): 324 | urlargs["cache-control"] = cache_control 325 | 326 | get_the_response = self.get( 327 | "iiifimageapi", 328 | urlargs=urlargs, 329 | ) 330 | 331 | self.assert200(get_the_response) 332 | 333 | self.assertTrue(cache.get(key)) 334 | 335 | cache.clear() 336 | 337 | def test_cache_ignore_errors(self): 338 | """Test if cache retrieval errors are ignored when configured.""" 339 | from flask import current_app 340 | 341 | info_args = dict(uuid="valid:id", version="v2") 342 | api_args = dict( 343 | uuid="valid:id", 344 | version="v2", 345 | region="full", 346 | size="full", 347 | rotation="0", 348 | quality="default", 349 | image_format="png", 350 | ) 351 | 352 | cache = self.app.config["IIIF_CACHE_HANDLER"].cache 353 | with patch.object(cache, "get", side_effect=Exception("test fail")): 354 | # Without ignoring errors 355 | self.assertRaisesRegex( 356 | Exception, "test fail", self.get, "iiifimageinfo", urlargs=info_args 357 | ) 358 | self.assertRaisesRegex( 359 | Exception, "test fail", self.get, "iiifimageapi", urlargs=api_args 360 | ) 361 | 362 | # Ignore errors 363 | old_value = current_app.config.get("IIIF_CACHE_IGNORE_ERRORS") 364 | current_app.config["IIIF_CACHE_IGNORE_ERRORS"] = True 365 | 366 | resp = self.get("iiifimageinfo", urlargs=info_args) 367 | self.assert200(resp) 368 | resp = self.get("iiifimageapi", urlargs=api_args) 369 | self.assert200(resp) 370 | 371 | current_app.config["IIIF_CACHE_REDIS_PREFIX"] = old_value 372 | --------------------------------------------------------------------------------