diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..e6bb784 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,53 @@ +name: "CodeQL" + +on: + push: + branches: [ main, develop ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ main, develop ] + schedule: + - cron: '34 21 * * 1' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..4e1ef42 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,31 @@ +# This workflows will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml new file mode 100644 index 0000000..f772172 --- /dev/null +++ b/.github/workflows/unit_tests.yml @@ -0,0 +1,71 @@ +name: unit tests + +on: + push: # run on every push or PR to any branch + pull_request: + schedule: # run automatically on main branch each Tuesday at 11am + - cron: "0 16 * * 2" + +jobs: + python-unit: + name: Python unit tests + runs-on: ubuntu-latest + strategy: + matrix: + python: [3.6, 3.7, 3.8] + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + + # We base the python cache on the hash of all requirements files, so that + # if any change, the cache is invalidated. + - name: Cache pip + uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: pip-${{ hashFiles('setup.py') }} + restore-keys: | + pip-${{ hashFiles('setup.py') }} + pip- + + - name: Install package with dependencies + run: | + pip install -e . + pip install -e '.[test]' + pip install codecov + + - name: Run pytest + run: py.test --cov=piffle --cov-report=xml + + - name: Upload test coverage to Codecov + uses: codecov/codecov-action@v1 + + # Set the color of the slack message used in the next step based on the + # status of the build: "danger" for failure, "good" for success, + # "warning" for error + - name: Set Slack message color based on build status + if: ${{ always() }} + env: + JOB_STATUS: ${{ job.status }} + run: echo "SLACK_COLOR=$(if [ "$JOB_STATUS" == "success" ]; then echo "good"; elif [ "$JOB_STATUS" == "failure" ]; then echo "danger"; else echo "warning"; fi)" >> $GITHUB_ENV + + # Send a message to slack to report the build status. The webhook is stored + # at the organization level and available to all repositories. Only run on + # scheduled builds & pushes, since PRs automatically report to Slack. + - name: Report status to Slack + uses: rtCamp/action-slack-notify@master + if: ${{ always() && (github.event_name == 'schedule' || github.event_name == 'push') }} + continue-on-error: true + env: + SLACK_COLOR: ${{ env.SLACK_COLOR }} + SLACK_WEBHOOK: ${{ secrets.ACTIONS_SLACK_WEBHOOK }} + SLACK_TITLE: "Workflow `${{ github.workflow }}` (python ${{ matrix.python }}): ${{ job.status }}" + SLACK_MESSAGE: "Run on " + SLACK_FOOTER: "" + MSG_MINIMAL: true # use compact slack message format diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 77f832d..0000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -language: python - -python: - - "2.7" - - "3.4" - - "3.5" - - "3.6" - -install: - - pip install --upgrade setuptools - - pip install -e . - - pip install -e ".[test]" - - pip install codecov - -script: - - py.test --cov=piffle - -after_success: - - codecov diff --git a/CHANGELOG.md b/CHANGELOG.md index 20c5277..a60acf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Change & Version Information +## 0.4 + +* Dropped support for Python versions 2.7, 3.4, 3.5 +* Now tested against python 3.7 and 3.8 +* Moved continues integration from Travis-CI to GitHub Actions +* Renamed `piffle.iiif` to `piffle.image`, but for backwards compatibility `piffle.iiif` will still work +* Now includes `piffle.presentation` for simple read access to IIIF Presentation content + ## 0.3.2 * Dropped support for Python 3.3 diff --git a/README.md b/README.md index 2e827db..f2d6d7b 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,14 @@ Python library for generating and parsing [IIIF Image API](http://iiif.io/api/image/2.1/) URLs in an object-oriented, pythonic fashion. -[![Build Status](https://travis-ci.org/Princeton-CDH/piffle.svg?branch=main)](https://travis-ci.org/Princeton-CDH/piffle) -[![Coverage Status](https://coveralls.io/repos/github/Princeton-CDH/piffle/badge.svg?branch=master)](https://coveralls.io/github/Princeton-CDH/piffle?branch=master) +[![Unit Test Status](https://github.com/Princeton-CDH/piffle/workflows/unit_tests/badge.svg)](https://github.com/Princeton-CDH/piffle/actions?query=workflow%3Aunit_tests) +[![codecov](https://codecov.io/gh/Princeton-CDH/piffle/branch/main/graph/badge.svg)](https://codecov.io/gh/Princeton-CDH/piffle) [![Maintainability](https://api.codeclimate.com/v1/badges/d37850d90592f9d628df/maintainability)](https://codeclimate.com/github/Princeton-CDH/piffle/maintainability) -Piffle is tested on Python 2.7 and 3.4-3.6. -Piffle was originally developed by Emory University as a part of -[Readux](https://github.com/ecds/readux>) and forked as a separate project -under [emory-lits-labs](https://github.com/emory-lits-labs/). +Piffle is tested on Python 3.6-3.8. + +Piffle was originally developed by Rebecca Sutton Koeser at Emory University as a part of [Readux](https://github.com/ecds/readux>) and forked as a separate project under [emory-lits-labs](https://github.com/emory-lits-labs/). It was later transferred to Rebecca Sutton Koeser at the Center for Digital Humanities at Princeton. ## Installation and example use: @@ -20,7 +19,7 @@ under [emory-lits-labs](https://github.com/emory-lits-labs/). Example use for generating an IIIF image url: ``` ->>> from piffle.iiif import IIIFImageClient +>>> from piffle.image import IIIFImageClient >>> myimg = IIIFImageClient('http://image.server/path/', 'myimgid') >>> print myimg http://image.server/path/myimgid/full/full/0/default.jpg @@ -33,7 +32,7 @@ http://image.server/path/myimgid/full/120,/0/default.png Example use for parsing an IIIF image url: ``` ->>> from piffle.iiif import IIIFImageClient +>>> from piffle.image import IIIFImageClient >>> myimg = IIIFImageClient.init_from_url('http://www.example.org/image-service/abcd1234/full/full/0/default.jpg') >>> print myimg http://www.example.org/image-service/abcd1234/full/full/0/default.jpg @@ -47,6 +46,30 @@ False 0.0 ``` +Example use for reading a IIIF manifest: + +``` +>>> from piffle.image import IIIFImageClient +>>> from piffle.presentation import IIIFPresentation +>>> manifest = IIIFPresentation.from_url('https://iiif.bodleian.ox.ac.uk/iiif/manifest/60834383-7146-41ab-bfe1-48ee97bc04be.json') +>>> manifest.label +'Bodleian Library MS. Bodl. 264' +>>> manifest.id +'https://iiif.bodleian.ox.ac.uk/iiif/manifest/60834383-7146-41ab-bfe1-48ee97bc04be.json' +>>> manifest.type +'sc:Manifest' +>>> for canvas in manifest.sequences[0].canvases[:5]: +... image_id = canvas.images[0].resource.id +... iiif_img = IIIFImageClient(*image_id.rsplit('/', 1)) +... print(str(iiif_img.size(height=250))) +... +https://iiif.bodleian.ox.ac.uk/iiif/image/90701d49-5e0c-4fb5-9c7d-45af96565468/full/,250/0/default.jpg +https://iiif.bodleian.ox.ac.uk/iiif/image/e878cc78-acd3-43ca-ba6e-90a392f15891/full/,250/0/default.jpg +https://iiif.bodleian.ox.ac.uk/iiif/image/0f1ed064-a972-4215-b884-d8d658acefc5/full/,250/0/default.jpg +https://iiif.bodleian.ox.ac.uk/iiif/image/6fe52b9a-5bb7-4b5b-bbcd-ad0489fcad2a/full/,250/0/default.jpg +https://iiif.bodleian.ox.ac.uk/iiif/image/483ff8ec-347d-4070-8442-dbc15bc7b4de/full/,250/0/default.jpg +``` + ## Development and Testing This project uses [git-flow](https://github.com/nvie/gitflow) branching conventions. diff --git a/piffle/__init__.py b/piffle/__init__.py index 0aabe71..606caa3 100644 --- a/piffle/__init__.py +++ b/piffle/__init__.py @@ -1,4 +1,4 @@ -__version_info__ = (0, 3, 2, None) +__version_info__ = (0, 4, 0, None) # Dot-connect all but the last. Last is dash-connected if not None. __version__ = '.'.join([str(i) for i in __version_info__[:-1]]) diff --git a/piffle/iiif.py b/piffle/iiif.py index bdda724..14bac32 100644 --- a/piffle/iiif.py +++ b/piffle/iiif.py @@ -1,729 +1,5 @@ -# iiifclient -# -*- coding: utf-8 -*- +# for backwards compatibility, allow import from piffle.iiif +from piffle.image import * -from collections import OrderedDict -from future.utils import python_2_unicode_compatible -import six -from six.moves.urllib.parse import urlparse - -from cached_property import cached_property -import requests - - -class IIIFImageClientException(Exception): - '''IIIFImageClient custom exception class''' - pass - - -class ParseError(IIIFImageClientException): - '''Exception raised when an IIIF image could not be parsed''' - pass - - -# NOTE: possible image component base class? -# commonalities so far: setting defaults on init / parse -# handling exact matches like full/square? (but maybe only region/size), -# validating options (and could add option type checking) - - -@python_2_unicode_compatible -class ImageRegion(object): - '''IIIF Image region. Intended to be used with :class:`IIIFImageClient`. - Can be initialized with related image object and region options. - - When associated with an image, region is callable and will return - an updated image object with the modified region options. - - :param img: :class:`IIFImageClient` - :param full: full region, defaults to true - :param square: square region, defaults to false - :param x: x coordinate - :param y: y coordinate - :param width: region width - :param height: region height - :param percent: region is a percentage - ''' - - # region options - options = OrderedDict([ - ('full', False), - ('square', False), - ('x', None), - ('y', None), - ('width', None), - ('height', None), - ('percent', False) - ]) - - region_defaults = { - 'full': True, - 'square': False, - 'x': None, - 'y': None, - 'width': None, - 'height': None, - 'percent': False - } - - coords = ['x', 'y', 'width', 'height'] - - def __init__(self, img=None, **options): - self.img = img - self.options = self.region_defaults.copy() - if options: - self.set_options(**options) - - def __call__(self, **options): - if self.img is not None: - img = self.img.get_copy() - img.region.set_options(**options) - return img - - def set_options(self, **options): - '''Update region options. Same parameters as initialiation.''' - allowed_options = list(self.options.keys()) - # error if an unrecoganized option is specified - for key in options: - if key not in allowed_options: - raise IIIFImageClientException('Unknown option: %s' % key) - - # error if some but not all coordinates are specified - # or if percentage is specified but not all coordinates are present - if (any([coord in options for coord in self.coords]) or - 'percent' in options) and not \ - all([coord in options for coord in self.coords]): - # partial region specified - raise IIIFImageClientException('Incomplete region specified') - - # TODO: do we need to type checking? bool/int/float? - - self.options.update(**options) - # if any non-full value is specified, set full to false - # NOTE: if e.g. square is specified but false, this is wrong - allowed_options.remove('full') - if any([(key in allowed_options and options[key]) - for key in options.keys()]): - self.options['full'] = False - - def as_dict(self): - '''Return region options as a dictionary''' - return self.options - - def __str__(self): - '''Render region information in IIIF region format''' - if self.options['full']: - return 'full' - if self.options['square']: - return 'square' - - coords = '%(x)g,%(y)g,%(width)g,%(height)g' % self.options - if self.options['percent']: - return 'pct:%s' % coords - - return coords - - def parse(self, region): - '''Parse an IIIF Image region string and update the current region''' - - # reset to defaults before parsing - self.options = self.region_defaults.copy() - - # full? - if region == 'full': - self.options['full'] = True - # return immediately - return - else: - self.options['full'] = False - - if region == 'square': - self.options['square'] = True - # return immediately - return self - - # percent? - if "pct" in region: - self.options['percent'] = True - region = region.split("pct:")[1] - - # split to dictionary - # if percentage type, cast to float - try: - if self.options['percent']: - coords = [float(region_c) for region_c in region.split(",")] - # else, force int - else: - coords = [int(region_c) for region_c in region.split(",")] - except ValueError: - # failure converting to integer or float - raise ParseError('Invalid region coordinates: %s' % region) - - if len(coords) != 4: - raise ParseError('Invalid region coordinates: %s' % region) - - x, y, width, height = coords - self.options.update({'x': x, 'y': y, 'width': width, 'height': height}) - - def canonicalize(self): - '''Canonicalize the current region options so that - serialization results in canonical format.''' - # From the spec: - # “full” if the whole image is requested, (including a “square” - # region of a square image), otherwise the x,y,w,h syntax. - - if self.options['full']: - # nothing to do - return - - # if already in x,y,w,h format - nothing to do - if not any([self.options['full'], self.options['square'], - self.options['percent']]): - return - - # possbly an error here for every other case if self.img is not set, - # since it's probably not possible to canonicalize without knowing - # image size (any exceptions?) - if self.img is None: - raise IIIFImageClientException('Cannot canonicalize without image') - - if self.options['square']: - # if image is square, then return full - if self.img is not None and \ - self.img.image_width == self.img.image_height: - self.options['full'] = True - self.options['square'] = False - return - - # otherwise convert to x,y,w,h - # from the spec: - # The region is defined as an area where the width and height - # are both equal to the length of the shorter dimension of the - # complete image. The region may be positioned anywhere in the - # longer dimension of the image content at the server’s - # discretion, and centered is often a reasonable default. - - # determine size of the short edge - short_edge = min(self.img.image_width, self.img.image_height) - width = height = short_edge - # calculate starting long edge point to center the square - if self.img.image_height == short_edge: - y = 0 - x = (self.img.image_width - short_edge) / 2 - else: - x = 0 - y = (self.img.image_height - short_edge) / 2 - - self.options.update({'x': x, 'y': y, 'width': width, - 'height': height, 'square': False}) - - if self.options['percent']: - # convert percentages to x,y,w,h - # From the spec: - # The region to be returned is specified as a sequence of - # percentages of the full image’s dimensions, as reported in - # the image information document. Thus, x represents the number - # of pixels from the 0 position on the horizontal axis, calculated - # as a percentage of the reported width. w represents the width - # of the region, also calculated as a percentage of the reported - # width. The same applies to y and h respectively. These may be - # floating point numbers. - - # convert percentages to dimensions based on image size - self.options.update({ - 'percent': False, - 'x': int((self.options['x']/100) * self.img.image_width), - 'y': int((self.options['y']/100) * self.img.image_height), - 'width': int((self.options['width']/100) * self.img.image_width), - 'height': int((self.options['height']/100) * self.img.image_height) - }) - return - - -@python_2_unicode_compatible -class ImageSize(object): - '''IIIF Image Size. Intended to be used with :class:`IIIFImageClient`. - Can be initialized with related image object and size options. - - When associated with an image, size is callable and will return - an updated image object with the modified size. - - :param img: :class:`IIFImageClient` - :param width: optional width - :param height: optional height - :param percent: optional percent - :param exact: size should be exact (boolean, optional) - ''' - - # size options - options = OrderedDict([ - ('full', False), - ('max', False), - ('width', None), - ('height', None), - ('percent', None), - ('exact', False) - ]) - - # NOTE: full is being deprecated and replaced with max; - # full is deprecated in 2.1 and will be removed for 3.0 - # Eventually piffle will need to address that, maybe with some kind of - # support for selecting a particular version of the IIIF image spec. - # For now, default size is still full, and max and full are treated as - # separate modes. A parsed url with max will return max, and a parsed - # url with full will return full, but that will probably change - # once the deprecated full is handled properly. - - size_defaults = { - 'full': True, - 'max': False, - 'width': None, - 'height': None, - 'percent': None, - 'exact': False - } - - def __init__(self, img=None, **options): - self.img = img - self.options = self.size_defaults.copy() - if options: - self.set_options(**options) - - def __call__(self, **options): - if self.img is not None: - img = self.img.get_copy() - img.size.set_options(**options) - return img - - def set_options(self, **options): - '''Update size options. Same parameters as initialiation.''' - allowed_options = list(self.options.keys()) - # error if an unrecoganized option is specified - for key in options: - if key not in allowed_options: - raise IIIFImageClientException('Unknown option: %s' % key) - - # TODO: do we need to type checking? bool/int/float? - - self.options.update(**options) - # if any non-full value is specified, set full to false - # NOTE: if e.g. square is specified but false, this is wrong - allowed_options.remove('full') - if any([key in allowed_options and options[key] - for key in options.keys()]): - self.options['full'] = False - - def as_dict(self): - '''Return size options as a dictionary''' - return self.options - - def __str__(self): - if self.options['full']: - return 'full' - if self.options['max']: - return 'max' - if self.options['percent']: - return 'pct:%g' % self.options['percent'] - - size = '%s,%s' % (self.options['width'] or '', - self.options['height'] or '') - if self.options['exact']: - return '!%s' % size - return size - - def parse(self, size): - # reset to defaults before parsing - self.options = self.size_defaults.copy() - - # full? - if size == 'full': - self.options['full'] = True - return - # for any other case, full should be false - else: - self.options['full'] = False - - # max? - if size == 'max': - self.options['max'] = True - return - - # percent? - if "pct" in size: - try: - self.options['percent'] = float(size.split(":")[1]) - return - except ValueError: - raise ParseError('Error parsing size: %s' % size) - - # exact? - if size.startswith('!'): - self.options['exact'] = True - size = size.lstrip('!') - - # split width and height - width, height = size.split(",") - try: - if width != '': - self.options['width'] = int(width) - if height != '': - self.options['height'] = int(height) - except ValueError: - raise ParseError('Error parsing size: %s' % size) - - def canonicalize(self): - '''Canonicalize the current size options so that - serialization results in canonical format.''' - # From the spec: - # “full” if the default size is requested, - # the w, syntax for images that should be scaled maintaining the - # aspect ratio, and the w,h syntax for explicit sizes that change - # the aspect ratio. - # Note: The size keyword “full” will be replaced with “max” in - # version 3.0 - - if self.options['full']: - # nothing to do - return - - # possbly an error here for every other case if self.img is not set, - # since it's probably not possible to canonicalize without knowing - # image size (any exceptions?) - if self.img is None: - raise IIIFImageClientException('Cannot canonicalize without image') - - if self.options['percent']: - # convert percentage to w,h - scale = self.options['percent'] / 100 - self.options.update({ - 'height': int(self.img.image_height * scale), - 'width': int(self.img.image_width * scale), - 'percent': None, - }) - return - - if self.options['exact']: - # from the spec: - # The image content is scaled for the best fit such that the - # resulting width and height are less than or equal to the - # requested width and height. The exact scaling may be determined - # by the service provider, based on characteristics including - # image quality and system performance. The dimensions of the - # returned image content are calculated to maintain the aspect - # ratio of the extracted region. - - # determine which edge results in a smaller scale - wscale = float(self.options['width']) / float(self.img.image_width) - hscale = float(self.options['height']) / float(self.img.image_height) - scale = min(wscale, hscale) - # use that scale on original image size, to preserve - # original aspect ratio - self.options.update({ - 'exact': False, - 'height': int(scale * self.img.image_height), - 'width': int(scale * self.img.image_width) - }) - return - - # if height only is specified (,h), convert to width only (w,) - if self.options['height'] and self.options['width'] is None: - # determine the scale in use, and convert from - # height only to width only - scale = float(self.options['height']) / float(self.img.image_height) - self.options.update({ - 'height': None, - 'width': int(scale * self.img.image_width) - }) - - -@python_2_unicode_compatible -class ImageRotation(object): - '''IIIF Image rotation Intended to be used with :class:`IIIFImageClient`. - Can be initialized with related image object and rotation options. - - When associated with an image, rotation is callable and will return - an updated image object with the modified rotatoin options. - - :param img: :class:`IIFImageClient` - :param degrees: degrees rotation, optional - :param mirrored: image should be mirrored (boolean, optional, default - is False) - ''' - - # rotation options - options = OrderedDict([ - ('degrees', None), - ('mirrored', False), - ]) - - rotation_defaults = { - 'degrees': 0, - 'mirrored': False - } - - def __init__(self, img=None, **options): - self.img = img - self.options = self.rotation_defaults.copy() - if options: - self.set_options(**options) - - def __call__(self, **options): - if self.img is not None: - img = self.img.get_copy() - img.rotation.set_options(**options) - return img - - def set_options(self, **options): - '''Update size options. Same parameters as initialiation.''' - allowed_options = self.options.keys() - # error if an unrecoganized option is specified - for key in options: - if key not in allowed_options: - raise IIIFImageClientException('Unknown option: %s' % key) - - # TODO: do we need to type checking? bool/int/float? - - self.options.update(**options) - - def as_dict(self): - '''Return rotation options as a dictionary''' - return self.options - - def __str__(self): - return '%s%g' % ('!' if self.options['mirrored'] else '', - self.options['degrees']) - - def parse(self, rotation): - # reset to defaults before parsing - self.options = self.rotation_defaults.copy() - - if str(rotation).startswith('!'): - self.options['mirrored'] = True - rotation = rotation.lstrip('!') - - # rotation allows float - self.options['degrees'] = float(rotation) - - def canonicalize(self): - '''Canonicalize the current region options so that - serialization results in canonical format.''' - # NOTE: explicitly including a canonicalize as a method to make it - # clear that this field supports canonicalization, but no work - # is needed since the existing render does the right things: - # - trim any trailing zeros in a decimal value - # - leading zero if less than 1 - # - ! if mirrored, followed by integer if possible - return - - -@python_2_unicode_compatible -class IIIFImageClient(object): - '''Simple IIIF Image API client for generating IIIF image urls - in an object-oriented, pythonic fashion. Can be extended, - when custom logic is needed to set the image id. Provides - a fluid interface, so that IIIF methods can be chained, e.g.:: - - iiif_img.size(width=300).rotation(90).format('png') - - Note that this returns a new image instance with the specified - options, and the original image will remain unchanged. - - .. Note:: - - Method to set quality not yet available. - ''' - - api_endpoint = None - image_id = None - default_format = 'jpg' - - # iiif defaults for each sections - image_defaults = { - 'quality': 'default', # color, gray, bitonal, default - 'fmt': default_format - } - allowed_formats = ['jpg', 'tif', 'png', 'gif', 'jp2', 'pdf', 'webp'] - - def __init__(self, api_endpoint=None, image_id=None, region=None, - size=None, rotation=None, quality=None, fmt=None): - self.image_options = self.image_defaults.copy() - # NOTE: using underscore to differenteate objects from methods - # but it could be reasonable to make objects public - self.region = ImageRegion(self) - self.size = ImageSize(self) - self.rotation = ImageRotation(self) - - if api_endpoint is not None: - # remove any trailing slash to avoid duplicate slashes - self.api_endpoint = api_endpoint.rstrip('/') - - # FIXME: image_id is not required on init to allow subclassing - # and customizing via get_image_id, but should probably cause - # an error if you attempt to serialize the url and it is not set - # (same for a few other options, probably, too...) - if image_id is not None: - self.image_id = image_id - - # for now, if region option is specified parse as string - if region is not None: - self.region.parse(region) - if size is not None: - self.size.parse(size) - if rotation is not None: - self.rotation.parse(rotation) - - if quality is not None: - self.image_options['quality'] = quality - if fmt is not None: - self.image_options['fmt'] = fmt - - def get_image_id(self): - 'Image id to be used in contructing urls' - return self.image_id - - def __str__(self): - info = self.image_options.copy() - info.update({ - 'endpoint': self.api_endpoint, - 'id': self.get_image_id(), - 'region': six.text_type(self.region), - 'size': six.text_type(self.size), - 'rot': six.text_type(self.rotation) - }) - return '%(endpoint)s/%(id)s/%(region)s/%(size)s/%(rot)s/%(quality)s.%(fmt)s' % info - - def __repr__(self): - return '' % self.get_image_id() - # include non-defaults? - - def info(self): - 'JSON info url' - return '%(endpoint)s/%(id)s/info.json' % { - 'endpoint': self.api_endpoint, - 'id': self.get_image_id(), - } - - @cached_property - def image_info(self): - 'Retrieve image information provided as JSON at info url' - resp = requests.get(self.info()) - if resp.status_code == requests.codes.ok: - return resp.json() - else: - resp.raise_for_status() - - @property - def image_width(self): - 'Image width as reported in :attr:`image_info`' - return self.image_info['width'] - - @property - def image_height(self): - 'Image height as reported in :attr:`image_info`' - return self.image_info['height'] - - def get_copy(self): - 'Get a clone of the current settings for modification.' - clone = self.__class__(self.api_endpoint, self.image_id, - **self.image_options) - # copy region, size, and rotation - no longer included in - # image_options dict - clone.region.set_options(**self.region.as_dict()) - clone.size.set_options(**self.size.as_dict()) - clone.rotation.set_options(**self.rotation.as_dict()) - return clone - - # method to set quality not yet implemented - - def format(self, image_format): - 'Set output image format' - if image_format not in self.allowed_formats: - raise IIIFImageClientException('Image format %s unknown' % image_format) - img = self.get_copy() - img.image_options['fmt'] = image_format - return img - - def canonicalize(self): - '''Canonicalize the URI''' - img = self.get_copy() - img.region.canonicalize() - img.size.canonicalize() - img.rotation.canonicalize() - return img - - @classmethod - def init_from_url(cls, url): - '''Init ImageClient using Image API parameters from URI. Detect - image vs. info request. Can count reliably from the end of the URI - backwards, but cannot assume how many slashes make up the api_endpoint. - Returns new instance of IIIFImageClient. - Per http://iiif.io/api/image/2.0/#image-request-uri-syntax, using - slashes to parse URI''' - - # first parse as a url - parsed_url = urlparse(url) - # then split the path on slashes - # and remove any empty strings - path_components = [path for path in parsed_url.path.split('/') if path] - if not path_components: - raise ParseError('Invalid IIIF image url: %s' - % url) - # pop off last portion of the url to determine if this is an info url - path_basename = path_components.pop() - opts = {} - - # info request - if path_basename == 'info.json': - # NOTE: this is unlikely to happen; more likely, if information is - # missing, we will misinterpret the api endpoint or the image id - if len(path_components) < 1: - raise ParseError('Invalid IIIF image information url: %s' - % url) - image_id = path_components.pop() - - # image request - else: - # check for enough IIIF parameters - if len(path_components) < 4: - raise ParseError('Invalid IIIF image request: %s' % url) - - # pop off url portions as they are used so we can easily - # make use of leftover path to reconstruct the api endpoint - quality, fmt = path_basename.split('.') - rotation = path_components.pop() - size = path_components.pop() - region = path_components.pop() - image_id = path_components.pop() - opts.update({ - 'region': region, - 'size': size, - 'rotation': rotation, - 'quality': quality, - 'fmt': fmt - }) - - # construct the api endpoint url from the parsed url and whatever - # portions of the url path are leftover - # remove empty strings from the remaining path components - path_components = [p for p in path_components if p] - api_endpoint = '%s://%s/%s' % ( - parsed_url.scheme, parsed_url.netloc, - '/'.join(path_components) if path_components else '') - - # init and return instance - return cls(api_endpoint=api_endpoint, image_id=image_id, **opts) - - def as_dict(self): - ''' - Dictionary of with all image request options. - request parameters. Returns a dictionary with all image request - parameters parsed to their most granular level. Can be helpful - for acting logically on particular request parameters like height, - width, mirroring, etc. - ''' - return OrderedDict([ - ('region', self.region.as_dict()), - ('size', self.size.as_dict()), - ('rotation', self.rotation.as_dict()), - ('quality', self.image_options['quality']), - ('format', self.image_options['fmt']) - ]) +# Could raise a deprecation warning if we care +#raise DeprecationWarning('piffle.iiif is deprecated; please use piffle.image') diff --git a/piffle/image.py b/piffle/image.py new file mode 100644 index 0000000..bda506c --- /dev/null +++ b/piffle/image.py @@ -0,0 +1,721 @@ +# iiifclient +# -*- coding: utf-8 -*- + +from collections import OrderedDict +from urllib.parse import urlparse + +from cached_property import cached_property +import requests + + +class IIIFImageClientException(Exception): + '''IIIFImageClient custom exception class''' + + +class ParseError(IIIFImageClientException): + '''Exception raised when an IIIF image could not be parsed''' + + +# NOTE: possible image component base class? +# commonalities so far: setting defaults on init / parse +# handling exact matches like full/square? (but maybe only region/size), +# validating options (and could add option type checking) + + +class ImageRegion(object): + '''IIIF Image region. Intended to be used with :class:`IIIFImageClient`. + Can be initialized with related image object and region options. + + When associated with an image, region is callable and will return + an updated image object with the modified region options. + + :param img: :class:`IIFImageClient` + :param full: full region, defaults to true + :param square: square region, defaults to false + :param x: x coordinate + :param y: y coordinate + :param width: region width + :param height: region height + :param percent: region is a percentage + ''' + + # region options + options = OrderedDict([ + ('full', False), + ('square', False), + ('x', None), + ('y', None), + ('width', None), + ('height', None), + ('percent', False) + ]) + + region_defaults = { + 'full': True, + 'square': False, + 'x': None, + 'y': None, + 'width': None, + 'height': None, + 'percent': False + } + + coords = ['x', 'y', 'width', 'height'] + + def __init__(self, img=None, **options): + self.img = img + self.options = self.region_defaults.copy() + if options: + self.set_options(**options) + + def __call__(self, **options): + if self.img is not None: + img = self.img.get_copy() + img.region.set_options(**options) + return img + + def set_options(self, **options): + '''Update region options. Same parameters as initialiation.''' + allowed_options = list(self.options.keys()) + # error if an unrecoganized option is specified + for key in options: + if key not in allowed_options: + raise IIIFImageClientException('Unknown option: %s' % key) + + # error if some but not all coordinates are specified + # or if percentage is specified but not all coordinates are present + if (any([coord in options for coord in self.coords]) or + 'percent' in options) and not \ + all([coord in options for coord in self.coords]): + # partial region specified + raise IIIFImageClientException('Incomplete region specified') + + # TODO: do we need to type checking? bool/int/float? + + self.options.update(**options) + # if any non-full value is specified, set full to false + # NOTE: if e.g. square is specified but false, this is wrong + allowed_options.remove('full') + if any([(key in allowed_options and options[key]) + for key in options.keys()]): + self.options['full'] = False + + def as_dict(self): + '''Return region options as a dictionary''' + return self.options + + def __str__(self): + '''Render region information in IIIF region format''' + if self.options['full']: + return 'full' + if self.options['square']: + return 'square' + + coords = '%(x)g,%(y)g,%(width)g,%(height)g' % self.options + if self.options['percent']: + return 'pct:%s' % coords + + return coords + + def parse(self, region): + '''Parse an IIIF Image region string and update the current region''' + + # reset to defaults before parsing + self.options = self.region_defaults.copy() + + # full? + if region == 'full': + self.options['full'] = True + # return immediately + return + + self.options['full'] = False + + if region == 'square': + self.options['square'] = True + # return immediately + return self + + # percent? + if "pct" in region: + self.options['percent'] = True + region = region.split("pct:")[1] + + # split to dictionary + # if percentage type, cast to float + try: + if self.options['percent']: + coords = [float(region_c) for region_c in region.split(",")] + # else, force int + else: + coords = [int(region_c) for region_c in region.split(",")] + except ValueError: + # failure converting to integer or float + raise ParseError('Invalid region coordinates: %s' % region) + + if len(coords) != 4: + raise ParseError('Invalid region coordinates: %s' % region) + + x, y, width, height = coords + self.options.update({'x': x, 'y': y, 'width': width, 'height': height}) + + def canonicalize(self): + '''Canonicalize the current region options so that + serialization results in canonical format.''' + # From the spec: + # “full” if the whole image is requested, (including a “square” + # region of a square image), otherwise the x,y,w,h syntax. + + if self.options['full']: + # nothing to do + return + + # if already in x,y,w,h format - nothing to do + if not any([self.options['full'], self.options['square'], + self.options['percent']]): + return + + # possbly an error here for every other case if self.img is not set, + # since it's probably not possible to canonicalize without knowing + # image size (any exceptions?) + if self.img is None: + raise IIIFImageClientException('Cannot canonicalize without image') + + if self.options['square']: + # if image is square, then return full + if self.img is not None and \ + self.img.image_width == self.img.image_height: + self.options['full'] = True + self.options['square'] = False + return + + # otherwise convert to x,y,w,h + # from the spec: + # The region is defined as an area where the width and height + # are both equal to the length of the shorter dimension of the + # complete image. The region may be positioned anywhere in the + # longer dimension of the image content at the server’s + # discretion, and centered is often a reasonable default. + + # determine size of the short edge + short_edge = min(self.img.image_width, self.img.image_height) + width = height = short_edge + # calculate starting long edge point to center the square + if self.img.image_height == short_edge: + y = 0 + x = (self.img.image_width - short_edge) / 2 + else: + x = 0 + y = (self.img.image_height - short_edge) / 2 + + self.options.update({'x': x, 'y': y, 'width': width, + 'height': height, 'square': False}) + + if self.options['percent']: + # convert percentages to x,y,w,h + # From the spec: + # The region to be returned is specified as a sequence of + # percentages of the full image’s dimensions, as reported in + # the image information document. Thus, x represents the number + # of pixels from the 0 position on the horizontal axis, calculated + # as a percentage of the reported width. w represents the width + # of the region, also calculated as a percentage of the reported + # width. The same applies to y and h respectively. These may be + # floating point numbers. + + # convert percentages to dimensions based on image size + self.options.update({ + 'percent': False, + 'x': int((self.options['x']/100) * self.img.image_width), + 'y': int((self.options['y']/100) * self.img.image_height), + 'width': int((self.options['width']/100) * self.img.image_width), + 'height': int((self.options['height']/100) * self.img.image_height) + }) + return + + +class ImageSize(object): + '''IIIF Image Size. Intended to be used with :class:`IIIFImageClient`. + Can be initialized with related image object and size options. + + When associated with an image, size is callable and will return + an updated image object with the modified size. + + :param img: :class:`IIFImageClient` + :param width: optional width + :param height: optional height + :param percent: optional percent + :param exact: size should be exact (boolean, optional) + ''' + + # size options + options = OrderedDict([ + ('full', False), + ('max', False), + ('width', None), + ('height', None), + ('percent', None), + ('exact', False) + ]) + + # NOTE: full is being deprecated and replaced with max; + # full is deprecated in 2.1 and will be removed for 3.0 + # Eventually piffle will need to address that, maybe with some kind of + # support for selecting a particular version of the IIIF image spec. + # For now, default size is still full, and max and full are treated as + # separate modes. A parsed url with max will return max, and a parsed + # url with full will return full, but that will probably change + # once the deprecated full is handled properly. + + size_defaults = { + 'full': True, + 'max': False, + 'width': None, + 'height': None, + 'percent': None, + 'exact': False + } + + def __init__(self, img=None, **options): + self.img = img + self.options = self.size_defaults.copy() + if options: + self.set_options(**options) + + def __call__(self, **options): + if self.img is not None: + img = self.img.get_copy() + img.size.set_options(**options) + return img + + def set_options(self, **options): + '''Update size options. Same parameters as initialiation.''' + allowed_options = list(self.options.keys()) + # error if an unrecoganized option is specified + for key in options: + if key not in allowed_options: + raise IIIFImageClientException('Unknown option: %s' % key) + + # TODO: do we need to type checking? bool/int/float? + + self.options.update(**options) + # if any non-full value is specified, set full to false + # NOTE: if e.g. square is specified but false, this is wrong + allowed_options.remove('full') + if any([key in allowed_options and options[key] + for key in options.keys()]): + self.options['full'] = False + + def as_dict(self): + '''Return size options as a dictionary''' + return self.options + + def __str__(self): + if self.options['full']: + return 'full' + if self.options['max']: + return 'max' + if self.options['percent']: + return 'pct:%g' % self.options['percent'] + + size = '%s,%s' % (self.options['width'] or '', + self.options['height'] or '') + if self.options['exact']: + return '!%s' % size + return size + + def parse(self, size): + # reset to defaults before parsing + self.options = self.size_defaults.copy() + + # full? + if size == 'full': + self.options['full'] = True + return + + # for any other case, full should be false + self.options['full'] = False + + # max? + if size == 'max': + self.options['max'] = True + return + + # percent? + if "pct" in size: + try: + self.options['percent'] = float(size.split(":")[1]) + return + except ValueError: + raise ParseError('Error parsing size: %s' % size) + + # exact? + if size.startswith('!'): + self.options['exact'] = True + size = size.lstrip('!') + + # split width and height + width, height = size.split(",") + try: + if width != '': + self.options['width'] = int(width) + if height != '': + self.options['height'] = int(height) + except ValueError: + raise ParseError('Error parsing size: %s' % size) + + def canonicalize(self): + '''Canonicalize the current size options so that + serialization results in canonical format.''' + # From the spec: + # “full” if the default size is requested, + # the w, syntax for images that should be scaled maintaining the + # aspect ratio, and the w,h syntax for explicit sizes that change + # the aspect ratio. + # Note: The size keyword “full” will be replaced with “max” in + # version 3.0 + + if self.options['full']: + # nothing to do + return + + # possbly an error here for every other case if self.img is not set, + # since it's probably not possible to canonicalize without knowing + # image size (any exceptions?) + if self.img is None: + raise IIIFImageClientException('Cannot canonicalize without image') + + if self.options['percent']: + # convert percentage to w,h + scale = self.options['percent'] / 100 + self.options.update({ + 'height': int(self.img.image_height * scale), + 'width': int(self.img.image_width * scale), + 'percent': None, + }) + return + + if self.options['exact']: + # from the spec: + # The image content is scaled for the best fit such that the + # resulting width and height are less than or equal to the + # requested width and height. The exact scaling may be determined + # by the service provider, based on characteristics including + # image quality and system performance. The dimensions of the + # returned image content are calculated to maintain the aspect + # ratio of the extracted region. + + # determine which edge results in a smaller scale + wscale = float(self.options['width']) / float(self.img.image_width) + hscale = float(self.options['height']) / float(self.img.image_height) + scale = min(wscale, hscale) + # use that scale on original image size, to preserve + # original aspect ratio + self.options.update({ + 'exact': False, + 'height': int(scale * self.img.image_height), + 'width': int(scale * self.img.image_width) + }) + return + + # if height only is specified (,h), convert to width only (w,) + if self.options['height'] and self.options['width'] is None: + # determine the scale in use, and convert from + # height only to width only + scale = float(self.options['height']) / float(self.img.image_height) + self.options.update({ + 'height': None, + 'width': int(scale * self.img.image_width) + }) + + +class ImageRotation(object): + '''IIIF Image rotation Intended to be used with :class:`IIIFImageClient`. + Can be initialized with related image object and rotation options. + + When associated with an image, rotation is callable and will return + an updated image object with the modified rotatoin options. + + :param img: :class:`IIFImageClient` + :param degrees: degrees rotation, optional + :param mirrored: image should be mirrored (boolean, optional, default + is False) + ''' + + # rotation options + options = OrderedDict([ + ('degrees', None), + ('mirrored', False), + ]) + + rotation_defaults = { + 'degrees': 0, + 'mirrored': False + } + + def __init__(self, img=None, **options): + self.img = img + self.options = self.rotation_defaults.copy() + if options: + self.set_options(**options) + + def __call__(self, **options): + if self.img is not None: + img = self.img.get_copy() + img.rotation.set_options(**options) + return img + + def set_options(self, **options): + '''Update size options. Same parameters as initialiation.''' + allowed_options = self.options.keys() + # error if an unrecoganized option is specified + for key in options: + if key not in allowed_options: + raise IIIFImageClientException('Unknown option: %s' % key) + + # TODO: do we need to type checking? bool/int/float? + + self.options.update(**options) + + def as_dict(self): + '''Return rotation options as a dictionary''' + return self.options + + def __str__(self): + return '%s%g' % ('!' if self.options['mirrored'] else '', + self.options['degrees']) + + def parse(self, rotation): + # reset to defaults before parsing + self.options = self.rotation_defaults.copy() + + if str(rotation).startswith('!'): + self.options['mirrored'] = True + rotation = rotation.lstrip('!') + + # rotation allows float + self.options['degrees'] = float(rotation) + + def canonicalize(self): + '''Canonicalize the current region options so that + serialization results in canonical format.''' + # NOTE: explicitly including a canonicalize as a method to make it + # clear that this field supports canonicalization, but no work + # is needed since the existing render does the right things: + # - trim any trailing zeros in a decimal value + # - leading zero if less than 1 + # - ! if mirrored, followed by integer if possible + return + + +class IIIFImageClient(object): + '''Simple IIIF Image API client for generating IIIF image urls + in an object-oriented, pythonic fashion. Can be extended, + when custom logic is needed to set the image id. Provides + a fluid interface, so that IIIF methods can be chained, e.g.:: + + iiif_img.size(width=300).rotation(90).format('png') + + Note that this returns a new image instance with the specified + options, and the original image will remain unchanged. + + .. Note:: + + Method to set quality not yet available. + ''' + + api_endpoint = None + image_id = None + default_format = 'jpg' + + # iiif defaults for each sections + image_defaults = { + 'quality': 'default', # color, gray, bitonal, default + 'fmt': default_format + } + allowed_formats = ['jpg', 'tif', 'png', 'gif', 'jp2', 'pdf', 'webp'] + + def __init__(self, api_endpoint=None, image_id=None, region=None, + size=None, rotation=None, quality=None, fmt=None): + self.image_options = self.image_defaults.copy() + # NOTE: using underscore to differenteate objects from methods + # but it could be reasonable to make objects public + self.region = ImageRegion(self) + self.size = ImageSize(self) + self.rotation = ImageRotation(self) + + if api_endpoint is not None: + # remove any trailing slash to avoid duplicate slashes + self.api_endpoint = api_endpoint.rstrip('/') + + # FIXME: image_id is not required on init to allow subclassing + # and customizing via get_image_id, but should probably cause + # an error if you attempt to serialize the url and it is not set + # (same for a few other options, probably, too...) + if image_id is not None: + self.image_id = image_id + + # for now, if region option is specified parse as string + if region is not None: + self.region.parse(region) + if size is not None: + self.size.parse(size) + if rotation is not None: + self.rotation.parse(rotation) + + if quality is not None: + self.image_options['quality'] = quality + if fmt is not None: + self.image_options['fmt'] = fmt + + def get_image_id(self): + 'Image id to be used in contructing urls' + return self.image_id + + def __str__(self): + info = self.image_options.copy() + info.update({ + 'endpoint': self.api_endpoint, + 'id': self.get_image_id(), + 'region': str(self.region), + 'size': str(self.size), + 'rot': str(self.rotation) + }) + return '%(endpoint)s/%(id)s/%(region)s/%(size)s/%(rot)s/%(quality)s.%(fmt)s' % info + + def __repr__(self): + return '' % self.get_image_id() + # include non-defaults? + + def info(self): + 'JSON info url' + return '%(endpoint)s/%(id)s/info.json' % { + 'endpoint': self.api_endpoint, + 'id': self.get_image_id(), + } + + @cached_property + def image_info(self): + 'Retrieve image information provided as JSON at info url' + resp = requests.get(self.info()) + if resp.status_code == requests.codes.ok: + return resp.json() + + resp.raise_for_status() + + @property + def image_width(self): + 'Image width as reported in :attr:`image_info`' + return self.image_info['width'] + + @property + def image_height(self): + 'Image height as reported in :attr:`image_info`' + return self.image_info['height'] + + def get_copy(self): + 'Get a clone of the current settings for modification.' + clone = self.__class__(self.api_endpoint, self.image_id, + **self.image_options) + # copy region, size, and rotation - no longer included in + # image_options dict + clone.region.set_options(**self.region.as_dict()) + clone.size.set_options(**self.size.as_dict()) + clone.rotation.set_options(**self.rotation.as_dict()) + return clone + + # method to set quality not yet implemented + + def format(self, image_format): + 'Set output image format' + if image_format not in self.allowed_formats: + raise IIIFImageClientException('Image format %s unknown' % image_format) + img = self.get_copy() + img.image_options['fmt'] = image_format + return img + + def canonicalize(self): + '''Canonicalize the URI''' + img = self.get_copy() + img.region.canonicalize() + img.size.canonicalize() + img.rotation.canonicalize() + return img + + @classmethod + def init_from_url(cls, url): + '''Init ImageClient using Image API parameters from URI. Detect + image vs. info request. Can count reliably from the end of the URI + backwards, but cannot assume how many slashes make up the api_endpoint. + Returns new instance of IIIFImageClient. + Per http://iiif.io/api/image/2.0/#image-request-uri-syntax, using + slashes to parse URI''' + + # first parse as a url + parsed_url = urlparse(url) + # then split the path on slashes + # and remove any empty strings + path_components = [path for path in parsed_url.path.split('/') if path] + if not path_components: + raise ParseError('Invalid IIIF image url: %s' + % url) + # pop off last portion of the url to determine if this is an info url + path_basename = path_components.pop() + opts = {} + + # info request + if path_basename == 'info.json': + # NOTE: this is unlikely to happen; more likely, if information is + # missing, we will misinterpret the api endpoint or the image id + if len(path_components) < 1: + raise ParseError('Invalid IIIF image information url: %s' + % url) + image_id = path_components.pop() + + # image request + else: + # check for enough IIIF parameters + if len(path_components) < 4: + raise ParseError('Invalid IIIF image request: %s' % url) + + # pop off url portions as they are used so we can easily + # make use of leftover path to reconstruct the api endpoint + quality, fmt = path_basename.split('.') + rotation = path_components.pop() + size = path_components.pop() + region = path_components.pop() + image_id = path_components.pop() + opts.update({ + 'region': region, + 'size': size, + 'rotation': rotation, + 'quality': quality, + 'fmt': fmt + }) + + # construct the api endpoint url from the parsed url and whatever + # portions of the url path are leftover + # remove empty strings from the remaining path components + path_components = [p for p in path_components if p] + api_endpoint = '%s://%s/%s' % ( + parsed_url.scheme, parsed_url.netloc, + '/'.join(path_components) if path_components else '') + + # init and return instance + return cls(api_endpoint=api_endpoint, image_id=image_id, **opts) + + def as_dict(self): + ''' + Dictionary of with all image request options. + request parameters. Returns a dictionary with all image request + parameters parsed to their most granular level. Can be helpful + for acting logically on particular request parameters like height, + width, mirroring, etc. + ''' + return OrderedDict([ + ('region', self.region.as_dict()), + ('size', self.size.as_dict()), + ('rotation', self.rotation.as_dict()), + ('quality', self.image_options['quality']), + ('format', self.image_options['fmt']) + ]) diff --git a/piffle/presentation.py b/piffle/presentation.py new file mode 100644 index 0000000..1cd7754 --- /dev/null +++ b/piffle/presentation.py @@ -0,0 +1,140 @@ +import json +import os.path +import urllib + +from attrdict import AttrMap + +import requests + + +class IIIFException(Exception): + '''Custom exception for IIIF errors''' + + +def get_iiif_url(url): + '''Wrapper around :meth:`requests.get` to support conditionally + adding an auth tokens or other parameters.''' + request_options = {} + # TODO: need some way of configuring hooks for e.g. setting auth tokens + return requests.get(url, **request_options) + + +class IIIFPresentation(AttrMap): + ''':class:`attrdict.AttrMap` subclass for read access to IIIF Presentation + content''' + + # TODO: document sample use, e.g. @ fields + + at_fields = ['type', 'id', 'context'] + + @classmethod + def from_file(cls, path): + '''Iniitialize :class:`IIIFPresentation` from a file.''' + with open(path) as manifest: + data = json.loads(manifest.read()) + return cls(data) + + @classmethod + def from_url(cls, uri): + '''Iniitialize :class:`IIIFPresentation` from a URL. + + :raises: :class:`IIIFException` if URL is not retrieved successfully, + if the response is not JSON content, or if the JSON cannot be parsed. + ''' + response = get_iiif_url(uri) + if response.status_code == requests.codes.ok: + try: + return cls(response.json()) + except json.decoder.JSONDecodeError as err: + # if json fails, two possibilities: + # - we didn't actually get json (e.g. redirect for auth) + if 'application/json' not in response.headers['content-type']: + raise IIIFException('No JSON found at %s' % uri) + # - there is something wrong with the json + raise IIIFException('Error parsing JSON for %s: %s' % + (uri, err)) + + raise IIIFException( + 'Error retrieving manifest at %s: %s %s' % + (uri, response.status_code, response.reason)) + + @classmethod + def is_url(cls, url): + '''Utility method to check if a path is a url or file''' + return urllib.parse.urlparse(url).scheme != "" + + @classmethod + def from_file_or_url(cls, path): + '''Iniitialize :class:`IIIFPresentation` from a file or a url.''' + if os.path.isfile(path): + return cls.from_file(path) + elif cls.is_url(path): + return cls.from_url(path) + else: + raise IIIFException('File not found: %s' % path) + + @classmethod + def short_id(cls, uri): + '''Generate a short id from full manifest/canvas uri identifiers + for use in local urls. Logic is based on the recommended + url pattern from the IIIF Presentation 2.0 specification.''' + + # shortening should work reliably for uris that follow + # recommended url patterns from the spec + # http://iiif.io/api/presentation/2.0/#a-summary-of-recommended-uri-patterns + # manifest: {scheme}://{host}/{prefix}/{identifier}/manifest + # canvas: {scheme}://{host}/{prefix}/{identifier}/canvas/{name} + + # remove trailing /manifest at the end of the url, if present + if uri.endswith('/manifest'): + uri = uri[:-len('/manifest')] + # split on slashes and return the last portion + return uri.split('/')[-1] + + def __getattr__(self, key): + """ + Access an item as an attribute. + """ + # override getattr to allow use of keys with leading @, + # which are otherwise not detected as present and not valid + at_key = self._handle_at_keys(key) + if key not in self or \ + (key not in self.at_fields and at_key not in self) or \ + not self._valid_name(key): + raise AttributeError( + "'{cls}' instance has no attribute '{name}'".format( + cls=self.__class__.__name__, name=key + ) + ) + return self._build(self[key]) + + def _handle_at_keys(self, key): + if key in self.at_fields: + key = '@%s' % key + return key + + def __getitem__(self, key): + """ + Access a value associated with a key. + """ + return self._mapping[self._handle_at_keys(key)] + + def __setitem__(self, key, value): + """ + Add a key-value pair to the instance. + """ + self._mapping[self._handle_at_keys(key)] = value + + def __delitem__(self, key): + """ + Delete a key-value pair + """ + del self._mapping[self._handle_at_keys(key)] + + @property + def first_label(self): + # label can be a string or list of strings + if isinstance(self.label, str): + return self.label + else: + return self.label[0] diff --git a/setup.py b/setup.py index b7e2dcf..59104f1 100755 --- a/setup.py +++ b/setup.py @@ -19,26 +19,24 @@ 'Natural Language :: English', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', 'Topic :: Software Development :: Libraries :: Python Modules', ] -test_requirements = ['pytest>=3.6', 'pytest-cov', 'mock'] +test_requirements = ['pytest>=3.6', 'pytest-cov'] setup( name='piffle', version=piffle.__version__, - author='Emory University Libraries', - author_email='libsysdev-l@listserv.cc.emory.edu', - url='https://github.com/emory-lits-labs/piffle', + author='The Center for Digital Humanities at Princeton', + author_email='cdhdevteam@princeton.edu', + url='https://github.com/princeton-cdh/piffle', license='Apache License, Version 2.0', packages=find_packages(), - install_requires=['requests', 'cached-property', 'six', 'future'], + install_requires=['requests', 'cached-property', 'attrdict'], setup_requires=['pytest-runner'], tests_require=test_requirements, extras_require={ diff --git a/tests/fixtures/chto-manifest.json b/tests/fixtures/chto-manifest.json new file mode 100644 index 0000000..9ce8de9 --- /dev/null +++ b/tests/fixtures/chto-manifest.json @@ -0,0 +1,899 @@ +{ + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest", + "@type": "sc:Manifest", + "label": [ + "Chto my stroim : Tetrad\u02b9 s kartinkami" + ], + "viewingHint": "paged", + "viewingDirection": "left-to-right", + "sequences": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/sequence/normal", + "@type": "sc:Sequence", + "canvases": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p02871v98d", + "@type": "sc:Canvas", + "label": "image 1", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/p02871v98d-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/p0%2F28%2F71%2Fv9%2F8d-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6470, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/p0%2F28%2F71%2Fv9%2F8d-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p02871v98d" + } + ], + "width": 6470, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/p02871v98d/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/prr172w722", + "@type": "sc:Canvas", + "label": "image 2", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/prr172w722-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pr%2Fr1%2F72%2Fw7%2F22-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6289, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pr%2Fr1%2F72%2Fw7%2F22-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/prr172w722" + } + ], + "width": 6289, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/prr172w722/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/png452h250", + "@type": "sc:Canvas", + "label": "image 3", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/png452h250-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pn%2Fg4%2F52%2Fh2%2F50-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6356, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pn%2Fg4%2F52%2Fh2%2F50-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/png452h250" + } + ], + "width": 6356, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/png452h250/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pcf95k9204", + "@type": "sc:Canvas", + "label": "image 4", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pcf95k9204-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pc%2Ff9%2F5k%2F92%2F04-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6290, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pc%2Ff9%2F5k%2F92%2F04-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pcf95k9204" + } + ], + "width": 6290, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pcf95k9204/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pvt151j60m", + "@type": "sc:Canvas", + "label": "image 5", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pvt151j60m-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pv%2Ft1%2F51%2Fj6%2F0m-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6356, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pv%2Ft1%2F51%2Fj6%2F0m-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pvt151j60m" + } + ], + "width": 6356, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pvt151j60m/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p08613m187", + "@type": "sc:Canvas", + "label": "image 6", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/p08613m187-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/p0%2F86%2F13%2Fm1%2F87-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6290, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/p0%2F86%2F13%2Fm1%2F87-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p08613m187" + } + ], + "width": 6290, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/p08613m187/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p7p88df99m", + "@type": "sc:Canvas", + "label": "image 7", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/p7p88df99m-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/p7%2Fp8%2F8d%2Ff9%2F9m-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6356, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/p7%2Fp8%2F8d%2Ff9%2F9m-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p7p88df99m" + } + ], + "width": 6356, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/p7p88df99m/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph702r431f", + "@type": "sc:Canvas", + "label": "image 8", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/ph702r431f-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2F70%2F2r%2F43%2F1f-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6290, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2F70%2F2r%2F43%2F1f-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph702r431f" + } + ], + "width": 6290, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/ph702r431f/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pth83n019h", + "@type": "sc:Canvas", + "label": "image 9", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pth83n019h-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pt%2Fh8%2F3n%2F01%2F9h-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6356, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pt%2Fh8%2F3n%2F01%2F9h-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pth83n019h" + } + ], + "width": 6356, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pth83n019h/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/phx11zd277", + "@type": "sc:Canvas", + "label": "image 10", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/phx11zd277-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2Fx1%2F1z%2Fd2%2F77-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6290, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2Fx1%2F1z%2Fd2%2F77-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/phx11zd277" + } + ], + "width": 6290, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/phx11zd277/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ppn89f6070", + "@type": "sc:Canvas", + "label": "image 11", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/ppn89f6070-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pp%2Fn8%2F9f%2F60%2F70-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6356, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pp%2Fn8%2F9f%2F60%2F70-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ppn89f6070" + } + ], + "width": 6356, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/ppn89f6070/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pqv33sx017", + "@type": "sc:Canvas", + "label": "image 12", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pqv33sx017-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pq%2Fv3%2F3s%2Fx0%2F17-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 7200, + "height": 4136, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pq%2Fv3%2F3s%2Fx0%2F17-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pqv33sx017" + } + ], + "width": 7200, + "height": 4136, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pqv33sx017/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pjm215n89q", + "@type": "sc:Canvas", + "label": "image 13", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pjm215n89q-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pj%2Fm2%2F15%2Fn8%2F9q-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6290, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pj%2Fm2%2F15%2Fn8%2F9q-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pjm215n89q" + } + ], + "width": 6290, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pjm215n89q/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pwm118n419", + "@type": "sc:Canvas", + "label": "image 14", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pwm118n419-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pw%2Fm1%2F18%2Fn4%2F19-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6356, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pw%2Fm1%2F18%2Fn4%2F19-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pwm118n419" + } + ], + "width": 6356, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pwm118n419/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph128pc97x", + "@type": "sc:Canvas", + "label": "image 15", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/ph128pc97x-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2F12%2F8p%2Fc9%2F7x-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6290, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2F12%2F8p%2Fc9%2F7x-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph128pc97x" + } + ], + "width": 6290, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/ph128pc97x/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pbk129890w", + "@type": "sc:Canvas", + "label": "image 16", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pbk129890w-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pb%2Fk1%2F29%2F89%2F0w-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6287, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pb%2Fk1%2F29%2F89%2F0w-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pbk129890w" + } + ], + "width": 6287, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pbk129890w/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/psq87cs902", + "@type": "sc:Canvas", + "label": "image 17", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/psq87cs902-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/ps%2Fq8%2F7c%2Fs9%2F02-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6290, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/ps%2Fq8%2F7c%2Fs9%2F02-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/psq87cs902" + } + ], + "width": 6290, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/psq87cs902/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph128pc987", + "@type": "sc:Canvas", + "label": "image 18", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/ph128pc987-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2F12%2F8p%2Fc9%2F87-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6287, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2F12%2F8p%2Fc9%2F87-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph128pc987" + } + ], + "width": 6287, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/ph128pc987/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/phm50vs000", + "@type": "sc:Canvas", + "label": "image 19", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/phm50vs000-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2Fm5%2F0v%2Fs0%2F00-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6290, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2Fm5%2F0v%2Fs0%2F00-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/phm50vs000" + } + ], + "width": 6290, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/phm50vs000/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pj09908750", + "@type": "sc:Canvas", + "label": "image 20", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pj09908750-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pj%2F09%2F90%2F87%2F50-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6289, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pj%2F09%2F90%2F87%2F50-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pj09908750" + } + ], + "width": 6289, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pj09908750/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/psn00bx49q", + "@type": "sc:Canvas", + "label": "image 21", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/psn00bx49q-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/ps%2Fn0%2F0b%2Fx4%2F9q-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 7200, + "height": 4077, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/ps%2Fn0%2F0b%2Fx4%2F9q-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/psn00bx49q" + } + ], + "width": 7200, + "height": 4077, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/psn00bx49q/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + }, + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pzg64vk52v", + "@type": "sc:Canvas", + "label": "image 22", + "images": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pzg64vk52v-image", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pz%2Fg6%2F4v%2Fk5%2F2v-intermediate_file.jp2/full/!600,600/0/default.jpg", + "@type": "dcterms:Image", + "format": "image/jpeg", + "width": 6290, + "height": 7200, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/pz%2Fg6%2F4v%2Fk5%2F2v-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pzg64vk52v" + } + ], + "width": 6290, + "height": 7200, + "otherContent": [ + { + "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pzg64vk52v/text", + "@type": "sc:AnnotationList", + "label": "Text of this Page" + } + ] + } + ], + "viewingHint": "paged", + "rendering": { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/pdf/gray", + "label": "Download as PDF", + "format": "application/pdf" + } + } + ], + "structures": [ + { + "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/range/g69883452374360", + "@type": "sc:Range", + "label": "Logical", + "viewingHint": "top", + "canvases": [ + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p02871v98d", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/prr172w722", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/png452h250", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pcf95k9204", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pvt151j60m", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p08613m187", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p7p88df99m", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph702r431f", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pth83n019h", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/phx11zd277", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ppn89f6070", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pqv33sx017", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pjm215n89q", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pwm118n419", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph128pc97x", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pbk129890w", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/psq87cs902", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph128pc987", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/phm50vs000", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pj09908750", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/psn00bx49q", + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pzg64vk52v" + ] + } + ], + "metadata": [ + { + "label": "Creator", + "value": [ + "Savel\u02b9ev, L. (Leonid), 1904-1941" + ] + }, + { + "label": "Publisher", + "value": [ + "[Leningrad ; Moskva] : Gosudarstvennoe izdatel\u02b9stvo, 1930." + ] + }, + { + "label": "Date created", + "value": [ + "1930-01-01T00:00:00Z" + ] + }, + { + "label": "Subject", + "value": [ + "Industrialization\u2014Soviet Union\u2014Juvenile literature", + "Building\u2014Soviet Union\u2014Juvenile literature", + "Natural resources\u2014Soviet Union\u2014Juvenile literature" + ] + }, + { + "label": "Language", + "value": [ + "rus" + ] + }, + { + "label": "Identifier", + "value": [ + "ark:/88435/7p88ck29d" + ] + }, + { + "label": "Replaces", + "value": [ + "pudl0127/4765261" + ] + }, + { + "label": "Format", + "value": "Book" + }, + { + "label": "Extent", + "value": [ + "[20] p. : color ill. ; 23 cm." + ] + }, + { + "label": "Call number", + "value": [ + "Pams / NR 20 / Cyrillic / Box 15 9331" + ] + }, + { + "label": "Author", + "value": [ + "Savel\u02b9ev, L. (Leonid), 1904-1941" + ] + }, + { + "label": "Illustrator", + "value": [ + "Tambi, V." + ] + }, + { + "label": "Collection", + "value": [ + "Soviet Era Books for Children and Youth", + "Treasures of the Cotsen Collection" + ] + } + ], + "thumbnail": { + "@id": "https://libimages1.princeton.edu/loris/plum_prod/p0%2F28%2F71%2Fv9%2F8d-intermediate_file.jp2/full/!200,150/0/default.jpg", + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://libimages1.princeton.edu/loris/plum_prod/p0%2F28%2F71%2Fv9%2F8d-intermediate_file.jp2", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "seeAlso": { + "@id": "https://bibdata.princeton.edu/bibliographic/4765261/jsonld", + "format": "application/ld+json" + }, + "license": "http://rightsstatements.org/vocab/NKC/1.0/", + "logo": "https://example.com/logo.png" +} \ No newline at end of file diff --git a/tests/test_piffle.py b/tests/test_image.py similarity index 61% rename from tests/test_piffle.py rename to tests/test_image.py index be9cd32..a7a0f4b 100644 --- a/tests/test_piffle.py +++ b/tests/test_image.py @@ -1,9 +1,9 @@ -from piffle import iiif -from mock import patch -import six +from unittest.mock import patch import pytest import requests +from piffle import image + api_endpoint = 'http://imgserver.co' image_id = 'img1' @@ -27,7 +27,7 @@ } sample_image_info = { - '@context': "http://iiif.io/api/image/2/context.json", + '@context': "http://image.io/api/image/2/context.json", '@id': VALID_URLS['simple'], 'height': 3039, 'width': 2113, @@ -35,8 +35,8 @@ def get_test_imgclient(): - return iiif.IIIFImageClient(api_endpoint=api_endpoint, - image_id=image_id) + return image.IIIFImageClient(api_endpoint=api_endpoint, + image_id=image_id) class TestIIIFImageClient: @@ -45,15 +45,15 @@ def test_defaults(self): img = get_test_imgclient() # default image url assert '%s/%s/full/full/0/default.jpg' % (api_endpoint, image_id) \ - == six.text_type(img) + == str(img) # info url assert '%s/%s/info.json' % (api_endpoint, image_id) \ - == six.text_type(img.info()) + == str(img.info()) def test_outputs(self): img = get_test_imgclient() # str and unicode should be equivalent - assert six.text_type(img.info()) == six.text_type(str(img.info())) + assert str(img.info()) == str(str(img.info())) # repr should have class and image id assert 'IIIFImageClient' in repr(img) assert img.get_image_id() in repr(img) @@ -61,7 +61,7 @@ def test_outputs(self): def test_init_opts(self): test_opts = {'region': '2560,2560,256,256', 'size': '256,', 'rotation': '90', 'quality': 'color', 'fmt': 'png'} - img = iiif.IIIFImageClient(api_endpoint=api_endpoint, + img = image.IIIFImageClient(api_endpoint=api_endpoint, image_id=image_id, **test_opts) assert img.api_endpoint == api_endpoint assert img.image_id == image_id @@ -70,37 +70,37 @@ def test_init_opts(self): del expected_img_opts['size'] del expected_img_opts['rotation'] assert img.image_options == expected_img_opts - assert six.text_type(img.region) == test_opts['region'] - assert six.text_type(img.size) == test_opts['size'] - assert six.text_type(img.rotation) == test_opts['rotation'] + assert str(img.region) == test_opts['region'] + assert str(img.size) == test_opts['size'] + assert str(img.rotation) == test_opts['rotation'] # TODO: should parse/verify options on init - # with pytest.raises(iiif.IIIFImageClientException): - # img = iiif.IIIFImageClient(api_endpoint=api_endpoint, fmt='bogus') + # with pytest.raises(image.IIIFImageClientException): + # img = image.IIIFImageClient(api_endpoint=api_endpoint, fmt='bogus') def test_size(self): img = get_test_imgclient() width, height, percent = 100, 150, 50 # width only assert '%s/%s/full/%s,/0/default.jpg' % (api_endpoint, image_id, width)\ - == six.text_type(img.size(width=width)) + == str(img.size(width=width)) # height only assert '%s/%s/full/,%s/0/default.jpg' % \ (api_endpoint, image_id, height) == \ - six.text_type(img.size(height=height)) + str(img.size(height=height)) # width and height assert '%s/%s/full/%s,%s/0/default.jpg' % \ (api_endpoint, image_id, width, height) == \ - six.text_type(img.size(width=width, height=height)) + str(img.size(width=width, height=height)) # exact width and height assert '%s/%s/full/!%s,%s/0/default.jpg' % \ (api_endpoint, image_id, width, height) == \ - six.text_type(img.size(width=width, height=height, exact=True)) + str(img.size(width=width, height=height, exact=True)) # percent assert '%s/%s/full/pct:%s/0/default.jpg' % \ (api_endpoint, image_id, percent) == \ - six.text_type(img.size(percent=percent)) + str(img.size(percent=percent)) def test_region(self): # region options passed through to region object and output @@ -108,15 +108,15 @@ def test_region(self): x, y, width, height = 5, 10, 100, 150 assert '%s/%s/%s,%s,%s,%s/full/0/default.jpg' % \ (api_endpoint, image_id, x, y, width, height) \ - == six.text_type(img.region(x=x, y=y, width=width, height=height)) + == str(img.region(x=x, y=y, width=width, height=height)) def test_rotation(self): # rotation options passed through to region object and output img = get_test_imgclient() assert '%s/%s/full/full/90/default.jpg' % \ (api_endpoint, image_id) \ - == six.text_type(img.rotation(degrees=90)) - with pytest.raises(iiif.IIIFImageClientException): + == str(img.rotation(degrees=90)) + with pytest.raises(image.IIIFImageClientException): img.rotation(foo='bar') def test_format(self): @@ -124,11 +124,11 @@ def test_format(self): png = img.format('png') jpg = img.format('jpg') gif = img.format('gif') - assert six.text_type(png).endswith('.png') - assert six.text_type(jpg).endswith('.jpg') - assert six.text_type(gif).endswith('.gif') + assert str(png).endswith('.png') + assert str(jpg).endswith('.jpg') + assert str(gif).endswith('.gif') - with pytest.raises(iiif.IIIFImageClientException): + with pytest.raises(image.IIIFImageClientException): img.format('bogus') def test_combine_options(self): @@ -137,72 +137,72 @@ def test_combine_options(self): fmt = 'png' assert '%s/%s/full/%s,/0/default.%s' % \ (api_endpoint, image_id, width, fmt)\ - == six.text_type(img.size(width=width).format(fmt)) + == str(img.size(width=width).format(fmt)) img = get_test_imgclient() x, y, width, height = 5, 10, 100, 150 assert '%s/%s/%s,%s,%s,%s/full/0/default.%s' % \ (api_endpoint, image_id, x, y, width, height, fmt) \ - == six.text_type(img.region(x=x, y=y, + == str(img.region(x=x, y=y, width=width, height=height).format(fmt)) assert '%s/%s/%s,%s,%s,%s/%s,/0/default.%s' % \ (api_endpoint, image_id, x, y, width, height, width, fmt) \ - == six.text_type(img.size(width=width) + == str(img.size(width=width) .region(x=x, y=y, width=width, height=height) .format(fmt)) rotation = 90 assert '%s/%s/%s,%s,%s,%s/%s,/%s/default.%s' % \ (api_endpoint, image_id, x, y, width, height, width, rotation, fmt) \ - == six.text_type(img.size(width=width) + == str(img.size(width=width) .region(x=x, y=y, width=width, height=height) .rotation(degrees=90) .format(fmt)) # original image object should be unchanged, and still show defaults assert '%s/%s/full/full/0/default.jpg' % (api_endpoint, image_id) \ - == six.text_type(img) + == str(img) def test_init_from_url(self): # well-formed # - info url - img = iiif.IIIFImageClient.init_from_url(VALID_URLS['info']) - assert isinstance(img, iiif.IIIFImageClient) + img = image.IIIFImageClient.init_from_url(VALID_URLS['info']) + assert isinstance(img, image.IIIFImageClient) assert img.image_id == image_id # round trip back to original url - assert six.text_type(img.info()) == VALID_URLS['info'] + assert str(img.info()) == VALID_URLS['info'] assert img.api_endpoint == api_endpoint # -info with more complex endpoint base url - img = iiif.IIIFImageClient.init_from_url(VALID_URLS['info-loris']) + img = image.IIIFImageClient.init_from_url(VALID_URLS['info-loris']) assert img.api_endpoint == '%s/loris' % api_endpoint # - image - img = iiif.IIIFImageClient.init_from_url(VALID_URLS['simple']) - assert isinstance(img, iiif.IIIFImageClient) - assert six.text_type(img) == VALID_URLS['simple'] - img = iiif.IIIFImageClient.init_from_url(VALID_URLS['complex']) - assert six.text_type(img) == VALID_URLS['complex'] - assert isinstance(img, iiif.IIIFImageClient) - img = iiif.IIIFImageClient.init_from_url(VALID_URLS['exact']) - assert six.text_type(img) == VALID_URLS['exact'] - assert isinstance(img, iiif.IIIFImageClient) + img = image.IIIFImageClient.init_from_url(VALID_URLS['simple']) + assert isinstance(img, image.IIIFImageClient) + assert str(img) == VALID_URLS['simple'] + img = image.IIIFImageClient.init_from_url(VALID_URLS['complex']) + assert str(img) == VALID_URLS['complex'] + assert isinstance(img, image.IIIFImageClient) + img = image.IIIFImageClient.init_from_url(VALID_URLS['exact']) + assert str(img) == VALID_URLS['exact'] + assert isinstance(img, image.IIIFImageClient) assert img.size.options['exact'] is True # malformed - with pytest.raises(iiif.ParseError): - img = iiif.IIIFImageClient.init_from_url(INVALID_URLS['info']) - with pytest.raises(iiif.ParseError): - iiif.IIIFImageClient.init_from_url(INVALID_URLS['simple']) - with pytest.raises(iiif.ParseError): - iiif.IIIFImageClient.init_from_url(INVALID_URLS['complex']) - with pytest.raises(iiif.ParseError): - iiif.IIIFImageClient.init_from_url(INVALID_URLS['bad_size']) - with pytest.raises(iiif.ParseError): - iiif.IIIFImageClient.init_from_url(INVALID_URLS['bad_region']) - with pytest.raises(iiif.ParseError): - iiif.IIIFImageClient.init_from_url('http://info.json') + with pytest.raises(image.ParseError): + img = image.IIIFImageClient.init_from_url(INVALID_URLS['info']) + with pytest.raises(image.ParseError): + image.IIIFImageClient.init_from_url(INVALID_URLS['simple']) + with pytest.raises(image.ParseError): + image.IIIFImageClient.init_from_url(INVALID_URLS['complex']) + with pytest.raises(image.ParseError): + image.IIIFImageClient.init_from_url(INVALID_URLS['bad_size']) + with pytest.raises(image.ParseError): + image.IIIFImageClient.init_from_url(INVALID_URLS['bad_region']) + with pytest.raises(image.ParseError): + image.IIIFImageClient.init_from_url('http://info.json') def test_as_dicts(self): - img = iiif.IIIFImageClient.init_from_url(VALID_URLS['complex']) + img = image.IIIFImageClient.init_from_url(VALID_URLS['complex']) assert img.as_dict() == { 'region': { 'full': False, @@ -229,7 +229,7 @@ def test_as_dicts(self): 'format': 'jpg' } - @patch('piffle.iiif.requests') + @patch('piffle.image.requests') def test_image_info(self, mockrequests): # test image info logic by mocking requests mockrequests.codes.ok = requests.codes.ok @@ -238,57 +238,57 @@ def test_image_info(self, mockrequests): mockresponse.json.return_value = sample_image_info # valid response - img = iiif.IIIFImageClient.init_from_url(VALID_URLS['simple']) + img = image.IIIFImageClient.init_from_url(VALID_URLS['simple']) assert img.image_info == sample_image_info mockrequests.get.assert_called_with(img.info()) mockresponse.json.assert_called_with() # error response mockresponse.status_code = 400 - img = iiif.IIIFImageClient.init_from_url(VALID_URLS['simple']) + img = image.IIIFImageClient.init_from_url(VALID_URLS['simple']) img.image_info mockresponse.raise_for_status.assert_called_with() def test_image_width_height(self): - img = iiif.IIIFImageClient.init_from_url(VALID_URLS['simple']) + img = image.IIIFImageClient.init_from_url(VALID_URLS['simple']) - with patch.object(iiif.IIIFImageClient, 'image_info', + with patch.object(image.IIIFImageClient, 'image_info', new=sample_image_info): assert img.image_width == sample_image_info['width'] assert img.image_height == sample_image_info['height'] def test_canonicalize(self): - img = iiif.IIIFImageClient.init_from_url(VALID_URLS['simple']) + img = image.IIIFImageClient.init_from_url(VALID_URLS['simple']) square_img_info = sample_image_info.copy() square_img_info.update({'height': 100, 'width': 100}) - with patch.object(iiif.IIIFImageClient, 'image_info', + with patch.object(image.IIIFImageClient, 'image_info', new=square_img_info): # square region for square image = full img.region.parse('square') # percentage: convert to w,h (= 25,25) img.size.parse('pct:25') img.rotation.parse('90.0') - assert six.text_type(img.canonicalize()) == \ + assert str(img.canonicalize()) == \ '%s/%s/full/25,25/90/default.jpg' % (api_endpoint, image_id) class TestImageRegion: def test_defaults(self): - region = iiif.ImageRegion() - assert six.text_type(region) == 'full' - assert region.as_dict() == iiif.ImageRegion.region_defaults + region = image.ImageRegion() + assert str(region) == 'full' + assert region.as_dict() == image.ImageRegion.region_defaults def test_init(self): # full - region = iiif.ImageRegion(full=True) + region = image.ImageRegion(full=True) assert region.as_dict()['full'] is True # square - region = iiif.ImageRegion(square=True) + region = image.ImageRegion(square=True) assert region.as_dict()['square'] is True assert region.as_dict()['full'] is False # region - region = iiif.ImageRegion(x=5, y=7, width=100, height=103) + region = image.ImageRegion(x=5, y=7, width=100, height=103) assert region.as_dict()['full'] is False assert region.as_dict()['x'] == 5 assert region.as_dict()['y'] == 7 @@ -296,8 +296,8 @@ def test_init(self): assert region.as_dict()['height'] == 103 assert region.as_dict()['percent'] is False # percentage region - region = iiif.ImageRegion(x=5, y=7, width=100, height=103, - percent=True) + region = image.ImageRegion(x=5, y=7, width=100, height=103, + percent=True) assert region.as_dict()['full'] is False assert region.as_dict()['x'] == 5 assert region.as_dict()['y'] == 7 @@ -306,54 +306,54 @@ def test_init(self): assert region.as_dict()['percent'] is True # errors - with pytest.raises(iiif.IIIFImageClientException): + with pytest.raises(image.IIIFImageClientException): # invalid parameter - iiif.ImageRegion(bogus='foo') + image.ImageRegion(bogus='foo') # incomplete options - iiif.ImageRegion(x=1) - iiif.ImageRegion(x=1, y=2) - iiif.ImageRegion(x=1, y=2, w=20) - iiif.ImageRegion(percent=True) - iiif.ImageRegion().set_options(percent=True, x=1) + image.ImageRegion(x=1) + image.ImageRegion(x=1, y=2) + image.ImageRegion(x=1, y=2, w=20) + image.ImageRegion(percent=True) + image.ImageRegion().set_options(percent=True, x=1) # TODO: type checking? (not yet implemented) - with pytest.raises(iiif.ParseError): - iiif.ImageRegion().parse('1,2') + with pytest.raises(image.ParseError): + image.ImageRegion().parse('1,2') def test_render(self): - region = iiif.ImageRegion(full=True) - assert six.text_type(region) == 'full' - region = iiif.ImageRegion(square=True) - assert six.text_type(region) == 'square' - region = iiif.ImageRegion(x=5, y=5, width=100, height=100) - assert six.text_type(region) == '5,5,100,100' - region = iiif.ImageRegion(x=5, y=5, width=100, height=100, - percent=True) - assert six.text_type(region) == 'pct:5,5,100,100' - region = iiif.ImageRegion(x=5.1, y=3.14, width=100.76, height=100.89, - percent=True) - assert six.text_type(region) == 'pct:5.1,3.14,100.76,100.89' + region = image.ImageRegion(full=True) + assert str(region) == 'full' + region = image.ImageRegion(square=True) + assert str(region) == 'square' + region = image.ImageRegion(x=5, y=5, width=100, height=100) + assert str(region) == '5,5,100,100' + region = image.ImageRegion(x=5, y=5, width=100, height=100, + percent=True) + assert str(region) == 'pct:5,5,100,100' + region = image.ImageRegion(x=5.1, y=3.14, width=100.76, height=100.89, + percent=True) + assert str(region) == 'pct:5.1,3.14,100.76,100.89' def test_parse(self): - region = iiif.ImageRegion() + region = image.ImageRegion() # full region_str = 'full' region.parse(region_str) - assert six.text_type(region) == region_str # round trip + assert str(region) == region_str # round trip assert region.as_dict()['full'] is True # square region_str = 'square' region.parse(region_str) - assert six.text_type(region) == region_str # round trip + assert str(region) == region_str # round trip assert region.as_dict()['full'] is False assert region.as_dict()['square'] is True # region x, y, w, h = [5, 7, 100, 200] region_str = '%d,%d,%d,%d' % (x, y, w, h) region.parse(region_str) - assert six.text_type(region) == region_str # round trip + assert str(region) == region_str # round trip region_opts = region.as_dict() assert region_opts['full'] is False assert region_opts['square'] is False @@ -364,7 +364,7 @@ def test_parse(self): # percentage region region_str = 'pct:%d,%d,%d,%d' % (x, y, w, h) region.parse(region_str) - assert six.text_type(region) == region_str # round trip + assert str(region) == region_str # round trip region_opts = region.as_dict() assert region_opts['full'] is False assert region_opts['square'] is False @@ -375,139 +375,138 @@ def test_parse(self): assert region_opts['percent'] is True # invalid or incomplete region strings - with pytest.raises(iiif.ParseError): + with pytest.raises(image.ParseError): region.parse('pct:1,3,') - with pytest.raises(iiif.ParseError): + with pytest.raises(image.ParseError): region.parse('one,two,three,four') def test_canonicalize(self): # any canonicalization that requires image dimensions to calculate # should raise an error - region = iiif.ImageRegion() + region = image.ImageRegion() region.parse('square') - with pytest.raises(iiif.IIIFImageClientException): + with pytest.raises(image.IIIFImageClientException): region.canonicalize() - img = iiif.IIIFImageClient.init_from_url(VALID_URLS['simple']) + img = image.IIIFImageClient.init_from_url(VALID_URLS['simple']) # full to full - trivial canonicalization img.region.canonicalize() - assert six.text_type(img.region) == 'full' + assert str(img.region) == 'full' # x,y,w,h should be preserved as is dimensions = '0,0,200,250' img.region.parse(dimensions) img.region.canonicalize() # round trip, should be the same - assert six.text_type(img.region) == dimensions + assert str(img.region) == dimensions # test with square image size square_img_info = sample_image_info.copy() square_img_info.update({'height': 100, 'width': 100}) - with patch.object(iiif.IIIFImageClient, 'image_info', + with patch.object(image.IIIFImageClient, 'image_info', new=square_img_info): # square requested, image is square = full img.region.parse('square') img.region.canonicalize() - assert six.text_type(img.region) == 'full' + assert str(img.region) == 'full' # percentages img.region.parse('pct:10,1,50,75') img.region.canonicalize() - assert six.text_type(img.region) == '10,1,50,75' + assert str(img.region) == '10,1,50,75' # percentages should be converted to integers img.region.parse('pct:10,1,50.5,75.3') - assert six.text_type(img.region) == 'pct:10,1,50.5,75.3' + assert str(img.region) == 'pct:10,1,50.5,75.3' img.region.canonicalize() - assert six.text_type(img.region) == '10,1,50,75' - + assert str(img.region) == '10,1,50,75' # test with square with non-square image size tall_img_info = sample_image_info.copy() tall_img_info.update({'width': 100, 'height': 150}) - with patch.object(iiif.IIIFImageClient, 'image_info', + with patch.object(image.IIIFImageClient, 'image_info', new=tall_img_info): # square requested, should convert to x,y,w,h img.region.parse('square') img.region.canonicalize() - assert six.text_type(img.region) == '0,25,100,100' + assert str(img.region) == '0,25,100,100' wide_img_info = sample_image_info.copy() wide_img_info.update({'width': 200, 'height': 50}) - with patch.object(iiif.IIIFImageClient, 'image_info', + with patch.object(image.IIIFImageClient, 'image_info', new=wide_img_info): # square requested, should convert to x,y,w,h img.region.parse('square') img.region.canonicalize() - assert six.text_type(img.region) == '75,0,50,50' + assert str(img.region) == '75,0,50,50' class TestImageSize: def test_defaults(self): - size = iiif.ImageSize() - assert six.text_type(size) == 'full' - assert size.as_dict() == iiif.ImageSize.size_defaults + size = image.ImageSize() + assert str(size) == 'full' + assert size.as_dict() == image.ImageSize.size_defaults def test_init(self): # full - size = iiif.ImageSize(full=True) + size = image.ImageSize(full=True) assert size.as_dict()['full'] is True # max - size = iiif.ImageSize(max=True) + size = image.ImageSize(max=True) assert size.as_dict()['max'] is True assert size.as_dict()['full'] is False # percentage - size = iiif.ImageSize(percent=50) + size = image.ImageSize(percent=50) assert size.as_dict()['full'] is False assert size.as_dict()['percent'] == 50 # width only - size = iiif.ImageSize(width=100) + size = image.ImageSize(width=100) assert size.as_dict()['width'] == 100 # height only - size = iiif.ImageSize(height=200) + size = image.ImageSize(height=200) assert size.as_dict()['height'] == 200 # errors - with pytest.raises(iiif.IIIFImageClientException): + with pytest.raises(image.IIIFImageClientException): # invalid parameter - iiif.ImageSize(bogus='foo') + image.ImageSize(bogus='foo') # incomplete options ? # type checking? (not yet implemented) def test_render(self): - size = iiif.ImageSize(full=True) - assert six.text_type(size) == 'full' - size = iiif.ImageSize(max=True) - assert six.text_type(size) == 'max' - size = iiif.ImageSize(percent=50) - assert six.text_type(size) == 'pct:50' - size = iiif.ImageSize(width=100, height=105) - assert six.text_type(size) == '100,105' - size = iiif.ImageSize(width=100) - assert six.text_type(size) == '100,' - size = iiif.ImageSize(height=105) - assert six.text_type(size) == ',105' + size = image.ImageSize(full=True) + assert str(size) == 'full' + size = image.ImageSize(max=True) + assert str(size) == 'max' + size = image.ImageSize(percent=50) + assert str(size) == 'pct:50' + size = image.ImageSize(width=100, height=105) + assert str(size) == '100,105' + size = image.ImageSize(width=100) + assert str(size) == '100,' + size = image.ImageSize(height=105) + assert str(size) == ',105' def test_parse(self): - size = iiif.ImageSize() + size = image.ImageSize() # full size_str = 'full' size.parse(size_str) - assert six.text_type(size) == size_str # round trip + assert str(size) == size_str # round trip assert size.as_dict()['full'] is True # max size_str = 'max' size.parse(size_str) - assert six.text_type(size) == size_str # round trip + assert str(size) == size_str # round trip assert size.as_dict()['full'] is False assert size.as_dict()['max'] is True # width and height w, h = [100, 200] size_str = '%d,%d' % (w, h) size.parse(size_str) - assert six.text_type(size) == size_str # round trip + assert str(size) == size_str # round trip size_opts = size.as_dict() assert size_opts['full'] is False assert size_opts['max'] is False @@ -516,102 +515,109 @@ def test_parse(self): # percentage size size_str = 'pct:55' size.parse(size_str) - assert six.text_type(size) == size_str # round trip + assert str(size) == size_str # round trip size_opts = size.as_dict() assert size_opts['full'] is False assert size_opts['percent'] == 55 # invalid or incomplete size strings - with pytest.raises(iiif.ParseError): + with pytest.raises(image.ParseError): size.parse('pct:') - with pytest.raises(iiif.ParseError): + with pytest.raises(image.ParseError): size.parse('one,two') def test_canonicalize(self): # any canonicalization that requires image dimensions to calculate # should raise an error - size = iiif.ImageSize() + size = image.ImageSize() size.parse(',5') - with pytest.raises(iiif.IIIFImageClientException): + with pytest.raises(image.IIIFImageClientException): size.canonicalize() - img = iiif.IIIFImageClient.init_from_url(VALID_URLS['simple']) + img = image.IIIFImageClient.init_from_url(VALID_URLS['simple']) # full to full - trivial canonicalization img.size.canonicalize() - assert six.text_type(img.size) == 'full' + assert str(img.size) == 'full' # test sizes with square image size square_img_info = sample_image_info.copy() square_img_info.update({'height': 100, 'width': 100}) - with patch.object(iiif.IIIFImageClient, 'image_info', + with patch.object(image.IIIFImageClient, 'image_info', new=square_img_info): # requested as ,h - convert to w, img.size.parse(',50') img.size.canonicalize() - assert six.text_type(img.size) == '50,' + assert str(img.size) == '50,' # percentage: convert to w,h img.size.parse('pct:25') img.size.canonicalize() - assert six.text_type(img.size) == '25,25' + assert str(img.size) == '25,25' # exact img.size.parse('!50,50') img.size.canonicalize() - assert six.text_type(img.size) == '50,50' + assert str(img.size) == '50,50' # test sizes with rectangular image size rect_img_info = sample_image_info.copy() rect_img_info.update({'width': 50, 'height': 100}) - with patch.object(iiif.IIIFImageClient, 'image_info', + with patch.object(image.IIIFImageClient, 'image_info', new=rect_img_info): img.size.parse('!50,50') img.size.canonicalize() - assert six.text_type(img.size) == '25,50' + assert str(img.size) == '25,50' class TestImageRotation: def test_defaults(self): - rotation = iiif.ImageRotation() - assert six.text_type(rotation) == '0' - assert rotation.as_dict() == iiif.ImageRotation.rotation_defaults + rotation = image.ImageRotation() + assert str(rotation) == '0' + assert rotation.as_dict() == image.ImageRotation.rotation_defaults def test_init(self): # degrees - rotation = iiif.ImageRotation(degrees=90) + rotation = image.ImageRotation(degrees=90) assert rotation.as_dict()['degrees'] == 90 assert rotation.as_dict()['mirrored'] is False - rotation = iiif.ImageRotation(degrees=95, mirrored=True) + rotation = image.ImageRotation(degrees=95, mirrored=True) assert rotation.as_dict()['degrees'] == 95 assert rotation.as_dict()['mirrored'] is True def test_render(self): - rotation = iiif.ImageRotation() - assert six.text_type(rotation) == '0' - rotation = iiif.ImageRotation(degrees=90) - assert six.text_type(rotation) == '90' - rotation = iiif.ImageRotation(degrees=95, mirrored=True) - assert six.text_type(rotation) == '!95' + rotation = image.ImageRotation() + assert str(rotation) == '0' + rotation = image.ImageRotation(degrees=90) + assert str(rotation) == '90' + rotation = image.ImageRotation(degrees=95, mirrored=True) + assert str(rotation) == '!95' # canonicalization # - trim any trailing zeros in a decimal value - assert six.text_type(iiif.ImageRotation(degrees=93.0)) == '93' + assert str(image.ImageRotation(degrees=93.0)) == '93' # - leading zero if less than 1 - assert six.text_type(iiif.ImageRotation(degrees=0.05)) == '0.05' + assert str(image.ImageRotation(degrees=0.05)) == '0.05' # - ! if mirrored, followed by integer if possible - rotation = iiif.ImageRotation(degrees=95.00, mirrored=True) - assert six.text_type(rotation) == '!95' + rotation = image.ImageRotation(degrees=95.00, mirrored=True) + assert str(rotation) == '!95' # explicitly test canonicalize method, even though it does nothing rotation.canonicalize() - assert six.text_type(rotation) == '!95' + assert str(rotation) == '!95' def test_parse(self): - rotation = iiif.ImageRotation() + rotation = image.ImageRotation() rotation_str = '180' rotation.parse(rotation_str) - assert six.text_type(rotation) == rotation_str # round trip + assert str(rotation) == rotation_str # round trip rotation_str = '!90' rotation.parse(rotation_str) - assert six.text_type(rotation) == rotation_str # round trip + assert str(rotation) == rotation_str # round trip assert rotation.as_dict()['mirrored'] is True + + +@pytest.mark.skip +def test_deprecated(): + with pytest.raises(DeprecationWarning) as warn: + from piffle import iiif + assert 'piffle.iiif is deprecated' in str(warn) diff --git a/tests/test_presentation.py b/tests/test_presentation.py new file mode 100644 index 0000000..c71f1b5 --- /dev/null +++ b/tests/test_presentation.py @@ -0,0 +1,122 @@ +import json +import os.path +from unittest.mock import patch + +import pytest +import requests + +from piffle.presentation import IIIFPresentation, IIIFException + + +FIXTURE_DIR = os.path.join(os.path.dirname(__file__), 'fixtures') + + +class TestIIIFPresentation: + test_manifest = os.path.join(FIXTURE_DIR, 'chto-manifest.json') + + def test_from_file(self): + pres = IIIFPresentation.from_file(self.test_manifest) + assert isinstance(pres, IIIFPresentation) + assert pres.type == 'sc:Manifest' + + def test_from_url(self): + manifest_url = 'http://ma.ni/fe.st' + with open(self.test_manifest) as manifest: + data = json.loads(manifest.read()) + with patch('piffle.presentation.requests') as mockrequests: + mockrequests.codes = requests.codes + mockresponse = mockrequests.get.return_value + mockresponse.status_code = requests.codes.ok + mockresponse.json.return_value = data + pres = IIIFPresentation.from_url(manifest_url) + assert pres.type == 'sc:Manifest' + mockrequests.get.assert_called_with(manifest_url) + mockrequests.get.return_value.json.assert_called_with() + + # error handling + # bad status code response on the url + with pytest.raises(IIIFException) as excinfo: + mockresponse.status_code = requests.codes.forbidden + mockresponse.reason = 'Forbidden' + IIIFPresentation.from_url(manifest_url) + assert 'Error retrieving manifest' in str(excinfo.value) + assert '403 Forbidden' in str(excinfo.value) + + # valid http response but not a json response + with pytest.raises(IIIFException) as excinfo: + mockresponse.status_code = requests.codes.ok + # content type header does not indicate json + mockresponse.headers = {'content-type': 'text/html'} + mockresponse.json.side_effect = \ + json.decoder.JSONDecodeError('err', 'doc', 1) + IIIFPresentation.from_url(manifest_url) + assert 'No JSON found' in str(excinfo.value) + + # json parsing error + with pytest.raises(IIIFException) as excinfo: + # content type header indicates json, but parsing failed + mockresponse.headers = {'content-type': 'application/json'} + mockresponse.json.side_effect = \ + json.decoder.JSONDecodeError('err', 'doc', 1) + IIIFPresentation.from_url(manifest_url) + assert 'Error parsing JSON' in str(excinfo.value) + + def test_from_url_or_file(self): + with patch.object(IIIFPresentation, 'from_url') as mock_from_url: + # local fixture file + pres = IIIFPresentation.from_file_or_url(self.test_manifest) + assert pres.type == 'sc:Manifest' + mock_from_url.assert_not_called() + + pres = IIIFPresentation.from_file_or_url('http://mani.fe/st') + mock_from_url.assert_called_with('http://mani.fe/st') + + # nonexistent file path + with pytest.raises(IIIFException) as excinfo: + IIIFPresentation.from_file_or_url('/manifest/not/found') + assert 'File not found: ' in str(excinfo.value) + + def test_short_id(self): + manifest_uri = 'https://ii.if/resources/p0c484h74c/manifest' + assert IIIFPresentation.short_id(manifest_uri) == 'p0c484h74c' + canvas_uri = 'https://ii.if/resources/p0c484h74c/manifest/canvas/ps7527b878' + assert IIIFPresentation.short_id(canvas_uri) == 'ps7527b878' + + def test_toplevel_attrs(self): + pres = IIIFPresentation.from_file(self.test_manifest) + assert pres.context == "http://iiif.io/api/presentation/2/context.json" + assert pres.id == "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest" + assert pres.type == "sc:Manifest" + assert pres.label[0] == "Chto my stroim : Tetrad\u02b9 s kartinkami" + assert pres.viewingHint == "paged" + assert pres.viewingDirection == "left-to-right" + + def test_nested_attrs(self): + pres = IIIFPresentation.from_file(self.test_manifest) + assert isinstance(pres.sequences, tuple) + assert pres.sequences[0].id == \ + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/sequence/normal" + assert pres.sequences[0].type == "sc:Sequence" + assert isinstance(pres.sequences[0].canvases, tuple) + assert pres.sequences[0].canvases[0].id == \ + "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p02871v98d" + + def test_set(self): + pres = IIIFPresentation.from_file(self.test_manifest) + pres.label = 'New title' + pres.type = 'sc:Collection' + assert pres.label == 'New title' + assert pres.type == 'sc:Collection' + + def test_del(self): + pres = IIIFPresentation.from_file(self.test_manifest) + del pres.label + del pres.type + assert not hasattr(pres, 'label') + assert not hasattr(pres, 'type') + + def test_first_label(self): + pres = IIIFPresentation.from_file(self.test_manifest) + assert pres.first_label == pres.label[0] + pres.label = 'unlisted single title' + assert pres.first_label == pres.label