├── .coveragerc ├── .gitignore ├── .pyup.yml ├── .travis.yml ├── HISTORY.rst ├── LICENSE ├── README.rst ├── docs ├── Makefile ├── api.rst ├── conf.py ├── configuration.rst ├── getting_started.rst └── index.rst ├── requirements.txt ├── setup.py ├── tests ├── __init__.py ├── fixtures │ ├── error_collection.json │ └── valid_manifest ├── test_base_validator.py ├── test_error_collection.py ├── test_iiif_validator.py └── validator_testing_tools.py ├── tripoli ├── __init__.py ├── exceptions.py ├── mixins.py ├── resource_validators │ ├── __init__.py │ ├── annotation_validator.py │ ├── base_validator.py │ ├── canvas_validator.py │ ├── image_content_validator.py │ ├── manifest_validator.py │ └── sequence_validator.py ├── tripoli.py └── validator_logging.py └── web ├── .gitignore ├── index.py ├── requirements.txt ├── static ├── pure.min.css └── style.css └── templates └── index.html /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | 3 | source = 4 | ./tripoli 5 | 6 | omit = 7 | *__init__* 8 | */tests/* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | test_manifests 2 | .idea 3 | __pycache__ 4 | *.pyc 5 | .env 6 | dist 7 | docs/_build 8 | tripoli.egg-info 9 | interactive.py 10 | .coverage 11 | htmlcov 12 | build/ 13 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | # autogenerated pyup.io config file # see https://pyup.io/docs/configuration/ for all available options 2 | 3 | update: insecure 4 | branch: develop 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.5" 4 | - "3.6" 5 | # command to install dependencies 6 | install: 7 | - pip install -r requirements.txt 8 | - pip install coveralls 9 | 10 | # command to run tests 11 | script: 12 | - python setup.py test 13 | 14 | after_script: 15 | - coverage run -m unittest discover 16 | 17 | after_success: 18 | - if [ "$TRAVIS_PYTHON_VERSION" == "3.6" ]; then coveralls; fi -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | Release History 4 | --------------- 5 | 6 | 2.0.0 (2018-02-22) 7 | ++++++++++++++++++ 8 | 9 | Tripoli v2.0.0 is functionally equivalent to v1.2.2. The major version number is being incremented 10 | to mirror the IIIF Presentation API version it supports. Tripoli v3 will only support Presentation 11 | API v3. 12 | 13 | 14 | 1.2.1 (2017-10-11) / 1.2.2 15 | ++++++++++++++++++ 16 | 17 | **Improvements** 18 | 19 | - Unit testing improvements and enhanced coverage 20 | - Enhanced testing fixtures 21 | - Travis-CI builds and Coveralls integration 22 | 23 | **Bugfixes** 24 | 25 | - Multiple sequences are now correctly validated. 26 | - Canvas images are more thoroughly checked for their values. 27 | 28 | NB: 1.2.2 is the same as 1.2.1, but incremented to try and address an issue with 29 | distributing via PyPI. 30 | 31 | 32 | 1.2.0 (2017-05-04) 33 | ++++++++++++++++++ 34 | 35 | **Improvements** 36 | 37 | - Configuration arguments for the basic IIIFValidator can now be passed in via kwargs 38 | on the `__init__` function. A validator can now be instantiated with all its settings 39 | in one line. 40 | 41 | **Bugfixes** 42 | 43 | - Web interface now specifically mentions that tripoli is for validating IIIF Manifests. 44 | 45 | 1.1.4 (2016-08-23) 46 | ++++++++++++++++++ 47 | 48 | **Bugfixes** 49 | 50 | - Fixed an issue with the default values of REQUIRED_FIELDS (and other field sets) 51 | being empty dicts instead of empty sets. 52 | 53 | 1.1.3 (2016-08-23) 54 | ++++++++++++++++++ 55 | 56 | **Bugfixes** 57 | 58 | - A warning message regarding uncertain HTML tags has been fixed to include the name 59 | of the tag. 60 | 61 | 1.1.2 (2016-08-22) 62 | ++++++++++++++++++ 63 | 64 | **Improvements** 65 | 66 | - Added IIIF manifest test suite to tests, ensuring that each throws an error. A 67 | number of new errors and warning have been added to this end. 68 | - Faster hash algorithms for ValidatorLogEntries increasing overall performance. 69 | - Added HISTORY.rst to track bugfixes and improvements. 70 | - README.rst and HISTORY.rst will be automatically read into the setup.py long_description 71 | field (idea taken from requests). 72 | 73 | **Bugfixes** 74 | 75 | - ``ViewingHint`` is now a common field which can be checked on any resource. 76 | - ``startCanvas`` is now validated properly. 77 | - ``Annotation`` no longer logs a warning if it has a ``@context`` field. 78 | - ``ImageResource`` now must have ``@type`` 'dctypes:Image'. 79 | - Presence of XML Comments or CDATA sections will cause an error to be logged. 80 | - Fixed exception when ``IIIFValidator`` could not discern the ``@type`` of a resource. 81 | 82 | 83 | 1.1.1 (2016-08-18) 84 | ++++++++++++++++++ 85 | 86 | **Bugfixes** 87 | 88 | - A bug was preventing ``descriptions`` from being validated in all resources. 89 | This has been fixed. 90 | 91 | 1.1 (2016-08-18) 92 | ++++++++++++++++ 93 | 94 | **New Features** 95 | 96 | - Added HTML validation. This will check that only fields which are allowed 97 | to contain HTML have it, that the HTML is valid, and that only allowed tags 98 | and attributes are included. 99 | - Added indices in error and message paths. These indices make it easier to 100 | figure out exactly which canvas is failing with an error (if indeed only a 101 | few are failing). 102 | - Added unique/non unique error aggregation. Using a ``unique_logging` property 103 | on a ``IIIFValidator``, users can decide whether all errors and warnings will be 104 | aggregated, or only unique ones. Here, unique means that only one instance of 105 | each error/warning per resource will be saved (that is, if every canvas has error 106 | A, then only the first instance of a canvas with error A will be saved). 107 | - Added ``verbose`` property to ``IIIFValidatior``. When ``True``, every error and 108 | warning will be logged immediatly to the screen when hit. 109 | 110 | **Bugfixes** 111 | 112 | - ``Annotations`` no longer log a warning when they are missing an ``@id`` field. 113 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Alex Parmentier, Andrew Hankinson 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Tripoli: IIIF Document Validation 2 | ================================= 3 | 4 | .. image:: https://travis-ci.org/DDMAL/tripoli.svg?branch=master 5 | :target: https://travis-ci.org/DDMAL/tripoli 6 | 7 | .. image:: https://coveralls.io/repos/github/DDMAL/tripoli/badge.svg?branch=master 8 | :target: https://coveralls.io/github/DDMAL/tripoli?branch=master 9 | 10 | Tripoli is a validator for documents conforming to the `IIIF 11 | presentation API 2.1 `__. It makes 12 | it easy to validate documents, apply provider specific heuristics, and 13 | even correct documents while they are being validated. 14 | 15 | Documentation 16 | ------------- 17 | 18 | Detailed documentation is available at 19 | http://tripoli.readthedocs.io/en/latest/ 20 | 21 | Installation 22 | ------------ 23 | 24 | Python 3.5+ is required to run Tripoli. You can install Tripoli using pip. 25 | 26 | .. code:: bash 27 | 28 | > pip install tripoli 29 | 30 | Quick start 31 | ----------- 32 | 33 | Once installed, it's easy to start validating. Tripoli can validate the 34 | entire document, and will log informative errors and warnings with 35 | helpful paths. 36 | 37 | .. code:: python 38 | 39 | >>> from tripoli import IIIFValidator 40 | 41 | >>> iv = IIIFValidator() 42 | >>> iv.validate(some_manifest) 43 | Error: Field has no '@language' key where one is required. - data['metadata']['value'] 44 | Error: viewingHint 'pages' is not valid and not uri. - data['sequences']['canvases']['viewingHint'] 45 | Warning: logo SHOULD be IIIF image service. - data['logo'] 46 | Warning: manifest SHOULD have thumbnail field. - data['thumbnail'] 47 | Warning: Unknown key 'see_also' in 'manifest' - data['see_also'] 48 | Warning: ImageResource SHOULD have @id field. - data['sequences']['canvases']['images']['@id'] 49 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help 18 | help: 19 | @echo "Please use \`make ' where is one of" 20 | @echo " html to make standalone HTML files" 21 | @echo " dirhtml to make HTML files named index.html in directories" 22 | @echo " singlehtml to make a single large HTML file" 23 | @echo " pickle to make pickle files" 24 | @echo " json to make JSON files" 25 | @echo " htmlhelp to make HTML files and a HTML help project" 26 | @echo " qthelp to make HTML files and a qthelp project" 27 | @echo " applehelp to make an Apple Help Book" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " epub3 to make an epub3" 31 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 32 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 33 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 34 | @echo " text to make text files" 35 | @echo " man to make manual pages" 36 | @echo " texinfo to make Texinfo files" 37 | @echo " info to make Texinfo files and run them through makeinfo" 38 | @echo " gettext to make PO message catalogs" 39 | @echo " changes to make an overview of all changed/added/deprecated items" 40 | @echo " xml to make Docutils-native XML files" 41 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 42 | @echo " linkcheck to check all external links for integrity" 43 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 44 | @echo " coverage to run coverage check of the documentation (if enabled)" 45 | @echo " dummy to check syntax errors of document sources" 46 | 47 | .PHONY: clean 48 | clean: 49 | rm -rf $(BUILDDIR)/* 50 | 51 | .PHONY: html 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | .PHONY: dirhtml 58 | dirhtml: 59 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 60 | @echo 61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 62 | 63 | .PHONY: singlehtml 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | .PHONY: pickle 70 | pickle: 71 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 72 | @echo 73 | @echo "Build finished; now you can process the pickle files." 74 | 75 | .PHONY: json 76 | json: 77 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 78 | @echo 79 | @echo "Build finished; now you can process the JSON files." 80 | 81 | .PHONY: htmlhelp 82 | htmlhelp: 83 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 84 | @echo 85 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 86 | ".hhp project file in $(BUILDDIR)/htmlhelp." 87 | 88 | .PHONY: qthelp 89 | qthelp: 90 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 91 | @echo 92 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 93 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 94 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Tripoli.qhcp" 95 | @echo "To view the help file:" 96 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Tripoli.qhc" 97 | 98 | .PHONY: applehelp 99 | applehelp: 100 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 101 | @echo 102 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 103 | @echo "N.B. You won't be able to view it unless you put it in" \ 104 | "~/Library/Documentation/Help or install it in your application" \ 105 | "bundle." 106 | 107 | .PHONY: devhelp 108 | devhelp: 109 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 110 | @echo 111 | @echo "Build finished." 112 | @echo "To view the help file:" 113 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Tripoli" 114 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Tripoli" 115 | @echo "# devhelp" 116 | 117 | .PHONY: epub 118 | epub: 119 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 120 | @echo 121 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 122 | 123 | .PHONY: epub3 124 | epub3: 125 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 126 | @echo 127 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 128 | 129 | .PHONY: latex 130 | latex: 131 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 132 | @echo 133 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 134 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 135 | "(use \`make latexpdf' here to do that automatically)." 136 | 137 | .PHONY: latexpdf 138 | latexpdf: 139 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 140 | @echo "Running LaTeX files through pdflatex..." 141 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 142 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 143 | 144 | .PHONY: latexpdfja 145 | latexpdfja: 146 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 147 | @echo "Running LaTeX files through platex and dvipdfmx..." 148 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 149 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 150 | 151 | .PHONY: text 152 | text: 153 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 154 | @echo 155 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 156 | 157 | .PHONY: man 158 | man: 159 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 160 | @echo 161 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 162 | 163 | .PHONY: texinfo 164 | texinfo: 165 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 166 | @echo 167 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 168 | @echo "Run \`make' in that directory to run these through makeinfo" \ 169 | "(use \`make info' here to do that automatically)." 170 | 171 | .PHONY: info 172 | info: 173 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 174 | @echo "Running Texinfo files through makeinfo..." 175 | make -C $(BUILDDIR)/texinfo info 176 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 177 | 178 | .PHONY: gettext 179 | gettext: 180 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 181 | @echo 182 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 183 | 184 | .PHONY: changes 185 | changes: 186 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 187 | @echo 188 | @echo "The overview file is in $(BUILDDIR)/changes." 189 | 190 | .PHONY: linkcheck 191 | linkcheck: 192 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 193 | @echo 194 | @echo "Link check complete; look for any errors in the above output " \ 195 | "or in $(BUILDDIR)/linkcheck/output.txt." 196 | 197 | .PHONY: doctest 198 | doctest: 199 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 200 | @echo "Testing of doctests in the sources finished, look at the " \ 201 | "results in $(BUILDDIR)/doctest/output.txt." 202 | 203 | .PHONY: coverage 204 | coverage: 205 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 206 | @echo "Testing of coverage in the sources finished, look at the " \ 207 | "results in $(BUILDDIR)/coverage/python.txt." 208 | 209 | .PHONY: xml 210 | xml: 211 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 212 | @echo 213 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 214 | 215 | .PHONY: pseudoxml 216 | pseudoxml: 217 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 218 | @echo 219 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 220 | 221 | .PHONY: dummy 222 | dummy: 223 | $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy 224 | @echo 225 | @echo "Build finished. Dummy builder generates no files." 226 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API Guide 2 | ========= 3 | 4 | Main Validator 5 | -------------- 6 | The ``IIIFValidator`` is the root validator, responsible for storing all settings 7 | and references to IIIF resource validators. If you are only using Tripoli to debug your own 8 | manifest creation algorithms, it should be the only class you need to know about. 9 | 10 | .. module:: tripoli 11 | .. autoclass:: IIIFValidator 12 | :members: 13 | :inherited-members: 14 | 15 | Error and Warning Logging 16 | ------------------------- 17 | .. module:: tripoli.validator_logging 18 | .. autoclass:: ValidatorLogError 19 | :members: print_trace 20 | :inherited-members: msg, path 21 | .. autoclass:: ValidatorLogWarning 22 | :members: print_trace 23 | :inherited-members: msg, path 24 | 25 | IIIF Resource Validators 26 | ------------------------ 27 | All validators inherit from the ``BaseValidator`` class. This, and all validators, 28 | requires a reference to a ``IIIFValidator`` to be initialized, as all settings and 29 | references to *other* validators are held therein. 30 | 31 | .. automodule:: tripoli.resource_validators 32 | .. autoclass:: BaseValidator 33 | :members: 34 | :inherited-members: 35 | 36 | .. autoclass:: ManifestValidator 37 | :members: 38 | :show-inheritance: 39 | 40 | .. autoclass:: SequenceValidator 41 | :members: 42 | :show-inheritance: 43 | 44 | .. autoclass:: CanvasValidator 45 | :members: 46 | :show-inheritance: 47 | 48 | .. autoclass:: AnnotationValidator 49 | :members: 50 | :show-inheritance: 51 | 52 | .. autoclass:: ImageContentValidator 53 | :members: 54 | :show-inheritance: -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Tripoli documentation build configuration file, created by 5 | # sphinx-quickstart on Mon Jul 18 15:20:49 2016. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | sys.path.insert(0, os.path.abspath('..')) 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ['sphinx.ext.autodoc'] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ['_templates'] 37 | 38 | # The suffix(es) of source filenames. 39 | # You can specify multiple suffix as a list of string: 40 | # 41 | # source_suffix = ['.rst', '.md'] 42 | source_suffix = '.rst' 43 | 44 | # The encoding of source files. 45 | # 46 | # source_encoding = 'utf-8-sig' 47 | 48 | # The master toctree document. 49 | master_doc = 'index' 50 | 51 | # General information about the project. 52 | project = 'Tripoli' 53 | copyright = '2016, Alex Parmentier' 54 | author = 'Alex Parmentier, Andrew Hankinson' 55 | 56 | # The version info for the project you're documenting, acts as replacement for 57 | # |version| and |release|, also used in various other places throughout the 58 | # built documents. 59 | # 60 | # The short X.Y version. 61 | version = '1.0' 62 | # The full version, including alpha/beta/rc tags. 63 | release = '1.0.0' 64 | 65 | # The language for content autogenerated by Sphinx. Refer to documentation 66 | # for a list of supported languages. 67 | # 68 | # This is also used if you do content translation via gettext catalogs. 69 | # Usually you set "language" from the command line for these cases. 70 | language = None 71 | 72 | # There are two options for replacing |today|: either, you set today to some 73 | # non-false value, then it is used: 74 | # 75 | # today = '' 76 | # 77 | # Else, today_fmt is used as the format for a strftime call. 78 | # 79 | # today_fmt = '%B %d, %Y' 80 | 81 | # List of patterns, relative to source directory, that match files and 82 | # directories to ignore when looking for source files. 83 | # This patterns also effect to html_static_path and html_extra_path 84 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 85 | 86 | # The reST default role (used for this markup: `text`) to use for all 87 | # documents. 88 | # 89 | # default_role = None 90 | 91 | # If true, '()' will be appended to :func: etc. cross-reference text. 92 | # 93 | # add_function_parentheses = True 94 | 95 | # If true, the current module name will be prepended to all description 96 | # unit titles (such as .. function::). 97 | # 98 | # add_module_names = True 99 | 100 | # If true, sectionauthor and moduleauthor directives will be shown in the 101 | # output. They are ignored by default. 102 | # 103 | # show_authors = False 104 | 105 | # The name of the Pygments (syntax highlighting) style to use. 106 | pygments_style = 'sphinx' 107 | 108 | # A list of ignored prefixes for module index sorting. 109 | # modindex_common_prefix = [] 110 | 111 | # If true, keep warnings as "system message" paragraphs in the built documents. 112 | # keep_warnings = False 113 | 114 | # If true, `todo` and `todoList` produce output, else they produce nothing. 115 | todo_include_todos = False 116 | 117 | 118 | # -- Options for HTML output ---------------------------------------------- 119 | 120 | # The theme to use for HTML and HTML Help pages. See the documentation for 121 | # a list of builtin themes. 122 | # 123 | html_theme = 'alabaster' 124 | 125 | # Theme options are theme-specific and customize the look and feel of a theme 126 | # further. For a list of options available for each theme, see the 127 | # documentation. 128 | # 129 | # html_theme_options = {} 130 | 131 | # Add any paths that contain custom themes here, relative to this directory. 132 | # html_theme_path = [] 133 | 134 | # The name for this set of Sphinx documents. 135 | # " v documentation" by default. 136 | # 137 | # html_title = 'Tripoli v0.1' 138 | 139 | # A shorter title for the navigation bar. Default is the same as html_title. 140 | # 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 | # 146 | # html_logo = None 147 | 148 | # The name of an image file (relative to this directory) to use as a favicon of 149 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 150 | # pixels large. 151 | # 152 | # html_favicon = None 153 | 154 | # Add any paths that contain custom static files (such as style sheets) here, 155 | # relative to this directory. They are copied after the builtin static files, 156 | # so a file named "default.css" will overwrite the builtin "default.css". 157 | html_static_path = ['_static'] 158 | 159 | # Add any extra paths that contain custom files (such as robots.txt or 160 | # .htaccess) here, relative to this directory. These files are copied 161 | # directly to the root of the documentation. 162 | # 163 | # html_extra_path = [] 164 | 165 | # If not None, a 'Last updated on:' timestamp is inserted at every page 166 | # bottom, using the given strftime format. 167 | # The empty string is equivalent to '%b %d, %Y'. 168 | # 169 | # html_last_updated_fmt = None 170 | 171 | # If true, SmartyPants will be used to convert quotes and dashes to 172 | # typographically correct entities. 173 | # 174 | # html_use_smartypants = True 175 | 176 | # Custom sidebar templates, maps document names to template names. 177 | # 178 | # html_sidebars = {} 179 | 180 | # Additional templates that should be rendered to pages, maps page names to 181 | # template names. 182 | # 183 | # html_additional_pages = {} 184 | 185 | # If false, no module index is generated. 186 | # 187 | # html_domain_indices = True 188 | 189 | # If false, no index is generated. 190 | # 191 | # html_use_index = True 192 | 193 | # If true, the index is split into individual pages for each letter. 194 | # 195 | # html_split_index = False 196 | 197 | # If true, links to the reST sources are added to the pages. 198 | # 199 | # html_show_sourcelink = True 200 | 201 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 202 | # 203 | # html_show_sphinx = True 204 | 205 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 206 | # 207 | # html_show_copyright = True 208 | 209 | # If true, an OpenSearch description file will be output, and all pages will 210 | # contain a tag referring to it. The value of this option must be the 211 | # base URL from which the finished HTML is served. 212 | # 213 | # html_use_opensearch = '' 214 | 215 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 216 | # html_file_suffix = None 217 | 218 | # Language to be used for generating the HTML full-text search index. 219 | # Sphinx supports the following languages: 220 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 221 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' 222 | # 223 | # html_search_language = 'en' 224 | 225 | # A dictionary with options for the search language support, empty by default. 226 | # 'ja' uses this config value. 227 | # 'zh' user can custom change `jieba` dictionary path. 228 | # 229 | # html_search_options = {'type': 'default'} 230 | 231 | # The name of a javascript file (relative to the configuration directory) that 232 | # implements a search results scorer. If empty, the default will be used. 233 | # 234 | # html_search_scorer = 'scorer.js' 235 | 236 | # Output file base name for HTML help builder. 237 | htmlhelp_basename = 'Tripolidoc' 238 | 239 | # -- Options for LaTeX output --------------------------------------------- 240 | 241 | latex_elements = { 242 | # The paper size ('letterpaper' or 'a4paper'). 243 | # 244 | # 'papersize': 'letterpaper', 245 | 246 | # The font size ('10pt', '11pt' or '12pt'). 247 | # 248 | # 'pointsize': '10pt', 249 | 250 | # Additional stuff for the LaTeX preamble. 251 | # 252 | # 'preamble': '', 253 | 254 | # Latex figure (float) alignment 255 | # 256 | # 'figure_align': 'htbp', 257 | } 258 | 259 | # Grouping the document tree into LaTeX files. List of tuples 260 | # (source start file, target name, title, 261 | # author, documentclass [howto, manual, or own class]). 262 | latex_documents = [ 263 | (master_doc, 'Tripoli.tex', 'Tripoli Documentation', 264 | 'Alex Parmentier, Andrew Hankinson', 'manual'), 265 | ] 266 | 267 | # The name of an image file (relative to this directory) to place at the top of 268 | # the title page. 269 | # 270 | # latex_logo = None 271 | 272 | # For "manual" documents, if this is true, then toplevel headings are parts, 273 | # not chapters. 274 | # 275 | # latex_use_parts = False 276 | 277 | # If true, show page references after internal links. 278 | # 279 | # latex_show_pagerefs = False 280 | 281 | # If true, show URL addresses after external links. 282 | # 283 | # latex_show_urls = False 284 | 285 | # Documents to append as an appendix to all manuals. 286 | # 287 | # latex_appendices = [] 288 | 289 | # It false, will not define \strong, \code, itleref, \crossref ... but only 290 | # \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added 291 | # packages. 292 | # 293 | # latex_keep_old_macro_names = True 294 | 295 | # If false, no module index is generated. 296 | # 297 | # latex_domain_indices = True 298 | 299 | 300 | # -- Options for manual page output --------------------------------------- 301 | 302 | # One entry per manual page. List of tuples 303 | # (source start file, name, description, authors, manual section). 304 | man_pages = [ 305 | (master_doc, 'tripoli', 'Tripoli Documentation', 306 | [author], 1) 307 | ] 308 | 309 | # If true, show URL addresses after external links. 310 | # 311 | # man_show_urls = False 312 | 313 | 314 | # -- Options for Texinfo output ------------------------------------------- 315 | 316 | # Grouping the document tree into Texinfo files. List of tuples 317 | # (source start file, target name, title, author, 318 | # dir menu entry, description, category) 319 | texinfo_documents = [ 320 | (master_doc, 'Tripoli', 'Tripoli Documentation', 321 | author, 'Tripoli', 'One line description of project.', 322 | 'Miscellaneous'), 323 | ] 324 | 325 | # Documents to append as an appendix to all manuals. 326 | # 327 | # texinfo_appendices = [] 328 | 329 | # If false, no module index is generated. 330 | # 331 | # texinfo_domain_indices = True 332 | 333 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 334 | # 335 | # texinfo_show_urls = 'footnote' 336 | 337 | # If true, do not generate a @detailmenu in the "Top" node's menu. 338 | # 339 | # texinfo_no_detailmenu = False 340 | -------------------------------------------------------------------------------- /docs/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | Tripoli was designed to make it simple to both ignore and override validation 4 | behaviour for particular parts of the API, and to apply corrections while 5 | validating. These features should be useful for those implementing a service 6 | which aggregates IIIF manifests. 7 | 8 | Overriding Validation Behaviour 9 | ------------------------------- 10 | 11 | Tripoli presents a class hierarchy for IIIF document validation that closely 12 | mirrors the structure of the API documentation itself. Each major IIIF manifest resource type has a class responsible 13 | for validating it, making it easy to override behaviour, and each field on the resource has a corresponding function 14 | which is responsible for checking that it's value is correct and calling ``self.log_error`` or ``self.log_warning`` 15 | otherwise. On our own aggregation service, `Musiclibs `_, we use this functionality to ignore 16 | or correct systematic errors made by providers. 17 | 18 | Ignoring Validation Errors 19 | -------------------------- 20 | 21 | To illustrate, we will use an example from our own project. We know that "Library X" has a bug 22 | in their manifest generation software: they have incorrectly copied the url of the 23 | presentation API context. Since this is a validation error, but does not present a problem with viewing the document, 24 | we want to change this error to a warning. If we try to validate one of their manifests, we get the 25 | following errors. :: 26 | 27 | >>> from tripoli import IIIFValidator 28 | >>> import requests 29 | 30 | >>> manifest = requests.get("http://libraryX.com/123/manifest.json").json() 31 | >>> iv = IIIFValidator() 32 | >>> iv.validate(manifest) 33 | >>> iv.print_errors(): 34 | Error: '@context' must be set to 'http://iiif.io/api/presentation/2/context.json' @ data['@context'] 35 | 36 | We can inspect the manifest itself to see what the ``@context`` key is. :: 37 | 38 | >>> manifest.get("@context") 39 | 'http://iiif.io/api/presentation/2/context.js' 40 | 41 | Just as suspected, the context URI has been incorrectly copied and the last two characters of "json" are missing. 42 | 43 | To change the validation behaviour we subclass the ``ManifestValidator``, changing the default field function for the 44 | '@context' key to one that expects this error and compensates for it. 45 | 46 | Here is a function that returns a IIIFValidator that will accept Library X documents. :: 47 | 48 | >>> from tripoli import IIIFValidator, ManifestValidator 49 | 50 | >>> def make_library_x_validator(): 51 | ... class PatchedManifestValidator(ManifestValidator): 52 | ... @override 53 | ... def context_field(self, value): 54 | ... if value == 'http://iiif.io/api/presentation/2/context.js': 55 | ... self.log_warning("@context", "Allowed Library X shenanigans.") 56 | ... return value 57 | ... else: 58 | ... return super().context_field(value) 59 | ... iv = IIIFValidator() 60 | ... iv.ManifestValidator = PatchedManifestValidator 61 | ... return iv 62 | 63 | Hopefully examining the above function will make it clear how simple it is to override behaviour 64 | to ignore particular errors as an aggregator. Essentially, we determine on what resource the 65 | error occurs (in this case on the top level manifest, so we import ``ManifestValidator``), then 66 | create a subclass of that resource's validator which can allow for the error. Reading the 67 | `API guide `_ will clarify some of the nitty gritty of how this works. 68 | 69 | Making Corrections 70 | ------------------ 71 | 72 | Tripoli can also make corrections to a manifest while validating. This is useful when 73 | you are aware of a systematic error in a provider's manifests, and this does have implications 74 | for viewing the document. 75 | 76 | Each validation function (those that end with ``*_field``) must return a value. By default, 77 | they return whatever value was passed to them, but this can easily be changed in order to 78 | compile a corrected document. 79 | 80 | For example, if "Library Y" sets the ``height`` and ``width`` keys of it's canvases 81 | as strings instead of ints, you can detect this and compensate for it in the appropriate 82 | validation functions. To handle this, we can write a new ``str_to_int`` function that attempts 83 | to coerce ints to strings and delegate the work of the ``height_field`` and ``width_field`` functions 84 | to it. Applying the same pattern as above: :: 85 | 86 | >>> from tripoli import IIIFValidator, CanvasValidator 87 | 88 | >>> def make_library_y_validator(): 89 | ... class PatchedCanvasValidator(CanvasValidator): 90 | ... def str_to_int(self, field, value): 91 | ... """Attempt to coerce value to int and log results.""" 92 | ... try: 93 | ... val = int(value) 94 | ... self.log_warning(field, "Coerced str to int (Library Y shenanigans)") 95 | ... return val 96 | ... except ValueError: 97 | ... self.log_error(field, "Could not coerce string to int") 98 | ... return value 99 | ... 100 | ... @override 101 | ... def height_field(self, value): 102 | ... return str_to_int("height", value) 103 | ... 104 | ... @override 105 | ... def width_field(self, value): 106 | ... return str_to_int("width", value) 107 | ... 108 | ... iv = IIIFValidator() 109 | ... iv.CanvasValidator = PatchedCanvasValidator 110 | ... return iv 111 | 112 | When you create this validator and run it on a manifest, it will retain the corrected 113 | document in a ``corrected_doc`` key. :: 114 | 115 | >>> iv = make_library_y_validator() 116 | >>> iv.validate(libraryY_manifest) 117 | >>> iv.corrected_doc # A document with the applied corrections 118 | 119 | Configuration Tools 120 | ------------------- 121 | 122 | A number of utility functions have been included in the ``BaseValidator`` class to simplify 123 | common configuration jobs. 124 | 125 | First among these are ``warnings_to_errors`` and ``errors_to_warnings`` decorators that can 126 | be used to wrap any function and either upgrade or downgrade its logging output. As an example, 127 | if you did not care about the thumbnails on manifests, you could easily coerce any errors found 128 | on that field into warnings with the following ``ManifestValidator``. :: 129 | 130 | >>> class PatchedManifestValidator(ManifestValidator): 131 | ... @ManifestValidator.errors_to_warnings 132 | ... def thumbnail_field(self, value): 133 | ... return super().thumbnail_field(value) 134 | 135 | Another useful tool is the ``mute_errors`` function. Given a function and an arbitrary amount 136 | of arguments, it will call the function on the arguments and return a 2-tuple with the return 137 | value of the function and a set of any errors it tried to log. These errors will not be logged 138 | and will not trigger a failure of the validation. The following example accomplishes the same 139 | goal as the one above :: 140 | 141 | >>> class PatchedManifestValidator(ManifestValidator): 142 | ... def thumbnail_field(self, value): 143 | ... val, errs = self.mute_errors(super().thumbnail_field, value) 144 | ... for err in errors: 145 | ... self.log_warning('thumbnail', err.message) 146 | ... return val 147 | 148 | When implementing a corrective or overriding behaviour, it may be difficult to figure 149 | out exactly which function needs to be overridden. In this case, setting ``debug`` to 150 | ``true`` on your ``IIIFValidator`` will include tracebacks with your errors and warnings, 151 | which can be inspected to figure out which function logged them. -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | Installing 5 | ---------- 6 | Tripoli can by installed using ``pip``. :: 7 | 8 | >>> pip install tripoli 9 | 10 | Validation 11 | ---------- 12 | Using Tripoli to validate a IIIF document is easy. :: 13 | 14 | >>> from tripoli import IIIFValidator 15 | >>> import requests # for example 16 | 17 | >>> iv = IIIFValidator() 18 | >>> manifest = requests.get("http://example.com/manifest.json") 19 | >>> iv.validate(manifest.text) 20 | >>> iv.is_valid 21 | True 22 | 23 | When Tripoli detects issues in a document, it provides informative errors and warnings with 24 | key paths to simplify the debugging progress. :: 25 | 26 | >>> man = requests.get("http://example.com/bad_manifest.json") 27 | >>> iv.validate(man) 28 | >>> iv.is_valid 29 | False 30 | 31 | >>> iv.print_warnings() 32 | Warning: logo SHOULD be IIIF image service. @ data['logo'] 33 | Warning: manifest SHOULD have thumbnail field. @ data['thumbnail'] 34 | Warning: Unknown key 'see_also' in 'manifest' @ data['see_also'] 35 | Warning: ImageResource SHOULD have @id field. @ data['sequences']['canvases']['images']['@id'] 36 | 37 | >>> iv.print_errors() 38 | Error: Field has no '@language' key where one is required. @ data['metadata']['value'] 39 | Error: viewingHint 'pages' is not valid and not uri. @ data['sequences']['canvases']['viewingHint'] 40 | 41 | Options 42 | ------- 43 | The ``IIIFValidator`` has a number of options to control its behaviour. 44 | 45 | .. module:: tripoli 46 | .. autoclass:: IIIFValidator 47 | :noindex: 48 | :members: collect_errors, collect_warnings, debug, fail_fast, verbose, unique_logging 49 | 50 | The complete interface can be found in the :doc:`API guide `. 51 | 52 | Tripoli can also be configured to log extra warnings, ignore particular 53 | errors, and correct errors in manifests. Refer to the :doc:`configuration section` 54 | for more information. 55 | 56 | 57 | Validating Online 58 | ----------------- 59 | 60 | You can use tripoli to validate online at https://validate.musiclibs.net. Simply pass in a link to a manifest 61 | as a query parameter named 'manifest'. :: 62 | 63 | >>> curl "https://validate.musiclibs.net/?manifest=${MANIFEST_URL}" -H "Accept: application/json" 64 | { 65 | "errors": [ 66 | "Error: '@context' must be set to 'http://iiif.io/api/presentation/2/context.json' @ data['@context']", 67 | "Error: @context field not allowed in embedded sequence. @ data['sequences']['@context']", 68 | "Error: Key 'on' is required in 'annotation' @ data['sequences']['canvases']['images']['on']" 69 | ], 70 | "is_valid": false, 71 | "manifest_url": ${MANIFEST_URL}, 72 | "warnings": [ 73 | "Warning: thumbnail SHOULD be IIIF image service. @ data['thumbnail']", 74 | "Warning: manifest SHOULD have description field. @ data['description']", 75 | "Warning: logo SHOULD be IIIF image service. @ data['logo']", 76 | "Warning: Unknown key '@context' in 'sequence' @ data['sequences']['@context']", 77 | "Warning: Unknown key '@context' in 'annotation' @ data['sequences']['canvases']['images']['@context']" 78 | ] 79 | } 80 | 81 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Tripoli documentation master file, created by 2 | sphinx-quickstart on Mon Jul 18 15:20:49 2016. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Tripoli Documentation 7 | ===================== 8 | 9 | Tripoli is a validator for documents conforming to the 10 | `IIIF presentation API 2.1 `_. 11 | The goals of this project are to: 12 | 13 | * Aid developers who are implementing the IIIF Presentation API. 14 | * Return errors and warnings that are detailed and helpful. 15 | * Simplify overriding and extended validation behaviour. 16 | * Provide a top level interface that is easy integrate into existing workflows. 17 | 18 | 19 | Table of Contents 20 | ----------------- 21 | .. toctree:: 22 | :maxdepth: 2 23 | 24 | getting_started 25 | configuration 26 | api 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | defusedxml==0.5.0 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016–2018 Alex Parmentier, Andrew Hankinson 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | import os 22 | import sys 23 | from setuptools import setup, find_packages 24 | from codecs import open 25 | 26 | from tripoli.tripoli import __version__ 27 | 28 | if sys.argv[-1] == 'publish': 29 | os.system('python setup.py sdist bdist_wheel upload') 30 | sys.exit() 31 | 32 | with open('README.rst', 'r', 'utf-8') as f: 33 | read_me = f.read() 34 | 35 | with open('HISTORY.rst', 'r', 'utf-8') as f: 36 | history = f.read() 37 | 38 | setup( 39 | name='tripoli', 40 | packages=find_packages(), 41 | version=__version__, 42 | license='https://opensource.org/licenses/MIT', 43 | description='IIIF document validation.', 44 | long_description=read_me + "\n\n" + history, 45 | author='Alex Parmentier, Andrew Hankinson', 46 | author_email='andrew.hankinson@bodleian.ox.ac.uk', 47 | url='https://github.com/DDMAL/tripoli', 48 | download_url='https://github.com/DDMAL/tripoli/tarball/master', 49 | keywords=['validator', 'IIIF'], 50 | python_requires='>=3.5', 51 | install_requires=['defusedxml'], 52 | classifiers=[ 53 | "License :: OSI Approved :: MIT License", 54 | "Development Status :: 5 - Production/Stable", 55 | "Operating System :: OS Independent", 56 | "Topic :: Software Development :: Quality Assurance", 57 | "Topic :: Multimedia :: Graphics :: Presentation", 58 | "Intended Audience :: Developers", 59 | "Environment :: Web Environment", 60 | "Programming Language :: Python"], 61 | test_suite='tests' 62 | ) 63 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DDMAL/tripoli/9c2371ab05549c433814d892a47cd3cff14035d3/tests/__init__.py -------------------------------------------------------------------------------- /tests/fixtures/error_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Collection of Errors", 3 | "manifests": [ 4 | { 5 | "label": "0: Non-JSON", 6 | "manifest": "asdf", 7 | "error": "Error: Could not parse json.", 8 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/0/manifest.json", 9 | "@type": "sc:Manifest" 10 | }, 11 | { 12 | "label": "1: Empty JSON", 13 | "manifest": "{}", 14 | "error": "Error: Resource has no @type.", 15 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/1/manifest.json", 16 | "@type": "sc:Manifest" 17 | }, 18 | { 19 | "label": "2: JSON without @context", 20 | "manifest": "{\"@id\":\"foo\"}", 21 | "error": "Error: Resource has no @type.", 22 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/2/manifest.json", 23 | "@type": "sc:Manifest" 24 | }, 25 | { 26 | "label": "3: JSON with empty @context", 27 | "manifest": "{\"@context\":\"\"}", 28 | "error": "Error: Resource has no @type.", 29 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/3/manifest.json", 30 | "@type": "sc:Manifest" 31 | }, 32 | { 33 | "label": "4: JSON with unknown @context", 34 | "manifest": "{\"@context\":\"http://example.com/context.json\"}", 35 | "error": "Error: Resource has no @type.", 36 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/4/manifest.json", 37 | "@type": "sc:Manifest" 38 | }, 39 | { 40 | "label": "5: JSON without a @type", 41 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\"\n}", 42 | "error": "Error: Resource has no @type.", 43 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/5/manifest.json", 44 | "@type": "sc:Manifest" 45 | }, 46 | { 47 | "label": "6: JSON with a nonsense @type", 48 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@type\": \"fish\"\n}", 49 | "error": "Error: Unknown @type: 'fish'", 50 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/6/manifest.json", 51 | "@type": "sc:Manifest" 52 | }, 53 | { 54 | "label": "7: Manifest without an @id", 55 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 56 | "error": "Error: Key '@id' is required in 'manifest' @ data['@id']", 57 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/7/manifest.json", 58 | "@type": "sc:Manifest" 59 | }, 60 | { 61 | "label": "8: Manifest with a nonsense @id", 62 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"fish\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 63 | "error": "Error: URI is not valid: 'fish' @ data['@id']", 64 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/8/manifest.json", 65 | "@type": "sc:Manifest" 66 | }, 67 | { 68 | "label": "9: Manifest without a label", 69 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 70 | "error": "Error: Key 'label' is required in 'manifest' @ data['label']", 71 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/9/manifest.json", 72 | "@type": "sc:Manifest" 73 | }, 74 | { 75 | "label": "10: Manifest with a non-string label", 76 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": 1.0, \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 77 | "error": "Error: Illegal type (should be str, list, or dict) @ data['label']", 78 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/10/manifest.json", 79 | "@type": "sc:Manifest" 80 | }, 81 | { 82 | "label": "11: Manifest without any Sequences (not present)", 83 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\"\n}", 84 | "error": "Error: Key 'sequences' is required in 'manifest' @ data['sequences']", 85 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/11/manifest.json", 86 | "@type": "sc:Manifest" 87 | }, 88 | { 89 | "label": "12: Manifest without any Sequences (empty)", 90 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": []\n}", 91 | "error": "Error: Manifest requires at least one sequence @ data['sequences']", 92 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/12/manifest.json", 93 | "@type": "sc:Manifest" 94 | }, 95 | { 96 | "label": "13: Manifest with broken Sequence", 97 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n 1\n ]\n}", 98 | "error": "Error: 'sequence' must be json-object, not int @ data['sequences'][0]['sequence']", 99 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/13/manifest.json", 100 | "@type": "sc:Manifest" 101 | }, 102 | { 103 | "label": "14: Manifest with non Sequence", 104 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": {\n \"1\": 2\n }\n}", 105 | "error": "Error: 'sequences' MUST be a list @ data['sequences']", 106 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/14/manifest.json", 107 | "@type": "sc:Manifest" 108 | }, 109 | { 110 | "label": "15: Manifest with non Sequence in list", 111 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"fish\"\n }\n ]\n}", 112 | "error": "Error: Key 'canvases' is required in 'sequence' @ data['sequences'][0]['canvases']", 113 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/15/manifest.json", 114 | "@type": "sc:Manifest" 115 | }, 116 | { 117 | "label": "16: Manifest with Sequence not in list", 118 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": {\n \"@type\": \"sc:Sequence\"\n }\n}", 119 | "error": "Error: 'sequences' MUST be a list @ data['sequences']", 120 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/16/manifest.json", 121 | "@type": "sc:Manifest" 122 | }, 123 | { 124 | "label": "17: Manifest with empty Sequence", 125 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\"\n }\n ]\n}", 126 | "error": "Error: Key 'canvases' is required in 'sequence' @ data['sequences'][0]['canvases']", 127 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/17/manifest.json", 128 | "@type": "sc:Manifest" 129 | }, 130 | { 131 | "label": "18: Manifest with empty Sequence", 132 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": []\n }\n ]\n}", 133 | "error": "Error: 'canvases' MUST have at least one entry @ data['sequences'][0]['canvases']", 134 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/18/manifest.json", 135 | "@type": "sc:Manifest" 136 | }, 137 | { 138 | "label": "19: Sequence with non Canvas / non list", 139 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": \"asdf\"\n }\n ]\n}", 140 | "error": "Error: 'canvases' MUST be a list. @ data['sequences'][0]['canvases']", 141 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/19/manifest.json", 142 | "@type": "sc:Manifest" 143 | }, 144 | { 145 | "label": "20: Sequence with non Canvas in a list", 146 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n \"asdf\"\n ]\n }\n ]\n}", 147 | "error": "Error: 'canvas' must be json-object, not str @ data['sequences'][0]['canvases'][0]['canvas']", 148 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/20/manifest.json", 149 | "@type": "sc:Manifest" 150 | }, 151 | { 152 | "label": "21: Sequence with Canvas not in a list", 153 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"label\": \"canvas\", \n \"width\": 1\n }\n }\n ]\n}", 154 | "error": "Error: 'canvases' MUST be a list. @ data['sequences'][0]['canvases']", 155 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/21/manifest.json", 156 | "@type": "sc:Manifest" 157 | }, 158 | { 159 | "label": "22: Canvas without id", 160 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 161 | "error": "Error: Key '@id' is required in 'canvas' @ data['sequences'][0]['canvases'][0]['@id']", 162 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/22/manifest.json", 163 | "@type": "sc:Manifest" 164 | }, 165 | { 166 | "label": "23: Canvas without real URI", 167 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"asdf\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 168 | "error": "Error: URI is not valid: 'asdf' @ data['sequences'][0]['canvases'][0]['@id']", 169 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/23/manifest.json", 170 | "@type": "sc:Manifest" 171 | }, 172 | { 173 | "label": "24: Canvas without label", 174 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"width\": 1\n }\n ]\n }\n ]\n}", 175 | "error": "Error: Key 'label' is required in 'canvas' @ data['sequences'][0]['canvases'][0]['label']", 176 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/24/manifest.json", 177 | "@type": "sc:Manifest" 178 | }, 179 | { 180 | "label": "25: Canvas with nonstring label", 181 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"label\": 1.0, \n \"width\": 1\n }\n ]\n }\n ]\n}", 182 | "error": "Error: Illegal type (should be str, list, or dict) @ data['sequences'][0]['canvases'][0]['label']", 183 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/25/manifest.json", 184 | "@type": "sc:Manifest" 185 | }, 186 | { 187 | "label": "26: Canvas without height", 188 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 189 | "error": "Error: Key 'height' is required in 'canvas' @ data['sequences'][0]['canvases'][0]['height']", 190 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/26/manifest.json", 191 | "@type": "sc:Manifest" 192 | }, 193 | { 194 | "label": "27: Canvas with non integer height", 195 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": \"two\", \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 196 | "error": "Error: height must be int. @ data['sequences'][0]['canvases'][0]['height']", 197 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/27/manifest.json", 198 | "@type": "sc:Manifest" 199 | }, 200 | { 201 | "label": "28: Canvas without width", 202 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"label\": \"canvas\"\n }\n ]\n }\n ]\n}", 203 | "error": "Error: Key 'width' is required in 'canvas' @ data['sequences'][0]['canvases'][0]['width']", 204 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/28/manifest.json", 205 | "@type": "sc:Manifest" 206 | }, 207 | { 208 | "label": "29: Canvas with non integer width", 209 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"label\": \"canvas\", \n \"width\": \"two\"\n }\n ]\n }\n ]\n}", 210 | "error": "Error: width must be int. @ data['sequences'][0]['canvases'][0]['width']", 211 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/29/manifest.json", 212 | "@type": "sc:Manifest" 213 | }, 214 | { 215 | "label": "30: Canvas with non list images", 216 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"images\": \"asdfasdf\", \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 217 | "error": "Error: 'images' MUST be a list. @ data['sequences'][0]['canvases'][0]['images']", 218 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/30/manifest.json", 219 | "@type": "sc:Manifest" 220 | }, 221 | { 222 | "label": "31: Canvas with list of non annotations in images", 223 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"images\": [\n \"asdfasdf\"\n ], \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 224 | "error": "Error: 'annotation' must be json-object, not str @ data['sequences'][0]['canvases'][0]['images'][0]['annotation']", 225 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/31/manifest.json", 226 | "@type": "sc:Manifest" 227 | }, 228 | { 229 | "label": "32: Canvas with annotation directly in images", 230 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"images\": {\n \"@type\": \"oa:Annotation\", \n \"motivation\": \"sc:painting\", \n \"on\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"resource\": {\n \"@id\": \"http://example.net/image.jpg\", \n \"@type\": \"dctypes:Image\"\n }\n }, \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 231 | "error": "Error: 'images' MUST be a list. @ data['sequences'][0]['canvases'][0]['images']", 232 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/32/manifest.json", 233 | "@type": "sc:Manifest" 234 | }, 235 | { 236 | "label": "33: Annotation without a motivation", 237 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"images\": [\n {\n \"@type\": \"oa:Annotation\", \n \"on\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"resource\": {\n \"@id\": \"http://example.net/image.jpg\", \n \"@type\": \"dctypes:Image\"\n }\n }\n ], \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 238 | "error": "Error: Key 'motivation' is required in 'annotation' @ data['sequences'][0]['canvases'][0]['images'][0]['motivation']", 239 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/33/manifest.json", 240 | "@type": "sc:Manifest" 241 | }, 242 | { 243 | "label": "34: Annotation with a nonsense motivation", 244 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"images\": [\n {\n \"@type\": \"oa:Annotation\", \n \"motivation\": \"somethingElse\", \n \"on\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"resource\": {\n \"@id\": \"http://example.net/image.jpg\", \n \"@type\": \"dctypes:Image\"\n }\n }\n ], \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 245 | "error": "Error: motivation must be 'sc:painting'. @ data['sequences'][0]['canvases'][0]['images'][0]['motivation']", 246 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/34/manifest.json", 247 | "@type": "sc:Manifest" 248 | }, 249 | { 250 | "label": "35: Annotation without a target/on", 251 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"images\": [\n {\n \"@type\": \"oa:Annotation\", \n \"motivation\": \"sc:painting\", \n \"resource\": {\n \"@id\": \"http://example.net/image.jpg\", \n \"@type\": \"dctypes:Image\"\n }\n }\n ], \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 252 | "error": "Error: Key 'on' is required in 'annotation' @ data['sequences'][0]['canvases'][0]['images'][0]['on']", 253 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/35/manifest.json", 254 | "@type": "sc:Manifest" 255 | }, 256 | { 257 | "label": "36: Annotation with a nonsense target/on", 258 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"images\": [\n {\n \"@type\": \"oa:Annotation\", \n \"motivation\": \"sc:painting\", \n \"on\": 1.0, \n \"resource\": {\n \"@id\": \"http://example.net/image.jpg\", \n \"@type\": \"dctypes:Image\"\n }\n }\n ], \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 259 | "error": "Error: 'on' must reference the canvas URI. @ data['sequences'][0]['canvases'][0]['images'][0]['on']", 260 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/36/manifest.json", 261 | "@type": "sc:Manifest" 262 | }, 263 | { 264 | "label": "37: Annotation without a body/resource", 265 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"images\": [\n {\n \"@type\": \"oa:Annotation\", \n \"motivation\": \"sc:painting\", \n \"on\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\"\n }\n ], \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 266 | "error": "Error: Key 'resource' is required in 'annotation' @ data['sequences'][0]['canvases'][0]['images'][0]['resource']", 267 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/37/manifest.json", 268 | "@type": "sc:Manifest" 269 | }, 270 | { 271 | "label": "38: Annotation with a nonsense body/resource", 272 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"images\": [\n {\n \"@type\": \"oa:Annotation\", \n \"motivation\": \"sc:painting\", \n \"on\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"resource\": 1.0\n }\n ], \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 273 | "error": "Error: 'resource' must be json-object, not float @ data['sequences'][0]['canvases'][0]['images'][0]['resource']['resource']", 274 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/38/manifest.json", 275 | "@type": "sc:Manifest" 276 | }, 277 | { 278 | "label": "39: Annotation resource without a type", 279 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"images\": [\n {\n \"@type\": \"oa:Annotation\", \n \"motivation\": \"sc:painting\", \n \"on\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"resource\": {\n \"@id\": \"http://example.net/image.jpg\"\n }\n }\n ], \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 280 | "error": "Error: Key '@type' is required in 'resource' @ data['sequences'][0]['canvases'][0]['images'][0]['resource']['@type']", 281 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/39/manifest.json", 282 | "@type": "sc:Manifest" 283 | }, 284 | { 285 | "label": "40: Annotation resource in images that isn't an Image", 286 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"images\": [\n {\n \"@type\": \"oa:Annotation\", \n \"motivation\": \"sc:painting\", \n \"on\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"resource\": {\n \"@id\": \"http://foo.bar.com/baz\", \n \"@type\": \"dctypes:Audio\"\n }\n }\n ], \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 287 | "error": "Error: @type MUST be 'dctypes:Image' @ data['sequences'][0]['canvases'][0]['images'][0]['resource']['@type']", 288 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/40/manifest.json", 289 | "@type": "sc:Manifest" 290 | }, 291 | { 292 | "label": "41: Image without an id", 293 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"images\": [\n {\n \"@type\": \"oa:Annotation\", \n \"motivation\": \"sc:painting\", \n \"on\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"resource\": {\n \"@type\": \"dctypes:Image\"\n }\n }\n ], \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 294 | "error": "Error: Key '@id' is required in 'resource' @ data['sequences'][0]['canvases'][0]['images'][0]['resource']['@id']", 295 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/41/manifest.json", 296 | "@type": "sc:Manifest" 297 | }, 298 | { 299 | "label": "42: Image without a real id", 300 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"images\": [\n {\n \"@type\": \"oa:Annotation\", \n \"motivation\": \"sc:painting\", \n \"on\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"resource\": {\n \"@id\": \"asdf\", \n \"@type\": \"dctypes:Image\"\n }\n }\n ], \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 301 | "error": "Error: URI is not valid: 'asdf' @ data['sequences'][0]['canvases'][0]['images'][0]['resource']['@id']", 302 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/42/manifest.json", 303 | "@type": "sc:Manifest" 304 | }, 305 | { 306 | "label": "43: Image with broken height", 307 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"images\": [\n {\n \"@type\": \"oa:Annotation\", \n \"motivation\": \"sc:painting\", \n \"on\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"resource\": {\n \"@id\": \"http://example.net/image.jpg\", \n \"@type\": \"dctypes:Image\", \n \"height\": \"six\"\n }\n }\n ], \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 308 | "error": "Error: height must be int. @ data['sequences'][0]['canvases'][0]['images'][0]['resource']['height']", 309 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/43/manifest.json", 310 | "@type": "sc:Manifest" 311 | }, 312 | { 313 | "label": "44: Image with broken width", 314 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"images\": [\n {\n \"@type\": \"oa:Annotation\", \n \"motivation\": \"sc:painting\", \n \"on\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"resource\": {\n \"@id\": \"http://example.net/image.jpg\", \n \"@type\": \"dctypes:Image\", \n \"width\": \"six\"\n }\n }\n ], \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 315 | "error": "Error: width must be int. @ data['sequences'][0]['canvases'][0]['images'][0]['resource']['width']", 316 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/44/manifest.json", 317 | "@type": "sc:Manifest" 318 | }, 319 | { 320 | "label": "45: Non HTML with < and > in description", 321 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"description\": {\n \"@value\": \" \"\n }, \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 322 | "error": "Error: Field contains tags but is not valid HTML. @ data['@value']", 323 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/45/manifest.json", 324 | "@type": "sc:Manifest" 325 | }, 326 | { 327 | "label": "46: No Value in description", 328 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"description\": {\n \"@language\": \"en\"\n }, \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 329 | "error": "Error: Field has no '@value' key where one is required. @ data['description']", 330 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/46/manifest.json", 331 | "@type": "sc:Manifest" 332 | }, 333 | { 334 | "label": "47: Vulnerable HTML attribute in description", 335 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"description\": \"Naughty\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 336 | "error": "Error: HTML tag '' not allowed attribute 'onmouseover'. @ data['description']", 337 | "@id": "http://iiif.io/api/presentation/2.0/example/errors/47/manifest.json", 338 | "@type": "sc:Manifest" 339 | }, 340 | { 341 | "label": "48: Vulnerable HTML tag in description", 342 | "manifest": "{\n \"@context\": \"http://iiif.io/api/presentation/2/context.json\", \n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/manifest.json\", \n \"@type\": \"sc:Manifest\", \n \"description\": \"Naughty\", \n \"label\": \"manifest\", \n \"sequences\": [\n {\n \"@type\": \"sc:Sequence\", \n \"canvases\": [\n {\n \"@id\": \"http://iiif.io/api/presentation/2.0/example/errors/canvas/c1.json\", \n \"@type\": \"sc:Canvas\", \n \"height\": 1, \n \"label\": \"canvas\", \n \"width\": 1\n }\n ]\n }\n ]\n}", 343 | "error": "Error: Forbidden tag '"), 187 | (Path(tuple()), 'description', "Bad attribute"), 188 | (Path(tuple()), 'description', "this is badly formatted."), 189 | } 190 | 191 | for p, f, v in invalid_inputs: 192 | self.test_subject._path = p 193 | self.test_subject._check_html(f, v) 194 | self.assertTrue(self.has_errors(), (p,f,v)) 195 | self.clear_errors_and_warnings() 196 | 197 | def test_mute_error(self): 198 | # Test error is caught. 199 | val, err = self.test_subject.mute_errors(self.fake_invalid, "value") 200 | self.assertIn(ValidatorLogError('test error', ('fake field',)), err) 201 | self.assertFalse(self.has_errors()) 202 | 203 | # Test fail fast is not triggered. 204 | self.test_subject._IIIFValidator.fail_fast = True 205 | try: 206 | val, err = self.test_subject.mute_errors(self.fake_invalid, "value") 207 | except FailFastException: 208 | self.fail("FailFastException was raised.") 209 | self.assertIn(ValidatorLogError('test error', ('fake field',)), err) 210 | self.assertFalse(self.has_errors()) 211 | 212 | def test_error_to_warning(self): 213 | @self.test_subject.errors_to_warnings 214 | def log_error(self, value): 215 | self.log_error("fake field", "test error") 216 | 217 | # Test that error was converted to warning properly. 218 | log_error(self.test_subject, "value") 219 | self.assertFalse(self.has_errors()) 220 | self.assertTrue(self.has_warnings()) 221 | self.assertEqual(self.test_subject.warnings.pop(), ValidatorLogWarning('test error', ('fake field',))) 222 | 223 | # Test that fail fast is not triggered. 224 | self.test_subject._IIIFValidator.fail_fast = True 225 | try: 226 | log_error(self.test_subject, "value") 227 | except FailFastException: 228 | self.fail("FailFastException was raised.") 229 | self.assertTrue(self.has_warnings()) 230 | self.assertFalse(self.has_errors()) 231 | 232 | def test_warning_to_error(self): 233 | @self.test_subject.warnings_to_errors 234 | def log_warning(self, value): 235 | self.log_warning("fake field", "test error") 236 | 237 | # Test that warning is converted to error properly 238 | log_warning(self.test_subject, "value") 239 | self.assertTrue(self.has_errors()) 240 | self.assertFalse(self.has_warnings()) 241 | self.assertEqual(self.test_subject.errors.pop(), ValidatorLogError('test error', ('fake field',))) 242 | 243 | def test_path_equality(self): 244 | a = Path(('sequences', 0, 'metadata')) 245 | b = Path(('sequences', 0, 'metadata')) 246 | self.assertTrue(a == b) 247 | 248 | def test_path_repr(self): 249 | a = Path(('sequences', 0, 'metadata')) 250 | self.assertEqual(repr(a), "Path('sequences', 0, 'metadata')") 251 | 252 | def test_path_add(self): 253 | a = Path(('sequences', 0, 'metadata')) 254 | b = Path(('sequences', 0, 'metadata')) 255 | self.assertEqual(repr(a + b), "Path('sequences', 0, 'metadata', 'sequences', 0, 'metadata')") 256 | 257 | def test_path_raises_typeerror(self): 258 | a = Path(('sequences', 0, 'metadata')) 259 | b = {'foo': 'bar'} 260 | with self.assertRaises(TypeError): 261 | a + b 262 | 263 | def test_path_returns_path(self): 264 | a = Path(('sequences', 0, 'metadata')) 265 | self.assertEqual(a.path, ('sequences', 0, 'metadata')) 266 | 267 | def test_no_index_path_eq(self): 268 | a = Path(('sequences', 0, 'metadata')) 269 | b = Path(('sequences', 1, 'metadata')) 270 | self.assertTrue(a.no_index_eq(b)) 271 | 272 | def test_no_index_endswith(self): 273 | a = Path(('sequences', 0, 'metadata', 1)) 274 | b = Path(('sequences', 1, 'metadata')) 275 | self.assertTrue(a.no_index_endswith(b)) 276 | 277 | def test_validator_log_lt(self): 278 | x = ValidatorLogWarning('test error', ('fake field',)) 279 | y = ValidatorLogError('test error', ('fake field', 'another')) 280 | self.assertTrue(x < y) 281 | 282 | def test_validator_log_warning(self): 283 | x = ValidatorLogWarning('test error', ('fake field',)) 284 | self.assertEqual(str(x), "Warning: test error @ data['fake field']") 285 | 286 | def test_validator_log_warning_repr(self): 287 | x = ValidatorLogWarning('test error', ('fake field',)) 288 | self.assertEqual(repr(x), "ValidatorLogWarning('test error', @ data['fake field'])") 289 | 290 | def test_validator_log_error_repr(self): 291 | x = ValidatorLogError('test error', ('fake field',)) 292 | self.assertEqual(repr(x), "ValidatorLogError('test error', @ data['fake field'])") 293 | 294 | # NB: This should be tested with an actual traceback scenario. 295 | def test_validator_log_print_trace(self): 296 | x = ValidatorLogError('test error', ('fake field',)) 297 | self.assertIsNone(x.print_trace()) 298 | 299 | def test_validator_log_raises_typeerror_on_bad_add(self): 300 | v = ValidatorLog() 301 | with self.assertRaises(TypeError): 302 | v.add("foo") 303 | 304 | def test_validator_unique_logging(self): 305 | v = ValidatorLog(unique_logging=False) 306 | e = ValidatorLogError('test error', ('fake field',)) 307 | v.add(e) 308 | self.assertEqual(len(v._entries), 1) 309 | 310 | 311 | -------------------------------------------------------------------------------- /tests/test_error_collection.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from .validator_testing_tools import ValidatorTestingTools 5 | from tripoli import IIIFValidator 6 | 7 | 8 | class TestErrorCollection(ValidatorTestingTools): 9 | 10 | def setUp(self): 11 | self.test_subject = IIIFValidator() 12 | self.test_subject.logger.disabled = True 13 | self.base_dir = os.path.dirname(os.path.realpath(__file__)) 14 | 15 | def test_error_collection(self): 16 | with open(os.path.join(self.base_dir, 'fixtures/error_collection.json')) as f: 17 | collection = json.load(f) 18 | 19 | for m in collection['manifests']: 20 | self.test_subject.validate(m['manifest']) 21 | self.assertTrue(self.has_errors(), "Did not catch error on '{}'".format(m['label'])) 22 | self.assertEqual(str(self.test_subject.errors[0]), m['error'], m['label']) 23 | self.clear_errors_and_warnings() 24 | -------------------------------------------------------------------------------- /tests/test_iiif_validator.py: -------------------------------------------------------------------------------- 1 | try: 2 | import ujson as json 3 | except ImportError: 4 | import json 5 | 6 | import os 7 | 8 | from .validator_testing_tools import ValidatorTestingTools 9 | from tripoli import IIIFValidator 10 | 11 | 12 | class TestIIIFValidator(ValidatorTestingTools): 13 | def setUp(self): 14 | self.test_subject = IIIFValidator() 15 | self.test_subject.logger.setLevel("CRITICAL") 16 | self.test_subject.fail_fast = False 17 | self.test_subject.collect_errors = True 18 | self.test_subject.collect_warnings = True 19 | self.test_subject.debug = True 20 | 21 | self.base_dir = os.path.dirname(os.path.realpath(__file__)) 22 | 23 | with open(os.path.join(self.base_dir, 'fixtures/valid_manifest')) as f: 24 | self.valid_manifest = json.load(f) 25 | 26 | with open(os.path.join(self.base_dir, 'fixtures/error_collection.json')) as f: 27 | self.error_collection = json.load(f) 28 | self.man_with_warnings_and_errors = self.error_collection['manifests'][-1]['manifest'] 29 | 30 | def test_valid_manifest(self): 31 | """Test that a valid manifest raises no errors.""" 32 | with open(os.path.join(self.base_dir, 'fixtures/valid_manifest')) as f: 33 | man = json.load(f) 34 | self.test_subject.validate(man) 35 | self.assertFalse(self.has_errors()) 36 | 37 | def test_text_manifest(self): 38 | """Test that a manifest can be passed as text.""" 39 | with open(os.path.join(self.base_dir, 'fixtures/valid_manifest')) as f: 40 | self.test_subject.validate(f.read()) 41 | self.assertFalse(self.has_errors()) 42 | 43 | def test_debug_setting(self): 44 | """Test that the debug setting works.""" 45 | iv = IIIFValidator(debug=True) 46 | iv.validate(self.man_with_warnings_and_errors) 47 | self.assertTrue(bool(iv.errors[0]._tb)) 48 | 49 | iv = IIIFValidator(debug=False) 50 | iv.validate(self.man_with_warnings_and_errors) 51 | self.assertFalse(bool(iv.errors[0]._tb)) 52 | 53 | def test_collect_warnings_setting(self): 54 | """Test that collect_warnings setting works.""" 55 | iv = IIIFValidator(collect_warnings=False) 56 | iv.validate(self.man_with_warnings_and_errors) 57 | -------------------------------------------------------------------------------- /tests/validator_testing_tools.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import inspect 3 | 4 | from tripoli.validator_logging import ValidatorLog 5 | 6 | 7 | class ValidatorTestingTools(unittest.TestCase): 8 | 9 | def setUp(self): 10 | self.test_subject = None 11 | raise NotImplemented 12 | 13 | def has_error(self, error): 14 | """Check if a particular error was raised.""" 15 | return error in self.test_subject._errors 16 | 17 | def has_errors(self): 18 | return bool(self.test_subject._errors) 19 | 20 | def has_warning(self, warning): 21 | return warning in self.test_subject._warnings 22 | 23 | def has_warnings(self): 24 | return bool(self.test_subject._warnings) 25 | 26 | def has_warnings_or_errors(self): 27 | return (bool(self.test_subject._warnings) or bool(self.test_subject._errors)) 28 | 29 | def clear_errors(self): 30 | self.test_subject._errors = ValidatorLog(self.test_subject.unique_logging) 31 | 32 | def clear_warnings(self): 33 | self.test_subject._warnings = ValidatorLog(self.test_subject.unique_logging) 34 | 35 | def clear_errors_and_warnings(self): 36 | self.test_subject._errors = ValidatorLog(self.test_subject.unique_logging) 37 | self.test_subject._warnings = ValidatorLog(self.test_subject.unique_logging) 38 | 39 | def assert_no_errors_with_inputs(self, fn, inputs): 40 | """Run validation fn on each input, asserting no error is logged.""" 41 | args = inspect.signature(fn).parameters 42 | for arg in inputs: 43 | val = fn('unknown_field', arg) if "field" in args else fn(arg) 44 | try: 45 | self.assertFalse(self.has_errors(), "{} is not valid input".format(arg)) 46 | self.assertEqual(arg, val) 47 | finally: 48 | self.clear_errors() 49 | 50 | def assert_errors_with_inputs(self, fn, inputs): 51 | args = inspect.signature(fn).parameters 52 | for arg in inputs: 53 | val = fn('unknown_field', arg) if "field" in args else fn(arg) 54 | try: 55 | self.assertTrue(self.has_errors(), "{} is valid input".format(arg)) 56 | self.assertEqual(arg, val) 57 | finally: 58 | self.clear_errors() 59 | 60 | def fake_valid(self, value): 61 | return value 62 | 63 | def fake_invalid(self, value): 64 | self.test_subject.log_error("fake field", "test error") 65 | return value 66 | 67 | def fake_change(self, value): 68 | return str(value) + "changed stuff" 69 | -------------------------------------------------------------------------------- /tripoli/__init__.py: -------------------------------------------------------------------------------- 1 | from .tripoli import IIIFValidator, AnnotationValidator, \ 2 | ManifestValidator, CanvasValidator, SequenceValidator, ImageContentValidator,\ 3 | __version__ 4 | from .resource_validators.base_validator import BaseValidator 5 | -------------------------------------------------------------------------------- /tripoli/exceptions.py: -------------------------------------------------------------------------------- 1 | class FailFastException(Exception): 2 | """Raised when validation should end because error is hit.""" 3 | pass 4 | 5 | 6 | class TypeParseException(Exception): 7 | """Raised when a `@type` can not be parsed from input data.""" 8 | pass 9 | -------------------------------------------------------------------------------- /tripoli/mixins.py: -------------------------------------------------------------------------------- 1 | from .validator_logging import ValidatorLog 2 | 3 | 4 | class LinkedValidatorMixin: 5 | """Basic support for storing 'global' references in a single administrative class.""" 6 | 7 | def __init__(self, iiif_validator=None): 8 | #: The IIIFValidator containing this validator. 9 | self._IIIFValidator = iiif_validator 10 | 11 | @property 12 | def collect_warnings(self): 13 | return self._IIIFValidator.collect_warnings 14 | 15 | @property 16 | def collect_errors(self): 17 | return self._IIIFValidator.collect_errors 18 | 19 | @property 20 | def debug(self): 21 | return self._IIIFValidator.debug 22 | 23 | @property 24 | def fail_fast(self): 25 | return self._IIIFValidator.fail_fast 26 | 27 | @property 28 | def verbose(self): 29 | return self._IIIFValidator.verbose 30 | 31 | @property 32 | def unique_logging(self): 33 | return self._IIIFValidator.unique_logging 34 | 35 | @property 36 | def ManifestValidator(self): 37 | return self._IIIFValidator._ManifestValidator 38 | 39 | @property 40 | def SequenceValidator(self): 41 | return self._IIIFValidator._SequenceValidator 42 | 43 | @property 44 | def CanvasValidator(self): 45 | return self._IIIFValidator._CanvasValidator 46 | 47 | @property 48 | def AnnotationValidator(self): 49 | return self._IIIFValidator._AnnotationValidator 50 | 51 | @property 52 | def ImageContentValidator(self): 53 | return self._IIIFValidator._ImageContentValidator 54 | 55 | 56 | class SubValidationMixin: 57 | """Provides needed parts to accumulate errors and delegate validation.""" 58 | 59 | def __init__(self): 60 | self._errors = ValidatorLog() 61 | self._warnings = ValidatorLog() 62 | self.is_valid = None 63 | 64 | @property 65 | def errors(self): 66 | """A list of ValidatorLogError from the previous call to validate().""" 67 | return list(self._errors) 68 | 69 | @property 70 | def warnings(self): 71 | """A list of ValidatorLogWarnings from the previous call to validate().""" 72 | return list(self._warnings) 73 | 74 | def print_errors(self): 75 | """Print accumulated errors in a nice format.""" 76 | for err in self._errors: 77 | print(err) 78 | 79 | def print_warnings(self): 80 | """Print accumulated warnings in a nice format.""" 81 | for warn in self._warnings: 82 | print(warn) 83 | 84 | def _sub_validate(self, subschema, value, path, **kwargs): 85 | """Validate a field using another Validator. 86 | 87 | :param subschema: A BaseValidator implementing object. 88 | :param value (dict): The data to be validated. 89 | :param path (tuple): The path where the above data exists. 90 | Example: ('sequences', 'canvases') for the CanvasValidator. 91 | :param kwargs: Any keys to subschema._run_validation() 92 | - canvas_uri: String passed to AnnotationValidator from 93 | CanvasValidator to ensure 'on' key is valid. 94 | - raise_warnings: bool to decide if warnings will be recorded 95 | or not. 96 | """ 97 | try: 98 | subschema._validate(value, path, **kwargs) 99 | finally: 100 | if subschema._errors: 101 | self._errors.update(subschema._errors) 102 | if subschema._warnings: 103 | self._warnings.update(subschema._warnings) 104 | if subschema.corrected_doc: 105 | return subschema.corrected_doc 106 | else: 107 | return subschema._json 108 | -------------------------------------------------------------------------------- /tripoli/resource_validators/__init__.py: -------------------------------------------------------------------------------- 1 | from .canvas_validator import CanvasValidator 2 | from .annotation_validator import AnnotationValidator 3 | from .manifest_validator import ManifestValidator 4 | from .sequence_validator import SequenceValidator 5 | from .image_content_validator import ImageContentValidator 6 | from .base_validator import BaseValidator 7 | -------------------------------------------------------------------------------- /tripoli/resource_validators/annotation_validator.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Alex Parmentier, Andrew Hankinson 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from collections import OrderedDict 22 | 23 | from .base_validator import BaseValidator 24 | 25 | 26 | class AnnotationValidator(BaseValidator): 27 | KNOWN_FIELDS = BaseValidator.COMMON_FIELDS | {"motivation", "resource", "on", "@context"} 28 | FORBIDDEN_FIELDS = {"format", "height", "width", "viewingDirection", "navDate", "startCanvas", "first", 29 | "last", "total", "next", "prev", "startIndex", "collections", "manifests", "members", 30 | "sequences", "structures", "canvases", "resources", "otherContent", "images", "ranges"} 31 | REQUIRED_FIELDS = {"@type", "on", "motivation", "resource"} 32 | 33 | def __init__(self, iiif_validator): 34 | super().__init__(iiif_validator) 35 | self.ImageSchema = OrderedDict(( 36 | ("@id", self.id_field), 37 | ('@type', self.type_field), 38 | ('motivation', self.motivation_field), 39 | ("on", self.on_field), 40 | ('height', self.height_field), 41 | ('width', self.width_field), 42 | ('resource', self.resource_field) 43 | )) 44 | 45 | self.canvas_uri = None 46 | self.setup() 47 | 48 | def _raise_additional_warnings(self, validation_results): 49 | pass 50 | 51 | def _run_validation(self, canvas_uri=None, **kwargs): 52 | self.canvas_uri = canvas_uri 53 | self._check_all_key_constraints("annotation", self._json) 54 | return self._compare_dicts(self.ImageSchema, self._json) 55 | 56 | def type_field(self, value): 57 | """Assert that ``@type == 'oa:Annotation'``.""" 58 | if value != "oa:Annotation": 59 | self.log_error("@type", "@type must be 'oa:Annotation'.") 60 | return value 61 | 62 | def motivation_field(self, value): 63 | """Assert that ``motivation == 'sc:painting'``.""" 64 | if value != "sc:painting": 65 | self.log_error("motivation", "motivation must be 'sc:painting'.") 66 | return value 67 | 68 | def on_field(self, value): 69 | """Validate the ``on`` field.""" 70 | if self.canvas_uri and value != self.canvas_uri: 71 | self.log_error("on", "'on' must reference the canvas URI.") 72 | return value 73 | 74 | def resource_field(self, value): 75 | """Validate ``resources`` list. 76 | 77 | Calls a sub-validation procedure handled by the :class:`ImageContentValidator`. 78 | """ 79 | path = self._path + ("resource", ) 80 | return self._sub_validate(self.ImageContentValidator, value, path) 81 | -------------------------------------------------------------------------------- /tripoli/resource_validators/base_validator.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Alex Parmentier, Andrew Hankinson 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | import contextlib 22 | import functools 23 | import traceback 24 | import urllib.parse 25 | import copy 26 | import uuid 27 | import re 28 | 29 | import defusedxml.ElementTree as ET 30 | 31 | from ..mixins import LinkedValidatorMixin, SubValidationMixin 32 | from ..validator_logging import ValidatorLogError, ValidatorLogWarning, Path, ValidatorLog 33 | from ..exceptions import FailFastException 34 | 35 | 36 | class BaseValidator(LinkedValidatorMixin, SubValidationMixin): 37 | """Defines basic validation behaviour and expected attributes 38 | of any IIIF validators that inherit from it.""" 39 | 40 | PRESENTATION_API_URI = "http://iiif.io/api/presentation/2/context.json" 41 | IMAGE_API_2 = "http://iiif.io/api/image/2/context.json" 42 | IMAGE_API_1 = "http://iiif.io/api/image/1/context.json" 43 | 44 | # The following constants will be iterated through and have their 45 | # values checked on every validation to produce warnings and errors 46 | # based on key constraints. Each inheritor should define these. 47 | 48 | #: The fields which may appear on this resource. 49 | KNOWN_FIELDS = set() 50 | 51 | #: The fields which are forbidden on this resource. 52 | FORBIDDEN_FIELDS = set() 53 | 54 | #: The fields which are required on this resource. 55 | REQUIRED_FIELDS = set() 56 | 57 | #: The fields which are recommended on this resource. 58 | RECOMMENDED_FIELDS = set() 59 | 60 | # The set of acceptable viewHints on this resource. 61 | VIEW_HINTS = set() 62 | 63 | # The set of acceptable viewDirections on this resource. 64 | VIEW_DIRS = set() 65 | 66 | # The set of fields which may appear on _any_ resource. 67 | COMMON_FIELDS = { 68 | "label", "metadata", "description", "thumbnail", "attribution", "license", "logo", 69 | "@id", "@type", "viewingHint", "seeAlso", "service", "related", "rendering", "within" 70 | } 71 | 72 | # The path suffixes which are allowed to contain HTML. 73 | HTML_ALLOWED_FIELDS = {('description',), ('attribution',), 74 | ('metadata', 'value'), ('metadata', 'label'), 75 | ('@value',)} 76 | 77 | # The attributes allowed for each html field. 78 | HTML_ALLOWED_ATTRIBUTES = { 79 | 'a': {'href'}, 80 | 'img': {'src', 'alt'} 81 | } 82 | 83 | # The HTML tags which are allowed to appear in a text field. 84 | HTML_ALLOWED_TAGS = {'a', 'b', 'br', 'i', 'img', 'p', 'span'} 85 | 86 | # The HTML tags which are expressly forbidden. 87 | HTML_FORBIDDEN_TAGS = {'script', 'style', 'object', 'form', 'input'} 88 | 89 | # Catch all regex for XML in string. 90 | _XML_TAG_REGEX = re.compile(r'<\/?(\w+)((\s+\w+(\s*=\s*(?:\".*?\"|\'.*?\'|[\^\'\">\s]+))?)+\s*|\s*)\/?>', re.DOTALL) 91 | _XML_COMMENT_REGEX = re.compile(r'', re.DOTALL) 92 | _XML_CDATA_REGEX = re.compile(r'') 93 | 94 | def __init__(self, iiif_validator=None): 95 | LinkedValidatorMixin.__init__(self, iiif_validator=iiif_validator) 96 | SubValidationMixin.__init__(self) 97 | self._path = Path(tuple()) 98 | self._json = None 99 | self.corrected_doc = None 100 | 101 | self._LangValPairs = { 102 | '@language': functools.partial(self._repeatable_string_type, "@language"), 103 | '@value': functools.partial(self._repeatable_string_type, "@value") 104 | } 105 | 106 | self._MetadataItemSchema = { 107 | 'label': functools.partial(self._str_or_val_lang_type, "label"), 108 | 'value': functools.partial(self._str_or_val_lang_type, "value") 109 | } 110 | 111 | self._common_fields_mapping = { 112 | "@id": self.id_field, 113 | "label": self.label_field, 114 | "metadata": self.metadata_field, 115 | "description": self.description_field, 116 | "thumbnail": self.thumbnail_field, 117 | "logo": self.logo_field, 118 | "attribution": self.attribution_field, 119 | "@type": self.type_field, 120 | "license": self.license_field, 121 | "related": self.related_field, 122 | "rendering": self.rendering_field, 123 | "service": self.service_field, 124 | "seeAlso": self.seeAlso_field, 125 | "within": self.within_field, 126 | 'viewingHint': self.viewing_hint_field 127 | } 128 | 129 | @staticmethod 130 | def errors_to_warnings(fn): 131 | """Cast any errors to warnings on any ``*_field`` or ``*_type`` function. 132 | 133 | Works by patching the BaseValidator.log_error to refer to 134 | BaseValidator.log_warning. These methods should not be 135 | overridden in children. 136 | """ 137 | def coerce_errors(*args, **kwargs): 138 | old_log_error = BaseValidator.log_error 139 | try: 140 | BaseValidator.log_error = BaseValidator.log_warning 141 | val = fn(*args, **kwargs) 142 | finally: 143 | BaseValidator.log_error = old_log_error 144 | return val 145 | 146 | return coerce_errors 147 | 148 | @staticmethod 149 | def warnings_to_errors(fn): 150 | """Cast any warnings to errors on any ``*_field`` or ``*_type`` function. 151 | 152 | Works by patching the BaseValidator.log_warning to refer to 153 | BaseValidator.log_error. These methods should not be 154 | overridden in children. 155 | """ 156 | 157 | def coerce_warnings(*args, **kwargs): 158 | old_log_warning = BaseValidator.log_warning 159 | try: 160 | BaseValidator.log_warning = BaseValidator.log_error 161 | val = fn(*args, **kwargs) 162 | finally: 163 | BaseValidator.log_warning = old_log_warning 164 | return val 165 | 166 | return coerce_warnings 167 | 168 | @contextlib.contextmanager 169 | def _temp_path(self, path): 170 | """Temporarily set the path to the given path. 171 | 172 | Useful when validating an embedded dictionary 173 | and adding its path to the current path. 174 | 175 | :param path (Path or tuple): The path to temporarily use. 176 | """ 177 | old_path = self._path 178 | try: 179 | if isinstance(path, tuple): 180 | self._path = Path(path) 181 | elif isinstance(path, Path): 182 | self._path = path 183 | else: 184 | raise ValueError("path must be Path or tuple.") 185 | yield 186 | finally: 187 | self._path = old_path 188 | 189 | def mute_errors(self, fn, *args, **kwargs): 190 | """Run given function and catch any of the errors it logged. 191 | 192 | The self._errors key will not be changed by using this function. 193 | 194 | Works by patching the BaseValidator.log_error function. 195 | BaseValidator.log_error should not be overridden in children. 196 | """ 197 | caught_errors = set() 198 | 199 | def patched_log_error(self, field, msg): 200 | tb = traceback.extract_stack()[:-1] if self.debug else None 201 | caught_errors.add(ValidatorLogError(msg, self._path + (field,), tb)) 202 | 203 | old_log_error = BaseValidator.log_error 204 | try: 205 | BaseValidator.log_error = patched_log_error 206 | val = fn(*args, **kwargs) 207 | finally: 208 | BaseValidator.log_error = old_log_error 209 | return val, caught_errors 210 | 211 | def _reset(self, path): 212 | """Reset the validator to handle a new chunk of data.""" 213 | self._json = None 214 | self.is_valid = None 215 | self._errors = ValidatorLog(self.unique_logging) 216 | self._warnings = ValidatorLog(self.unique_logging) 217 | self._path = path 218 | 219 | def setup(self): 220 | pass 221 | 222 | def _validate(self, json_dict, path=None, **kwargs): 223 | """Entry point for callers to validate a chunk of data.""" 224 | 225 | # Reset the validator object constants. 226 | if not path: 227 | path = Path(tuple()) 228 | self._reset(path) 229 | 230 | self._json = json_dict 231 | try: 232 | val = self._run_validation(**kwargs) 233 | val = self._check_common_fields(val) 234 | self._raise_additional_warnings(val) 235 | self.corrected_doc = self.modify_final_return(val) 236 | finally: 237 | if self._errors: 238 | self.is_valid = False 239 | else: 240 | self.is_valid = True 241 | 242 | def _compare_dicts(self, schema, value): 243 | """Compare a schema to a dict. 244 | 245 | Emulates the behaviors of the voluptuous library, which was 246 | previously used. Iterates through the schema (keys), calling each 247 | function (values) on the corresponding entry in the `value` dict. 248 | 249 | :param schema: A dict where each key maps to a function. 250 | :param value: A dict to validate against the schema.""" 251 | corrected = copy.copy(value) 252 | for key, fn in schema.items(): 253 | if key in value: 254 | corrected[key] = fn(corrected[key]) 255 | return corrected 256 | 257 | def _run_validation(self, **kwargs): 258 | """Do the actual action of validation. Called by validate().""" 259 | raise NotImplemented 260 | 261 | def _raise_additional_warnings(self, validation_results): 262 | """Inspect the block and raise any SHOULD warnings. 263 | 264 | This method is called only if the manifest validates without errors. 265 | It is passed the block that was just validated. This is the opportunity 266 | to inspect for fields which SHOULD be there and throw warnings. 267 | """ 268 | pass 269 | 270 | def modify_final_return(self, validation_results): 271 | """Do any final corrections or checks on a block before it is returned. 272 | 273 | This method is passed whatever value the validator is about to return to 274 | it's caller. Here you can check for missing keys, compare neighbours, 275 | make modifications or additions: anything you'd like to check or correct 276 | before return. 277 | 278 | :param validation_results: A dict representing a json object. 279 | :return (dict): The sole argument, with some modification applied to it. 280 | """ 281 | return validation_results 282 | 283 | def log_warning(self, field, msg): 284 | """Add a warning to the validator if warnings are being caught. 285 | 286 | This method should not be overridden in subclasses, as doing so 287 | is likely to break the error and warning coercion decorators. 288 | 289 | :param field: The field the warning was raised on. 290 | :param msg: The message to associate with the warning. 291 | """ 292 | if self.collect_warnings: 293 | tb = traceback.extract_stack()[:-1] if self.debug else None 294 | warn = ValidatorLogWarning(msg, self._path + (field,), tb) 295 | if self.verbose: 296 | self._IIIFValidator.logger.warning(str(warn)) 297 | self._warnings.add(warn) 298 | 299 | def log_error(self, field, msg): 300 | """Add an error to the validator. 301 | 302 | This method should not be overridden in subclasses, as doing so 303 | is likely to break the error and warning coercion decorators. 304 | 305 | :param field: The field the error was raised on. 306 | :param msg: The message to associate with the error. 307 | """ 308 | if self.collect_errors: 309 | tb = traceback.extract_stack()[:-1] if self.debug else None 310 | err = ValidatorLogError(msg, self._path + (field,), tb) 311 | if self.verbose: 312 | self._IIIFValidator.logger.error(str(err)) 313 | self._errors.add(err) 314 | if self.fail_fast: 315 | raise FailFastException 316 | 317 | def _check_common_fields(self, val): 318 | """Validate fields that could appear on any resource.""" 319 | return self._compare_dicts(self._common_fields_mapping, val) 320 | 321 | def _check_recommended_fields(self, resource, r_dict, fields): 322 | """Log warnings if fields which should be in r_dict are not. 323 | 324 | :param resource (str): The name of the resource represented by r_dict 325 | :param r_dict (dict): The dict that will have it's keys checked. 326 | :param fields (list): The keys to check for in r_dict. 327 | """ 328 | for f in fields: 329 | if not r_dict.get(f): 330 | self.log_warning(f, "{} SHOULD have {} field.".format(resource, f)) 331 | 332 | def _check_unknown_fields(self, resource, r_dict, fields): 333 | """Log warnings if any fields which are not known in context are present. 334 | 335 | :param resource (str): The name of the resource represented by r_dict 336 | :param r_dict (dict): The dict to have its keys checked. 337 | :param fields (set): Known key names for this resource. 338 | """ 339 | for key in r_dict.keys(): 340 | if key not in fields: 341 | self.log_warning(key, "Unknown key '{}' in '{}'".format(key, resource)) 342 | 343 | def _check_forbidden_fields(self, resource, r_dict, fields): 344 | """Log warnings if keys which are forbidden in context are present. 345 | 346 | :param resource (str): The name of the resource represented by r_dict. 347 | :param r_dict (dict): The dict to have its keys checked. 348 | :param fields (set): Forbidden key names for this resource. 349 | """ 350 | for key in r_dict.keys(): 351 | if key in fields: 352 | self.log_error(key, "Key '{}' is not allowed in '{}'".format(key, resource)) 353 | 354 | def _check_required_fields(self, resource, r_dict, fields): 355 | """Log errors if the required fields are missing. 356 | 357 | :param resource (str): The name of the resource represented by r_dict. 358 | :param r_dict (dict): The dict to have its keys checked. 359 | :param fields (set): Forbidden key names for this resource. 360 | """ 361 | for f in fields: 362 | if f not in r_dict: 363 | self.log_error(f, "Key '{}' is required in '{}'".format(f, resource)) 364 | 365 | def _check_all_key_constraints(self, resource, r_dict): 366 | """Call all key constraint checking methods.""" 367 | if not isinstance(r_dict, dict): 368 | self.log_error(resource, "'{}' must be json-object, not {}".format(resource, type(r_dict).__name__)) 369 | return r_dict 370 | 371 | self._check_forbidden_fields(resource, r_dict, self.FORBIDDEN_FIELDS) 372 | self._check_required_fields(resource, r_dict, self.REQUIRED_FIELDS) 373 | self._check_recommended_fields(resource, r_dict, self.RECOMMENDED_FIELDS) 374 | self._check_unknown_fields(resource, r_dict, self.KNOWN_FIELDS) 375 | return self._check_common_fields(r_dict) 376 | 377 | # Field definitions # 378 | def _optional(self, field, fn): 379 | """Wrap a function to make its value optional (null and '' allows)""" 380 | 381 | def new_fn(*args): 382 | if args[0] == "" or args[0] is None: 383 | self.log_warning(field, "'{}' field should not be included if it is empty.".format(field)) 384 | return args[0] 385 | return fn(*args) 386 | 387 | return new_fn 388 | 389 | def _not_allowed(self, field, value): 390 | """Raise invalid as this key is not allowed in the context.""" 391 | self.log_error(field, "'{}' is not allowed here".format(field)) 392 | return value 393 | 394 | def _str_or_val_lang_type(self, field, value): 395 | """Check value is str or lang/val pairs, else raise ValidatorLogError. 396 | 397 | Allows for repeated strings as per 5.3.2. 398 | """ 399 | if isinstance(value, str): 400 | # Check for invalid and forbidden html. 401 | self._check_html(field, value) 402 | return value 403 | if isinstance(value, list): 404 | return [self._str_or_val_lang_type(field, val) for val in value] 405 | if isinstance(value, dict): 406 | if "@value" not in value: 407 | self.log_error(field, "Field has no '@value' key where one is required.") 408 | return value 409 | return self._compare_dicts(self._LangValPairs, value) 410 | self.log_error(field, "Illegal type (should be str, list, or dict)") 411 | return value 412 | 413 | def _repeatable_string_type(self, field, value): 414 | """Allows for repeated strings as per 5.3.2.""" 415 | if isinstance(value, str): 416 | # Check for invalid and forbidden html. 417 | self._check_html(field, value) 418 | return value 419 | if isinstance(value, list): 420 | for val in value: 421 | if not isinstance(val, str): 422 | self.log_error(field, "Overly nested strings: '{}'".format(value)) 423 | return value 424 | self.log_error(field, "Got '{}' when expecting string or repeated string.".format(value)) 425 | return value 426 | 427 | def _repeatable_service_type(self, field, value): 428 | """ Allow for repeated service types, either referenced or embedded. 429 | """ 430 | if isinstance(value, list): 431 | return [self._service_type(field, val) for val in value] 432 | else: 433 | return self._service_type(field, value) 434 | 435 | def _service_type(self, field, value): 436 | if not isinstance(value, dict): 437 | self.log_error(field, "Illegal type (MUST be an object): '{}'" 438 | .format(value)) 439 | return value 440 | if not '@context' in value: 441 | self.log_error(field, "{} MUST be valid JSON-LD, but has no " 442 | "'@context' key where one is required." 443 | .format(field)) 444 | return value 445 | if not '@id' in value: 446 | self.log_warning(field, "{} SHOULD have an '@id' key.".format(field)) 447 | if not 'profile' in value: 448 | self.log_warning(field, "{} SHOULD have a 'profile' key to " 449 | "allow for determining the type of service." 450 | .format(field)) 451 | return value 452 | 453 | def _repeatable_uri_type(self, field, value): 454 | """Allow single or repeating URIs. 455 | 456 | Based on 5.3.2 of Presentation API 457 | """ 458 | if isinstance(value, list): 459 | return [self._uri_type(field, val) for val in value] 460 | else: 461 | return self._uri_type(field, value) 462 | 463 | def _http_uri_type(self, field, value): 464 | """Allow single URI that MUST be http(s) 465 | 466 | Based on 5.3.2 of Presentation API 467 | """ 468 | return self._uri_type(field, value, http=True) 469 | 470 | def _uri_type(self, field, value, http=False): 471 | """Check value is URI type or raise ValidatorLogError. 472 | 473 | Allows for multiple URI representations, as per 5.3.1 of the 474 | Presentation API. 475 | """ 476 | if isinstance(value, str): 477 | return self._string_uri(field, value, http) 478 | elif isinstance(value, dict): 479 | emb_uri = value.get('@id') 480 | if not emb_uri: 481 | self.log_error(field, "URI not found: '{}'".format(value)) 482 | return value 483 | value['@id'] = self._string_uri(field, emb_uri, http) 484 | return value 485 | else: 486 | self.log_error(field, "Can't parse URI: {}".format(value)) 487 | return value 488 | 489 | def _string_uri(self, field, value, http=False): 490 | """Validate that value is a string that can be parsed as URI. 491 | 492 | This is the last stop on the recursive structure for URI checking. 493 | Should not actually be used in schema. 494 | """ 495 | # Always raise invalid if the string field is not a string. 496 | if not isinstance(value, str): 497 | self.log_error(field, "URI is not string: '{}'".format(value)) 498 | return value 499 | 500 | # Check for invalid and forbidden html. 501 | self._check_html(field, value) 502 | 503 | # Try to parse the url. 504 | try: 505 | pieces = urllib.parse.urlparse(value) 506 | except AttributeError as a: 507 | self.log_error(field, "URI is not valid: '{}'".format(value)) 508 | return value 509 | if not all([pieces.scheme, pieces.netloc]): 510 | self.log_error(field, "URI is not valid: '{}'".format(value)) 511 | if http and pieces.scheme not in ['http', 'https']: 512 | self.log_error(field, "URI must be http: '{}'".format(value)) 513 | return value 514 | 515 | def _check_html(self, field, value): 516 | """Check that the value does not contain html where not allowed. 517 | 518 | Logs a warning if any tag not in HTML_ALLOWED_TAGS is present. 519 | Logs an error if any tag in HTML_FORBIDDEN_TAGS is present. 520 | Logs an error if any html tag is found in a field not in HTML_ALLOWED_FIELDS. 521 | """ 522 | # Disregarding indices in paths, check if the suffix of the current path 523 | # is one which can validly contain html. 524 | temp_path = self._path + field 525 | field_allowed_html = any(temp_path.no_index_endswith(x) for x in self.HTML_ALLOWED_FIELDS) 526 | 527 | # Bool marking if this field contains valid xml markup. 528 | field_is_valid_xml = False 529 | 530 | # Bool marking if this field contains any tags. 531 | field_contains_tags = bool(self._XML_TAG_REGEX.search(value)) 532 | 533 | # Bail if tags detected but first char is not '<' 534 | if field_contains_tags and value[0] != "<": 535 | self.log_error(field, "If field contains HTML, it must start with character '<'.") 536 | return 537 | 538 | # Error and exit if XML comments are detected. 539 | field_contains_comments = bool(self._XML_COMMENT_REGEX.search(value)) 540 | if field_contains_comments: 541 | self.log_error(field, "XML comments not allowed.") 542 | return 543 | 544 | # Error and exit if CDATA sections are detected. 545 | field_contains_cdata = bool(self._XML_CDATA_REGEX.search(value)) 546 | if field_contains_cdata: 547 | self.log_error(field, "CDATA sections not allowed.") 548 | return 549 | 550 | # Try to parse the field and record if the field is valid xml. 551 | if field_contains_tags: 552 | try: 553 | et = ET.fromstring(value) 554 | field_is_valid_xml = True 555 | except ET.ParseError: 556 | field_is_valid_xml = False 557 | 558 | # Return now if no tags are found. 559 | if not field_contains_tags: 560 | return 561 | 562 | # Log error and return if this field is not allowed to have HTML in it. 563 | if (field_is_valid_xml or field_contains_tags) and not field_allowed_html: 564 | self.log_error(field, "HTML not allowed in this field.") 565 | return 566 | 567 | # Log error and return if the HTML is malformed in some way. 568 | if field_contains_tags and not field_is_valid_xml: 569 | self.log_error(field, "Field contains tags but is not valid HTML.") 570 | return 571 | 572 | def check_html_element(elem): 573 | """Recursively validate elements in etree.""" 574 | tag, attributes = elem.tag, elem.attrib.keys() 575 | 576 | # Log error and return if tag is forbidden. 577 | if tag in self.HTML_FORBIDDEN_TAGS: 578 | self.log_error(field, "Forbidden tag '<{}>' in html.".format(tag)) 579 | return False 580 | 581 | # Log error and return if forbidden attributes are present. 582 | allowed_attributes = self.HTML_ALLOWED_ATTRIBUTES.get(tag, set()) 583 | for attr in attributes: 584 | if attr not in allowed_attributes: 585 | self.log_error(field, "HTML tag '<{}>' not allowed attribute '{}'.".format(tag, attr)) 586 | return False 587 | 588 | # Log warning if tag is not explicitly mentioned as being safe. 589 | if tag not in self.HTML_ALLOWED_TAGS: 590 | self.log_warning(field, "HTML tag '<{}>' of uncertain validity " 591 | "(valid tags are , ,
, , ,

, and )".format(tag)) 592 | 593 | for child_elem in elem: 594 | child_valid = check_html_element(child_elem) 595 | if not child_valid: 596 | return False 597 | return True 598 | 599 | check_html_element(et) 600 | 601 | # Common field definitions. 602 | def id_field(self, value): 603 | """Validate the ``@id`` field of the resource.""" 604 | if value.startswith("urn:uuid:"): 605 | id_uuid = value.replace("urn:uuid:", "") 606 | try: 607 | uuid.UUID(id_uuid) 608 | except ValueError: 609 | self.log_error("@id", "Invalid UUID in @id.") 610 | finally: 611 | return value 612 | return self._http_uri_type("@id", value) 613 | 614 | def type_field(self, value): 615 | """Validate the ``@type`` field of the resource.""" 616 | raise NotImplemented 617 | 618 | def label_field(self, value): 619 | """Validate the ``label`` field of the resource.""" 620 | return self._str_or_val_lang_type("label", value) 621 | 622 | def description_field(self, value): 623 | """Validate the ``description`` field of the resource.""" 624 | return self._str_or_val_lang_type("description", value) 625 | 626 | def attribution_field(self, value): 627 | """Validate the ``attribution`` field of the resource.""" 628 | return self._str_or_val_lang_type("attribution", value) 629 | 630 | def license_field(self, value): 631 | """Validate the ``license`` field of the resource.""" 632 | return self._repeatable_uri_type("license", value) 633 | 634 | def related_field(self, value): 635 | """Validate the ``related`` field of the resource.""" 636 | return self._repeatable_uri_type("related", value) 637 | 638 | def rendering_field(self, value): 639 | """Validate the ``rendering`` field of the resource.""" 640 | return self._repeatable_uri_type("rendering", value) 641 | 642 | def service_field(self, value): 643 | """Validate the ``service`` field of the resource.""" 644 | return self._repeatable_service_type("service", value) 645 | 646 | def seeAlso_field(self, value): 647 | """Validate the ``seeAlso`` field of the resource.""" 648 | return self._repeatable_uri_type("seeAlso", value) 649 | 650 | def within_field(self, value): 651 | """Validate the ``within`` field of the resource.""" 652 | return self._repeatable_uri_type("within", value) 653 | 654 | def height_field(self, value): 655 | """Validate ``height`` field.""" 656 | if not isinstance(value, int): 657 | self.log_error("height", "height must be int.") 658 | return value 659 | 660 | def width_field(self, value): 661 | """Validate ``width`` field.""" 662 | if not isinstance(value, int): 663 | self.log_error("width", "width must be int.") 664 | return value 665 | 666 | def metadata_field(self, value): 667 | """Validate the `metadata` field of the resource. 668 | 669 | Recurse into keys/values and checks that they are properly formatted. 670 | """ 671 | if not isinstance(value, list): 672 | self.log_error("metadata", "Metadata MUST be a list") 673 | return value 674 | 675 | result = [] 676 | with self._temp_path(self._path + ("metadata",)): 677 | for i, m in enumerate(value): 678 | with self._temp_path(self._path + i): 679 | result.append(self._metadata_entry(m)) 680 | return result 681 | 682 | def _metadata_entry(self, value): 683 | if not isinstance(value, dict): 684 | self.log_error("value", "Entries must be dictionaries.") 685 | return value 686 | if "label" not in value: 687 | self.log_error("label", "metadata entries must have labels.") 688 | return value 689 | elif "value" not in value: 690 | self.log_error("value", "metadata entries must have values") 691 | return value 692 | else: 693 | return { 694 | 'label': self._str_or_val_lang_type("label", value.get("label")), 695 | 'value': self._str_or_val_lang_type("value", value.get("value")) 696 | } 697 | 698 | def thumbnail_field(self, value): 699 | """Validate the ``thumbnail`` field of the resource.""" 700 | return self._general_image_resource("thumbnail", value) 701 | 702 | def logo_field(self, value): 703 | """Validate the ``logo`` field of the resource.""" 704 | return self._general_image_resource("logo", value) 705 | 706 | def _general_image_resource(self, field, value): 707 | """Image resource validator for logos and thumbnails. Basic logic is: 708 | 709 | -Check if field is string. If yes, warn that IIIF image service is preferred. 710 | -If a IIIF image service is avaliable, try to validate it. 711 | -Otherwise, check that it's ID is at least a uri. 712 | """ 713 | 714 | if isinstance(value, str): 715 | self.log_warning(field, "{} SHOULD be IIIF image service.".format(field)) 716 | return self._uri_type(field, value) 717 | if isinstance(value, dict): 718 | service = value.get("service") 719 | if service and service.get("@context") == "http://iiif.io/api/image/2/context.json": 720 | value['service'] = self.ImageContentValidator.service_field(service) 721 | return value 722 | else: 723 | val = self._uri_type(field, value) 724 | self.log_warning(field, "{} SHOULD be IIIF image service.".format(field)) 725 | return val 726 | self.log_error(field, "{} type should be string or dict.".format(field)) 727 | return value 728 | 729 | def viewing_hint_field(self, value): 730 | """Validate ``viewingHint`` field against ``VIEW_HINTS`` set.""" 731 | if value not in self.VIEW_HINTS: 732 | val, errors = self.mute_errors(self._uri_type, "viewingHint", value) 733 | if errors: 734 | self.log_error("viewingHint", "viewingHint '{}' is not valid and not uri.".format(value)) 735 | return value 736 | 737 | def viewing_dir_field(self, value): 738 | """Validate ``viewingDir`` field against ``VIEW_DIRS`` set.""" 739 | if value not in self.VIEW_DIRS: 740 | self.log_error("viewingDirection", "viewingDirection '{}' is not valid and not uri.".format(value)) 741 | return value 742 | -------------------------------------------------------------------------------- /tripoli/resource_validators/canvas_validator.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Alex Parmentier, Andrew Hankinson 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from collections import OrderedDict 22 | 23 | from .base_validator import BaseValidator 24 | 25 | 26 | class CanvasValidator(BaseValidator): 27 | VIEW_HINTS = {'non-paged', 'facing-pages'} 28 | 29 | KNOWN_FIELDS = BaseValidator.COMMON_FIELDS | {"height", "width", "otherContent", "images"} 30 | FORBIDDEN_FIELDS = {"format", "viewingDirection", "navDate", "startCanvas", "first", "last", "total", 31 | "next", "prev", "startIndex", "collections", "manifests", "members", "sequences", 32 | "structures", "canvases", "resources", "ranges"} 33 | REQUIRED_FIELDS = {"label", "@id", "@type", "height", "width"} 34 | 35 | def __init__(self, iiif_validator): 36 | super().__init__(iiif_validator) 37 | self.CanvasSchema = OrderedDict(( 38 | ('@id', self.id_field), 39 | ('@type', self.type_field), 40 | ('label', self.label_field), 41 | ('height', self.height_field), 42 | ('width', self.width_field), 43 | ('other_content', self.other_content_field), 44 | ('images', self.images_field) 45 | )) 46 | self.setup() 47 | 48 | def _run_validation(self, **kwargs): 49 | self._check_all_key_constraints("canvas", self._json) 50 | self.canvas_uri = self._json['@id'] 51 | return self._compare_dicts(self.CanvasSchema, self._json) 52 | 53 | def _raise_additional_warnings(self, validation_results): 54 | # Canvas should have a thumbnail if it has multiple images. 55 | if len(validation_results.get('images', [])) > 1 and not validation_results.get("thumbnail"): 56 | self.log_warning("thumbnail", "Canvas SHOULD have a thumbnail when there is more than one image") 57 | 58 | def type_field(self, value): 59 | """Assert that ``@type == 'sc:Canvas``""" 60 | if value != "sc:Canvas": 61 | self.log_error("@type", "@type MUST be 'sc:Canvas'.") 62 | return value 63 | 64 | def images_field(self, value): 65 | """Validate ``images`` list. 66 | 67 | Calls a sub-validation procedure handled by the :class:`AnnotationValidator`. 68 | """ 69 | if not value or not isinstance(value, list): 70 | self.log_error("images", "'images' MUST be a list.") 71 | return value 72 | 73 | path = self._path + ("images",) 74 | results = [] 75 | for i, anno in enumerate(value): 76 | temp_path = path + i 77 | results.append(self._sub_validate(self.AnnotationValidator, anno, temp_path, 78 | canvas_uri=self.canvas_uri)) 79 | return results 80 | 81 | def other_content_field(self, value): 82 | """Validate ``otherContent`` field.""" 83 | if not isinstance(value, list): 84 | self.log_error("otherContent", "otherContent must be a list.") 85 | return value 86 | return [self._uri_type("otherContent", item['@id']) for item in value] 87 | -------------------------------------------------------------------------------- /tripoli/resource_validators/image_content_validator.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Alex Parmentier, Andrew Hankinson 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from collections import OrderedDict 22 | 23 | from .base_validator import BaseValidator 24 | 25 | 26 | class ImageContentValidator(BaseValidator): 27 | KNOWN_FIELDS = BaseValidator.COMMON_FIELDS | {"@context", "height", "width", "format"} 28 | FORBIDDEN_FIELDS = {"viewingDirection", "navDate", "startCanvas", "first", "last", "total", 29 | "next", "prev", "startIndex", "collections", "manifests", "members", 30 | "sequences", "structures", "canvases", "resources", "otherContent", 31 | "images", "ranges"} 32 | REQUIRED_FIELDS = {'@type', '@id'} 33 | 34 | def __init__(self, iiif_validator): 35 | super().__init__(iiif_validator) 36 | self.ImageContentSchema = OrderedDict(( 37 | ('@id', self.id_field), 38 | ('@type', self.type_field), 39 | ('height', self.height_field), 40 | ('width', self.width_field), 41 | ('service', self.service_field) 42 | )) 43 | self.setup() 44 | 45 | def _run_validation(self, **kwargs): 46 | self._check_all_key_constraints("resource", self._json) 47 | return self._compare_dicts(self.ImageContentSchema, self._json) 48 | 49 | def type_field(self, value): 50 | """Warn if ``@type != 'dctypes:Image'``""" 51 | if value != 'dctypes:Image': 52 | self.log_error('@type', "@type MUST be \'dctypes:Image\'") 53 | return value 54 | 55 | def service_field(self, value): 56 | """Validate the image service in this resource.""" 57 | with self._temp_path(self._path + ('service',)): 58 | self._check_required_fields("image service", value, ['@id', '@context']) 59 | self._check_recommended_fields("image service", value, ['profile']) 60 | context = value.get("@context") 61 | if context and context != self.IMAGE_API_2: 62 | if context != self.IMAGE_API_1: 63 | self.log_error('@context', "Must reference IIIF image API.") 64 | else: 65 | self.log_warning('@context', "SHOULD upgrade to 2.0 IIIF image service.") 66 | return value 67 | -------------------------------------------------------------------------------- /tripoli/resource_validators/manifest_validator.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Alex Parmentier, Andrew Hankinson 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from collections import OrderedDict 22 | 23 | from .base_validator import BaseValidator 24 | 25 | 26 | class ManifestValidator(BaseValidator): 27 | VIEW_DIRS = ['left-to-right', 'right-to-left', 28 | 'top-to-bottom', 'bottom-to-top'] 29 | VIEW_HINTS = ['individuals', 'paged', 'continuous'] 30 | 31 | KNOWN_FIELDS = BaseValidator.COMMON_FIELDS | {"viewingDirection", "navDate", "sequences", "structures", "@context"} 32 | FORBIDDEN_FIELDS = {"format", "height", "width", "startCanvas", "first", "last", "total", "next", "prev", 33 | "startIndex", "collections", "manifests", "members", "canvases", "resources", "otherContent", 34 | "images", "ranges"} 35 | REQUIRED_FIELDS = {"label", "@context", "@id", "@type", "sequences"} 36 | RECOMMENDED_FIELDS = {"metadata", "description", "thumbnail"} 37 | 38 | def __init__(self, iiif_validator): 39 | super().__init__(iiif_validator) 40 | self.ManifestSchema = OrderedDict(( 41 | ('@context', self.context_field), 42 | ('structures', self.structures_field), 43 | ('sequences', self.sequences_field), 44 | ('viewingDirection', self.viewing_dir_field), 45 | )) 46 | self.setup() 47 | 48 | def _run_validation(self, **kwargs): 49 | self._check_all_key_constraints("manifest", self._json) 50 | return self._compare_dicts(self.ManifestSchema, self._json) 51 | 52 | def type_field(self, value): 53 | """Assert that ``@type`` == ``sc:Manifest``. """ 54 | if not value == 'sc:Manifest': 55 | self.log_error("@type", "@type must be 'sc:Manifest'.") 56 | return value 57 | 58 | def context_field(self, value): 59 | """Assert that ``@context`` is the IIIF 2.0 presentation API.""" 60 | if isinstance(value, str): 61 | if not value == self.PRESENTATION_API_URI: 62 | self.log_error("@context", "'@context' must be set to '{}'".format(self.PRESENTATION_API_URI)) 63 | if isinstance(value, list): 64 | if self.PRESENTATION_API_URI not in value: 65 | self.log_error("@context", "'@context' must be set to '{}'".format(self.PRESENTATION_API_URI)) 66 | return value 67 | 68 | def structures_field(self, value): 69 | """Validate the ``structures`` field.""" 70 | return value 71 | 72 | def sequences_field(self, value): 73 | """Validate ``sequences`` list for Manifest. 74 | 75 | Checks that at least 1 sequence is embedded. 76 | """ 77 | if not isinstance(value, list): 78 | self.log_error("sequences", "'sequences' MUST be a list") 79 | return value 80 | 81 | if len(value) == 0: 82 | self.log_error("sequences", "Manifest requires at least one sequence") 83 | return value 84 | 85 | results = [] 86 | path = self._path + ("sequences",) 87 | for i, seq in enumerate(value): 88 | temp_path = path + i 89 | if i == 0: 90 | results.append(self._sub_validate(self.SequenceValidator, seq, temp_path, emb=True)) 91 | else: 92 | results.append(self._sub_validate(self.SequenceValidator, seq, temp_path, emb=False)) 93 | return results 94 | -------------------------------------------------------------------------------- /tripoli/resource_validators/sequence_validator.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Alex Parmentier, Andrew Hankinson 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from collections import OrderedDict 22 | 23 | from .base_validator import BaseValidator 24 | 25 | 26 | class SequenceValidator(BaseValidator): 27 | VIEW_DIRS = {'left-to-right', 'right-to-left', 28 | 'top-to-bottom', 'bottom-to-top'} 29 | VIEW_HINTS = {'individuals', 'paged', 'continuous'} 30 | 31 | KNOWN_FIELDS = BaseValidator.COMMON_FIELDS | {"viewingDirection", "startCanvas", "canvases"} 32 | FORBIDDEN_FIELDS = {"format", "height", "width", "navDate", "first", "last", "total", "next", "prev", 33 | "startIndex", "collections", "manifests", "sequences", "structures", "resources", 34 | "otherContent", "images", "ranges"} 35 | REQUIRED_FIELDS = {"@type", "canvases"} 36 | 37 | def __init__(self, iiif_validator): 38 | super().__init__(iiif_validator) 39 | self.emb = None 40 | self.EmbSequenceSchema = OrderedDict(( 41 | ('@type', self.type_field), 42 | ('@context', self.context_field), 43 | ('@id', self.id_field), 44 | ('startCanvas', self.startCanvas_field), 45 | ('viewingDirection', self.viewing_dir_field), 46 | ('canvases', self.canvases_field), 47 | )) 48 | 49 | self.LinkedSequenceSchema = OrderedDict(( 50 | ('@type', self.type_field), 51 | ('@id', self.id_field), 52 | ('canvases', self._canvas_not_allowed) 53 | )) 54 | self.setup() 55 | 56 | def _run_validation(self, **kwargs): 57 | self._check_all_key_constraints("sequence", self._json) 58 | return self._validate_sequence(**kwargs) 59 | 60 | def _canvas_not_allowed(self, value): 61 | return self._not_allowed('canvas', value) 62 | 63 | def _validate_sequence(self, emb=True): 64 | self.emb = emb 65 | if self.emb: 66 | return self._compare_dicts(self.EmbSequenceSchema, self._json) 67 | else: 68 | return self._compare_dicts(self.LinkedSequenceSchema, self._json) 69 | 70 | def _raise_additional_warnings(self, validation_results): 71 | pass 72 | 73 | def type_field(self, value): 74 | """Assert that ``@type`` == ``sc:Sequence``""" 75 | if value != "sc:Sequence": 76 | self.log_error("@type", "@type must be 'sc:Sequence'") 77 | return value 78 | 79 | def context_field(self, value): 80 | """Assert that ``@context`` is the IIIF 2.0 presentation API if it is allowed.""" 81 | if self.emb: 82 | self.log_error("@context", "@context field not allowed in embedded sequence.") 83 | return value 84 | 85 | if value != self.PRESENTATION_API_URI: 86 | self.log_error("@context", "unknown context.") 87 | return value 88 | 89 | def startCanvas_field(self, value): 90 | """Validate ``startCanvas`` field.""" 91 | canvases = self._json.get('canvases', []) 92 | 93 | if any(True for can in canvases if can.get('@id') == value): 94 | pass 95 | else: 96 | self.log_error("startCanvas", "'startCanvas' MUST refer to the @id of some canvas in this sequence.") 97 | 98 | return value 99 | 100 | def canvases_field(self, value): 101 | """Validate ``canvases`` list for Sequence.""" 102 | if not isinstance(value, list): 103 | self.log_error("canvases", "'canvases' MUST be a list.") 104 | return value 105 | 106 | if len(value) < 1: 107 | self.log_error("canvases", "'canvases' MUST have at least one entry") 108 | return value 109 | 110 | path = self._path + ("canvases",) 111 | results = [] 112 | 113 | for i, canvas in enumerate(value): 114 | temp_path = path + i 115 | results.append(self._sub_validate(self.CanvasValidator, canvas, temp_path)) 116 | 117 | return results 118 | -------------------------------------------------------------------------------- /tripoli/tripoli.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Alex Parmentier, Andrew Hankinson 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | import json 22 | import logging 23 | 24 | from .exceptions import FailFastException, TypeParseException 25 | from .mixins import SubValidationMixin 26 | from .validator_logging import ValidatorLogError, ValidatorLog, Path 27 | from .resource_validators import ( 28 | ManifestValidator, SequenceValidator, CanvasValidator, 29 | ImageContentValidator, AnnotationValidator) 30 | 31 | __version__ = "2.0.0" 32 | 33 | 34 | class IIIFValidator(SubValidationMixin): 35 | #: Sets whether or not to save tracebacks in warnings/errors. 36 | debug = False 37 | 38 | #: Sets whether or not warnings are logged. 39 | #: Default ``True``. 40 | collect_warnings = True 41 | 42 | #: Sets whether or not errors are logged. 43 | collect_errors = True 44 | 45 | #: When ``True``, validation stops at first error hit (faster). 46 | #: If ``False``, entire document will always be validated. 47 | #: 48 | #: Note: Turning ``fail_fast`` off may cause the validator to raise 49 | #: unexpected exceptions if the the document is grossly invalid 50 | #: (for instance, if an integer is supplied where a list is expected). 51 | fail_fast = True 52 | 53 | #: If ``True``, prints all errors and warnings as they occur. 54 | #: If ``False``, errors and warnings only printed after de-duplication. 55 | verbose = False 56 | 57 | #: If ``True``, only one instance of duplicate logged messages will be saved. 58 | #: If ``False``, all logged messages will be saved. 59 | #: 60 | #: Example: If set to true, then if every canvas has error A, instead 61 | #: of having the errors (Error(A, canvas[0]), Error(A, canvas[1]), ...), you 62 | #: will only get Error(A, canvas[0]) (the first error of type A on a canvas). 63 | unique_logging = True 64 | 65 | def __init__(self, debug=False, collect_warnings=True, collect_errors=True, fail_fast=True, 66 | verbose=False, unique_logging=True): 67 | super().__init__() 68 | self._ManifestValidator = None 69 | self._AnnotationValidator = None 70 | self._CanvasValidator = None 71 | self._SequenceValidator = None 72 | self._ImageContentValidator = None 73 | 74 | self.debug = debug 75 | self.collect_warnings = collect_warnings 76 | self.collect_errors = collect_errors 77 | self.fail_fast = fail_fast 78 | self.verbose = verbose 79 | self.unique_logging = unique_logging 80 | 81 | #: ``logging.getLogger()`` used to print output. 82 | self.logger = logging.getLogger("tripoli") 83 | 84 | #: If corrections were made during validation, the corrected document 85 | #: will be placed here. 86 | corrected_doc = {} 87 | 88 | self._setup_to_validate() 89 | 90 | @property 91 | def ManifestValidator(self): 92 | """An instance of a ManifestValidator.""" 93 | return self._ManifestValidator 94 | 95 | @property 96 | def SequenceValidator(self): 97 | """An instance of a SequenceValidator.""" 98 | return self._SequenceValidator 99 | 100 | @property 101 | def CanvasValidator(self): 102 | """An instance of a CanvasValidator.""" 103 | return self._CanvasValidator 104 | 105 | @property 106 | def AnnotationValidator(self): 107 | """An instance of an AnnotationValidator""" 108 | return self._AnnotationValidator 109 | 110 | @property 111 | def ImageContentValidator(self): 112 | """An instance of an ImageContentValidator""" 113 | return self._ImageContentValidator 114 | 115 | @ManifestValidator.setter 116 | def ManifestValidator(self, value): 117 | self._ManifestValidator = value(self) 118 | 119 | @SequenceValidator.setter 120 | def SequenceValidator(self, value): 121 | self._SequenceValidator = value(self) 122 | 123 | @CanvasValidator.setter 124 | def CanvasValidator(self, value): 125 | self._CanvasValidator = value(self) 126 | 127 | @AnnotationValidator.setter 128 | def AnnotationValidator(self, value): 129 | self._AnnotationValidator = value(self) 130 | 131 | @ImageContentValidator.setter 132 | def ImageContentValidator(self, value): 133 | self._ImageContentValidator = value(self) 134 | 135 | def _setup_to_validate(self): 136 | """Make sure all links to sub validators exist.""" 137 | if not self._ManifestValidator: 138 | self._ManifestValidator = ManifestValidator(self) 139 | if not self._AnnotationValidator: 140 | self._AnnotationValidator = AnnotationValidator(self) 141 | if not self._CanvasValidator: 142 | self._CanvasValidator = CanvasValidator(self) 143 | if not self._SequenceValidator: 144 | self._SequenceValidator = SequenceValidator(self) 145 | if not self._ImageContentValidator: 146 | self._ImageContentValidator = ImageContentValidator(self) 147 | 148 | self._TYPE_MAP = { 149 | "sc:Manifest": self._ManifestValidator, 150 | "sc:Sequence": self._SequenceValidator, 151 | "sc:Canvas": self._CanvasValidator, 152 | "oa:Annotation": self._AnnotationValidator 153 | } 154 | self._errors = ValidatorLog(self.unique_logging) 155 | self._warnings = ValidatorLog(self.unique_logging) 156 | self.corrected_doc = {} 157 | 158 | def _set_from_sub(self, sub): 159 | """Set the validation attributes to those of a sub_validator. 160 | 161 | Called after sub_validate'ing with validator sub. 162 | 163 | :param sub: A BaseValidator implementing Validator. 164 | """ 165 | self.is_valid = sub.is_valid 166 | self.corrected_doc = sub.corrected_doc 167 | 168 | def _output_logging(self): 169 | """Sends errors and warnings to the logger.""" 170 | for err in self.errors: 171 | self.logger.error(err.log_str()) 172 | for warn in self.warnings: 173 | self.logger.warning(warn.log_str()) 174 | 175 | def _parse_json(self, json_dict): 176 | if isinstance(json_dict, str): 177 | try: 178 | json_dict = json.loads(json_dict) 179 | except ValueError: 180 | self._exit_early("Could not parse json.") 181 | return json_dict 182 | 183 | def _get_validator(self, json_dict): 184 | """Parse json_dict and return the correct validator. 185 | 186 | Raises a TypeParseException if this cannot be done. 187 | """ 188 | 189 | doc_type = json_dict.get("@type") 190 | if not doc_type: 191 | self._exit_early("Resource has no @type.") 192 | 193 | validator = self._TYPE_MAP.get(doc_type) 194 | if not validator: 195 | self._exit_early("Unknown @type: '{}'".format(doc_type)) 196 | return validator 197 | 198 | def _exit_early(self, msg): 199 | """Log an error with message, set is_valid to false, and raise TypeParseException.""" 200 | self._errors.add(ValidatorLogError(msg, Path())) 201 | self.is_valid = False 202 | raise TypeParseException 203 | 204 | def validate(self, json_dict, **kwargs): 205 | """Determine the correct validator and validate a resource. 206 | 207 | :param json_dict: A dict or str of a json resource. 208 | """ 209 | self._setup_to_validate() 210 | try: 211 | json_dict = self._parse_json(json_dict) 212 | validator = self._get_validator(json_dict) 213 | except TypeParseException: 214 | self._output_logging() 215 | return 216 | 217 | try: 218 | self._sub_validate(validator, json_dict, path=None, **kwargs) 219 | except FailFastException: 220 | pass 221 | self._set_from_sub(validator) 222 | self._output_logging() 223 | -------------------------------------------------------------------------------- /tripoli/validator_logging.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Alex Parmentier, Andrew Hankinson 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | import traceback 22 | from itertools import zip_longest 23 | 24 | 25 | class Path: 26 | """Class representing path within document.""" 27 | def __init__(self, path=None): 28 | """ Create a Path. 29 | 30 | :param path: Tuple of strings and ints. 31 | """ 32 | self._path = path if path is not None else tuple() 33 | self.__no_index_path = None 34 | 35 | def __eq__(self, other): 36 | return self.__no_index_path == other.__no_index_path 37 | 38 | def __len__(self): 39 | return len(self._path) 40 | 41 | def __str__(self): 42 | return ' @ data[%s]' % ']['.join(map(repr, self._path)) if self._path else '' 43 | 44 | def __repr__(self): 45 | return 'Path({})'.format(", ".join(repr(x) for x in self._path)) 46 | 47 | def __iter__(self): 48 | return self._path.__iter__() 49 | 50 | def __add__(self, other): 51 | if isinstance(other, (str, int)): 52 | return Path(self._path + (other,)) 53 | if isinstance(other, tuple): 54 | return Path(self._path + other) 55 | if isinstance(other, Path): 56 | return Path(self._path + other._path) 57 | return NotImplemented 58 | 59 | def __hash__(self): 60 | return hash(self._path) 61 | 62 | @property 63 | def no_index_path(self): 64 | if self.__no_index_path is None: 65 | self.__no_index_path = tuple(filter(lambda x: isinstance(x, str), self._path)) 66 | return self.__no_index_path 67 | 68 | @property 69 | def path(self): 70 | return self._path 71 | 72 | def no_index_eq(self, other): 73 | """Return true if paths are the same, ignoring indexes. 74 | 75 | Useful for hashing, as we typically only care about storing 76 | one instance of each error/warning per list. 77 | """ 78 | return self.no_index_path == other.no_index_path 79 | 80 | def no_index_endswith(self, path): 81 | """Return true is self.no_index_path has ``path`` as a suffix. 82 | 83 | :param path: Either a str-tuple or a Path. 84 | """ 85 | if isinstance(path, Path): 86 | path = path.no_index_path 87 | return path == self.no_index_path[-(len(path)):] 88 | 89 | 90 | class ValidatorLog: 91 | """Log which provides unified interface for either set or list like behaviour.""" 92 | def __init__(self, unique_logging=True): 93 | self.unique_logging = unique_logging 94 | self._entries = set() if unique_logging else [] 95 | 96 | def add(self, log_entry): 97 | """Add an entry to the log. 98 | 99 | :param log_entry: A ValidatorLogEntry to add to self. 100 | """ 101 | if isinstance(log_entry, ValidatorLogEntry): 102 | if self.unique_logging: 103 | self._entries.add(log_entry) 104 | else: 105 | self._entries.append(log_entry) 106 | else: 107 | raise TypeError('log_entry must be a ValidatorLogEntry') 108 | 109 | def update(self, log_entry): 110 | """Add all entries from log_entry to self. 111 | 112 | :param log_entry: A ValidatorLog to update from. 113 | """ 114 | for entry in log_entry._entries: 115 | self.add(entry) 116 | 117 | def __iter__(self): 118 | return iter(self._entries) 119 | 120 | def __bool__(self): 121 | return bool(self._entries) 122 | 123 | 124 | class ValidatorLogEntry: 125 | """Basic error logging class with comparison behavior for hashing.""" 126 | 127 | def __init__(self, msg, path, tb=None): 128 | """ 129 | 130 | :param msg: A message associated with the log entry. 131 | :param path: A tuple representing the path where entry was logged. 132 | :param tb: A traceback.extract_stack() list from the point entry was logged. 133 | """ 134 | 135 | #: A message associated with the log entry. 136 | self.msg = msg 137 | 138 | #: A tuple representing the path where the entry was created. 139 | self.path = Path(path) 140 | 141 | self._tb = tb if tb else [] 142 | 143 | def print_trace(self): 144 | """Print the stored traceback if it exists.""" 145 | traceback.print_list(self._tb) 146 | 147 | def path_str(self): 148 | return str(self.path) 149 | 150 | def log_str(self): 151 | return self.msg + self.path_str() 152 | 153 | def __lt__(self, other): 154 | return len(self.path) < len(other.path) 155 | 156 | def __hash__(self): 157 | return hash(self.path.no_index_path) ^ hash(self.msg) 158 | 159 | def __eq__(self, other): 160 | return self.path.no_index_path == other.path.no_index_path\ 161 | and self.msg == other.msg 162 | 163 | 164 | class ValidatorLogWarning(ValidatorLogEntry): 165 | """Class to hold and present warnings.""" 166 | 167 | def __str__(self): 168 | output = "Warning: {}".format(self.msg) 169 | return output + self.path_str() 170 | 171 | def __repr__(self): 172 | return "ValidatorLogWarning('{}', {})".format(self.msg, self.path) 173 | 174 | 175 | class ValidatorLogError(ValidatorLogEntry): 176 | """Class to hold and present errors.""" 177 | 178 | def __str__(self): 179 | output = "Error: {}".format(self.msg) 180 | return output + self.path_str() 181 | 182 | def __repr__(self): 183 | return "ValidatorLogError('{}', {})".format(self.msg, self.path) 184 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | secret_key 2 | -------------------------------------------------------------------------------- /web/index.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request, jsonify, render_template, session, abort 2 | import tripoli 3 | import requests 4 | import ujson as json 5 | 6 | JSON_TYPE = 0 7 | TEXT_TYPE = 1 8 | 9 | app = Flask(__name__) 10 | app.config['json_encoder'] = json 11 | 12 | with open('secret_key', 'rb') as f: 13 | app.secret_key = f.read() 14 | 15 | 16 | class NetworkError(Exception): 17 | def __index__(self, err): 18 | self.err = err 19 | 20 | 21 | def val_with_content_type(value, template): 22 | """Return either json or text/html with value dict.""" 23 | mimes = request.accept_mimetypes 24 | json_score = mimes['application/json'] if 'application/json' in mimes else 0 25 | text_html_score = mimes['text/html'] if 'text/html' in mimes else 0 26 | if json_score > text_html_score: 27 | return jsonify(value) 28 | else: 29 | return render_template(template, **value) 30 | 31 | 32 | def fetch_manifest(manifest_url): 33 | try: 34 | resp = requests.get(manifest_url) 35 | except Exception as e: 36 | raise NetworkError(e) 37 | return resp 38 | 39 | 40 | @app.route('/', methods=['GET']) 41 | def index(): 42 | manifest_url = request.args.get('manifest') 43 | 44 | if manifest_url: 45 | return validate_manifest(manifest_url) 46 | else: 47 | return index_get() 48 | 49 | 50 | def index_get(): 51 | val = {"message": "GET with query parameter 'manifest' to validate.", 52 | "version": tripoli.tripoli.__version__} 53 | return val_with_content_type(val, 'index.html') 54 | 55 | 56 | def validate_manifest(manifest_url): 57 | if manifest_url: 58 | try: 59 | req = fetch_manifest(manifest_url) 60 | except NetworkError as e: 61 | resp = jsonify({"message": "Encountered network error while requesting '{}'".format(manifest_url)}) 62 | resp.status_code = 400 63 | return resp 64 | 65 | if req.status_code < 200 or req.status_code >= 400: 66 | resp = jsonify({"message": "Could not retrieve json at '{}'." 67 | " Server responded with status code {}.".format(manifest_url, req.status_code)}) 68 | resp.status_code = 400 69 | return resp 70 | 71 | try: 72 | man = json.loads(req.content) 73 | except Exception as e: 74 | resp = jsonify({"message": "Could not parse json at '{}'".format(manifest_url)}) 75 | resp.status_code = 400 76 | return resp 77 | 78 | iv = tripoli.IIIFValidator() 79 | iv.fail_fast = False 80 | iv.logger.setLevel("CRITICAL") 81 | iv.validate(man) 82 | 83 | resp = {"errors": [str(err) for err in sorted(iv.errors)], 84 | "warnings": [str(warn) for warn in sorted(iv.warnings)], 85 | "is_valid": iv.is_valid, 86 | "manifest_url": manifest_url, 87 | "version": tripoli.tripoli.__version__} 88 | return val_with_content_type(resp, 'index.html') 89 | 90 | if __name__ == "__main__": 91 | app.run() 92 | -------------------------------------------------------------------------------- /web/requirements.txt: -------------------------------------------------------------------------------- 1 | aniso8601==1.1.0 2 | click==6.6 3 | Flask==0.11.1 4 | Flask-RESTful==0.3.5 5 | itsdangerous==0.24 6 | Jinja2==2.8 7 | MarkupSafe==0.23 8 | python-dateutil==2.5.3 9 | pytz==2016.6.1 10 | requests==2.10.0 11 | six==1.10.0 12 | tripoli==1.0 13 | ujson==1.35 14 | Werkzeug==0.15.3 15 | aniso8601==1.1.0 16 | click==6.6 17 | Flask==0.11.1 18 | itsdangerous==0.24 19 | Jinja2==2.8 20 | MarkupSafe==0.23 21 | python-dateutil==2.5.3 22 | pytz==2016.6.1 23 | requests==2.10.0 24 | six==1.10.0 25 | tripoli==1.0 26 | ujson==1.35 27 | Werkzeug==0.15.3 28 | -------------------------------------------------------------------------------- /web/static/pure.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | Pure v0.6.0 3 | Copyright 2014 Yahoo! Inc. All rights reserved. 4 | Licensed under the BSD License. 5 | https://github.com/yahoo/pure/blob/master/LICENSE.md 6 | */ 7 | /*! 8 | normalize.css v^3.0 | MIT License | git.io/normalize 9 | Copyright (c) Nicolas Gallagher and Jonathan Neal 10 | */ 11 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}.hidden,[hidden]{display:none!important}.pure-img{max-width:100%;height:auto;display:block}.pure-g{letter-spacing:-.31em;*letter-spacing:normal;*word-spacing:-.43em;text-rendering:optimizespeed;font-family:FreeSans,Arimo,"Droid Sans",Helvetica,Arial,sans-serif;display:-webkit-flex;-webkit-flex-flow:row wrap;display:-ms-flexbox;-ms-flex-flow:row wrap;-ms-align-content:flex-start;-webkit-align-content:flex-start;align-content:flex-start}.opera-only :-o-prefocus,.pure-g{word-spacing:-.43em}.pure-u{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-g [class *="pure-u"]{font-family:sans-serif}.pure-u-1,.pure-u-1-1,.pure-u-1-2,.pure-u-1-3,.pure-u-2-3,.pure-u-1-4,.pure-u-3-4,.pure-u-1-5,.pure-u-2-5,.pure-u-3-5,.pure-u-4-5,.pure-u-5-5,.pure-u-1-6,.pure-u-5-6,.pure-u-1-8,.pure-u-3-8,.pure-u-5-8,.pure-u-7-8,.pure-u-1-12,.pure-u-5-12,.pure-u-7-12,.pure-u-11-12,.pure-u-1-24,.pure-u-2-24,.pure-u-3-24,.pure-u-4-24,.pure-u-5-24,.pure-u-6-24,.pure-u-7-24,.pure-u-8-24,.pure-u-9-24,.pure-u-10-24,.pure-u-11-24,.pure-u-12-24,.pure-u-13-24,.pure-u-14-24,.pure-u-15-24,.pure-u-16-24,.pure-u-17-24,.pure-u-18-24,.pure-u-19-24,.pure-u-20-24,.pure-u-21-24,.pure-u-22-24,.pure-u-23-24,.pure-u-24-24{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-1-24{width:4.1667%;*width:4.1357%}.pure-u-1-12,.pure-u-2-24{width:8.3333%;*width:8.3023%}.pure-u-1-8,.pure-u-3-24{width:12.5%;*width:12.469%}.pure-u-1-6,.pure-u-4-24{width:16.6667%;*width:16.6357%}.pure-u-1-5{width:20%;*width:19.969%}.pure-u-5-24{width:20.8333%;*width:20.8023%}.pure-u-1-4,.pure-u-6-24{width:25%;*width:24.969%}.pure-u-7-24{width:29.1667%;*width:29.1357%}.pure-u-1-3,.pure-u-8-24{width:33.3333%;*width:33.3023%}.pure-u-3-8,.pure-u-9-24{width:37.5%;*width:37.469%}.pure-u-2-5{width:40%;*width:39.969%}.pure-u-5-12,.pure-u-10-24{width:41.6667%;*width:41.6357%}.pure-u-11-24{width:45.8333%;*width:45.8023%}.pure-u-1-2,.pure-u-12-24{width:50%;*width:49.969%}.pure-u-13-24{width:54.1667%;*width:54.1357%}.pure-u-7-12,.pure-u-14-24{width:58.3333%;*width:58.3023%}.pure-u-3-5{width:60%;*width:59.969%}.pure-u-5-8,.pure-u-15-24{width:62.5%;*width:62.469%}.pure-u-2-3,.pure-u-16-24{width:66.6667%;*width:66.6357%}.pure-u-17-24{width:70.8333%;*width:70.8023%}.pure-u-3-4,.pure-u-18-24{width:75%;*width:74.969%}.pure-u-19-24{width:79.1667%;*width:79.1357%}.pure-u-4-5{width:80%;*width:79.969%}.pure-u-5-6,.pure-u-20-24{width:83.3333%;*width:83.3023%}.pure-u-7-8,.pure-u-21-24{width:87.5%;*width:87.469%}.pure-u-11-12,.pure-u-22-24{width:91.6667%;*width:91.6357%}.pure-u-23-24{width:95.8333%;*width:95.8023%}.pure-u-1,.pure-u-1-1,.pure-u-5-5,.pure-u-24-24{width:100%}.pure-button{display:inline-block;zoom:1;line-height:normal;white-space:nowrap;vertical-align:middle;text-align:center;cursor:pointer;-webkit-user-drag:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.pure-button::-moz-focus-inner{padding:0;border:0}.pure-button{font-family:inherit;font-size:100%;padding:.5em 1em;color:#444;color:rgba(0,0,0,.8);border:1px solid #999;border:0 rgba(0,0,0,0);background-color:#E6E6E6;text-decoration:none;border-radius:2px}.pure-button-hover,.pure-button:hover,.pure-button:focus{filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#1a000000', GradientType=0);background-image:-webkit-gradient(linear,0 0,0 100%,from(transparent),color-stop(40%,rgba(0,0,0,.05)),to(rgba(0,0,0,.1)));background-image:-webkit-linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1));background-image:-moz-linear-gradient(top,rgba(0,0,0,.05) 0,rgba(0,0,0,.1));background-image:-o-linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1));background-image:linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1))}.pure-button:focus{outline:0}.pure-button-active,.pure-button:active{box-shadow:0 0 0 1px rgba(0,0,0,.15) inset,0 0 6px rgba(0,0,0,.2) inset;border-color:#000\9}.pure-button[disabled],.pure-button-disabled,.pure-button-disabled:hover,.pure-button-disabled:focus,.pure-button-disabled:active{border:0;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);filter:alpha(opacity=40);-khtml-opacity:.4;-moz-opacity:.4;opacity:.4;cursor:not-allowed;box-shadow:none}.pure-button-hidden{display:none}.pure-button::-moz-focus-inner{padding:0;border:0}.pure-button-primary,.pure-button-selected,a.pure-button-primary,a.pure-button-selected{background-color:#0078e7;color:#fff}.pure-form input[type=text],.pure-form input[type=password],.pure-form input[type=email],.pure-form input[type=url],.pure-form input[type=date],.pure-form input[type=month],.pure-form input[type=time],.pure-form input[type=datetime],.pure-form input[type=datetime-local],.pure-form input[type=week],.pure-form input[type=number],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=color],.pure-form select,.pure-form textarea{padding:.5em .6em;display:inline-block;border:1px solid #ccc;box-shadow:inset 0 1px 3px #ddd;border-radius:4px;vertical-align:middle;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.pure-form input:not([type]){padding:.5em .6em;display:inline-block;border:1px solid #ccc;box-shadow:inset 0 1px 3px #ddd;border-radius:4px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.pure-form input[type=color]{padding:.2em .5em}.pure-form input[type=text]:focus,.pure-form input[type=password]:focus,.pure-form input[type=email]:focus,.pure-form input[type=url]:focus,.pure-form input[type=date]:focus,.pure-form input[type=month]:focus,.pure-form input[type=time]:focus,.pure-form input[type=datetime]:focus,.pure-form input[type=datetime-local]:focus,.pure-form input[type=week]:focus,.pure-form input[type=number]:focus,.pure-form input[type=search]:focus,.pure-form input[type=tel]:focus,.pure-form input[type=color]:focus,.pure-form select:focus,.pure-form textarea:focus{outline:0;border-color:#129FEA}.pure-form input:not([type]):focus{outline:0;border-color:#129FEA}.pure-form input[type=file]:focus,.pure-form input[type=radio]:focus,.pure-form input[type=checkbox]:focus{outline:thin solid #129FEA;outline:1px auto #129FEA}.pure-form .pure-checkbox,.pure-form .pure-radio{margin:.5em 0;display:block}.pure-form input[type=text][disabled],.pure-form input[type=password][disabled],.pure-form input[type=email][disabled],.pure-form input[type=url][disabled],.pure-form input[type=date][disabled],.pure-form input[type=month][disabled],.pure-form input[type=time][disabled],.pure-form input[type=datetime][disabled],.pure-form input[type=datetime-local][disabled],.pure-form input[type=week][disabled],.pure-form input[type=number][disabled],.pure-form input[type=search][disabled],.pure-form input[type=tel][disabled],.pure-form input[type=color][disabled],.pure-form select[disabled],.pure-form textarea[disabled]{cursor:not-allowed;background-color:#eaeded;color:#cad2d3}.pure-form input:not([type])[disabled]{cursor:not-allowed;background-color:#eaeded;color:#cad2d3}.pure-form input[readonly],.pure-form select[readonly],.pure-form textarea[readonly]{background-color:#eee;color:#777;border-color:#ccc}.pure-form input:focus:invalid,.pure-form textarea:focus:invalid,.pure-form select:focus:invalid{color:#b94a48;border-color:#e9322d}.pure-form input[type=file]:focus:invalid:focus,.pure-form input[type=radio]:focus:invalid:focus,.pure-form input[type=checkbox]:focus:invalid:focus{outline-color:#e9322d}.pure-form select{height:2.25em;border:1px solid #ccc;background-color:#fff}.pure-form select[multiple]{height:auto}.pure-form label{margin:.5em 0 .2em}.pure-form fieldset{margin:0;padding:.35em 0 .75em;border:0}.pure-form legend{display:block;width:100%;padding:.3em 0;margin-bottom:.3em;color:#333;border-bottom:1px solid #e5e5e5}.pure-form-stacked input[type=text],.pure-form-stacked input[type=password],.pure-form-stacked input[type=email],.pure-form-stacked input[type=url],.pure-form-stacked input[type=date],.pure-form-stacked input[type=month],.pure-form-stacked input[type=time],.pure-form-stacked input[type=datetime],.pure-form-stacked input[type=datetime-local],.pure-form-stacked input[type=week],.pure-form-stacked input[type=number],.pure-form-stacked input[type=search],.pure-form-stacked input[type=tel],.pure-form-stacked input[type=color],.pure-form-stacked input[type=file],.pure-form-stacked select,.pure-form-stacked label,.pure-form-stacked textarea{display:block;margin:.25em 0}.pure-form-stacked input:not([type]){display:block;margin:.25em 0}.pure-form-aligned input,.pure-form-aligned textarea,.pure-form-aligned select,.pure-form-aligned .pure-help-inline,.pure-form-message-inline{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.pure-form-aligned textarea{vertical-align:top}.pure-form-aligned .pure-control-group{margin-bottom:.5em}.pure-form-aligned .pure-control-group label{text-align:right;display:inline-block;vertical-align:middle;width:10em;margin:0 1em 0 0}.pure-form-aligned .pure-controls{margin:1.5em 0 0 11em}.pure-form input.pure-input-rounded,.pure-form .pure-input-rounded{border-radius:2em;padding:.5em 1em}.pure-form .pure-group fieldset{margin-bottom:10px}.pure-form .pure-group input,.pure-form .pure-group textarea{display:block;padding:10px;margin:0 0 -1px;border-radius:0;position:relative;top:-1px}.pure-form .pure-group input:focus,.pure-form .pure-group textarea:focus{z-index:3}.pure-form .pure-group input:first-child,.pure-form .pure-group textarea:first-child{top:1px;border-radius:4px 4px 0 0;margin:0}.pure-form .pure-group input:first-child:last-child,.pure-form .pure-group textarea:first-child:last-child{top:1px;border-radius:4px;margin:0}.pure-form .pure-group input:last-child,.pure-form .pure-group textarea:last-child{top:-2px;border-radius:0 0 4px 4px;margin:0}.pure-form .pure-group button{margin:.35em 0}.pure-form .pure-input-1{width:100%}.pure-form .pure-input-2-3{width:66%}.pure-form .pure-input-1-2{width:50%}.pure-form .pure-input-1-3{width:33%}.pure-form .pure-input-1-4{width:25%}.pure-form .pure-help-inline,.pure-form-message-inline{display:inline-block;padding-left:.3em;color:#666;vertical-align:middle;font-size:.875em}.pure-form-message{display:block;color:#666;font-size:.875em}@media only screen and (max-width :480px){.pure-form button[type=submit]{margin:.7em 0 0}.pure-form input:not([type]),.pure-form input[type=text],.pure-form input[type=password],.pure-form input[type=email],.pure-form input[type=url],.pure-form input[type=date],.pure-form input[type=month],.pure-form input[type=time],.pure-form input[type=datetime],.pure-form input[type=datetime-local],.pure-form input[type=week],.pure-form input[type=number],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=color],.pure-form label{margin-bottom:.3em;display:block}.pure-group input:not([type]),.pure-group input[type=text],.pure-group input[type=password],.pure-group input[type=email],.pure-group input[type=url],.pure-group input[type=date],.pure-group input[type=month],.pure-group input[type=time],.pure-group input[type=datetime],.pure-group input[type=datetime-local],.pure-group input[type=week],.pure-group input[type=number],.pure-group input[type=search],.pure-group input[type=tel],.pure-group input[type=color]{margin-bottom:0}.pure-form-aligned .pure-control-group label{margin-bottom:.3em;text-align:left;display:block;width:100%}.pure-form-aligned .pure-controls{margin:1.5em 0 0}.pure-form .pure-help-inline,.pure-form-message-inline,.pure-form-message{display:block;font-size:.75em;padding:.2em 0 .8em}}.pure-menu{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.pure-menu-fixed{position:fixed;left:0;top:0;z-index:3}.pure-menu-list,.pure-menu-item{position:relative}.pure-menu-list{list-style:none;margin:0;padding:0}.pure-menu-item{padding:0;margin:0;height:100%}.pure-menu-link,.pure-menu-heading{display:block;text-decoration:none;white-space:nowrap}.pure-menu-horizontal{width:100%;white-space:nowrap}.pure-menu-horizontal .pure-menu-list{display:inline-block}.pure-menu-horizontal .pure-menu-item,.pure-menu-horizontal .pure-menu-heading,.pure-menu-horizontal .pure-menu-separator{display:inline-block;*display:inline;zoom:1;vertical-align:middle}.pure-menu-item .pure-menu-item{display:block}.pure-menu-children{display:none;position:absolute;left:100%;top:0;margin:0;padding:0;z-index:3}.pure-menu-horizontal .pure-menu-children{left:0;top:auto;width:inherit}.pure-menu-allow-hover:hover>.pure-menu-children,.pure-menu-active>.pure-menu-children{display:block;position:absolute}.pure-menu-has-children>.pure-menu-link:after{padding-left:.5em;content:"\25B8";font-size:small}.pure-menu-horizontal .pure-menu-has-children>.pure-menu-link:after{content:"\25BE"}.pure-menu-scrollable{overflow-y:scroll;overflow-x:hidden}.pure-menu-scrollable .pure-menu-list{display:block}.pure-menu-horizontal.pure-menu-scrollable .pure-menu-list{display:inline-block}.pure-menu-horizontal.pure-menu-scrollable{white-space:nowrap;overflow-y:hidden;overflow-x:auto;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;padding:.5em 0}.pure-menu-horizontal.pure-menu-scrollable::-webkit-scrollbar{display:none}.pure-menu-separator{background-color:#ccc;height:1px;margin:.3em 0}.pure-menu-horizontal .pure-menu-separator{width:1px;height:1.3em;margin:0 .3em}.pure-menu-heading{text-transform:uppercase;color:#565d64}.pure-menu-link{color:#777}.pure-menu-children{background-color:#fff}.pure-menu-link,.pure-menu-disabled,.pure-menu-heading{padding:.5em 1em}.pure-menu-disabled{opacity:.5}.pure-menu-disabled .pure-menu-link:hover{background-color:transparent}.pure-menu-active>.pure-menu-link,.pure-menu-link:hover,.pure-menu-link:focus{background-color:#eee}.pure-menu-selected .pure-menu-link,.pure-menu-selected .pure-menu-link:visited{color:#000}.pure-table{border-collapse:collapse;border-spacing:0;empty-cells:show;border:1px solid #cbcbcb}.pure-table caption{color:#000;font:italic 85%/1 arial,sans-serif;padding:1em 0;text-align:center}.pure-table td,.pure-table th{border-left:1px solid #cbcbcb;border-width:0 0 0 1px;font-size:inherit;margin:0;overflow:visible;padding:.5em 1em}.pure-table td:first-child,.pure-table th:first-child{border-left-width:0}.pure-table thead{background-color:#e0e0e0;color:#000;text-align:left;vertical-align:bottom}.pure-table td{background-color:transparent}.pure-table-odd td{background-color:#f2f2f2}.pure-table-striped tr:nth-child(2n-1) td{background-color:#f2f2f2}.pure-table-bordered td{border-bottom:1px solid #cbcbcb}.pure-table-bordered tbody>tr:last-child>td{border-bottom-width:0}.pure-table-horizontal td,.pure-table-horizontal th{border-width:0 0 1px;border-bottom:1px solid #cbcbcb}.pure-table-horizontal tbody>tr:last-child>td{border-bottom-width:0} 12 | -------------------------------------------------------------------------------- /web/static/style.css: -------------------------------------------------------------------------------- 1 | .site { 2 | display: flex; 3 | min-height: 100vh; 4 | flex-direction: column; 5 | } 6 | 7 | .site-content { 8 | margin-left: auto; 9 | margin-right: auto; 10 | margin-top: 50px; 11 | width:50em; 12 | flex: 1; 13 | } 14 | 15 | .site-footer 16 | { 17 | margin-left: auto; 18 | margin-right: auto; 19 | margin-top: 30px; 20 | width: 50em; 21 | padding-bottom: 1em; 22 | text-align: center; 23 | } 24 | 25 | body { 26 | color: #555; 27 | font-family: "Helvetica", "Arial", sans-serif; 28 | } 29 | 30 | h1, 31 | h2, 32 | strong { 33 | color: #333; 34 | } 35 | 36 | a { 37 | text-decoration: none; 38 | color: #4078c0; 39 | } 40 | a:visited { 41 | color: #4078c0; 42 | } 43 | 44 | a:hover { 45 | text-decoration: underline; 46 | } 47 | -------------------------------------------------------------------------------- /web/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% block title %}Tripoli{% endblock %} 4 | 5 | 6 | 7 | 8 | 9 |

10 |

Validate IIIF Manifests with Tripoli.

11 |
12 |
13 | 14 |
15 | 16 |
​ 17 |
18 |
19 | {% if manifest_url %} 20 |

Results for: {{ manifest_url }}

21 | {% endif %} 22 | 23 | {% if is_valid == True %} 24 | Manifest passed validation. 25 | {% elif is_valid == False %} 26 | Manifest failed validation. 27 | {% endif %} 28 | 29 |
    30 | {% for error in errors %} 31 |
  • {{ error }}
  • 32 | {% endfor %} 33 | {% for warn in warnings %} 34 |
  • {{ warn }}
  • 35 | {% endfor %} 36 |
37 |
38 | 39 | 45 | --------------------------------------------------------------------------------