From 7694644b00860935b8943f4494415f4c222a57a5 Mon Sep 17 00:00:00 2001 From: Leonard Richardson Date: Mon, 28 Sep 2020 09:58:49 -0400 Subject: [PATCH 01/11] Update README.md --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f5360c..9dcfcc7 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# python-opds-parser \ No newline at end of file +# python-webpub-manifest-parser + +A parser for the Readium Web Publication Manifest and OPDS 2.0 formats. From 79e3a971bb2ccb05769f9893e4da75b4d83de86d Mon Sep 17 00:00:00 2001 From: Viacheslav Bessonov Date: Mon, 19 Oct 2020 23:50:46 +0500 Subject: [PATCH 02/11] Initial commit --- .bumpversion.cfg | 7 + .gitignore | 66 ++ .python-version | 4 + .travis.yml | 35 + Makefile | 50 ++ README.md | 64 +- manifest.py | 510 ----------- poetry.lock | 640 ++++++++++++++ pylintrc | 9 + pyproject.toml | 41 + src/webpub_manifest_parser/__init__.py | 1 + .../webpub_manifest_parser/core/__init__.py | 0 src/webpub_manifest_parser/core/ast.py | 831 ++++++++++++++++++ src/webpub_manifest_parser/core/errors.py | 9 + src/webpub_manifest_parser/core/parsers.py | 751 ++++++++++++++++ src/webpub_manifest_parser/core/properties.py | 581 ++++++++++++ src/webpub_manifest_parser/core/registry.py | 158 ++++ src/webpub_manifest_parser/core/semantic.py | 201 +++++ src/webpub_manifest_parser/core/syntax.py | 268 ++++++ src/webpub_manifest_parser/epub/__init__.py | 0 src/webpub_manifest_parser/epub/ast.py | 51 ++ src/webpub_manifest_parser/errors.py | 48 + src/webpub_manifest_parser/opds2/__init__.py | 0 src/webpub_manifest_parser/opds2/ast.py | 480 ++++++++++ src/webpub_manifest_parser/opds2/parsers.py | 29 + src/webpub_manifest_parser/opds2/registry.py | 70 ++ src/webpub_manifest_parser/opds2/semantic.py | 226 +++++ src/webpub_manifest_parser/opds2/syntax.py | 14 + src/webpub_manifest_parser/rwpm/__init__.py | 0 src/webpub_manifest_parser/rwpm/ast.py | 61 ++ src/webpub_manifest_parser/rwpm/parser.py | 29 + src/webpub_manifest_parser/rwpm/registry.py | 90 ++ src/webpub_manifest_parser/rwpm/semantic.py | 117 +++ src/webpub_manifest_parser/rwpm/syntax.py | 14 + src/webpub_manifest_parser/utils.py | 78 ++ tests/__init__.py | 9 - tests/files/opds2/feed.json | 120 +++ tests/test_manifest.py | 16 - tests/webpub_manifest_parser/__init__.py | 0 tests/webpub_manifest_parser/core/__init__.py | 0 .../core/test_parsers.py | 105 +++ .../core/test_properties.py | 26 + .../core/test_semantic.py | 82 ++ .../webpub_manifest_parser/opds2/__init__.py | 30 + .../opds2/test_parser.py | 176 ++++ .../opds2/test_semantic.py | 261 ++++++ tests/webpub_manifest_parser/rwpm/__init__.py | 0 .../rwpm/test_parser.py | 130 +++ .../rwpm/test_semantic.py | 126 +++ .../rwpm/test_syntax.py | 491 +++++++++++ tests/webpub_manifest_parser/test_registry.py | 72 ++ tests/webpub_manifest_parser/test_utils.py | 44 + tox.ini | 128 +++ 53 files changed, 6813 insertions(+), 536 deletions(-) create mode 100644 .bumpversion.cfg create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 .travis.yml create mode 100644 Makefile delete mode 100644 manifest.py create mode 100644 poetry.lock create mode 100644 pylintrc create mode 100644 pyproject.toml create mode 100644 src/webpub_manifest_parser/__init__.py rename __init__.py => src/webpub_manifest_parser/core/__init__.py (100%) create mode 100644 src/webpub_manifest_parser/core/ast.py create mode 100644 src/webpub_manifest_parser/core/errors.py create mode 100644 src/webpub_manifest_parser/core/parsers.py create mode 100644 src/webpub_manifest_parser/core/properties.py create mode 100644 src/webpub_manifest_parser/core/registry.py create mode 100644 src/webpub_manifest_parser/core/semantic.py create mode 100644 src/webpub_manifest_parser/core/syntax.py create mode 100644 src/webpub_manifest_parser/epub/__init__.py create mode 100644 src/webpub_manifest_parser/epub/ast.py create mode 100644 src/webpub_manifest_parser/errors.py create mode 100644 src/webpub_manifest_parser/opds2/__init__.py create mode 100644 src/webpub_manifest_parser/opds2/ast.py create mode 100644 src/webpub_manifest_parser/opds2/parsers.py create mode 100644 src/webpub_manifest_parser/opds2/registry.py create mode 100644 src/webpub_manifest_parser/opds2/semantic.py create mode 100644 src/webpub_manifest_parser/opds2/syntax.py create mode 100644 src/webpub_manifest_parser/rwpm/__init__.py create mode 100644 src/webpub_manifest_parser/rwpm/ast.py create mode 100644 src/webpub_manifest_parser/rwpm/parser.py create mode 100644 src/webpub_manifest_parser/rwpm/registry.py create mode 100644 src/webpub_manifest_parser/rwpm/semantic.py create mode 100644 src/webpub_manifest_parser/rwpm/syntax.py create mode 100644 src/webpub_manifest_parser/utils.py create mode 100644 tests/files/opds2/feed.json delete mode 100644 tests/test_manifest.py create mode 100644 tests/webpub_manifest_parser/__init__.py create mode 100644 tests/webpub_manifest_parser/core/__init__.py create mode 100644 tests/webpub_manifest_parser/core/test_parsers.py create mode 100644 tests/webpub_manifest_parser/core/test_properties.py create mode 100644 tests/webpub_manifest_parser/core/test_semantic.py create mode 100644 tests/webpub_manifest_parser/opds2/__init__.py create mode 100644 tests/webpub_manifest_parser/opds2/test_parser.py create mode 100644 tests/webpub_manifest_parser/opds2/test_semantic.py create mode 100644 tests/webpub_manifest_parser/rwpm/__init__.py create mode 100644 tests/webpub_manifest_parser/rwpm/test_parser.py create mode 100644 tests/webpub_manifest_parser/rwpm/test_semantic.py create mode 100644 tests/webpub_manifest_parser/rwpm/test_syntax.py create mode 100644 tests/webpub_manifest_parser/test_registry.py create mode 100644 tests/webpub_manifest_parser/test_utils.py create mode 100644 tox.ini diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 0000000..643ed28 --- /dev/null +++ b/.bumpversion.cfg @@ -0,0 +1,7 @@ +[bumpversion] +current_version = 0.0.1 +commit = True +tag = True + +[bumpversion:file:pyproject.toml] +[bumpversion:file:src/webpub_manifest_parser/__init__.py] \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aed570a --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +# Editors +.vscode/ +.idea/ + +# Mac/OSX +.DS_Store + +# Source for the following rules: https://raw.githubusercontent.com/github/gitignore/master/Python.gitignore +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..3f62aed --- /dev/null +++ b/.python-version @@ -0,0 +1,4 @@ +2.7.18 +3.6.9 +3.7.8 +3.8.5 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..85d9553 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,35 @@ +dist: xenial + +language: python + +#branches: +# only: +# - master + +matrix: + include: + - stage: Lint (Python 3.6) + python: 3.6 + env: TOXENV=isort,black,flake8,pylint + + - stage: Tests (Python 2.7) + python: 2.7 + env: TOXENV=py27 + + - stage: Tests (Python 3.6) + python: 3.6 + env: TOXENV=py36 + + - stage: Tests (Python 3.7) + python: 3.7 + env: TOXENV=py37 + + - stage: Tests (Python 3.8) + python: 3.8 + env: TOXENV=py38 + +install: + - pip install tox + +script: + - tox \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ec40a78 --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +.PHONY: init install build publish clean reformat lint test-py27 test-py36 test-py37 test-py38 test +.DEFAULT_GOAL := all + +all: install lint build + +POETRY_VERSION = 1.0.10 + +PROJECT = python-webpub-manifest-parser +PACKAGE = webpub_manifest_parser + +init: + python -m pip install pip + pip uninstall -y enum34 # This line is required for Python versions greater than 2.7 to work correctly + python -m pip install poetry==${POETRY_VERSION} + poetry config virtualenvs.create false + +install: + poetry install -vvv + +build: + poetry build + +publish: + make prepare + make config + poetry publish --build -r pypi + +clean: + rm -rf build dist + +reformat: + tox -e isort-reformat,black-reformat + +lint: + tox -e isort,black,flake8,pylint + +test-py27: + tox -e py27 + +test-py36: + tox -e py36 + +test-py37: + tox -e py37 + +test-py38: + tox -e py38 + +test: + tox -e py27,py36,py37,py38 \ No newline at end of file diff --git a/README.md b/README.md index 9dcfcc7..2c8b7e4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,65 @@ # python-webpub-manifest-parser -A parser for the Readium Web Publication Manifest and OPDS 2.0 formats. +[![Build Status](https://travis-ci.com/vbessonov/python-webpub-manifest-parser.svg?branch=master)](https://travis-ci.com/vbessonov/python-webpub-manifest-parser) + +A parser for the [Readium Web Publication Manifest (RWPM)](https://github.com/readium/webpub-manifest) and [Open Publication Distribution System 2.0 (OPDS 2.0)](https://drafts.opds.io/opds-2.0) formats. + +## Usage +1. Install [pyenv](https://github.com/pyenv/pyenv#installation) + +3. Install one of the supported Python versions mentioned in [.python-version](.python-version) or other PATCH versions of the same MINOR versions: +```bash +pyenv install +``` + +4. Install [pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv#installation) plugin + +5. Create a virtual environment: +```bash +pyenv virtualenv +pyenv activate +``` + +6. Install the library +```bash +pip install webpub-manifest-parser +``` + + +# Setting up a development environment + +## Running tests using tox +1. Make sure that a virtual environment is not activated and deactivate it if needed: +```bash +deactivate +``` + +2. Install `tox` and `tox-pyenv` globally: +```bash +pip install tox tox-pyenv +``` + +3. Make your code prettier using isort and black: +```bash +make reformat +``` + +4. Run the linters: +```bash +make lint +``` + +5. To run the unit tests use the following command: +```bash +make test- +``` +where `` is one of supported python versions: +- py27 +- py36 +- py37 +- py38 + +For example, to run the unit test using Python 2.7 run the following command: +```bash +make test-py27 +``` \ No newline at end of file diff --git a/manifest.py b/manifest.py deleted file mode 100644 index be44f1a..0000000 --- a/manifest.py +++ /dev/null @@ -1,510 +0,0 @@ -class Role(object): - - def __init__(self, name, compact=True, type_required=False): - """Constructor. - """ - self.name = name - self.compact = compact - self.type_required = type_required - - def validation_errors(self, manifest, collection): - """Enforce validation rules on a subcollection presumed to have this - role. - - :param manifest: A Manifestlike - :param collection: A Collection - :return: A 2-typle ([validation errors], [warnings]) - """ - errors = [] - if self.compact and not collection.is_compact_collection: - errors.append( - "Collection with role %s must be compact." % self.name - ) - - if self.type_required: - # "All resources listed in readingOrder and resources must - # indicate their media type using type." - # - https://github.com/readium/webpub-manifest#21-sub-collections - for link in collection.links: - if not link.type: - errors.append( - 'Link to "%s" is missing required type' % link.href - ) - return errors, [] - -# Create Role objects for each role mentioned in the RWPM Collection -# Roles Registry: -# https://github.com/readium/webpub-manifest/blob/master/roles.md - -# RWPM Core -Role.readingOrder = Role("readingOrder", compact=True, type_required=True) -Role.resources = Role("resources", compact=True, type_required=True) -Role.toc = Role("toc", compact=True) - -# RWPM Extensions -for role in ("guided", "landmarks", "loa", "loi", "lot", "lov", "pageList"): - setattr(Role, role, Role(role)) - -# OPDS 2.0 -for role in ("navigation", "images"): - setattr(Role, role, Role(role)) - -for role in ("publications", "facets", "groups"): - setattr(Role, role, Role(role, compact=False)) - -# End Role code. - -class MediaTypes(object): - """Constants for media types mentioned in the RWPM or OPDS 2.0 specs.""" - - # https://github.com/readium/webpub-manifest#4-media-type - MANIFEST = "application/webpub+json" - - # https://github.com/readium/webpub-manifest#7-cover - JPEG = "image/jpeg" - PNG = "image/png" - GIF = "image/gif" - WEBP = "image/webp" - SVG = "image/svg" - - # https://drafts.opds.io/opds-2.0.html#overview - OPDS_FEED = "application/opds+json" - - # https://drafts.opds.io/opds-2.0.html#41-opds-publication - OPDS_PUBLICATION = "application/opds-publication+json" - - # https://github.com/readium/webpub-manifest#9-package - WEB_PUBLICATION_PACKAGE = "application/webpub+zip" - - -class LinkRelations(object): - """Constants for link relations mentioned in the RWPM Link Relations - Registry. - - https://github.com/readium/webpub-manifest/blob/master/relationships.md - """ - - ALTERNATE = "alternate" - CONTENTS = "contents" - COVER = "cover" - MANIFEST = "manifest" - SEARCH = "search" - SELF = "self" - - ACQUISITION = "http://opds-spec.org/acquisition" - OPEN_ACCESS = "http://opds-spec.org/acquisition/open-access" - BORROW = "http://opds-spec.org/acquisition/borrow" - BUY = "http://opds-spec.org/acquisition/buy" - SAMPLE = "http://opds-spec.org/acquisition/sample" - PREVIEW = "preview" - SUBSCRIBE = "http://opds-spec.org/acquisition/subscribe" - - -class JSONable(object): - - """An object whose Unicode representation is a JSON dump - of a dictionary. - """ - - def __unicode__(self): - return json.dumps(self.as_dict) - - # For Python 2 compatibility - __str__ = __unicode__ - - @property - def as_dict(self): - raise NotImplementedError() - - @classmethod - def json_ready(cls, value): - if isinstance(value, JSONable): - return value.as_dict - elif isinstance(value, list): - return [cls.json_ready(x) for x in value] - else: - return value - - -class Collection(JSONable): - """ - - "A collection is defined as a grouping of metadata, links and - sub-collections." - RWPM - - """ - - REQUIRED_SUBCOLLECTIONS = [] - - def __init__(self, metadata=None, links=None, - subcollections=None, role=None, **extra): - """Constructor. - - :param metadata: A Metadata object. - :param links: A list of Link objects. - :param subcollections: A list of Collection objects. - :param role: The role of this Collection with respect to its parent, - if any. Either a Role object or a string. - :param extra: Any extra information that should be preserved - as-is in the JSON representation of this collection. - """ - - if metadata is None: - metadata = Metadata() - if links is None: - links = [] - if subcollections is None: - subcollections = dict() - self.metadata = metadata - self.links = links - self.subcollections = {} - for s in subcollections: - self.add_subcollection(s) - if not isinstance(role, Role): - role = Role(role, compact=False) - self.role = role - self.extra = extra - - def add_subcollection(self, subcollection): - """Add a subcollection.""" - if not subcollection.role: - raise ValueError("Subcollection must have a role.") - self.subcollections[subcollection.role] = subcollection - - @classmethod - def from_json(cls, data, role=None): - if not isinstance(data, dict): - data = json.loads(data) - if not isinstance(data, dict): - raise ValueError("Input must be a JSON object.") - - metadata = Metadata(data.get('metadata', {})) - - links = data.get('links', []) - links = [Link(**link_data) for link_data in links] - - # Everything other than metadata and links is either a - # subcollection, or extra information that should be left - # alone. - subcollections = [] - extra = {} - for role, collection_data in data.items(): - if role in ('metadata', 'links'): - continue - if isinstance(collection_data, dict): - collection = cls.from_json(collection_data, role=Role(role)) - subcollections.append(collection) - else: - extra[role] = collection_data - - return cls( - metadata=metadata, links=links, subcollections=subcollections, - role=role, extra=extra - ) - - # These properties test the terminology defined in - # https://github.com/readium/webpub-manifest#12-terminology - @property - def is_full_collection(self): - """Is this a full collection? - - "A collection that contains at least two or more data items - among metadata, links and sub-collections." - """ - count = 0 - if len(self.metadata): - count +=1 - if len(self.links) > 0: - count += 1 - if len(self.subcollections) > 0: - count += 1 - return count >= 2 - - @property - def is_compact_collection(self): - """Is this a compact collection? - - "A collection that contains one or more links, but doesn't - contain any metadata or sub-collections." - """ - return ( - len(self.links) >= 1 and not self.keys() - and not self.subcollections - ) - - @property - def is_manifest(self): - """Is this a manifest? - - "A manifest is a full collection that represents structured - information about a publication." - """ - # See the Manifest class. - return False - - # End properties for checking terminology. - - def by_role(self, role): - """Find the subcollection, if any, with the given role. - - :param role: A Role or string. - :return: A Collection, or None if there is no subcollection - with this role. - """ - if isinstance(role, Role): - role = role.name - return self.subcollections.get(role) - - def validation_errors(self, manifest): - """Validate this Collection and everything inside. - - :param manifest: A Manifestlike object setting ground rules for - validation. - :return: A 2-typle ([validation errors], [warnings]) - """ - errors = [] - warnings = [] - - # Subcollections with different roles are subject to different - # validation rules. - if self.role: - errors.extend(self.role.validation_errors(manifest, self)) - - for required_role in self.REQUIRED_SUBCOLLECTIONS: - if not self.by_role(required_role): - errors.append( - "No subcollection with required role %s" % self.role.namef - ) - - # Validate the components of this collection: its - # sub-Collections (recursively), each of its Links, and its - # Metadata. - for needs_validation in ( - self.subcollections.values(), - self.links, - [self.metadata] - ): - for item in needs_validation: - new_errors, new_warnings = item.validation_errors(self) - errors.extend(new_errors) - warnings.extend(new_warnings) - return errors, warnings - - @property - def as_dict(self): - """Convert this collection and its contents to a Python dictionary. - - :return: a dict - """ - d = dict( - metadata=self.metadata.as_dict - links=[x.as_dict for x in self.links] - ) - for collection in subcollections: - d[collection.role] = collection.as_dict - d.update(self.extra) - return d - - -class Metadata(dict, JSONable): - """Metadata associated with a Collection. - - :TODO: Currently any special JSON-LD features will be ignored; data is - added to the dictionary without any normalization. - """ - - def validation_errors(self, manifest): - """Validate the metadata. - - :param manifest: A Manifestlike object setting ground rules for - validation. - :return: A 2-typle ([validation errors], [warnings]) - """ - errors = [] - warnings = [] - - for field in manifest.required_fields: - if field not in self: - errors.append( - "Required metadata '{}' is missing.".format(field) - ) - - for field in manifestlike.recommended_fields: - if field not in self: - warnings.append( - "Recommended metadata '{}' is missing.".format(field) - ) - - return errors, warnings - - @property - def as_dict(self): - return self - - -class Link(JSONable): - """A link to another resource.""" - def __init__( - self, href, templated=False, type=None, title=None, rel=None, - properties=None, height=None, width=None, duration=None, - bitrate=None, language=None, alternate=None, children=None, - **kwargs - ): - """Constructor. - - :param href: URI or URI template of the linked resource - :param templated: Indicates that `href` is a URI template - :param type: Media type of the linked resource - :param title: Title of the linked resource - :param rel: Relation between the resource and its containing collection - :param properties: Properties associated to the linked resource - :param height: Height of the linked resource in pixels - :param width: Width of the linked resource in pixels - :param duration: Duration of the linked resource in seconds - :param bitrate: Bit rate of the linked resource in kilobits per second - :param language: Expected language of the linked resource - :param alternate: Alternate resources for the linked resource - :param children: One or more Link objects representing resources that are children of the linked resource, in the context of a given collection role. - :param kwargs: Any properties introduced by extensions. These will - be presented as-is. - - """ - self.href = href - self.templated = templated - self.type = type - self.rel = rel - self.properties = properties - self.height = height - self.width = width - self.duration = duration - self.bitrate = bitrate - self.language = self._guarantee_list(language) - self.alternate = self._guarantee_list(alternate) - self.children = self._guarantee_list(children) - self._extra = kwargs - - def _guarantee_list(self, x): - """If `x` is not a list, return a one-item list containing it.""" - if not isinstance(x, list): - return [x] - return x - - def validation_errors(self, manifest): - """Validate the data in this Link object. - - :param manifest: A Manifestlike object setting ground rules for - validation. - :return: A 2-typle ([validation errors], [warnings]) - """ - errors = [] - - # https://github.com/readium/webpub-manifest#24-the-link-object - if not self.href: - errors.append( - 'Link missing required field "href": %r' % self.as_dict - ) - return errors - - @property - def as_dict(self): - d = dict( - href=self.href, - rel=self.rel, - properties=self.properties - height=self.height, - width=self.width, - duration=self.duration, - bitrate=self.bitrate, - language=self._guarantee_list(self.language) - alternate=[ - x.as_dict for x in self._guarantee_list(self.alternate) - ] - children=self._format_list(self.children), - ) - d.update(self._extra) - - if self.templated: - d['templated'] = self.templated - - # Compress the dictionary by deleting keys with no value. - for k, v in list(dict.items()): - if v in (None, []): - del d[k] - return d - - -class Manifestlike(Collection): - """Base class for Manifest (Readium Web Publication Manifest) - and Feed (OPDS 2) - - An OPDS 2 feed is defined as a RWPM with enumerated exceptions. - - This class implements the behavior common to both specs. The - alternative is to have the Feed class subclass Manifest and then - implement a lot of exceptions. - """ - - # All subclasses of this class MUST set a value for this constant. - DEFAULT_CONTEXT = None - - # These are optional. - REQUIRED_METADATA_FIELDS = [] - RECOMMENDED_METADATA_FIELDS = [] - - def __init__(self, *args, **kwargs)): - self.context = kwargs.pop('context', self.DEFAULT_CONTEXT) - super(Manifestlike, self).__init__(*args, **kwargs) - - @property - def as_dict(self): - d = super(Manifestlike, self).as_dict - d['@context'] = self.context - return d - - def validation_errors(self): - errors, warnings = super(Manifestlike, self).validation_errors(self) - for relation in self.REQUIRED_LINKS: - if not self.for_rel(relation): - errors.append( - 'Required link with rel="{}" is missing.'.format(relation) - ) - for role in self.REQUIRED_ROLES: - if not self.by_role(role): - errors.append( - "Required subcollection '{}' is missing.".format( - role.name - ) - ) - return errors, warnings - - -class Manifest(Manifestlike): - """A Readium Web Publication Manifest.""" - - # https://github.com/readium/webpub-manifest#22-metadata - DEFAULT_CONTEXT = "http://readium.org/webpub/default.jsonld" - - # "The Readium Web Publication Manifest has a single - # requirement in terms of metadata: all publications must - # include a title." - - # https://github.com/readium/webpub-manifest#22-metadata - REQUIRED_METADATA_FIELDS = ["title"] - - # "In addition all publications should include a @type key to - # describe the nature of the publication." - RECOMMENDED_METADATA_FIELDS = ["@type"] - - # "A manifest must contain at least one link using the self - # relationship where href is an absolute URI to the canonical - # location of the manifest." - # - https://github.com/readium/webpub-manifest#23-links - REQUIRED_LINKS = [LinkRelations.SELF] - - # "A manifest must contain a readingOrder sub-collection." - # https://github.com/readium/webpub-manifest#21-sub-collections - REQUIRED_SUBCOLLECTIONS = [Roles.readingOrder] - - @property - def is_manifest(self): - """Override of Collection.is_manifest.""" - return True diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..ccb6d7b --- /dev/null +++ b/poetry.lock @@ -0,0 +1,640 @@ +[[package]] +category = "dev" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +name = "appdirs" +optional = false +python-versions = "*" +version = "1.4.4" + +[[package]] +category = "main" +description = "Classes Without Boilerplate" +name = "attrs" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "20.2.0" + +[package.extras] +dev = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "sphinx-rtd-theme", "pre-commit"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] +tests_no_zope = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] + +[[package]] +category = "dev" +description = "Cross-platform colored terminal text." +marker = "platform_system == \"Windows\"" +name = "colorama" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "0.4.3" + +[[package]] +category = "main" +description = "Updated configparser from Python 3.7 for Python 2.6+." +marker = "python_version < \"3\"" +name = "configparser" +optional = false +python-versions = ">=2.6" +version = "4.0.2" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=3.5,<3.7.3 || >3.7.3)", "pytest-checkdocs (>=1.2)", "pytest-flake8", "pytest-black-multipy"] + +[[package]] +category = "main" +description = "Backports and enhancements for the contextlib module" +marker = "python_version < \"3.4\"" +name = "contextlib2" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.6.0.post1" + +[[package]] +category = "dev" +description = "Distribution utilities" +name = "distlib" +optional = false +python-versions = "*" +version = "0.3.1" + +[[package]] +category = "main" +description = "Python 3.4 Enum backported to 3.3, 3.2, 3.1, 2.7, 2.6, 2.5, and 2.4" +name = "enum34" +optional = false +python-versions = "*" +version = "1.1.10" + +[[package]] +category = "dev" +description = "A platform independent file lock." +name = "filelock" +optional = false +python-versions = "*" +version = "3.0.12" + +[[package]] +category = "dev" +description = "Python function signatures from PEP362 for Python 2.6, 2.7 and 3.2+" +marker = "python_version < \"3.3\"" +name = "funcsigs" +optional = false +python-versions = "*" +version = "1.0.2" + +[[package]] +category = "main" +description = "Backport of the functools module from Python 3.2.3 for use on 2.7 and PyPy." +marker = "python_version < \"3\"" +name = "functools32" +optional = false +python-versions = "*" +version = "3.2.3-2" + +[[package]] +category = "main" +description = "Read metadata from Python packages" +marker = "python_version < \"3.8\"" +name = "importlib-metadata" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +version = "1.7.0" + +[package.dependencies] +zipp = ">=0.5" + +[package.dependencies.configparser] +python = "<3" +version = ">=3.5" + +[package.dependencies.contextlib2] +python = "<3" +version = "*" + +[package.dependencies.pathlib2] +python = "<3" +version = "*" + +[package.extras] +docs = ["sphinx", "rst.linker"] +testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] + +[[package]] +category = "dev" +description = "Read resources from Python packages" +marker = "python_version < \"3.7\"" +name = "importlib-resources" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +version = "3.0.0" + +[package.dependencies] +[package.dependencies.contextlib2] +python = "<3" +version = "*" + +[package.dependencies.pathlib2] +python = "<3" +version = "*" + +[package.dependencies.singledispatch] +python = "<3.4" +version = "*" + +[package.dependencies.typing] +python = "<3.5" +version = "*" + +[package.dependencies.zipp] +python = "<3.8" +version = ">=0.4" + +[package.extras] +docs = ["sphinx", "rst.linker", "jaraco.packaging"] + +[[package]] +category = "main" +description = "An implementation of JSON Schema validation for Python" +name = "jsonschema" +optional = false +python-versions = "*" +version = "3.2.0" + +[package.dependencies] +attrs = ">=17.4.0" +pyrsistent = ">=0.14.0" +setuptools = "*" +six = ">=1.11.0" + +[package.dependencies.functools32] +python = "<3" +version = "*" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = "*" + +[package.extras] +format = ["idna", "jsonpointer (>1.13)", "rfc3987", "strict-rfc3339", "webcolors"] +format_nongpl = ["idna", "jsonpointer (>1.13)", "webcolors", "rfc3986-validator (>0.1.0)", "rfc3339-validator"] + +[[package]] +category = "dev" +description = "Rolling backport of unittest.mock for all Pythons" +name = "mock" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "3.0.5" + +[package.dependencies] +six = "*" + +[package.dependencies.funcsigs] +python = "<3.3" +version = ">=1" + +[package.extras] +build = ["twine", "wheel", "blurb"] +docs = ["sphinx"] +test = ["pytest", "pytest-cov"] + +[[package]] +category = "main" +description = "Multiple dispatch" +name = "multipledispatch" +optional = false +python-versions = "*" +version = "0.6.0" + +[package.dependencies] +six = "*" + +[[package]] +category = "dev" +description = "nose extends unittest to make testing easier" +name = "nose" +optional = false +python-versions = "*" +version = "1.3.7" + +[[package]] +category = "dev" +description = "A timer plugin for nosetests" +name = "nose-timer" +optional = false +python-versions = "*" +version = "1.0.0" + +[package.dependencies] +nose = "*" + +[[package]] +category = "dev" +description = "Core utilities for Python packages" +name = "packaging" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "20.4" + +[package.dependencies] +pyparsing = ">=2.0.2" +six = "*" + +[[package]] +category = "dev" +description = "Parameterized testing with any Python test framework" +name = "parameterized" +optional = false +python-versions = "*" +version = "0.7.4" + +[package.extras] +dev = ["jinja2"] + +[[package]] +category = "main" +description = "Object-oriented filesystem paths" +marker = "python_version < \"3.4\" and sys_platform != \"win32\" or python_version < \"3\"" +name = "pathlib2" +optional = false +python-versions = "*" +version = "2.3.5" + +[package.dependencies] +six = "*" + +[package.dependencies.scandir] +python = "<3.5" +version = "*" + +[[package]] +category = "dev" +description = "plugin and hook calling mechanisms for python" +name = "pluggy" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.13.1" + +[package.dependencies] +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12" + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +category = "dev" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +name = "py" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.9.0" + +[[package]] +category = "dev" +description = "pyfakefs implements a fake file system that mocks the Python file system modules." +name = "pyfakefs" +optional = false +python-versions = "*" +version = "3.7" + +[[package]] +category = "dev" +description = "Python parsing module" +name = "pyparsing" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +version = "2.4.7" + +[[package]] +category = "main" +description = "Persistent/Functional/Immutable data structures" +name = "pyrsistent" +optional = false +python-versions = ">=2.7" +version = "0.16.1" + +[package.dependencies] +six = "*" + +[[package]] +category = "main" +description = "Parsing and validation of URIs (RFC 3986) and IRIs (RFC 3987)" +name = "rfc3987" +optional = false +python-versions = "*" +version = "1.3.8" + +[[package]] +category = "main" +description = "scandir, a better directory iterator and faster os.walk()" +marker = "python_version < \"3\" and sys_platform != \"win32\" or python_version < \"3\" or python_version < \"3.4\" and sys_platform != \"win32\" and (python_version < \"3.4\" and sys_platform != \"win32\" or python_version < \"3\")" +name = "scandir" +optional = false +python-versions = "*" +version = "1.10.0" + +[[package]] +category = "dev" +description = "This library brings functools.singledispatch from Python 3.4 to Python 2.6-3.3." +marker = "python_version < \"3.4\"" +name = "singledispatch" +optional = false +python-versions = "*" +version = "3.4.0.3" + +[package.dependencies] +six = "*" + +[[package]] +category = "main" +description = "Python 2 and 3 compatibility utilities" +name = "six" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +version = "1.15.0" + +[[package]] +category = "main" +description = "Strict, simple, lightweight RFC3339 functions" +name = "strict-rfc3339" +optional = false +python-versions = "*" +version = "0.7" + +[[package]] +category = "dev" +description = "Python Library for Tom's Obvious, Minimal Language" +name = "toml" +optional = false +python-versions = "*" +version = "0.10.1" + +[[package]] +category = "dev" +description = "tox is a generic virtualenv management and test command line tool" +name = "tox" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +version = "3.20.0" + +[package.dependencies] +colorama = ">=0.4.1" +filelock = ">=3.0.0" +packaging = ">=14" +pluggy = ">=0.12.0" +py = ">=1.4.17" +six = ">=1.14.0" +toml = ">=0.9.4" +virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12,<2" + +[package.extras] +docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] +testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pathlib2 (>=2.3.3)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "pytest-xdist (>=1.22.2)"] + +[[package]] +category = "dev" +description = "tox plugin that makes tox use `pyenv which` to find python executables" +name = "tox-pyenv" +optional = false +python-versions = "*" +version = "1.1.0" + +[package.dependencies] +tox = ">=2.0" + +[[package]] +category = "dev" +description = "Type Hints for Python" +marker = "python_version < \"3.5\"" +name = "typing" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "3.7.4.3" + +[[package]] +category = "main" +description = "URI templates" +name = "uritemplate" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "3.0.1" + +[[package]] +category = "dev" +description = "Virtual Python Environment builder" +name = "virtualenv" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +version = "20.0.31" + +[package.dependencies] +appdirs = ">=1.4.3,<2" +distlib = ">=0.3.1,<1" +filelock = ">=3.0.0,<4" +six = ">=1.9.0,<2" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12,<2" + +[package.dependencies.importlib-resources] +python = "<3.7" +version = ">=1.0" + +[package.dependencies.pathlib2] +python = "<3.4" +version = ">=2.3.3,<3" + +[package.extras] +docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] +testing = ["coverage (>=5)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "pytest-xdist (>=1.31.0)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] + +[[package]] +category = "main" +description = "Backport of pathlib-compatible object wrapper for zip files" +marker = "python_version < \"3.8\"" +name = "zipp" +optional = false +python-versions = ">=2.7" +version = "1.2.0" + +[package.dependencies] +[package.dependencies.contextlib2] +python = "<3.4" +version = "*" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["pathlib2", "unittest2", "jaraco.itertools", "func-timeout"] + +[metadata] +content-hash = "0b42c9de70229f13c3ddbfaa6c4c4e6786b80aa17e71cdfbe26db98dd38c5a8d" +lock-version = "1.0" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[metadata.files] +appdirs = [ + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +] +attrs = [ + {file = "attrs-20.2.0-py2.py3-none-any.whl", hash = "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"}, + {file = "attrs-20.2.0.tar.gz", hash = "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594"}, +] +colorama = [ + {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, + {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, +] +configparser = [ + {file = "configparser-4.0.2-py2.py3-none-any.whl", hash = "sha256:254c1d9c79f60c45dfde850850883d5aaa7f19a23f13561243a050d5a7c3fe4c"}, + {file = "configparser-4.0.2.tar.gz", hash = "sha256:c7d282687a5308319bf3d2e7706e575c635b0a470342641c93bea0ea3b5331df"}, +] +contextlib2 = [ + {file = "contextlib2-0.6.0.post1-py2.py3-none-any.whl", hash = "sha256:3355078a159fbb44ee60ea80abd0d87b80b78c248643b49aa6d94673b413609b"}, + {file = "contextlib2-0.6.0.post1.tar.gz", hash = "sha256:01f490098c18b19d2bd5bb5dc445b2054d2fa97f09a4280ba2c5f3c394c8162e"}, +] +distlib = [ + {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, + {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, +] +enum34 = [ + {file = "enum34-1.1.10-py2-none-any.whl", hash = "sha256:a98a201d6de3f2ab3db284e70a33b0f896fbf35f8086594e8c9e74b909058d53"}, + {file = "enum34-1.1.10-py3-none-any.whl", hash = "sha256:c3858660960c984d6ab0ebad691265180da2b43f07e061c0f8dca9ef3cffd328"}, + {file = "enum34-1.1.10.tar.gz", hash = "sha256:cce6a7477ed816bd2542d03d53db9f0db935dd013b70f336a95c73979289f248"}, +] +filelock = [ + {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, + {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, +] +funcsigs = [ + {file = "funcsigs-1.0.2-py2.py3-none-any.whl", hash = "sha256:330cc27ccbf7f1e992e69fef78261dc7c6569012cf397db8d3de0234e6c937ca"}, + {file = "funcsigs-1.0.2.tar.gz", hash = "sha256:a7bb0f2cf3a3fd1ab2732cb49eba4252c2af4240442415b4abce3b87022a8f50"}, +] +functools32 = [ + {file = "functools32-3.2.3-2.tar.gz", hash = "sha256:f6253dfbe0538ad2e387bd8fdfd9293c925d63553f5813c4e587745416501e6d"}, + {file = "functools32-3.2.3-2.zip", hash = "sha256:89d824aa6c358c421a234d7f9ee0bd75933a67c29588ce50aaa3acdf4d403fa0"}, +] +importlib-metadata = [ + {file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"}, + {file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"}, +] +importlib-resources = [ + {file = "importlib_resources-3.0.0-py2.py3-none-any.whl", hash = "sha256:d028f66b66c0d5732dae86ba4276999855e162a749c92620a38c1d779ed138a7"}, + {file = "importlib_resources-3.0.0.tar.gz", hash = "sha256:19f745a6eca188b490b1428c8d1d4a0d2368759f32370ea8fb89cad2ab1106c3"}, +] +jsonschema = [ + {file = "jsonschema-3.2.0-py2.py3-none-any.whl", hash = "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163"}, + {file = "jsonschema-3.2.0.tar.gz", hash = "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a"}, +] +mock = [ + {file = "mock-3.0.5-py2.py3-none-any.whl", hash = "sha256:d157e52d4e5b938c550f39eb2fd15610db062441a9c2747d3dbfa9298211d0f8"}, + {file = "mock-3.0.5.tar.gz", hash = "sha256:83657d894c90d5681d62155c82bda9c1187827525880eda8ff5df4ec813437c3"}, +] +multipledispatch = [ + {file = "multipledispatch-0.6.0-py2-none-any.whl", hash = "sha256:407e6d8c5fa27075968ba07c4db3ef5f02bea4e871e959570eeb69ee39a6565b"}, + {file = "multipledispatch-0.6.0-py3-none-any.whl", hash = "sha256:a55c512128fb3f7c2efd2533f2550accb93c35f1045242ef74645fc92a2c3cba"}, + {file = "multipledispatch-0.6.0.tar.gz", hash = "sha256:a7ab1451fd0bf9b92cab3edbd7b205622fb767aeefb4fb536c2e3de9e0a38bea"}, +] +nose = [ + {file = "nose-1.3.7-py2-none-any.whl", hash = "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a"}, + {file = "nose-1.3.7-py3-none-any.whl", hash = "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac"}, + {file = "nose-1.3.7.tar.gz", hash = "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98"}, +] +nose-timer = [ + {file = "nose-timer-1.0.0.tar.gz", hash = "sha256:4c383d67ae6affd67173217e23689308c1a2bb178bfaf8b05b06c4cf16971c26"}, +] +packaging = [ + {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, + {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, +] +parameterized = [ + {file = "parameterized-0.7.4-py2.py3-none-any.whl", hash = "sha256:59ab908e31c01505a987a2be78854e19cb1630c047bbab7848169c371d614d56"}, + {file = "parameterized-0.7.4.tar.gz", hash = "sha256:190f8cc7230eee0b56b30d7f074fd4d165f7c45e6077582d0813c8557e738490"}, +] +pathlib2 = [ + {file = "pathlib2-2.3.5-py2.py3-none-any.whl", hash = "sha256:0ec8205a157c80d7acc301c0b18fbd5d44fe655968f5d947b6ecef5290fc35db"}, + {file = "pathlib2-2.3.5.tar.gz", hash = "sha256:6cd9a47b597b37cc57de1c05e56fb1a1c9cc9fab04fe78c29acd090418529868"}, +] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] +py = [ + {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, + {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, +] +pyfakefs = [ + {file = "pyfakefs-3.7-py2.py3-none-any.whl", hash = "sha256:dbe4c6e9d1f2d9c2e22e9b0a5778080de8213e44bf164b58d5a7a89fcce1c3e7"}, + {file = "pyfakefs-3.7.tar.gz", hash = "sha256:74cb0c53959f185f6b0fe6c9f6882359b2390df861d4857a9f1a95481b466ace"}, +] +pyparsing = [ + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, +] +pyrsistent = [ + {file = "pyrsistent-0.16.1.tar.gz", hash = "sha256:aa2ae1c2e496f4d6777f869ea5de7166a8ccb9c2e06ebcf6c7ff1b670c98c5ef"}, +] +rfc3987 = [ + {file = "rfc3987-1.3.8-py2.py3-none-any.whl", hash = "sha256:10702b1e51e5658843460b189b185c0366d2cf4cff716f13111b0ea9fd2dce53"}, + {file = "rfc3987-1.3.8.tar.gz", hash = "sha256:d3c4d257a560d544e9826b38bc81db676890c79ab9d7ac92b39c7a253d5ca733"}, +] +scandir = [ + {file = "scandir-1.10.0-cp27-cp27m-win32.whl", hash = "sha256:92c85ac42f41ffdc35b6da57ed991575bdbe69db895507af88b9f499b701c188"}, + {file = "scandir-1.10.0-cp27-cp27m-win_amd64.whl", hash = "sha256:cb925555f43060a1745d0a321cca94bcea927c50114b623d73179189a4e100ac"}, + {file = "scandir-1.10.0-cp34-cp34m-win32.whl", hash = "sha256:2c712840c2e2ee8dfaf36034080108d30060d759c7b73a01a52251cc8989f11f"}, + {file = "scandir-1.10.0-cp34-cp34m-win_amd64.whl", hash = "sha256:2586c94e907d99617887daed6c1d102b5ca28f1085f90446554abf1faf73123e"}, + {file = "scandir-1.10.0-cp35-cp35m-win32.whl", hash = "sha256:2b8e3888b11abb2217a32af0766bc06b65cc4a928d8727828ee68af5a967fa6f"}, + {file = "scandir-1.10.0-cp35-cp35m-win_amd64.whl", hash = "sha256:8c5922863e44ffc00c5c693190648daa6d15e7c1207ed02d6f46a8dcc2869d32"}, + {file = "scandir-1.10.0-cp36-cp36m-win32.whl", hash = "sha256:2ae41f43797ca0c11591c0c35f2f5875fa99f8797cb1a1fd440497ec0ae4b022"}, + {file = "scandir-1.10.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7d2d7a06a252764061a020407b997dd036f7bd6a175a5ba2b345f0a357f0b3f4"}, + {file = "scandir-1.10.0-cp37-cp37m-win32.whl", hash = "sha256:67f15b6f83e6507fdc6fca22fedf6ef8b334b399ca27c6b568cbfaa82a364173"}, + {file = "scandir-1.10.0-cp37-cp37m-win_amd64.whl", hash = "sha256:b24086f2375c4a094a6b51e78b4cf7ca16c721dcee2eddd7aa6494b42d6d519d"}, + {file = "scandir-1.10.0.tar.gz", hash = "sha256:4d4631f6062e658e9007ab3149a9b914f3548cb38bfb021c64f39a025ce578ae"}, +] +singledispatch = [ + {file = "singledispatch-3.4.0.3-py2.py3-none-any.whl", hash = "sha256:833b46966687b3de7f438c761ac475213e53b306740f1abfaa86e1d1aae56aa8"}, + {file = "singledispatch-3.4.0.3.tar.gz", hash = "sha256:5b06af87df13818d14f08a028e42f566640aef80805c3b50c5056b086e3c2b9c"}, +] +six = [ + {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, + {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, +] +strict-rfc3339 = [ + {file = "strict-rfc3339-0.7.tar.gz", hash = "sha256:5cad17bedfc3af57b399db0fed32771f18fc54bbd917e85546088607ac5e1277"}, +] +toml = [ + {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, + {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, +] +tox = [ + {file = "tox-3.20.0-py2.py3-none-any.whl", hash = "sha256:e6318f404aff16522ff5211c88cab82b39af121735a443674e4e2e65f4e4637b"}, + {file = "tox-3.20.0.tar.gz", hash = "sha256:eb629ddc60e8542fd4a1956b2462e3b8771d49f1ff630cecceacaa0fbfb7605a"}, +] +tox-pyenv = [ + {file = "tox-pyenv-1.1.0.tar.gz", hash = "sha256:916c2213577aec0b3b5452c5bfb32fd077f3a3196f50a81ad57d7ef3fc2599e4"}, + {file = "tox_pyenv-1.1.0-py2.py3-none-any.whl", hash = "sha256:e470c18af115fe52eeff95e7e3cdd0793613eca19709966fc2724b79d55246cb"}, +] +typing = [ + {file = "typing-3.7.4.3-py2-none-any.whl", hash = "sha256:283d868f5071ab9ad873e5e52268d611e851c870a2ba354193026f2dfb29d8b5"}, + {file = "typing-3.7.4.3.tar.gz", hash = "sha256:1187fb9c82fd670d10aa07bbb6cfcfe4bdda42d6fab8d5134f04e8c4d0b71cc9"}, +] +uritemplate = [ + {file = "uritemplate-3.0.1-py2.py3-none-any.whl", hash = "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f"}, + {file = "uritemplate-3.0.1.tar.gz", hash = "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae"}, +] +virtualenv = [ + {file = "virtualenv-20.0.31-py2.py3-none-any.whl", hash = "sha256:e0305af10299a7fb0d69393d8f04cb2965dda9351140d11ac8db4e5e3970451b"}, + {file = "virtualenv-20.0.31.tar.gz", hash = "sha256:43add625c53c596d38f971a465553f6318decc39d98512bc100fa1b1e839c8dc"}, +] +zipp = [ + {file = "zipp-1.2.0-py2.py3-none-any.whl", hash = "sha256:e0d9e63797e483a30d27e09fffd308c59a700d365ec34e93cc100844168bf921"}, + {file = "zipp-1.2.0.tar.gz", hash = "sha256:c70410551488251b0fee67b460fb9a536af8d6f9f008ad10ac51f615b6a521b1"}, +] diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..5a317a8 --- /dev/null +++ b/pylintrc @@ -0,0 +1,9 @@ +[FORMAT] +max-line-length=120 + +[MESSAGES CONTROL] +disable= + function-redefined, + logging-format-interpolation, + missing-docstring, + too-few-public-methods \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..016ef3c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[tool.poetry] +name = "webpub_manifest_parser" +version = "0.0.1" +description = "A parser for the Readium Web Publication Manifest and OPDS 2.0 formats." +license = "Apache-2.0" +authors = ["Leonard Richardson "] +maintainers = ["Viacheslav Bessonov "] +keywords = ["Readium Web Publication Manifest", "RWPM", "Open Publication Distribution System 2.0", "OPDS 2.0"] +classifiers = [ + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache-2.0 License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +[tool.poetry.dependencies] +python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +multipledispatch = "^0.6.0" +enum34 = "^1.1.10" +jsonschema = "^3.2.0" +strict-rfc3339 = "^0.7" +rfc3987 = "^1.3.8" +pyrsistent = "0.16.1" +uritemplate = "^3.0.1" + +[tool.poetry.dev-dependencies] +pyfakefs = "3.7" +parameterized = "^0.7.4" +nose = "^1.3.7" +nose-timer = "^1.0.0" +tox = "^3.20.0" +tox-pyenv = "^1.1.0" +mock = "3.0.5" + +[build-system] +requires = ["poetry>=0.12"] +build-backend = "poetry.masonry.api" diff --git a/src/webpub_manifest_parser/__init__.py b/src/webpub_manifest_parser/__init__.py new file mode 100644 index 0000000..f102a9c --- /dev/null +++ b/src/webpub_manifest_parser/__init__.py @@ -0,0 +1 @@ +__version__ = "0.0.1" diff --git a/__init__.py b/src/webpub_manifest_parser/core/__init__.py similarity index 100% rename from __init__.py rename to src/webpub_manifest_parser/core/__init__.py diff --git a/src/webpub_manifest_parser/core/ast.py b/src/webpub_manifest_parser/core/ast.py new file mode 100644 index 0000000..e63760d --- /dev/null +++ b/src/webpub_manifest_parser/core/ast.py @@ -0,0 +1,831 @@ +from abc import ABCMeta, abstractmethod + +import six + +from webpub_manifest_parser.core.parsers import ( + AnyOfParser, + ArrayParser, + StringParser, + TypeParser, +) +from webpub_manifest_parser.core.properties import ( + ArrayOfStringsProperty, + ArrayProperty, + BooleanProperty, + DateOrTimeProperty, + DateTimeProperty, + EnumProperty, + IntegerProperty, + LanguageProperty, + ListProperty, + LocalizableStringProperty, + NumberProperty, + ParsableProperty, + PropertiesGrouping, + Property, + StringProperty, + TypeProperty, + URIProperty, + URITemplateProperty, +) +from webpub_manifest_parser.core.registry import CollectionRole +from webpub_manifest_parser.utils import encode + + +@six.add_metaclass(ABCMeta) +class Visitor(object): + """Interface for visitors walking through abstract syntax trees (AST).""" + + @abstractmethod + def visit(self, node): + """Process the specified node. + + :param node: AST node + :type node: Node + """ + raise NotImplementedError() + + +@six.add_metaclass(ABCMeta) +class Visitable(object): + """Interface for objects walkable by AST visitors.""" + + @abstractmethod + def accept(self, visitor): + """Accept the specified visitor. + + :param visitor: Visitor object + :type visitor: Visitor + """ + raise NotImplementedError() + + +@six.add_metaclass(ABCMeta) +class Extendable(object): + """Abstract class adding ability to extend classes. + + For example, RWPM link properties can be extended by EPUB link properties and OPDS 2.0 link properties. + """ + + extensions = None + + @classmethod + def get_extension(cls): + """Return a new class having all extensions as mixins. + + :return: New class containing extensions as mixins. + :rtype: Type + """ + if not cls.extensions: + return cls + + if len(cls.extensions) == 1: + return cls.extensions[0] + + class_names = [cls.__name__] + [ + extension.__name__ for extension in cls.extensions + ] + extended_class_name = "_".join(class_names) + extended_class = type(extended_class_name, tuple(cls.extensions), {}) + + return extended_class + + +class Node(PropertiesGrouping, Visitable, Extendable): + """Base class for all AST nodes.""" + + def accept(self, visitor): + """Accept the specified visitor. + + :param visitor: Visitor object + :type visitor: Visitor + """ + visitor.visit(self) + + +class LinkProperties(Node): + """Link properties.""" + + clipped = BooleanProperty("clipped", required=False) + fit = EnumProperty( + "fit", required=False, items=["contain", "cover", "width", "height"] + ) + orientation = EnumProperty( + "orientation", required=False, items=["auto", "landscape", "portrait"] + ) + page = EnumProperty("page", required=False, items=["left", "right", "center"]) + spread = EnumProperty( + "spread", required=False, items=["auto", "both", "none", "landscape"] + ) + + def __hash__(self): + """Calculate the hash. + + :return: Hash + :rtype: int + """ + return hash((self.clipped, self.orientation, self.page, self.spread)) + + def __repr__(self): + """Return a string representation of the object. + + :return: String representation + :rtype: str + """ + return u"".format( + self.clipped, self.fit, self.orientation, self.page, self.spread + ) + + +class Link(Node): + """Link to another resource.""" + + href = URITemplateProperty("href", required=True) + templated = BooleanProperty("templated", required=False) + type = StringProperty("type", required=False) + title = StringProperty("title", required=False) + rel = ArrayOfStringsProperty("rel", required=False) + properties = TypeProperty("properties", required=False, nested_type=LinkProperties) + height = IntegerProperty("height", required=False, exclusive_minimum=0) + width = IntegerProperty("width", required=False, exclusive_minimum=0) + bitrate = NumberProperty("bitrate", required=False, exclusive_minimum=0) + duration = NumberProperty("duration", required=False, exclusive_minimum=0) + language = LanguageProperty("language", required=False) + alternate = ArrayProperty( + "alternate", + required=False, + item_parser=TypeParser("webpub_manifest_parser.core.ast.Link"), + ) + children = ArrayProperty( + "children", + required=False, + item_parser=TypeParser("webpub_manifest_parser.core.ast.Link"), + ) + + def __init__( # pylint: disable=R0913 + self, + href=None, + templated=None, + _type=None, + title=None, + rel=None, + properties=None, + height=None, + width=None, + duration=None, + bitrate=None, + language=None, + alternate=None, + children=None, + ): + """Initialize a new instance of Link class. + + :param href: Link's URL + :type href: str + + :param templated: Boolean value indicating whether href is a URI template + :type templated: bool + + :param _type: Media type of the linked resource + :type _type: Union[str, MediaType] + + :param title: Title of the linked resource + :type title: str + + :param rel: Relation between the resource and its containing collection + :type rel: List[registry.LinkRelation] + + :param properties: Relation between the resource and its containing collection + :type properties: object + + :param height: Height of the linked resource in pixels + :type height: int + + :param width: Width of the linked resource in pixels + :type width: int + + :param duration: Duration of the linked resource in seconds + :type duration: float + + :param bitrate: Bit rate of the linked resource in kilobits per second + :type bitrate: float + + :param language: Expected language of the linked resource + :type language: List[str] + + :param alternate: Alternate resources for the linked resource + :type alternate: List[Link] + + :param children: Resources that are children of the linked resource, in the context of a given collection role + :type children: List[Link] + """ + super(Link, self).__init__() + + self.href = href + self.templated = templated + self.type = _type + self.title = title + self.rel = rel + self.properties = properties + self.height = height + self.width = width + self.duration = duration + self.bitrate = bitrate + self.language = language + self.alternate = alternate + self.children = children + + def __hash__(self): + """Calculate the hash. + + :return: Hash + :rtype: int + """ + return hash( + ( + self.href, + self.templated, + self.type, + self.title, + frozenset(self.rel), + self.properties, + self.height, + self.width, + self.duration, + self.bitrate, + frozenset(self.language) + if isinstance(self.language, list) + else self.language, + frozenset(self.alternate), + frozenset(self.children), + ) + ) + + def __repr__(self): + """Return a string representation of the object. + + :return: String representation + :rtype: str + """ + return ( + u"".format( + encode(self.name), + self.identifier, + self.sort_as, + self.role, + self.position, + self.links, + ) + + +class ContributorProperty(ParsableProperty): + """Property containing information about a contributor.""" + + PARSER = AnyOfParser( + [ + StringParser(), + ArrayParser(AnyOfParser([StringParser(), TypeParser(Contributor)])), + TypeParser(Contributor), + ] + ) + + +class Subject(Node, PropertiesGrouping): + """Subject object.""" + + name = LocalizableStringProperty("name", required=True) + sort_as = StringProperty("sortAs", required=False) + code = StringProperty("code", required=False) + scheme = URIProperty("scheme", required=False) + links = ArrayOfLinksProperty("links", required=False) + + def __hash__(self): + """Calculate the hash. + + :return: Hash + :rtype: int + """ + return hash( + (self.name, self.sort_as, self.code, self.scheme, frozenset(self.links)) + ) + + def __repr__(self): + """Return a string representation of the object. + + :return: String representation + :rtype: str + """ + return ( + u"".format( + self.name, self.sort_as, self.code, self.scheme, self.links + ) + ) + + +class SubjectProperty(ParsableProperty): + """Property containing information about a subject.""" + + PARSER = AnyOfParser( + [ + StringParser(), + ArrayParser(AnyOfParser([StringParser(), TypeParser(Subject)])), + TypeParser(Subject), + ] + ) + + +class Owner(Node, PropertiesGrouping): + """Object containing information about the collection's owners.""" + + collection = TypeProperty("collection", required=False, nested_type=Contributor) + series = TypeProperty("series", required=False, nested_type=Contributor) + + def __hash__(self): + """Calculate the hash. + + :return: Hash + :rtype: int + """ + return hash((self.collection, self.series)) + + def __repr__(self): + """Return a string representation of the object. + + :return: String representation + :rtype: str + """ + return u"".format( + self.collection, self.series + ) + + +class Metadata(Node): + """Dictionary containing manifest's metadata.""" + + identifier = URIProperty("identifier", required=False) + type = URIProperty("@type", required=False) + title = LocalizableStringProperty("title", required=True) + subtitle = LocalizableStringProperty("subtitle", required=False) + modified = DateTimeProperty("modified", required=False) + published = DateOrTimeProperty("published", required=False) + language = LanguageProperty("language", required=False) + sort_as = StringProperty("sortAs", required=False) + author = ContributorProperty("author", required=False) + translator = ContributorProperty("translator", required=False) + editor = ContributorProperty("editor", required=False) + artist = ContributorProperty("artist", required=False) + illustrator = ContributorProperty("illustrator", required=False) + letterer = ContributorProperty("letterer", required=False) + penciler = ContributorProperty("penciler", required=False) + colorist = ContributorProperty("colorist", required=False) + inker = ContributorProperty("inker", required=False) + narrator = ContributorProperty("narrator", required=False) + contributor = ContributorProperty("contributor", required=False) + publisher = ContributorProperty("publisher", required=False) + imprint = ContributorProperty("imprint", required=False) + subject = SubjectProperty("subject", required=False) + reading_progression = EnumProperty( + "readingProgression", + required=False, + items=["rtl", "ltr", "ttb", "btt", "auto"], + default_value="auto", + ) + description = StringProperty("description", required=False) + duration = NumberProperty("duration", required=False, exclusive_minimum=0) + number_of_pages = NumberProperty( + "numberOfPages", required=False, exclusive_minimum=0 + ) + belongs_to = TypeProperty("belongsTo", required=False, nested_type=Owner) + + def __init__( # pylint: disable=R0913, R0914 + self, + title=None, + identifier=None, + subtitle=None, + modified=None, + published=None, + language=None, + sort_as=None, + author=None, + translator=None, + editor=None, + artist=None, + illustrator=None, + letterer=None, + penciler=None, + colorist=None, + inker=None, + narrator=None, + contributor=None, + publisher=None, + imprint=None, + subject=None, + description=None, + duration=None, + number_of_pages=None, + belongs_to=None, + ): + """Initialize a new instance of Metadata class.""" + super(Metadata, self).__init__() + self.title = title + self.identifier = identifier + self.title = title + self.subtitle = subtitle + self.modified = modified + self.published = published + self.language = language + self.sort_as = sort_as + self.author = author + self.translator = translator + self.editor = editor + self.artist = artist + self.illustrator = illustrator + self.letterer = letterer + self.penciler = penciler + self.colorist = colorist + self.inker = inker + self.narrator = narrator + self.contributor = contributor + self.publisher = publisher + self.imprint = imprint + self.subject = subject + self.description = description + self.duration = duration + self.number_of_pages = number_of_pages + self.belongs_to = belongs_to + + def __hash__(self): + """Calculate the hash. + + :return: Hash + :rtype: int + """ + return hash( + ( + self.identifier, + self.type, + self.title, + self.subtitle, + self.modified, + self.published, + self.language, + self.sort_as, + self.author, + self.translator, + self.editor, + self.artist, + self.illustrator, + self.letterer, + self.penciler, + self.colorist, + self.inker, + self.narrator, + self.contributor, + self.publisher, + self.imprint, + self.subject, + self.description, + self.duration, + self.number_of_pages, + self.belongs_to, + ) + ) + + +class PresentationMetadata(Metadata): + """RWPM extension containing presentation metadata.""" + + clipped = BooleanProperty("clipped", False) + continuous = BooleanProperty("continuous", False) + fit = EnumProperty("fit", False, ["width", "height", "contain", "cover"]) + orientation = EnumProperty("orientation", False, ["auto", "landscape", "portrait"]) + overflow = EnumProperty( + "overflow", False, ["auto", "paginated", "scrolled", "scrolled-continuous"] + ) + spread = EnumProperty("spread", False, ["auto", "both", "none", "landscape"]) + + +class CompactCollection(Node): + """A compact collection is defined as a grouping of links.""" + + links = ArrayOfLinksProperty(key="links", required=True) + + def __init__(self, role=None, links=None): + """Initialize a new instance of Collection class. + + :param role: Collection's role (can be empty when self is a manifest) + :type role: Optional[CollectionRole] + + :param links: Collection's links + :type links: Optional[LinksList] + """ + super(CompactCollection, self).__init__() + + self._role = role + self.links = links + + @property + def role(self): + """Return the collection's role. + + :return: Collection's role. + :rtype: Optional[CollectionRole] + """ + return self._role + + +class Collection(CompactCollection): + """A collection is defined as a grouping of metadata, links and sub-collections.""" + + def __init__(self, role=None, links=None, metadata=None): + """Initialize a new instance of Collection class. + + :param role: Collection's role (can be empty when self is a manifest) + :type role: Optional[CollectionRole] + + :param links: Collection's links + :type links: Optional[LinksList] + + :param metadata: Collection's metadata + :type metadata: Optional[Metadata] + """ + super(Collection, self).__init__(role, links) + + self._role = role + self._sub_collections = CollectionList() + self.metadata = metadata + + metadata = TypeProperty( + key="metadata", required=True, nested_type=PresentationMetadata + ) + + @property + def sub_collections(self): + """Return a list of sub-collections. + + :return: List of sub-collections. + :rtype: CollectionList + """ + return self._sub_collections + + @property + def compact(self): + """Return a boolean value indicating if this collection is compact. + + :return: Boolean value indicating if this collection is compact + :rtype: bool + """ + return self.metadata is None and len(self._sub_collections) == 0 + + @property + def full(self): + """Return a boolean value indicating if this collection is full. + + :return: Boolean value indicating if this collection is full + :rtype: bool + """ + return self.metadata is not None and len(self._sub_collections) > 0 + + +class CollectionList(Node, list): + """List of sub-collections.""" + + def __init__(self, items=None): + """Initialize a new instance of CollectionList class. + + :param items: (Optional) Items to be added to the list + :type items: Optional[List] + """ + super(CollectionList, self).__init__() + + if items is not None: + if not isinstance(items, list): + raise ValueError("Items argument must be a list") + + self.extend(items) + + def get_by_role(self, role): + """Return collections with the specific role. + + :param role: Collection's role + :type role: str + + :return: Collections with the specific role + :rtype: List[Collection] + """ + return [collection for collection in self if collection.role.key == role] + + +class CompactCollectionProperty(Property): + """Property allowing to contain a compact sub-collection.""" + + def __init__(self, key, required, role, collection_class=CompactCollection): + """Initialize a new instance of CompactCollectionProperty class. + + :param key: Property's key + :type key: str + + :param required: Boolean value indicating whether the property is required or not + :type required: bool + + :param role: Collection role + :type role: CollectionRole + """ + if not isinstance(role, CollectionRole): + raise ValueError( + "Role argument must be an instance of CollectionRole class" + ) + if not issubclass(collection_class, CompactCollection): + raise ValueError( + "Collection class argument must be a subclass of CompactCollection class" + ) + + super(CompactCollectionProperty, self).__init__( + key, required, TypeParser(collection_class) + ) + + self._role = role + + @property + def role(self): + """Return the sub-collection's role. + + :return: Sub-collection's role + :rtype: CollectionRole + """ + return self._role + + +class ArrayOfCollectionsProperty(ListProperty): + """Property allowing to contain a compact sub-collection.""" + + def __init__(self, key, required, role, collection_type=Collection): + """Initialize a new instance of ArrayOfCollectionsProperty class. + + :param key: Property's key + :type key: str + + :param required: Boolean value indicating whether the property is required or not + :type required: bool + + :param role: Collection role + :type role: CollectionRole + """ + if not isinstance(role, CollectionRole): + raise ValueError( + "Role argument must be an instance of CollectionRole class" + ) + if not issubclass(collection_type, Collection): + raise ValueError( + "Collection type argument must be a subclass of Collection class" + ) + + super(ArrayOfCollectionsProperty, self).__init__( + key, + required, + ArrayParser(TypeParser(collection_type), True), + CollectionList, + ) + + self._role = role + + @property + def role(self): + """Return the sub-collection's role. + + :return: Sub-collection's role + :rtype: CollectionRole + """ + return self._role + + +class Manifestlike(Collection): + """Base class for Manifest (Readium Web Publication Manifest) and Feed (OPDS 2). + + An OPDS 2 feed is defined as a RWPM with enumerated exceptions. + + This class implements the behavior common to both specs. The + alternative is to have the Feed class subclass Manifest and then + implement a lot of exceptions. + """ diff --git a/src/webpub_manifest_parser/core/errors.py b/src/webpub_manifest_parser/core/errors.py new file mode 100644 index 0000000..aa64f9b --- /dev/null +++ b/src/webpub_manifest_parser/core/errors.py @@ -0,0 +1,9 @@ +from webpub_manifest_parser.errors import BaseError + + +class BaseSyntaxError(BaseError): + """Exception raised in the case of syntax errors.""" + + +class BaseSemanticError(BaseError): + """Exception raised in the case of any semantic errors.""" diff --git a/src/webpub_manifest_parser/core/parsers.py b/src/webpub_manifest_parser/core/parsers.py new file mode 100644 index 0000000..9226081 --- /dev/null +++ b/src/webpub_manifest_parser/core/parsers.py @@ -0,0 +1,751 @@ +import datetime +import io +import logging +import re +from abc import ABCMeta, abstractmethod +from pydoc import locate +from tempfile import NamedTemporaryFile + +import jsonschema # noqa: I201 +import requests # noqa: I201 +import six # noqa: I201 +import strict_rfc3339 # noqa: I201 +from jsonschema import FormatError # noqa: I201, I100 +from uritemplate import URITemplate # noqa: I201 + +import webpub_manifest_parser.core.errors +from webpub_manifest_parser.utils import encode, is_string + + +class ValueParsingError(webpub_manifest_parser.core.errors.BaseSyntaxError): + """Base class for all errors raised by value parsers.""" + + +@six.add_metaclass(ABCMeta) +class ValueParser(object): + """Base parser class.""" + + @abstractmethod + def parse(self, value): + """Parse the value, raise ParsingError if the value is not correct, otherwise return the processed value. + + :param value: Value to validate + :type value: Any + + :return: Processed value (for example, datetime object in the case of a value containing a date&time string) + :rtype: Any + + :raise: ValidationError + """ + raise NotImplementedError() + + +class AnyOfParser(ValueParser): + """Parser making sure that at least one of the inner parsers succeed.""" + + def __init__(self, inner_parsers): + """Initialize a new instance of AnyOfParser class. + + :param inner_parsers: List of composed parsers + :type inner_parsers: List[ValueParser] + """ + self._inner_parsers = inner_parsers + self._logger = logging.getLogger(__name__) + + @property + def inner_parsers(self): + """Return the inner parsers. + + :return: Inner parsers + :rtype: List[ValueParser] + """ + return self._inner_parsers + + def parse(self, value): + """Make sure that at least one of the inner parsers succeed, otherwise raise the first validation error. + + :param value: Value + :type value: Any + + :return: First valid value + :rtype: Any + + :raise: ValidationError + """ + first_validation_error = None + + for parser in self._inner_parsers: + self._logger.debug(u"Running {0} parser".format(parser)) + + try: + result = parser.parse(value) + + self._logger.debug( + u"Parser {0} succeeded: {1}".format(parser, encode(result)) + ) + + return result + except ValueParsingError as error: + self._logger.debug(u"Parser {0} failed".format(encode(parser))) + + if first_validation_error is None: + first_validation_error = error + + self._logger.debug(u"All parsers failed") + + raise first_validation_error + + +@six.add_metaclass(ABCMeta) +class NumericParser(ValueParser): + """Numeric parser.""" + + def __init__( + self, minimum=None, exclusive_minimum=None, maximum=None, exclusive_maximum=None + ): + """Initialize a new instance of NumericParser class. + + :param minimum: Minimum value + :type minimum: Numeric + + :param exclusive_minimum: Exclusive minimum value + :type exclusive_minimum: Numeric + + :param maximum: Maximum value + :type maximum: Numeric + + :param exclusive_maximum: Exclusive maximum value + :type exclusive_maximum: Numeric + """ + self._minimum = minimum + self._exclusive_minimum = exclusive_minimum + self._maximum = maximum + self._exclusive_maximum = exclusive_maximum + + @abstractmethod + def _parse(self, value): + """Parse a numeric string into a Python numeric object (int or float). + + :param value: Value + :type value: Any + + :return: Parsed numeric value + :rtype: Numeric + + :raise: ValidationError + """ + raise NotImplementedError() + + def parse(self, value): + """Parse a numeric value. + + :param value: Value + :type value: Any + + :return: Parsed numeric value + :rtype: Numeric + + :raise: ValidationError + """ + value = self._parse(value) + + if self._minimum is not None and value < self._minimum: + raise ValueParsingError( + u"Value {0} is less than the minimum ({1})".format(value, self._minimum) + ) + if self._exclusive_minimum is not None and value <= self._exclusive_minimum: + raise ValueParsingError( + u"Value {0} is less or equal than the exclusive minimum ({1})".format( + value, self._exclusive_minimum + ) + ) + if self._maximum is not None and value > self._maximum: + raise ValueParsingError( + u"Value {0} is greater than the maximum ({1})".format( + value, self._maximum + ) + ) + if self._exclusive_maximum is not None and value >= self._exclusive_maximum: + raise ValueParsingError( + u"Value {0} is greater or equal than the exclusive maximum ({1})".format( + value, self._exclusive_maximum + ) + ) + + return value + + +class IntegerParser(NumericParser): + """Integer parser.""" + + def _parse(self, value): + """Parse an integer value. + + :param value: Value + :type value: Any + + :return: Parsed integer + :rtype: int + + :raise: ValidationError + """ + try: + return int(value) + except ValueError as error: + raise ValueParsingError(str(error), error) + + +class NumberParser(NumericParser): + """Number parser.""" + + def _parse(self, value): + """Parse a float number. + + :param value: Value + :type value: Any + + :return: Parsed float number + :rtype: int + + :raise: ValidationError + """ + try: + return float(value) + except ValueError as error: + raise ValueParsingError(str(error), error) + + +class BooleanParser(ValueParser): + """Boolean parser.""" + + def parse(self, value): + """Parse a boolean value. + + :param value: Value + :type value: Any + + :return: Parsed boolean value + :rtype: int + + :raise: ValidationError + """ + if isinstance(value, bool): + return value + + if is_string(value): + if value == "false": + return False + + if value == "true": + return True + + raise ValueParsingError(u"Value '{0}' must be boolean".format(encode(value))) + + +class StringParser(ValueParser): + """String parser.""" + + def parse(self, value): + """Parse a string value. + + :param value: Value + :type value: Any + + :return: Parsed string value + :rtype: str + + :raise: ValidationError + """ + if not is_string(value): + raise ValueParsingError( + u"Value '{0}' must be a string".format(encode(value)) + ) + + return value + + +class StringPatternParser(StringParser): + """String parser using a regular expression pattern.""" + + def __init__(self, pattern): + """Initialize a new instance of StringPatternParser class. + + :param pattern: Regular expression which string's value must conform to + :type pattern: RegEx + """ + if not is_string(pattern): + raise ValueError("Pattern argument must be a string") + + self._pattern = pattern + self._regex = re.compile(pattern) if pattern is not None else None + + def parse(self, value): + """Parse a string value using the specified regular expression. + + :param value: Value + :type value: Any + + :return: Parsed string value + :rtype: int + + :raise: ValidationError + """ + value = super(StringPatternParser, self).parse(value) + + if not self._regex.match(value): + raise ValueParsingError( + u"String value '{0}' does not match regular expression {1}".format( + encode(value), self._pattern + ) + ) + + return value + + +class EnumParser(StringParser): + """Enum parser.""" + + def __init__(self, items): + """Initialize a new instance of EnumParser class. + + :param items: Enumeration items + :type items: List[str] + """ + self._items = items + + def parse(self, value): + """Make sure that the value is a part of the enumeration and return it back. + + :param value: Value + :type value: Any + + :return: Parsed string value + :rtype: int + + :raise: ValidationError + """ + value = super(EnumParser, self).parse(value) + + if value not in self._items: + raise ValueParsingError( + u"Value '{0}' is not among {1}".format(encode(value), self._items) + ) + + return value + + +@six.add_metaclass(ABCMeta) +class FormatChecker(StringParser): + """Base class for all parsers using jsonschema FormatChecker.""" + + def __init__(self, json_schema_format): + """Initialize a new instance of FormatParser class. + + :param json_schema_format: One of the jsonschema allowed string formats (color, date, date-time, etc.) + :type json_schema_format: str + """ + self._json_schema_format = json_schema_format + self._format_checker = jsonschema.FormatChecker([json_schema_format]) + + def _validate(self, value): + """Check the value's format. + + :param value: Value + :type value: Any + + :raise: ValidationError + """ + try: + self._format_checker.check(value, self._json_schema_format) + except FormatError as error: + raise ValueParsingError(str(error), error) + + def _parse(self, value): + """Parse the value into an appropriate Python type. + + :param value: Value + :type value: Any + + :return: Parsed value + :rtype: Any + + :raise: ValidationError + """ + return value + + def parse(self, value): + """Parse the value according to the specified format. + + :param value: Value + :type value: Any + + :return: Parsed value + :rtype: Any + + :raise: ValidationError + """ + self._validate(value) + result = self._parse(value) + + return result + + +class URIParser(FormatChecker): + """URI parser.""" + + def __init__(self): + """Initialize a new instance of URIParser class.""" + super(URIParser, self).__init__("uri") + + def _parse(self, value): + """Parse the URI into a Python dictionary containing URI's subcomponents. + + :param value: Value + :type value: Any + + :return: URI + :rtype: str + """ + return value + + +class URITemplateParser(StringParser): + """URI template parser.""" + + def parse(self, value): + """Parse the URI into a Python dictionary containing URI's subcomponents. + + :param value: Value + :type value: Any + + :return: URI template + :rtype: URITemplate + """ + URITemplate(value) + + return value + + +class URIReferenceParser(FormatChecker): + """URI-reference parser.""" + + def __init__(self): + """Initialize a new instance of URIReferenceParser class.""" + super(URIReferenceParser, self).__init__("uri-reference") + + def _parse(self, value): + """Parse the URI-reference into a Python dictionary containing URI's subcomponents. + + :param value: Value + :type value: Any + + :return: URI-reference + :rtype: str + """ + return value + + +class DateParser(FormatChecker): + """Date parser.""" + + def __init__(self): + """Initialize a new instance of DateParser class.""" + super(DateParser, self).__init__("date") + + def _parse(self, value): + """Parse a date & time string into datetime object. + + :param value: Value + :type value: Any + + :return: Parsed date object + :rtype: datetime.datetime + """ + return datetime.datetime.strptime(value, "%Y-%m-%d") + + +class DateTimeParser(FormatChecker): + """Date & time parser.""" + + def __init__(self): + """Initialize a new instance of DateTimeParser class.""" + super(DateTimeParser, self).__init__("date-time") + + def _parse(self, value): + """Parse a date & time string into datetime object. + + :param value: Value + :type value: Any + + :return: Parsed date & time object + :rtype: datetime.datetime + """ + timestamp = strict_rfc3339.rfc3339_to_timestamp(value) + + return datetime.datetime.utcfromtimestamp(timestamp) + + +class ArrayParser(ValueParser): + """Array parser.""" + + def __init__(self, item_parser, unique_items=False): + """Initialize a new instance of ArrayParser class. + + :param item_parser: Parser used for parsing array items + :type item_parser: ValueParser + + :param unique_items: Boolean value indicating whether array must contain only unique items + :type unique_items: bool + """ + self._item_parser = item_parser + self._unique_items = unique_items + self._logger = logging.getLogger(__name__) + + @property + def item_parser(self): + """Return the parser used for parsing array items. + + :return: Parser used for parsing array items + :rtype: ValueParser + """ + return self._item_parser + + def parse(self, value): + """Parse the value into a list of parsed values. + + :param value: Value + :type value: Any + + :return: List consisting of parsed items + :rtype: List + + :raise: ValidationError + """ + if not isinstance(value, list): + raise ValueParsingError(u"Value '{0}' must be a list".format(encode(value))) + + result = [] + seen = set() + + for item in value: + item = self._item_parser.parse(item) + + if self._unique_items and item in seen: + raise ValueParsingError( + u"Item '{0}' is not unique".format(encode(item)) + ) + + result.append(item) + seen.add(item) + + return list(result) + + +class ObjectParser(ValueParser): + """Object parser.""" + + def __init__(self, properties_parser, properties_pattern=None): + """Initialize a new instance of ObjectParser class. + + :param properties_parser: Properties parser + :type properties_parser: ValueParser + + :param properties_pattern: Properties regex pattern + :type properties_pattern: Optional[str] + """ + self._properties_parser = properties_parser + self._properties_regex = ( + re.compile(properties_pattern) if properties_pattern is not None else None + ) + + def parse(self, value): + """Parse a JSON object into a Python dictionary. + + :param value: Value + :type value: Any + + :return: Python dictionary containing parsed items + :rtype: Dict + + :raise: ValidationError + """ + if not isinstance(value, dict): + raise ValueParsingError("Value must be a dictionary") + + result = {} + + for key, item in value.items(): + if not isinstance(key, str): + raise ValueParsingError( + u"Key '{0}' must be a string".format(encode(key)) + ) + + if self._properties_regex and not self._properties_regex.match(key): + raise ValueParsingError( + u"Key '{0}' does not match the pattern '{1}'".format( + encode(key), self._properties_regex + ) + ) + + item = self._properties_parser.parse(item) + + result[key] = item + + return result + + +class LocalizableStringParser(ObjectParser): + """Parser for localizable strings represented as a dictionary with keys equal to language codes.""" + + LANGUAGE_PATTERN = "^((?P(en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)|(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang))|((?P([A-Za-z]{2,3}(-(?P[A-Za-z]{3}(-[A-Za-z]{3}){0,2}))?)|[A-Za-z]{4}|[A-Za-z]{5,8})(-(?P