├── requirements.txt ├── isbnlib ├── test │ ├── __init__.py │ ├── test_goom.py │ ├── test_webservice.py │ ├── test_openl.py │ ├── test_words.py │ ├── test_unicode_to_utf8tex.py │ ├── test_wiki.py │ ├── test_registry.py │ ├── test_exceptions.py │ ├── test_speed.py │ ├── test_cache.py │ ├── test_fmt.py │ ├── test_cache_decorator.py │ ├── test_info.py │ ├── test_isbn.py │ ├── test_metadata.py │ ├── test_vias.py │ ├── test_classify.py │ ├── test_helpers.py │ ├── test_editions.py │ ├── data4tests.py │ ├── test_files.py │ ├── test_ext.py │ ├── test_data.py │ ├── test_rename.py │ └── test_core.py ├── _data │ ├── __init__.py │ └── data4info.py ├── _doitotex.py ├── dev │ ├── helpers.py │ ├── __init__.py │ ├── _bouth23.py │ ├── _decorators.py │ ├── vias.py │ ├── webquery.py │ ├── _exceptions.py │ ├── _helpers.py │ ├── _data.py │ ├── _files.py │ ├── webservice.py │ └── _fmt.py ├── _cover.py ├── _metadata.py ├── _wikied.py ├── _infogroup.py ├── _desc.py ├── _isbn.py ├── _thinged.py ├── _gwords.py ├── config.py ├── _openled.py ├── _imcache.py ├── _msk.py ├── __init__.py ├── _exceptions.py ├── _openl.py ├── _goom.py ├── _editions.py ├── _goob.py ├── _ext.py ├── _oclc.py ├── _wiki.py ├── registry.py └── _core.py ├── .coveragerc ├── .mention-bot ├── .github ├── CODEOWNERS ├── labeler.yml ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── other_issue.md │ ├── feature_request.md │ └── bug_report.md ├── automation │ ├── label.yml │ ├── greetings.yml │ └── stale.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── wip.yml │ ├── create-release.yaml │ ├── codeql-analysis.yml │ └── basictests.yml ├── PLUGIN.zip ├── release.md ├── MANIFEST.in ├── SECURITY.md ├── AUTHORS.md ├── requirements-dev.txt ├── tox.ini ├── COPYRIGHT.txt ├── setup.cfg ├── CODE_OF_CONDUCT.md ├── setup.py ├── CHANGES.txt ├── CONTRIBUTING.md ├── LICENSE-LGPL-3.0.txt └── README.rst /requirements.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /isbnlib/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /isbnlib/_data/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | vias.py 4 | -------------------------------------------------------------------------------- /.mention-bot: -------------------------------------------------------------------------------- 1 | { 2 | "maxReviewers": 1 3 | } 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | isbnlib/* @xlcnd 2 | docs/* @xlcnd 3 | -------------------------------------------------------------------------------- /PLUGIN.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KBNLresearch/isbnlib/dev/PLUGIN.zip -------------------------------------------------------------------------------- /release.md: -------------------------------------------------------------------------------- 1 | ## What's new? 2 | 3 | 1. Updated data for new issued books. 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | include *.rst 3 | include *.coveragerc 4 | include LICENSE-LGPL-3.0.txt 5 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | repo: 2 | - ./* 3 | 4 | test: 5 | - isbnlib/test/* 6 | 7 | docs: 8 | - docs/* 9 | 10 | core: 11 | - isbnlib/* 12 | 13 | dev: 14 | - isbnlib/dev/* 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "06:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/other_issue.md: -------------------------------------------------------------------------------- 1 | ## Required Information 2 | 3 | *Please, classify the issue below.* 4 | 5 | **Question, Discussion or Info?** 6 | *Type*: here 7 | 8 | 9 | ## Issue Description 10 | 11 | Describe your issue here 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/automation/label.yml: -------------------------------------------------------------------------------- 1 | name: Labeler 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | label: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/labeler@v2 12 | with: 13 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 14 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **IMPORTANT** 2 | 3 | Make your pull request against the ``dev`` branch, please! 4 | 5 | 6 | On follow-up of issue #??? 7 | 8 | 9 | Changes proposed in this pull request: 10 | 11 | - 12 | 13 | - 14 | 15 | - 16 | 17 | 18 | @xlcnd 19 | -------------------------------------------------------------------------------- /.github/workflows/wip.yml: -------------------------------------------------------------------------------- 1 | name: WIP 2 | on: 3 | pull_request: 4 | types: [ opened, synchronize, reopened, edited ] 5 | 6 | jobs: 7 | wip: 8 | runs-on: ubuntu-latest 9 | env: 10 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 11 | steps: 12 | - uses: wip/action@v1.1.0 13 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | If a vulnerability is found a new maintenance version will be release 6 | as soon as possible. All the previous versions should be considered unsafe. 7 | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | If you found a vulnerability, please send an email to xlcnd@outlook.com, and I will give you access 12 | to a private part of the repository to discuss further the issue. 13 | -------------------------------------------------------------------------------- /isbnlib/test/test_goom.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """ 5 | nose tests 6 | """ 7 | 8 | from nose.tools import assert_equals 9 | 10 | from .. import _goom as goom 11 | 12 | 13 | def test_goom(): 14 | """Test the Google's Multiple Books service.""" 15 | assert_equals(len(repr(goom.query('the old man and the sea'))) > 500, True) 16 | assert_equals(len(repr(goom.query('plato republic'))) > 500, True) 17 | -------------------------------------------------------------------------------- /isbnlib/test/test_webservice.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """ 5 | nose tests 6 | """ 7 | 8 | from nose.tools import assert_equals 9 | 10 | from ..dev.webservice import query as wsquery 11 | 12 | 13 | def test_webservice(): 14 | """Test that values can be passed to a WebService query.""" 15 | assert_equals( 16 | len(repr(wsquery('http://example.org', values={'some': 'values'}))) > 0, True, 17 | ) 18 | -------------------------------------------------------------------------------- /.github/automation/greetings.yml: -------------------------------------------------------------------------------- 1 | name: Greetings 2 | 3 | on: [pull_request, issues] 4 | 5 | jobs: 6 | greeting: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/first-interaction@v1 10 | with: 11 | repo-token: ${{ secrets.GITHUB_TOKEN }} 12 | issue-message: 'Thank you for your interest in `isbnlib`. I hope to answer your issue soon.' 13 | pr-message: 'Thanks for you contribution to `isbnlib`. I hope to review it soon.' 14 | -------------------------------------------------------------------------------- /isbnlib/test/test_openl.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """ nose tests 5 | """ 6 | 7 | from nose.tools import assert_equals 8 | 9 | from .._metadata import query 10 | 11 | 12 | def test_query(): 13 | """Test 'openl' metadata service.""" 14 | # test query from metadata 15 | assert_equals(len(repr(query('9780195132861', 'openl'))) > 140, True) 16 | assert_equals(len(repr(query('9780156001311', 'openl'))) > 140, True) 17 | -------------------------------------------------------------------------------- /isbnlib/test/test_words.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """ 5 | nose tests 6 | """ 7 | 8 | from nose.tools import assert_equals 9 | 10 | from .. import _gwords as words 11 | 12 | 13 | def test_words(): 14 | """Test 'isbn_from_words' function.""" 15 | assert_equals(len(words.goos('the old man and the sea')), 13) 16 | #assert_equals(len(words.goos('Pessoa Desassossego')), 13) 17 | # assert_equals(words.goos('-ISBN -isbn') in ('9781364200329', None), True) 18 | -------------------------------------------------------------------------------- /.github/automation/stale.yml: -------------------------------------------------------------------------------- 1 | name: Mark stale issues and pull requests 2 | 3 | on: 4 | schedule: 5 | - cron: "0 3 * * *" 6 | 7 | jobs: 8 | stale: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/stale@v1 14 | with: 15 | repo-token: ${{ secrets.GITHUB_TOKEN }} 16 | stale-issue-message: 'This needs attention!' 17 | stale-pr-message: 'What happened?' 18 | stale-issue-label: 'no-issue-activity' 19 | stale-pr-label: 'no-pr-activity' 20 | -------------------------------------------------------------------------------- /isbnlib/test/test_unicode_to_utf8tex.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """ 5 | nose tests 6 | """ 7 | 8 | from nose.tools import assert_equals 9 | 10 | from ..dev._helpers import unicode_to_utf8tex 11 | 12 | 13 | def test_unicode_to_utf8tex(): 14 | """Test 'unicode_to_utf8tex' identity transformation.""" 15 | assert_equals(unicode_to_utf8tex(u'\u00E2 \u00F5'), b'\^{a}\space \~{o}') 16 | assert_equals(unicode_to_utf8tex(u'\u00E2 \u00F5', (b'\\space ',)), b'\^{a} \~{o}') 17 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | 2 | Main Author 3 | =========== 4 | 5 | Alexandre Conde (@xlcnd) 6 | 7 | 8 | 9 | 10 | With fine contributions from 11 | ---------------------------- 12 | 13 | Nicolas Cisco (@NickCis) 14 | 15 | Deirdre Connolly (@dconnolly) 16 | 17 | Daniel Himmelstein (@dhimmel) 18 | 19 | Robert Schütz (@dotlambda) 20 | 21 | 22 | 23 | 24 | 25 | --- 26 | https://github.com/xlcnd/isbnlib/graphs/contributors 27 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | bandit==1.7.0 2 | coverage==6.0.1 3 | flake8==3.9.2 4 | flake8-blind-except==0.2.0 5 | flake8-bugbear==21.9.2 6 | flake8-commas==2.0.0 7 | flake8-comprehensions==3.6.1 8 | flake8-deprecated==1.3 9 | flake8-docstrings==1.6.0 10 | flake8-import-order==0.18.1 11 | flake8-mutable==1.2.0 12 | flake8-pep3101==1.3.0 13 | flake8-polyfill==1.0.2 14 | flake8-string-format==0.3.0 15 | flake8-tidy-imports==4.4.1 16 | isort==5.9.3 17 | nose==1.3.7 18 | pep8-naming==0.12.1 19 | radon==5.1.0 20 | tox==3.24.4 21 | wheel==0.37.0 22 | yapf==0.31.0 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | 2 | **Is your feature request related to a problem? Please describe.** 3 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 4 | 5 | **Describe the solution you'd like** 6 | A clear and concise description of what you want to happen. 7 | 8 | **Describe alternatives you've considered** 9 | A clear and concise description of any alternative solutions or features you've considered. 10 | 11 | **Additional context** 12 | Add any other context or screenshots about the feature request here. 13 | -------------------------------------------------------------------------------- /isbnlib/test/test_wiki.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """ nose tests for wikipedia.""" 5 | 6 | from nose.tools import assert_equals 7 | 8 | from .._metadata import query 9 | 10 | 11 | def test_query(): 12 | """Test 'wiki' metadata service.""" 13 | # test query from metadata 14 | assert_equals(len(repr(query('9780195132861', 'wiki'))) > 100, True) 15 | assert_equals(len(repr(query('9780375869020', 'wiki'))) > 100, True) 16 | data = query('9780596003302', 'wiki') 17 | assert_equals(len(repr(data['Authors'])) > 5, True) 18 | assert_equals(len(repr(data['Publisher'])) > 5, True) 19 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 4 | ignore=C901,D105,D107,E126,E722,E741,I100,I101,I201,N802,N806,W503 5 | exclude=*/test/*,*/_data/* 6 | max-complexity=11 7 | 8 | [tox] 9 | envlist=py27,py36,py37,py38,py39,nightly,checkers 10 | basepython=python3.7 11 | 12 | [testenv] 13 | deps= 14 | nose 15 | coverage 16 | setenv = APPVEYOR = {env:APPVEYOR:} 17 | commands= 18 | nosetests -v --with-coverage --cover-package=isbnlib --cover-min-percentage=90 19 | 20 | [testenv:checkers] 21 | #basepython=python3.7 22 | deps= 23 | flake8 24 | flake8-bugbear 25 | flake8-commas 26 | flake8-import-order 27 | pep8-naming 28 | commands= 29 | flake8 isbnlib 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | 2 | **Describe the bug** 3 | A clear and concise description of what the bug is. 4 | 5 | **To Reproduce** 6 | Steps to reproduce the behavior: 7 | 1. Run this code '...' 8 | 2. Use this argument on function '....' 9 | 10 | **Expected behavior** 11 | A clear and concise description of what you expected to happen. 12 | 13 | **Screenshots** 14 | If applicable, add screenshots to help explain your problem. 15 | 16 | **Your computer (please complete the following information):** 17 | - OS: [e.g. Linux] 18 | - Python version [e.g. 3.6.5] 19 | - isbnlib version [e.g. 3.8.5] 20 | 21 | **How did you install isbnlib?** 22 | [e.g. `pip install isbnlib`] 23 | 24 | **Additional context** 25 | Add any other context about the problem here. 26 | -------------------------------------------------------------------------------- /isbnlib/_doitotex.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Return metadata, of a given DOI, formatted as BibTeX.""" 3 | 4 | import logging 5 | 6 | from .dev import cache 7 | from .dev.webservice import query 8 | 9 | LOGGER = logging.getLogger(__name__) 10 | 11 | URL = 'http://dx.doi.org/{DOI}' 12 | UA = 'isbnlib (gzip)' 13 | 14 | 15 | @cache 16 | def doi2tex(doi): 17 | """Get the bibtex ref for doi.""" 18 | data = query( 19 | URL.format(DOI=doi), 20 | user_agent=UA, 21 | appheaders={ 22 | 'Accept': 'application/x-bibtex; charset=utf-8', 23 | }, 24 | ) # noqa pragma: no cover 25 | if not data: # pragma: no cover 26 | LOGGER.warning('no data return for doi: %s', doi) 27 | return data if data else None # pragma: no cover 28 | -------------------------------------------------------------------------------- /isbnlib/dev/helpers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8:noqa 3 | # pylint: skip-file 4 | """Expose useful features.""" 5 | 6 | from .._imcache import IMCache 7 | from ._files import File, cwdfiles 8 | from ._fmt import _fmtbib, _fmts 9 | from ._helpers import ( 10 | cutoff_tokens, 11 | fake_isbn, 12 | last_first, 13 | normalize_space, 14 | parse_placeholders, 15 | ) 16 | from ._helpers import unicode_to_utf8tex as to_utf8tex 17 | 18 | # alias (to keep backwards compatibility) 19 | fmtbib = _fmtbib 20 | fmts = _fmts 21 | 22 | __all__ = [ 23 | 'File', 24 | 'IMCache', 25 | 'cutoff_tokens', 26 | 'cwdfiles', 27 | 'fmtbib', 28 | 'fmts', 29 | 'last_first', 30 | 'normalize_space', 31 | 'parse_placeholders', 32 | 'to_utf8tex', 33 | 'fake_isbn', 34 | ] 35 | -------------------------------------------------------------------------------- /COPYRIGHT.txt: -------------------------------------------------------------------------------- 1 | 2 | isbnlib - tools for extracting, cleaning and transforming ISBNs 3 | Copyright (C) 2014-2021 Alexandre Lima Conde 4 | SPDX-License-Identifier: LGPL-3.0-or-later 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU Lesser General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU Lesser General Public License 17 | along with this program. If not, see . 18 | -------------------------------------------------------------------------------- /isbnlib/test/test_registry.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | 5 | from nose.tools import assert_equals, assert_raises 6 | 7 | from ..registry import setdefaultbibformatter, setdefaultservice 8 | 9 | 10 | # nose tests 11 | def test_setdefaultbibformatter(): 12 | """Test setdefaultbibformatter.""" 13 | assert_equals(setdefaultbibformatter('json'), None) 14 | assert_raises(Exception, setdefaultbibformatter, 'default') 15 | assert_raises(Exception, setdefaultbibformatter, '') 16 | assert_raises(Exception, setdefaultbibformatter, 'xxx') 17 | 18 | 19 | def test_setdefaultservice(): 20 | """Test setdefaultservice.""" 21 | assert_equals(setdefaultservice('goob'), None) 22 | assert_raises(Exception, setdefaultservice, 'default') 23 | assert_raises(Exception, setdefaultservice, '') 24 | assert_raises(Exception, setdefaultservice, 'xxx') 25 | -------------------------------------------------------------------------------- /isbnlib/_cover.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Get image links of the book's cover.""" 3 | 4 | import logging 5 | 6 | from .dev import cache 7 | from .dev.webquery import query as wquery 8 | 9 | LOGGER = logging.getLogger(__name__) 10 | 11 | UA = 'isbnlib (gzip)' 12 | SERVICE_URL = ( 13 | 'https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn}' 14 | '&fields=items/volumeInfo(title,subtitle,authors,publisher,publishedDate,' 15 | 'language,industryIdentifiers,description,imageLinks)&maxResults=1') 16 | 17 | 18 | @cache 19 | def cover(isbn): 20 | """Get the urls for covers from Google Books.""" 21 | data = wquery(SERVICE_URL.format(isbn=isbn), user_agent=UA) 22 | urls = {} 23 | try: 24 | urls = data['items'][0]['volumeInfo']['imageLinks'] 25 | except (KeyError, IndexError): # pragma: no cover 26 | LOGGER.debug('No cover img data for %s', isbn) 27 | return urls 28 | -------------------------------------------------------------------------------- /.github/workflows/create-release.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v*' 5 | 6 | name: Create Release 7 | 8 | jobs: 9 | build: 10 | name: Create Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2.3.5 15 | - name: Check if it is 'NOT RELEASED' 16 | shell: bash 17 | run: | 18 | [[ ! -z $(tail -1 CHANGES.txt | grep -o 'NOT RELEASED\|TENTATIVE') ]] && echo 'NO_RELEASE condition detected! Skip release.' && exit 1 || exit 0 19 | - name: Create Release 20 | id: create_release 21 | uses: actions/create-release@v1.1.4 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | with: 25 | tag_name: ${{ github.ref }} 26 | release_name: Release ${{ github.ref }} 27 | body_path: release.md 28 | draft: false 29 | prerelease: false 30 | -------------------------------------------------------------------------------- /isbnlib/_metadata.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # isort:skip_file 3 | """Query providers for metadata.""" 4 | 5 | import logging 6 | 7 | from ._core import EAN13 8 | from ._exceptions import NotRecognizedServiceError, NotValidISBNError 9 | from .dev import cache 10 | 11 | LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | @cache 15 | def query(isbn, service='default'): 16 | """Query services like Google Books (JSON API), ... for metadata.""" 17 | # validate inputs 18 | ean = EAN13(isbn) 19 | if not ean: 20 | LOGGER.critical('%s is not a valid ISBN', isbn) 21 | raise NotValidISBNError(isbn) 22 | isbn = ean 23 | # only import when needed 24 | from .registry import services 25 | 26 | if service != 'default' and service not in services: # pragma: no cover 27 | LOGGER.critical('%s is not a valid service', service) 28 | raise NotRecognizedServiceError(service) 29 | meta = services[service](isbn) 30 | return meta or {} 31 | -------------------------------------------------------------------------------- /isbnlib/test/test_exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """nose tests for Exceptions.""" 5 | 6 | from nose.tools import assert_equals, assert_raises 7 | 8 | from .. import ISBNLibException 9 | from .._ext import meta 10 | 11 | 12 | def test_catchall(): 13 | """Test the 'catch all' exception (ISBNLibException).""" 14 | assert_raises(Exception, meta, '9781849692343', 'goob', None) 15 | 16 | def f1(): 17 | try: 18 | meta('9781849692343') 19 | except ISBNLibException as ex: 20 | return str(ex.message) 21 | 22 | assert_equals(f1(), '(9781849692343) is not a valid ISBN') 23 | 24 | def f2(): 25 | try: 26 | meta('9789720049612', 'xxx') 27 | except ISBNLibException as ex: 28 | return str(ex.message) 29 | 30 | assert_equals(f2(), '(xxx) is not a recognized service') 31 | 32 | 33 | # NOTE the tests for other Exceptions are spread in the other tests 34 | -------------------------------------------------------------------------------- /isbnlib/dev/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Interface for namespace 'isbnlib.dev'.""" 3 | 4 | from ._data import Metadata, stdmeta 5 | from ._decorators import cache 6 | from ._exceptions import ( 7 | DataNotFoundAtServiceError, 8 | DataWrongShapeError, 9 | ISBNLibDevException, 10 | ISBNLibHTTPError, 11 | ISBNLibURLError, 12 | NoAPIKeyError, 13 | NoDataForSelectorError, 14 | NotValidMetadataError, 15 | RecordMappingError, 16 | ServiceIsDownError, 17 | ) 18 | from .webquery import WEBQuery 19 | from .webservice import WEBService 20 | 21 | __all__ = ( 22 | 'ISBNLibDevException', 23 | 'ISBNLibURLError', 24 | 'ISBNLibHTTPError', 25 | 'DataNotFoundAtServiceError', 26 | 'ServiceIsDownError', 27 | 'DataWrongShapeError', 28 | 'NotValidMetadataError', 29 | 'NoDataForSelectorError', 30 | 'RecordMappingError', 31 | 'NoAPIKeyError', 32 | 'Metadata', 33 | 'stdmeta', 34 | 'WEBService', 35 | 'WEBQuery', 36 | 'cache', 37 | ) 38 | -------------------------------------------------------------------------------- /isbnlib/test/test_speed.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """Crude Timer for 'import isbnlib'.""" 5 | 6 | import sys 7 | import time 8 | 9 | 10 | def test_speed_isbnlib(): 11 | """Test import speed of 'isbnlib'.""" 12 | if sys.version < '3': return True 13 | t = time.process_time() 14 | import isbnlib 15 | 16 | elapsed_time = time.process_time() - t 17 | millis = int(elapsed_time * 1000) 18 | print('(isbnlib) {} milliseconds < 100 milliseconds'.format(millis)) 19 | assert millis < 100 20 | isbnlib.__version__ 21 | 22 | 23 | def test_speed_registry(): 24 | """Test import speed of 'registry'.""" 25 | if sys.version < '3': return True 26 | t = time.process_time() 27 | from isbnlib import registry 28 | 29 | elapsed_time = time.process_time() - t 30 | millis = int(elapsed_time * 1000) 31 | print('(registry) {} milliseconds < 135 milliseconds'.format(millis)) 32 | assert millis < 135 33 | registry.BIBFORMATS 34 | -------------------------------------------------------------------------------- /isbnlib/_wikied.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Query the wikipedia.org service for related ISBNs.""" 3 | 4 | import logging 5 | 6 | from ._core import to_isbn13 7 | from .dev.webquery import query as wquery 8 | 9 | UA = 'isbnlib (gzip)' 10 | SERVICE_URL = 'https://en.wikipedia.org/api/rest_v1/data/citation/mediawiki/{isbn}' 11 | LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | # pylint: disable=broad-except 15 | def _parser(isbn, data): 16 | """Parse the response from the Wikipedia service.""" 17 | editions = [to_isbn13(isbn)] 18 | try: 19 | records = data[0].get('ISBN', []) 20 | eds = {to_isbn13(isbn) for isbn in records} 21 | editions.extend(eds) 22 | except Exception: # pragma: no cover 23 | LOGGER.debug('No data from "wikipedia" for isbn %s', isbn) 24 | return editions 25 | 26 | 27 | def query(isbn): 28 | """Query the wikipedia.org service for 'editions'.""" 29 | data = wquery(SERVICE_URL.format(isbn=isbn), user_agent=UA, throttling=0) 30 | return set(_parser(isbn, data)) 31 | -------------------------------------------------------------------------------- /isbnlib/dev/_bouth23.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8:noqa 3 | # pylint: skip-file 4 | """Help code to run in py2 and py3.""" 5 | 6 | import sys 7 | 8 | if sys.version < '3': 9 | 10 | def s(x): 11 | return x 12 | 13 | def b(x): 14 | return x 15 | 16 | def u(x): 17 | try: 18 | return unicode(x, 'utf-8') 19 | except TypeError: 20 | return x 21 | 22 | def b2u3(x): 23 | return x.encode('utf-8') 24 | 25 | def type3str(): 26 | return type(u'') 27 | 28 | def bstream(x): 29 | from StringIO import StringIO 30 | 31 | return StringIO(x) 32 | 33 | else: 34 | 35 | def s(x): 36 | return x.decode('utf-8', 'ignore') 37 | 38 | def b(x): 39 | return x.encode('utf-8') 40 | 41 | def u(x): 42 | return x 43 | 44 | def b2u3(x): 45 | return x 46 | 47 | def type3str(): 48 | return type('') 49 | 50 | def bstream(x): 51 | from io import BytesIO 52 | 53 | return BytesIO(x) 54 | -------------------------------------------------------------------------------- /isbnlib/test/test_cache.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """Tests for the cache.""" 5 | 6 | from nose.tools import assert_equals 7 | 8 | from .._imcache import IMCache 9 | 10 | cache = IMCache() 11 | 12 | 13 | def setup_module(): 14 | cache['123'] = 'abc' # <-- set 15 | 16 | 17 | def teardown_module(): 18 | del cache['123'] 19 | 20 | 21 | def test_cache_set(): 22 | """Test 'cache' operations (set).""" 23 | cache['567'] = 'jkl' 24 | assert_equals('jkl' == cache['567'], True) 25 | 26 | 27 | def test_cache_get(): 28 | """Test 'cache' operations (get).""" 29 | assert_equals(cache.get('123'), cache['123']) 30 | assert_equals(cache.get('000'), None) 31 | assert_equals(cache.get('000', ''), '') 32 | 33 | 34 | def test_cache_contains(): 35 | """Test 'cache' operations (contains).""" 36 | assert_equals('123' in cache, True) 37 | 38 | 39 | def test_cache_del(): 40 | """Test 'cache' operations (del).""" 41 | del cache['567'] 42 | assert_equals('567' not in cache, True) 43 | -------------------------------------------------------------------------------- /isbnlib/test/test_fmt.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """ 5 | nose tests 6 | """ 7 | 8 | from nose.tools import assert_equals 9 | 10 | from ..dev._bouth23 import u 11 | from ..dev._fmt import _fmtbib 12 | 13 | canonical = { 14 | 'ISBN-13': u('9780123456789'), 15 | 'Title': u('A book about nothing'), 16 | 'Publisher': u('No Paper Press'), 17 | 'Year': u('2000'), 18 | 'Language': u('en'), 19 | 'Authors': [u('John Smith'), u('José Silva')], 20 | } 21 | 22 | 23 | def test_fmtbib(): 24 | """Test the formatting into several bibliographic formats.""" 25 | assert_equals(len(_fmtbib('bibtex', canonical)), 182) 26 | assert_equals(len(_fmtbib('labels', canonical)), 158) 27 | assert_equals(len(_fmtbib('endnote', canonical)), 103) 28 | assert_equals(len(_fmtbib('msword', canonical)), 485) 29 | assert_equals(len(_fmtbib('json', canonical)), 229) 30 | assert_equals(len(_fmtbib('csl', canonical)), 253) 31 | assert_equals(len(_fmtbib('csv', canonical)), 94) 32 | assert_equals(len(_fmtbib('ris', canonical)), 130) 33 | assert_equals(len(_fmtbib('opf', canonical)), 861) 34 | -------------------------------------------------------------------------------- /isbnlib/_infogroup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Get the Language/Country of an ISBN.""" 3 | 4 | import logging 5 | 6 | from ._core import EAN13 7 | from ._data.data4info import countries, identifiers 8 | from ._exceptions import NotValidISBNError 9 | 10 | LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | def infogroup(isbn): 14 | """Get the Language/Country of this ISBN.""" 15 | # if isbn is not a valid ISBN this def can give a wrong result! 16 | # => clean and validate 17 | isbn = EAN13(isbn) 18 | if not isbn: 19 | LOGGER.critical('%s is not a valid ISBN', isbn) 20 | raise NotValidISBNError(isbn) 21 | # put isbn in the form 978-... 22 | prefix = isbn[0:3] + '-' 23 | isbn = prefix + isbn[3:] 24 | dtxt = countries 25 | idents = identifiers 26 | ixi, ixf = 4, 5 27 | for ident in idents: 28 | iid = prefix + isbn[ixi:ixf] 29 | ixf += 1 30 | # stop if identifier is found 31 | if iid in ident: 32 | return dtxt[iid] 33 | LOGGER.debug( 34 | 'Identifier not found for %s (probably not issued yet!)', 35 | isbn, 36 | ) 37 | return '' 38 | -------------------------------------------------------------------------------- /isbnlib/_desc.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Return a small description of the book.""" 3 | 4 | import logging 5 | from json import loads 6 | from textwrap import fill 7 | 8 | from .dev import cache 9 | from .dev.webservice import query as wsquery 10 | 11 | LOGGER = logging.getLogger(__name__) 12 | 13 | UA = 'isbnlib (gzip)' 14 | SERVICE_URL = ( 15 | 'https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn}' 16 | '&fields=items/volumeInfo(title,subtitle,authors,publisher,publishedDate,' 17 | 'language,industryIdentifiers,description,imageLinks)&maxResults=1') 18 | 19 | 20 | # pylint: disable=broad-except 21 | @cache 22 | def goo_desc(isbn): 23 | """Get description from Google Books api.""" 24 | url = SERVICE_URL.format(isbn=isbn) 25 | content = wsquery(url, user_agent=UA) 26 | try: 27 | content = loads(content) 28 | content = content['items'][0]['volumeInfo']['description'] 29 | # TODO(MV) don't format content here! 30 | content = fill(content, width=75) if content else '' 31 | return content 32 | except Exception: # pragma: no cover 33 | LOGGER.debug('No description for %s', isbn) 34 | return '' 35 | -------------------------------------------------------------------------------- /isbnlib/test/test_cache_decorator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """Tests for the @cache.""" 5 | 6 | # TODO add more tests for other operations 7 | 8 | from nose.tools import assert_equals 9 | 10 | from .. import classify, meta, registry 11 | 12 | cache = registry.metadata_cache 13 | 14 | 15 | def setup_module(): 16 | meta('9780375869020') # <-- set 17 | classify('9781118241257') # <-- set 18 | 19 | 20 | def teardown_module(): 21 | del cache["query('9780375869020', 'default'){}"] 22 | 23 | 24 | def test_cache_meta(): 25 | """Test '@cache' meta.""" 26 | assert_equals( 27 | len(repr(cache.get("query('9780375869020', 'default'){}"))) > 100, True, 28 | ) 29 | assert_equals( 30 | len(repr(cache.get("query('9780375869020', 'default'){}"))), 31 | len(repr(cache["query('9780375869020', 'default'){}"])), 32 | ) 33 | 34 | 35 | # def test_cache_classify(): 36 | # """Test '@cache' classify.""" 37 | # assert_equals(len(repr(cache.get("query_classify('9781118241257',){}"))) > 5, True) 38 | # assert_equals( 39 | # len(repr(cache.get("query_classify('9781118241257',){}"))), 40 | # len(repr(cache["query_classify('9781118241257',){}"])), 41 | # ) 42 | -------------------------------------------------------------------------------- /isbnlib/_isbn.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Isbn class.""" 3 | 4 | import logging as _logging 5 | 6 | from ._core import EAN13, canonical, to_isbn10 7 | from ._exceptions import NotValidISBNError 8 | from ._ext import doi, info, mask 9 | 10 | LOGGER = _logging.getLogger(__name__) 11 | 12 | 13 | # pylint: disable=useless-object-inheritance 14 | # pylint: disable=too-many-instance-attributes 15 | # pylint: disable=too-few-public-methods 16 | # pylint: disable=broad-except 17 | class Isbn(object): 18 | """Class for ISBN objects.""" 19 | def __init__(self, isbnlike): 20 | try: 21 | self.ean13 = EAN13(isbnlike) 22 | if not self.ean13: 23 | raise NotValidISBNError(isbnlike) 24 | except Exception: 25 | LOGGER.debug('error: %s is not a valid ISBN', isbnlike) 26 | raise NotValidISBNError(isbnlike) 27 | self.canonical = canonical(isbnlike) 28 | self.isbn13 = mask(self.ean13) or self.ean13 29 | self.isbn10 = mask(to_isbn10(self.ean13)) or to_isbn10(self.ean13) 30 | self.doi = doi(self.ean13) 31 | self.info = info(self.ean13) 32 | self.issued = '-' in self.isbn13 33 | 34 | def __str__(self): 35 | return str(vars(self)) 36 | 37 | def __repr__(self): 38 | return self.__str__() 39 | -------------------------------------------------------------------------------- /isbnlib/_thinged.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Query the ThingISBN api (Library Thing) for related ISBNs.""" 3 | 4 | import logging 5 | from xml.dom.minidom import parseString 6 | 7 | from ._core import EAN13 8 | from .dev.webquery import query as wquery 9 | 10 | LOGGER = logging.getLogger(__name__) 11 | UA = 'isbnlib (gzip)' 12 | SERVICE_URL = 'http://www.librarything.com/api/thingISBN/{isbn}' 13 | 14 | 15 | def _get_text(topnode): 16 | """Get the text values in the child nodes.""" 17 | text = '' 18 | for node in topnode.childNodes: 19 | if node.nodeType == node.TEXT_NODE: 20 | text = text + node.data 21 | return text 22 | 23 | 24 | def parser_thinged(xml): 25 | """Parse the response from the ThingISBN service.""" 26 | dom = parseString(xml) 27 | nodes = dom.getElementsByTagName('idlist')[0].getElementsByTagName('isbn') 28 | return [EAN13(_get_text(isbn)) for isbn in nodes] 29 | 30 | 31 | def query(isbn): 32 | """Query the ThingISBN service for related ISBNs.""" 33 | data = wquery( 34 | SERVICE_URL.format(isbn=isbn), 35 | user_agent=UA, 36 | parser=parser_thinged, 37 | ) 38 | if not data: # pragma: no cover 39 | LOGGER.debug('No data from ThingISBN for isbn %s', isbn) 40 | data = [] 41 | data.append(EAN13(isbn)) 42 | return set(data) 43 | -------------------------------------------------------------------------------- /isbnlib/test/test_info.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | 5 | from nose.tools import assert_equals, assert_raises 6 | 7 | from .._ext import info 8 | from .._infogroup import infogroup 9 | 10 | 11 | # nose tests 12 | def test_infogroup(): 13 | """Test 'infogroup' language/country function.""" 14 | assert_equals(infogroup('9789727576807'), 'Portugal') 15 | assert_equals(infogroup('978-972-757-680-7'), 'Portugal') 16 | assert_equals(infogroup('7500117019'), "China, People's Republic") 17 | assert_equals(infogroup('7-5001-1701-9'), "China, People's Republic") 18 | assert_equals(infogroup('9524712946'), 'Finland') 19 | assert_equals(infogroup('0330284983'), 'English language') 20 | assert_equals(infogroup('3796519008'), 'German language') 21 | assert_raises(Exception, infogroup, '92xxxxxxxxxxx') 22 | assert_raises(Exception, infogroup, '') 23 | assert_equals(infogroup('9791090636071'), 'France') 24 | assert_equals(infogroup('9786131796364'), 'Mauritius') 25 | assert_equals(infogroup('9789992158104'), 'Qatar') 26 | 27 | 28 | def test_ext_info(): 29 | """Test 'info' language/country function.""" 30 | assert_equals(info('9524712946'), 'Finland') 31 | assert_raises(Exception, info, '') 32 | 33 | 34 | def test_ext_info(): 35 | """Test 'info' not issued ISBN.""" 36 | assert_equals(info('9789999999991'), '') 37 | -------------------------------------------------------------------------------- /isbnlib/_gwords.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Use Google to get an ISBN from words from title and author's name.""" 3 | 4 | import logging 5 | 6 | from ._core import get_canonical_isbn, get_isbnlike 7 | from .dev import cache, webservice 8 | 9 | try: # pragma: no cover 10 | from urllib.parse import quote 11 | except ImportError: # pragma: no cover 12 | from urllib import quote 13 | 14 | LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | @cache 18 | def goos(words): 19 | """Use Google to get an ISBN from words from title and author's name.""" 20 | service_url = 'http://www.google.com/search?q=ISBN+' 21 | search_url = service_url + quote(words.replace(' ', '+')) 22 | 23 | user_agent = 'w3m/0.5.3' 24 | 25 | content = webservice.query( 26 | search_url, 27 | user_agent=user_agent, 28 | appheaders={ 29 | 'Content-Type': 'text/plain; charset="UTF-8"', 30 | 'Content-Transfer-Encoding': 'Quoted-Printable', 31 | }, 32 | ) 33 | isbns = get_isbnlike(content) 34 | isbn = '' 35 | try: 36 | for item in isbns: 37 | isbn = get_canonical_isbn(item, output='isbn13') 38 | if isbn: # pragma: no cover 39 | break 40 | except IndexError: # pragma: no cover 41 | pass 42 | if not isbns or not isbn: # pragma: no cover 43 | LOGGER.debug('No ISBN found for %s', words) 44 | return isbn 45 | -------------------------------------------------------------------------------- /isbnlib/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Config file for isbnlib.""" 3 | 4 | # --> Import only external modules! <-- 5 | 6 | # API keys 7 | apikeys = {} 8 | 9 | 10 | def add_apikey(service, apikey): # pragma: no cover 11 | """Add API keys.""" 12 | global apikeys 13 | apikeys[service.lower()] = apikey 14 | 15 | 16 | # Options 17 | options = { 18 | 'LOAD_FORMATTER_PLUGINS': True, 19 | 'LOAD_METADATA_PLUGINS': True, 20 | 'THREADS_TIMEOUT': 12, # seconds 21 | 'URLOPEN_TIMEOUT': 10, # seconds 22 | 'VIAS_MERGE': 'parallel', 23 | } 24 | 25 | 26 | def set_option(option, value): # pragma: no cover 27 | """Set the value for option.""" 28 | global options 29 | options[option.upper()] = value 30 | 31 | 32 | # URLOPEN_TIMEOUT is used by webservice 33 | def seturlopentimeout(seconds): # pragma: no cover 34 | """Set the value of URLOPEN_TIMEOUT (in seconds).""" 35 | set_option('URLOPEN_TIMEOUT', seconds) 36 | 37 | 38 | # THREADS_TIMEOUT is a parameter used downstream by Thread calls (see vias.py) 39 | def setthreadstimeout(seconds): # pragma: no cover 40 | """Set the value of THREADS_TIMEOUT (in seconds).""" 41 | set_option('THREADS_TIMEOUT', seconds) 42 | 43 | 44 | def setloadplugins(boolean=True): # pragma: no cover 45 | """Set the value for all LOAD_XXX_PLUGINS.""" 46 | set_option('LOAD_METADATA_PLUGINS', boolean) 47 | set_option('LOAD_FORMATTER_PLUGINS', boolean) 48 | -------------------------------------------------------------------------------- /isbnlib/_openled.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Query the Open Library for related ISBNs.""" 3 | 4 | import logging 5 | 6 | from . import get_canonical_isbn, get_isbnlike 7 | from .dev._bouth23 import u 8 | from .dev.webquery import query as wquery 9 | 10 | LOGGER = logging.getLogger(__name__) 11 | UA = 'isbnlib (gzip)' 12 | SERVICE_URL = 'http://openlibrary.org/query.json?type=/type/edition&{selectors}' 13 | CODES = 'isbn_13={isbn}' 14 | ISBNS = '{code}&isbn_13=&isbn_10=' # FIXME(delete '&isbn_10=') 15 | 16 | 17 | # pylint: disable=broad-except 18 | def query(isbn): 19 | """Query the Open Library for related ISBNs.""" 20 | try: 21 | data = wquery( 22 | SERVICE_URL.format(selectors=CODES.format(isbn=isbn)), 23 | user_agent=UA, 24 | ) 25 | codes = {rec['key'] for rec in data} 26 | isbnlikes = [isbn] 27 | for code in codes: 28 | txt = wquery( 29 | SERVICE_URL.format(selectors=ISBNS.format(code=code)), 30 | user_agent=UA, 31 | parser=None, 32 | ) 33 | isbnlikes.extend(get_isbnlike(txt)) 34 | isbns = {u(get_canonical_isbn(isbnlike)) for isbnlike in isbnlikes} 35 | except Exception as ex: # pragma: no cover 36 | LOGGER.debug( 37 | 'No data from Open Library for isbn %s -- %s', 38 | isbn, 39 | str(ex), 40 | ) 41 | return {get_canonical_isbn(isbn)} 42 | return isbns 43 | -------------------------------------------------------------------------------- /isbnlib/dev/_decorators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # isort:skip_file 3 | """Decorator for isbnlib.""" 4 | from functools import wraps 5 | from .._imcache import IMCache 6 | 7 | im_cache = IMCache(maxlen=200) 8 | 9 | 10 | def cache(func): 11 | """Cache decorator (cache).""" 12 | # noqa 13 | @wraps(func) 14 | def memoized_func(*args, **kwargs): 15 | from ..registry import metadata_cache # <-- dynamic and lazy 16 | cch = metadata_cache 17 | if cch is None: # pragma: no cover 18 | return func(*args, **kwargs) 19 | 20 | # Persistent caches will NOT work IF 21 | # 'func' has callables in the arguments 22 | key = str(func.__name__) + str(args) + str(kwargs) 23 | 24 | if key in cch: 25 | return cch[key] 26 | else: 27 | value = func(*args, **kwargs) 28 | if value: 29 | cch[key] = value 30 | return value 31 | 32 | return memoized_func 33 | 34 | 35 | def imcache(func): 36 | """Cache decorator (imcache).""" 37 | # noqa 38 | @wraps(func) 39 | def memoized_func(*args, **kwargs): 40 | cch = im_cache 41 | 42 | key = str(func.__name__) + str(args) + str(kwargs) 43 | 44 | if key in cch: 45 | return cch[key] 46 | else: 47 | value = func(*args, **kwargs) 48 | if value: 49 | cch[key] = value 50 | return value 51 | 52 | return memoized_func 53 | -------------------------------------------------------------------------------- /isbnlib/test/test_isbn.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """nose tests""" 5 | 6 | from nose.tools import assert_equals, assert_raises 7 | 8 | from .._isbn import Isbn 9 | 10 | isbn=Isbn('9781250158062') 11 | 12 | 13 | def test_ean13(): 14 | """Test the 'Isbn class' for ean13.""" 15 | assert_equals(isbn.ean13, '9781250158062') 16 | 17 | def test_isbn13(): 18 | """Test the 'Isbn class' for isbn13.""" 19 | assert_equals(isbn.isbn13, '978-1-250-15806-2') 20 | 21 | def test_isbn10(): 22 | """Test the 'Isbn class' for isbn10.""" 23 | assert_equals(isbn.isbn10, '1-250-15806-0') 24 | 25 | def test_doi(): 26 | """Test the 'Isbn class' for doi.""" 27 | assert_equals(isbn.doi, '10.978.1250/158062') 28 | 29 | def test_issued(): 30 | """Test the 'Isbn class' for 'issued'.""" 31 | assert_equals(isbn.issued, True) 32 | isbn2=Isbn('9786610326266') 33 | assert_equals(isbn2.issued, False) 34 | 35 | def test_info(): 36 | """Test the 'Isbn class' for 'info'.""" 37 | assert_equals(isbn.info, 'English language') 38 | 39 | def test_errors(): 40 | """Test the 'Isbn class' for 'bad isbn'.""" 41 | assert_raises(Exception, Isbn, '781250158062') 42 | 43 | def test_str(): 44 | """Test the 'Isbn class' for 'str'.""" 45 | assert_equals(len(str(isbn)) > 20, True) 46 | 47 | def test_repr(): 48 | """Test the 'Isbn class' for 'repr'.""" 49 | assert_equals(len(repr(isbn)) > 20, True) 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /isbnlib/test/test_metadata.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """nose tests for metadata.""" 5 | 6 | from random import randrange 7 | 8 | from nose.tools import assert_equals, assert_raises 9 | 10 | from .._ext import meta 11 | from .._metadata import query 12 | 13 | 14 | def test_query(): 15 | """Test the query of metadata with 'low level' queries.""" 16 | # test query from metadata 17 | assert_raises(Exception, query, '9781849692341', 'goog') 18 | assert_raises(Exception, query, '9781849692343', 'goob') 19 | # assert_equals(query('9789934015960', 'goob'), {}) 20 | assert_equals(len(repr(query('9780321534965'))) > 100, True) 21 | assert_equals(len(repr(query('9780321534965', 'goob'))) > 100, True) 22 | # assert_equals(len(repr(query('9789934015960'))) > 100, True) 23 | assert_equals(len(repr(query(u'9781118241257'))) > 100, True) 24 | assert_raises(Exception, query, '9780000000', 'goob') 25 | assert_raises(Exception, query, randrange(0, 1000000), 'goob') 26 | 27 | 28 | def test_ext_meta(): 29 | """Test the query of metadata with 'high level' meta function.""" 30 | # test meta from core 31 | assert_equals(len(repr(meta('9780321534965', 'goob'))) > 100, True) 32 | assert_equals(len(repr(meta('9780321534965'))) > 100, True) 33 | assert_raises(Exception, meta, '9780000000', 'goob') 34 | assert_raises(Exception, meta, randrange(0, 1000000), 'goob') 35 | assert_raises(Exception, meta, '9781849692343', 'goob') 36 | -------------------------------------------------------------------------------- /isbnlib/_imcache.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Read and write to a dict-like cache.""" 3 | 4 | try: 5 | from collections.abc import MutableMapping # noqa 6 | except ImportError: # PY27 7 | from collections import MutableMapping # noqa 8 | 9 | 10 | class IMCache(MutableMapping): 11 | """Read and write to a dict-like cache.""" 12 | 13 | MAXLEN = 1000 14 | 15 | # pylint: disable=keyword-arg-before-vararg 16 | def __init__(self, maxlen=MAXLEN, *a, **k): 17 | self.filepath = 'IN MEMORY' 18 | self.maxlen = maxlen 19 | self.d = dict(*a, **k) 20 | while len(self) > maxlen: # pragma: no cache 21 | self.popitem() 22 | 23 | def __iter__(self): 24 | return iter(self.d) 25 | 26 | def __len__(self): 27 | return len(self.d) 28 | 29 | def __getitem__(self, k): 30 | return self.d[k] 31 | 32 | def __setitem__(self, k, v): 33 | if k not in self and len(self) == self.maxlen: 34 | self.popitem() 35 | self.d[k] = v 36 | 37 | def __contains__(self, key): 38 | return key in self.d 39 | 40 | def __delitem__(self, k): 41 | del self.d[k] 42 | 43 | def __bool__(self): 44 | return len(self) != 0 45 | 46 | # For PY2 compatibility 47 | __nonzero__ = __bool__ 48 | 49 | def __call__(self, k): 50 | """Implement function call operator.""" 51 | try: 52 | return self.__getitem__(k) 53 | except KeyError: 54 | return None 55 | -------------------------------------------------------------------------------- /isbnlib/test/test_vias.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """ 5 | nose tests 6 | """ 7 | 8 | import os 9 | import platform 10 | 11 | from nose.tools import assert_equals 12 | 13 | from ..dev import vias 14 | 15 | 16 | def task1(arg): 17 | return arg * arg 18 | 19 | 20 | def task2(arg): 21 | return arg + arg 22 | 23 | 24 | def test_vias_serial(): 25 | """Test 'vias' (serial).""" 26 | named_tasks = (('task1', task1), ('task2', task2)) 27 | results = vias.serial(named_tasks, 5) 28 | data1 = results.get('task1', 0) 29 | data2 = results.get('task2', 0) 30 | data = data1 + data2 31 | assert_equals(data, 5 * 5 + 5 + 5) 32 | 33 | 34 | def test_vias_parallel(): 35 | """Test 'vias' (parallel).""" 36 | named_tasks = (('task1', task1), ('task2', task2)) 37 | results = vias.parallel(named_tasks, 5) 38 | data1 = results.get('task1', 0) 39 | data2 = results.get('task2', 0) 40 | data = data1 + data2 41 | assert_equals(data, 5 * 5 + 5 + 5) 42 | 43 | 44 | def test_vias_multi(): 45 | """Test 'vias' (multi).""" 46 | # Is NOT allowed in Windows & macOS! 47 | if os.getenv('APPVEYOR', '') != '': 48 | return True 49 | if os.getenv('GITHUB_OS', '') == 'windows': 50 | return True 51 | if os.getenv('GITHUB_OS', '') == 'macOS': 52 | return True 53 | if platform.system() == 'Windows': 54 | return True 55 | if platform.system() == 'Darwin': 56 | return True 57 | named_tasks = (('task1', task1), ('task2', task2)) 58 | results = vias.multi(named_tasks, 5) 59 | data1 = results.get('task1', 0) 60 | data2 = results.get('task2', 0) 61 | data = data1 + data2 62 | assert_equals(data, 5 * 5 + 5 + 5) 63 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "code scanning" 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 2 * * 4' 8 | 9 | jobs: 10 | CodeQL-Build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2.3.5 17 | with: 18 | # We must fetch at least the immediate parents so that if this is 19 | # a pull request then we can checkout the head. 20 | fetch-depth: 2 21 | 22 | # If this run was triggered by a pull request event, then checkout 23 | # the head of the pull request instead of the merge commit. 24 | - run: git checkout HEAD^2 25 | if: ${{ github.event_name == 'pull_request' }} 26 | 27 | # Initializes the CodeQL tools for scanning. 28 | - name: Initialize CodeQL 29 | uses: github/codeql-action/init@v1 30 | # Override language selection by uncommenting this and choosing your languages 31 | # with: 32 | # languages: go, javascript, csharp, python, cpp, java 33 | 34 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 35 | # If this step fails, then you should remove it and run the build manually (see below) 36 | - name: Autobuild 37 | uses: github/codeql-action/autobuild@v1 38 | 39 | # ℹ️ Command-line programs to run using the OS shell. 40 | # 📚 https://git.io/JvXDl 41 | 42 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 43 | # and modify them (or add more) to build your code if your project 44 | # uses a compiled language 45 | 46 | #- run: | 47 | # make bootstrap 48 | # make release 49 | 50 | - name: Perform CodeQL Analysis 51 | uses: github/codeql-action/analyze@v1 52 | -------------------------------------------------------------------------------- /isbnlib/test/test_classify.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """nose tests for classifiers.""" 5 | 6 | from nose.tools import assert_equals 7 | 8 | from .._oclc import query_classify as query 9 | 10 | # this is a slow service 11 | 12 | q1 = query('9781118241257') or {} 13 | q2 = query('9780425284629') or {} 14 | 15 | 16 | 17 | def test_query(): 18 | """Test the query of classifiers (oclc.org) with 'low level' queries.""" 19 | assert_equals(len(repr(query('9782253112105'))) > 10, True) 20 | assert_equals(len(repr(q1)) > 10, True) 21 | assert_equals(len(repr(q2)) > 10, True) 22 | 23 | 24 | def test_query_no_data(): 25 | """Test the query of classifiers (oclc.org) with 'low level' queries (no data).""" 26 | assert_equals(len(repr(query('9781849692341'))) == 2, True) 27 | assert_equals(len(repr(query('9781849692343'))) == 2, True) 28 | 29 | 30 | def test_query_exists_ddc(): 31 | """Test exists 'DDC'.""" 32 | assert_equals(len(repr(q1['ddc'])) > 2, True) 33 | assert_equals(len(repr(q2['ddc'])) > 2, True) 34 | 35 | 36 | def test_query_exists_lcc(): 37 | """Test exists 'LCC'.""" 38 | assert_equals(len(repr(q1['lcc'])) > 2, True) 39 | assert_equals(len(repr(q2['lcc'])) > 2, True) 40 | 41 | 42 | def test_query_fast(): 43 | """Test exists 'fast' classifiers.""" 44 | assert_equals(len(repr(q1['fast'])) > 10, True) 45 | assert_equals(len(repr(q2['fast'])) > 10, True) 46 | 47 | 48 | def test_query_owi(): 49 | """Test exists 'owi' classifiers.""" 50 | assert_equals(len(repr(q1['owi'])) > 10, True) 51 | assert_equals(len(repr(q2['owi'])) > 10, True) 52 | 53 | 54 | def test_query_oclc(): 55 | """Test exists 'oclc' classifiers.""" 56 | assert_equals(len(repr(q1['oclc'])) > 10, True) 57 | assert_equals(len(repr(q2['oclc'])) > 10, True) 58 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license=LGPL v3 3 | license_file=LICENSE-LGPL-3.0.txt 4 | platforms=any 5 | description=Extract, clean, transform, hyphenate and metadata for ISBNs (International Standard Book Number). 6 | long_description=file: README.rst 7 | keywords=ISBN, metadata, World_Catalogue, Google_Books, Wikipedia, Open_Library, BibTeX, EndNote, RefWorks, MSWord, opf, BibJSON 8 | classifier= 9 | Programming Language :: Python 10 | Programming Language :: Python :: 3 11 | Programming Language :: Python :: 3.6 12 | Programming Language :: Python :: 3.7 13 | Programming Language :: Python :: 3.8 14 | Programming Language :: Python :: 3.9 15 | License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3) 16 | Operating System :: OS Independent 17 | Development Status :: 5 - Production/Stable 18 | Intended Audience :: Developers 19 | Topic :: Text Processing :: General 20 | Topic :: Software Development :: Libraries :: Python Modules 21 | 22 | 23 | [bdist_wheel] 24 | universal=1 25 | 26 | 27 | [nosetests] 28 | verbosity=1 29 | with-coverage=1 30 | cover-package=isbnlib 31 | cover-branches=1 32 | cover-min-percentage=90 33 | 34 | 35 | [flake8] 36 | max-line-length=88 37 | exclude=*/test/*,*/_data/* 38 | max-complexity=11 39 | # To work with Black 40 | # E501: line too long 41 | # W503: Line break occurred before a binary operator 42 | # E203: Whitespace before ':' 43 | # D202 No blank lines allowed after function docstring 44 | # W504 line break after binary operator 45 | ignore= 46 | E501, 47 | W503, 48 | E203, 49 | D202, 50 | W504, 51 | C901, 52 | D105, 53 | D107, 54 | D204, 55 | E126, 56 | E722, 57 | E741, 58 | I100, 59 | I101, 60 | I201, 61 | N802, 62 | N806, 63 | S001, 64 | W503 65 | extend-ignore=E203,S001 66 | 67 | 68 | [pep8] 69 | ignore=E701,E70,E702,W503 70 | max-line-length=80 71 | -------------------------------------------------------------------------------- /isbnlib/test/test_helpers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """ 5 | nose tests 6 | """ 7 | 8 | from nose.tools import assert_equals 9 | 10 | from ..dev._helpers import cutoff_tokens, fake_isbn, last_first, parse_placeholders 11 | 12 | 13 | def test_last_first(): 14 | """Test the parsing of author's name into (Surname, First Name).""" 15 | assert_equals( 16 | last_first('Surname, First Name'), {'last': 'Surname', 'first': 'First Name'}, 17 | ) 18 | assert_equals( 19 | last_first('First Name Surname'), {'last': 'Surname', 'first': 'First Name'}, 20 | ) 21 | assert_equals( 22 | last_first('Surname1, First1 and Sur2, First2'), 23 | {'last': 'Surname1', 'first': 'First1 and Sur2, First2'}, 24 | ) 25 | 26 | 27 | def test_cutoff_tokens(): 28 | """Test the 'cutoff_tokens' function.""" 29 | assert_equals(cutoff_tokens(['1', '23', '456'], 3), ['1', '23']) 30 | 31 | 32 | def test_parse_placeholders(): 33 | """Test the parsing of placeholders.""" 34 | assert_equals(parse_placeholders('{isbn}_akaj_{name}'), ['{isbn}', '{name}']) 35 | 36 | 37 | def test_fake_isbn(): 38 | """Test the 'fake_isbn' function.""" 39 | assert_equals(fake_isbn(' Hello?? Wer, ! ksDf: asdf. ; '), '1111006407537') 40 | assert_equals( 41 | fake_isbn(' Hello?? Wer, ! ksDf: asdf. ; ', author=''), '1108449680873', 42 | ) 43 | assert_equals( 44 | fake_isbn(' Hello?? Wer, ! ksDf: asdf. ; ', author=' '), '1108449680873', 45 | ) 46 | assert_equals( 47 | fake_isbn(' Hello?? Wer, ! ksDf: asdf. ; ', author='', publisher=''), 48 | '1181593982422', 49 | ) 50 | assert_equals( 51 | fake_isbn(' Hello?? Wer, ! ksDf: asdf. ; ', author='a', publisher='K'), 52 | '1895031085488', 53 | ) 54 | assert_equals( 55 | fake_isbn(' Hello?? Wer, ! ksDf: asdf. ; ', author='A', publisher='k'), 56 | '1895031085488', 57 | ) 58 | -------------------------------------------------------------------------------- /isbnlib/test/test_editions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | 5 | try: 6 | from time import process_time as timer 7 | except: # for py2 8 | import timeit 9 | 10 | timer = timeit.default_timer 11 | 12 | from nose.tools import assert_equals, raises 13 | 14 | from .._exceptions import NotRecognizedServiceError, NotValidISBNError 15 | from .._ext import editions 16 | 17 | # nose tests 18 | 19 | 20 | def test_editions_openl(): 21 | """Test the 'openl editions' service.""" 22 | assert_equals(len(editions('9780099536017', service='openl')) > 1, True) 23 | 24 | 25 | def test_editions_thingl(): 26 | """Test the 'thingl editions' service.""" 27 | assert_equals(len(editions('9780151446476', service='thingl')) > 2, True) 28 | 29 | 30 | def test_editions_wiki(): 31 | """Test the 'wiki editions' service.""" 32 | assert_equals(len(editions('9780375869020', service='wiki')) > 5, True) 33 | 34 | 35 | def test_editions_any(): 36 | """Test the 'any editions' service.""" 37 | assert_equals(len(editions('9780151446476', service='any')) > 1, True) 38 | 39 | 40 | def test_editions_merge(): 41 | """Test the 'merge editions' service.""" 42 | assert_equals(len(editions('9780151446476', service='merge')) > 2, True) 43 | 44 | 45 | @raises(NotValidISBNError) 46 | def test_editions_NotValidISBNError(): 47 | """Test the 'editions' service error detection (NotValidISBNError).""" 48 | editions('978') 49 | 50 | 51 | @raises(NotRecognizedServiceError) 52 | def test_editions_NotRecognizedServiceError(): 53 | """Test the 'editions' service error detection (NotRecognizedServiceError).""" 54 | editions('9780156001311', service='xxx') 55 | 56 | 57 | def test_cache(): 58 | """Test the 'editions' cache.""" 59 | t = timer() 60 | assert_equals(len(editions('9780151446476', service='merge')) > 19, True) 61 | elapsed_time = timer() - t 62 | millis = int(elapsed_time * 1000) 63 | assert_equals(millis < 100, True) 64 | -------------------------------------------------------------------------------- /isbnlib/_msk.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Hyphenate an ISBN.""" 3 | 4 | import logging 5 | 6 | from ._core import EAN13, canonical, to_isbn13 7 | from ._data.data4mask import ranges 8 | from ._exceptions import NotValidISBNError 9 | 10 | LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | def msk(isbn, separator='-'): 14 | """Transform a canonical ISBN to a `masked` one. 15 | 16 | `Mask` the ISBN, separating by identifier 17 | ISBN-10 identifiers: country-publisher-title-check 18 | 19 | Used the iterative version of the `sliding-window` algorithm. 20 | Not pretty, but fast! Lines 42-52 implement the search loop: 21 | O(n) for n (number of keys), 22 | with data structure 'ranges' (see data4mask.py) 23 | """ 24 | if not isbn: 25 | return '' 26 | ib = canonical(isbn) 27 | ean = EAN13(ib) 28 | if len(ib) not in (10, 13) or not ean: 29 | LOGGER.critical('%s is not a valid ISBN', isbn) 30 | raise NotValidISBNError(isbn) 31 | 32 | isbn10 = False 33 | if len(ib) == 10: 34 | check10 = ib[-1] 35 | ib = to_isbn13(ib) 36 | isbn10 = True 37 | 38 | idx = None 39 | check = ib[-1:] # <-- HACK! 40 | cur = 3 41 | igi = cur 42 | buf = ib[igi:cur + 1] 43 | group = ib[0:cur] + '-' + buf 44 | 45 | for _ in range(6): # pragma: no cover 46 | if group in ranges: 47 | sevens = ib[cur + 1:cur + 8].ljust(7, '0') 48 | for l in ranges[group]: 49 | if l[0] <= int(sevens) <= l[1]: 50 | idx = l[2] 51 | break 52 | break 53 | cur += 1 54 | buf = ib[igi:cur + 1] 55 | group = group + buf[-1:] # <-- HACK! 56 | 57 | if idx: 58 | if isbn10: 59 | group = group[4:] 60 | check = check10 61 | return separator.join( 62 | [group, ib[cur + 1:cur + idx + 1], ib[cur + idx + 1:-1], check]) 63 | LOGGER.warning('identifier not found!') # pragma: no cover 64 | return '' # pragma: no cover 65 | -------------------------------------------------------------------------------- /isbnlib/test/data4tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | ISBNs = r""" 3 | ISBN 9781849284677 4 | qwe iuwer 9780312640583 kjhfds 5 | bh bh isbn 978-0312640583 6 | bh bh isbn 978 031264058 3 7 | bh bh isbn978 031264058 3 8 | ISBN 0-330-28498-3 jhjhISBN 1-58182-008-9 9 | ISBN 2-226-05257-7 10 | ISBN 3-7965-1900-8 11 | ISBN 4-19-830127-1 12 | ISBN 5-85270-001-0 13 | ISBN 978-600-119-125-1 14 | ISBN 978-601-7151-13-3 15 | ISBN 978-602-8328-22-7 16 | ISBN 978-603-500-045-1 17 | ISBN 605-384-057-2 18 | ISBN 978-606-8126-35-7 19 | ISBN 978-607-455-035-1 20 | ISBN 978-608-203-023-4 21 | ISBN 978-612-45165-9-7 22 | ISBN 978-614-404-018-8 23 | ISBN 978-615-5014-99-4 24 | ISBN 7-301-10299-2 25 | ISBN 80-85983-44-3 26 | ISBN 81-7215-399-6 27 | ISBN 82-530-0983-6 28 | ISBN 83-08-01587-5 29 | ISBN 84-86546-08-7 30 | ISBN 85-7531-015-1 31 | ISBN 86-341-0846-5 32 | ISBN 87-595-2277-1 33 | ISBN 88-04-47328-2 34 | ISBN 90-5691-187-2 35 | ISBN 91-1-811692-2 36 | ISBN 92-67-10370-9 37 | ISBN 93-8011-236-7 38 | ISBN 94-414-0063-3 39 | ISBN 950-04-0442-7 40 | ISBN 951-0-11369-7 41 | ISBN 952-471-294-6 42 | ISBN 953-157-105-8 43 | ISBN 954-430-603-X 44 | ISBN 955-20-3051-X 45 | ISBN 956-7291-48-9 46 | ISBN 957-01-7429-3 47 | ISBN 958-04-6278-X 48 | ISBN 959-10-0363-3 49 | ISBN 961-6403-23-0 50 | ISBN 962-04-0195-6 51 | ISBN 963-7971-51-3 52 | ISBN 964-6194-70-2 53 | ISBN 965-359-002-2 54 | ISBN 966-95440-5-X 55 | ISBN 967-978-753-2 56 | ISBN 968-6031-02-2 57 | ISBN 969-031-02-2 58 | ISBN 970-20-0242-7 59 | ISBN 971-8845-10-0 60 | ISBN 972-37-0274-6 61 | ISBN 973-43-0179-9 62 | ISBN 974-85854-7-6 63 | ISBN 975-293-381-5 64 | ISBN 976-640-140-3 65 | ISBN 977-734-520-8 66 | ISBN 978-37186-2-2 67 | ISBN 979-553-483-1 68 | ISBN 980-01-0194-2 69 | ISBN 981-3018-39-9 70 | ISBN 982-301-001-3 71 | ISBN 983-52-0157-9 72 | ISBN 984-458-089-7 73 | ISBN 986-417-191-7 74 | ISBN 987-98184-2-3 75 | ISBN 978-988-00-3827-3 76 | ISBN 978-9928400529 77 | ISBN 978-9929801646 78 | ISBN 978-9930943106 79 | ISBN 978-9933101473 80 | ISBN 978-9934015960 81 | ISBN 978-99937-1-056-1 82 | ISBN 978-99965-2-047-1 83 | """ 84 | 85 | # flake8: noqa 86 | -------------------------------------------------------------------------------- /isbnlib/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | """Library to validate, clean, transform and get metadata of ISBN strings (for devs).""" 4 | 5 | # Define isbnlib API and set lib environment 6 | 7 | import logging as _logging 8 | 9 | from ._core import ( 10 | EAN13, 11 | GTIN13, 12 | RE_ISBN10, 13 | RE_ISBN13, 14 | RE_LOOSE, 15 | RE_NORMAL, 16 | RE_STRICT, 17 | canonical, 18 | check_digit10, 19 | check_digit13, 20 | clean, 21 | get_canonical_isbn, 22 | get_isbnlike, 23 | is_isbn10, 24 | is_isbn13, 25 | notisbn, 26 | to_isbn10, 27 | to_isbn13, 28 | ) 29 | from ._data.data4info import RDDATE 30 | from ._doitotex import doi2tex 31 | from ._exceptions import ( 32 | ISBNLibException, 33 | NotRecognizedServiceError, 34 | NotValidDefaultFormatterError, 35 | NotValidDefaultServiceError, 36 | NotValidISBNError, 37 | PluginNotLoadedError, 38 | quiet_errors, 39 | ) 40 | from ._ext import cover, desc, doi, editions, info, isbn_from_words, mask, meta, ren 41 | from ._goom import query as goom 42 | from ._isbn import Isbn 43 | from ._oclc import query_classify as classify 44 | 45 | # config _logging for lib 46 | _nh = _logging.NullHandler() 47 | _logging.getLogger('isbnlib').addHandler(_nh) 48 | 49 | # alias 50 | ean13 = EAN13 51 | ISBN13 = EAN13 52 | 53 | # dunders 54 | __all__ = ( 55 | 'canonical', 56 | 'check_digit10', 57 | 'check_digit13', 58 | 'classify', 59 | 'clean', 60 | 'cover', 61 | 'desc', 62 | 'doi', 63 | 'doi2tex', 64 | 'ean13', 65 | 'EAN13', 66 | 'editions', 67 | 'get_canonical_isbn', 68 | 'get_isbnlike', 69 | 'goom', 70 | 'GTIN13', 71 | 'info', 72 | 'Isbn', 73 | 'ISBN13', 74 | 'isbn_from_words', 75 | 'ISBNLibException', 76 | 'is_isbn10', 77 | 'is_isbn13', 78 | 'mask', 79 | 'meta', 80 | 'notisbn', 81 | 'NotRecognizedServiceError', 82 | 'NotValidDefaultFormatterError', 83 | 'NotValidDefaultServiceError', 84 | 'NotValidISBNError', 85 | 'PluginNotLoadedError', 86 | 'quiet_errors', 87 | 'RDDATE', 88 | 'ren', 89 | 'to_isbn10', 90 | 'to_isbn13', 91 | '__support__', 92 | '__version__', 93 | ) 94 | __version__ = '3.10.9' 95 | __support__ = 'py27, py35, py36, py37, py38, py39, pypy, pypy3' 96 | -------------------------------------------------------------------------------- /isbnlib/dev/vias.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Process tasks in several modes.""" 3 | 4 | import logging 5 | 6 | from ..config import options 7 | 8 | LOGGER = logging.getLogger(__name__) 9 | 10 | 11 | # pylint: disable=broad-except 12 | def serial(named_tasks, arg): 13 | """Use serial calls.""" 14 | results = {} 15 | for name, task in named_tasks: 16 | try: 17 | results[name] = task(arg) 18 | except Exception: # pragma: no cover 19 | LOGGER.debug( 20 | "No result in 'serial' for %s[%s](%s)", 21 | task, 22 | name, 23 | arg, 24 | ) 25 | results[name] = None 26 | return results 27 | 28 | 29 | # pylint: disable=broad-except 30 | def parallel(named_tasks, arg): 31 | """Use threaded calls.""" 32 | from threading import Thread 33 | 34 | results = {} 35 | 36 | def _worker(name, task, arg): 37 | try: 38 | results[name] = task(arg) 39 | except Exception: # pragma: no cover 40 | LOGGER.debug( 41 | "No result in 'parallel' for %s[%s](%s)", 42 | task, 43 | name, 44 | arg, 45 | ) 46 | results[name] = None 47 | 48 | for name, task in named_tasks: 49 | t = Thread(target=_worker, args=(name, task, arg)) 50 | t.start() 51 | t.join(options.get('THREADS_TIMEOUT')) 52 | return results 53 | 54 | 55 | # pylint: disable=broad-except 56 | def multi(named_tasks, arg): 57 | """Use several cores (if available).""" 58 | from multiprocessing import Process, Queue 59 | 60 | results = {} 61 | q = Queue() 62 | 63 | def _worker(name, task, arg, q): 64 | try: # pragma: no cover 65 | q.put((name, task(arg))) 66 | except Exception: # pragma: no cover 67 | LOGGER.debug( 68 | "No result in 'multi' for %s[%s](%s)", 69 | task, 70 | name, 71 | arg, 72 | ) 73 | q.put((name, None)) 74 | 75 | for name, task in named_tasks: 76 | p = Process(target=_worker, args=(name, task, arg, q)) 77 | p.start() 78 | p.join(options.get('THREADS_TIMEOUT')) 79 | q.put('STOP') 80 | 81 | while True: 82 | el = q.get() 83 | if el == 'STOP': 84 | break 85 | results[el[0]] = el[1] 86 | return results 87 | -------------------------------------------------------------------------------- /isbnlib/test/test_files.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """ nose tests 5 | 6 | """ 7 | 8 | import locale 9 | import os 10 | 11 | from ..dev._files import File, cwdfiles 12 | 13 | WINDOWS = os.name == 'nt' 14 | ENCODING = locale.getpreferredencoding() 15 | if ENCODING == 'UTF-8': 16 | TESTFILE = './ç-deleteme.pdf' if WINDOWS else '/tmp/海明威-deleteme.pdf' 17 | NEW_BASENAME = 'ç-deleteme-PLEASE.pdf' if WINDOWS else '海明威-deleteme-PLEASE.pdf' 18 | else: 19 | print( 20 | "Your default locale encoding (%s) doesn't allow unicode filenames!" % ENCODING, 21 | ) 22 | TESTFILE = './deleteme.pdf' 23 | NEW_BASENAME = 'deleteme-PLEASE.pdf' 24 | 25 | 26 | def setup_module(): 27 | with open(TESTFILE, 'w') as f: 28 | f.write('ooo') 29 | os.chdir(os.path.dirname(TESTFILE)) 30 | 31 | 32 | def teardown_module(): 33 | os.remove(os.path.join(os.path.dirname(TESTFILE), NEW_BASENAME)) 34 | 35 | 36 | def test_isfile(): 37 | """Test if a path is a file.""" 38 | f = File(TESTFILE) 39 | assert f.isfile(TESTFILE) == True 40 | 41 | 42 | def test_exists(): 43 | """Test if a path is a file or a directory.""" 44 | f = File(TESTFILE) 45 | assert f.exists(TESTFILE) == True 46 | 47 | 48 | def test_validate(): 49 | """Test if a string is a valid filename for 'ren' command.""" 50 | f = File(TESTFILE) 51 | assert f.validate('basename.pdf') == True 52 | assert f.validate('as/basename.pdf') == False 53 | assert f.validate('.basename.pdf') == True 54 | assert f.validate('.basename') == False 55 | assert f.validate('') == False 56 | 57 | 58 | def test_mkwinsafe(): 59 | """Test if a string is a valid basename in Windows.""" 60 | f = File(TESTFILE) 61 | assert f.mkwinsafe('Açtr: ') == 'Açtr' 62 | assert f.mkwinsafe('as/tiõ') == 'astiõ' 63 | assert f.mkwinsafe('file ""name?') == 'file name' 64 | assert f.mkwinsafe('file ""name?', space='_') == 'file_name' 65 | assert f.mkwinsafe('file name ') == 'file name' 66 | 67 | 68 | def test_baserename(): 69 | """Test the rename of a basename.""" 70 | f = File(TESTFILE) 71 | assert f.baserename(NEW_BASENAME) == True 72 | assert f.baserename(NEW_BASENAME) == True 73 | 74 | 75 | def test_cwdfiles(): 76 | """Test the renaming of files in cwd.""" 77 | assert (NEW_BASENAME in cwdfiles()) == True 78 | assert (NEW_BASENAME in cwdfiles('*.pdf')) == True 79 | assert (NEW_BASENAME in cwdfiles('*.txt')) == False 80 | -------------------------------------------------------------------------------- /isbnlib/_exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | """Exceptions for isbnlib.""" 4 | 5 | import sys 6 | 7 | 8 | # pylint: disable=unused-argument 9 | def quiet_errors(exc_type, exc_value, traceback): 10 | """Define error format suitable for end user scripts. 11 | 12 | Usage: enter the following lines in your script 13 | from isbnlib import quiet_errors 14 | sys.excepthook = quiet_errors 15 | """ 16 | sys.stderr.write('Error: %s\n' % exc_value) # pragma: no cover 17 | 18 | 19 | class ISBNLibException(Exception): 20 | """Base class for isbnlib exceptions. 21 | 22 | This exception should not be raised directly, 23 | only subclasses of this exception should be used! 24 | """ 25 | def __str__(self): 26 | """Print message.""" 27 | return getattr(self, 'message', '') # pragma: no cover 28 | 29 | 30 | # pylint: disable=super-init-not-called 31 | class NotRecognizedServiceError(ISBNLibException): 32 | """Exception raised when the service is not in config.py.""" 33 | def __init__(self, service): 34 | """Define message.""" 35 | self.message = '(%s) is not a recognized service' % service 36 | 37 | 38 | # pylint: disable=super-init-not-called 39 | class NotValidDefaultServiceError(ISBNLibException): 40 | """Exception raised when the service is not valid for default.""" 41 | def __init__(self, service): 42 | """Define message.""" 43 | self.message = '(%s) is not a valid default service' % service 44 | 45 | 46 | # pylint: disable=super-init-not-called 47 | class NotValidDefaultFormatterError(ISBNLibException): 48 | """Exception raised when the formatter is not valid for default.""" 49 | def __init__(self, formatter): 50 | """Define message.""" 51 | self.message = '(%s) is not a valid default formatter' % formatter 52 | 53 | 54 | # pylint: disable=super-init-not-called 55 | class NotValidISBNError(ISBNLibException): 56 | """Exception raised when the ISBN is not valid.""" 57 | def __init__(self, isbnlike): 58 | """Define message.""" 59 | self.message = '(%s) is not a valid ISBN' % isbnlike 60 | 61 | 62 | # pylint: disable=super-init-not-called 63 | class PluginNotLoadedError(ISBNLibException): # pragma: no cover 64 | """Exception raised when the plugin's loader doesn't load the plugin. 65 | 66 | TODO: Delete this in version 4? 67 | """ 68 | def __init__(self, path): 69 | """Define message.""" 70 | self.message = "plugin (%s) wasn't loaded" % path 71 | -------------------------------------------------------------------------------- /isbnlib/dev/webquery.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Base class to query a webservice and parse the result to py objects.""" 3 | 4 | import json 5 | import logging 6 | from time import sleep 7 | from time import time as timestamp 8 | 9 | from . import webservice 10 | from ._exceptions import DataNotFoundAtServiceError, ServiceIsDownError 11 | 12 | UA = 'isbnlib (gzip)' 13 | OUT_OF_SERVICE = 'Temporarily out of service' 14 | BOOK_NOT_FOUND = 'No results match your search' 15 | LOGGER = logging.getLogger(__name__) 16 | THROTTLING = 1 17 | 18 | 19 | # pylint: disable=useless-object-inheritance 20 | class WEBQuery(object): 21 | """Base class to query a webservice and parse the result to py objects.""" 22 | 23 | T = {'id': timestamp()} # noqa 24 | 25 | def __init__(self, service_url, ua=UA, throttling=THROTTLING): 26 | """Initialize & call webservice.""" 27 | srv = service_url[8:20] 28 | last = WEBQuery.T[srv] if srv in WEBQuery.T else 0.0 29 | wait = 0 if timestamp() - last > throttling else throttling 30 | sleep(wait) 31 | self.url = service_url 32 | self.data = webservice.query(service_url, ua) 33 | WEBQuery.T[srv] = timestamp() 34 | 35 | def check_data(self, data_checker=None): # pragma: no cover 36 | """Check the data & handle errors.""" 37 | if data_checker: 38 | return data_checker(self.data) 39 | if self.data == '{}': # noqa 40 | LOGGER.warning('DataNotFoundAtServiceError for %s', self.url) 41 | raise DataNotFoundAtServiceError(self.url) 42 | if BOOK_NOT_FOUND in self.data: 43 | LOGGER.warning('DataNotFoundAtServiceError for %s', self.url) 44 | raise DataNotFoundAtServiceError(self.url) 45 | if OUT_OF_SERVICE in self.data: 46 | LOGGER.critical('ServiceIsDownError for %s', self.url) 47 | raise ServiceIsDownError(self.url) 48 | return True 49 | 50 | def parse_data(self, parser=json.loads): 51 | """Parse the data (default JSON -> PY).""" 52 | if parser is None: # pragma: no cover 53 | return self.data 54 | return parser(self.data) # <-- data is now unicode 55 | 56 | 57 | def query( 58 | url, 59 | user_agent=UA, 60 | data_checker=None, 61 | parser=json.loads, 62 | throttling=THROTTLING, 63 | ): 64 | """Put call and return the data from the web service.""" 65 | wq = WEBQuery(url, user_agent, throttling) 66 | return wq.parse_data(parser) if wq.check_data(data_checker) else None 67 | -------------------------------------------------------------------------------- /isbnlib/test/test_ext.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | 5 | from nose.tools import assert_equals, assert_raises 6 | 7 | from .._ext import cover, desc, doi, isbn_from_words, mask 8 | 9 | # nose tests 10 | 11 | 12 | def test_mask(): 13 | """Test 'mask' command.""" 14 | assert_equals(mask('5852700010'), '5-85270-001-0') 15 | assert_equals(mask('0330284983'), '0-330-28498-3') 16 | assert_equals(mask('3796519008'), '3-7965-1900-8') 17 | assert_equals(mask('4198301271'), '4-19-830127-1') 18 | assert_equals(mask('2226052577'), '2-226-05257-7') 19 | assert_equals(mask('6053840572'), '605-384-057-2') 20 | assert_equals(mask('7301102992'), '7-301-10299-2') 21 | assert_equals(mask('8085983443'), '80-85983-44-3') 22 | assert_equals(mask('9056911872'), '90-5691-187-2') 23 | assert_equals(mask('9500404427'), '950-04-0442-7') 24 | assert_equals(mask('9800101942'), '980-01-0194-2') 25 | assert_equals(mask('9813018399'), '981-3018-39-9') 26 | assert_equals(mask('9786001191251'), '978-600-119-125-1') 27 | assert_equals(mask('9780321534965'), '978-0-321-53496-5') 28 | assert_equals(mask('9781590593561'), '978-1-59059-356-1') 29 | assert_equals(mask('9789993075899'), '978-99930-75-89-9') 30 | assert_equals(mask('0-330284983'), '0-330-28498-3') 31 | assert_equals(mask('9791090636071'), '979-10-90636-07-1') 32 | assert_equals(mask('9786131796364'), '978-613-1-79636-4') # <-- prefix with 1 rule 33 | assert_equals(mask('isbn 979-10-90636-07-1'), '979-10-90636-07-1') 34 | assert_equals(mask(''), '') 35 | assert_raises(Exception, mask, '9786') 36 | assert_raises(Exception, mask, '0000000000000') 37 | 38 | 39 | def test_isbn_from_words(): 40 | """Test 'isbn_from_words' command.""" 41 | assert_equals(len(isbn_from_words('old men and sea')), 13) 42 | 43 | 44 | def test_doi(): 45 | """Test 'doi' command.""" 46 | assert_equals(doi('9780195132861'), '10.978.019/5132861') 47 | assert_equals(doi('9780321534965'), '10.978.0321/534965') 48 | assert_equals(doi('9791090636071'), '10.979.1090636/071') 49 | 50 | 51 | def test_desc(): 52 | """Test 'desc' command.""" 53 | assert_equals(len(desc('9780156001311')) > 10, True) 54 | assert_equals(desc('9780000000000'), '') 55 | 56 | 57 | def test_cover(): 58 | """Test 'cover' command.""" 59 | assert_equals(len(repr(cover('9780156001311'))) > 50, True) 60 | assert_equals(cover('9780000000000'), {}) # <-- invalid ISBN 61 | assert_equals(len(repr(cover('9781408835029'))) > 50, True) 62 | assert_equals( 63 | len(repr(cover('9789727576807'))) < 50, True, 64 | ) # <-- no image of any size 65 | -------------------------------------------------------------------------------- /isbnlib/_openl.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Query the openlibrary.org service for metadata.""" 3 | 4 | import logging 5 | import re 6 | 7 | from .dev import stdmeta 8 | from .dev._bouth23 import u 9 | from .dev._exceptions import RecordMappingError 10 | from .dev.webquery import query as wquery 11 | 12 | UA = 'isbnlib (gzip)' 13 | SERVICE_URL = ('http://openlibrary.org/api/books?bibkeys=' 14 | 'ISBN:{isbn}&format=json&jscmd=data') 15 | LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | # pylint: disable=broad-except 19 | def _mapper(isbn, records): 20 | """Map canonical <- records.""" 21 | # canonical: 22 | # -> ISBN-13, Title, Authors, Publisher, Year, Language 23 | try: 24 | # mapping: canonical <- records 25 | canonical = {} 26 | canonical['ISBN-13'] = u(isbn) 27 | title = records.get('title', u('')).replace(' :', ':') 28 | subtitle = records.get('subtitle', u('')) 29 | title = title + ' - ' + subtitle if subtitle else title 30 | canonical['Title'] = title 31 | canonical['Authors'] = [ 32 | a['name'] for a in records.get( 33 | 'authors', 34 | ({ 35 | 'name': u(''), 36 | }, ), 37 | ) 38 | ] 39 | canonical['Publisher'] = records.get( 40 | 'publishers', 41 | [ 42 | { 43 | 'name': u(''), 44 | }, 45 | ], 46 | )[0]['name'] 47 | canonical['Year'] = u('') 48 | strdate = records.get('publish_date', u('')) 49 | if strdate: # pragma: no cover 50 | match = re.search(r'\d{4}', strdate) 51 | if match: 52 | canonical['Year'] = match.group(0) 53 | except Exception: # pragma: no cover 54 | LOGGER.debug('RecordMappingError for %s with data %s', isbn, records) 55 | raise RecordMappingError(isbn) 56 | # call stdmeta for extra cleaning and validation 57 | return stdmeta(canonical) 58 | 59 | 60 | # pylint: disable=broad-except 61 | def _records(isbn, data): 62 | """Classify (canonically) the parsed data.""" 63 | try: 64 | # put the selected data in records 65 | records = data['ISBN:%s' % isbn] 66 | except Exception: # pragma: no cover 67 | # don't raise exception! 68 | LOGGER.debug('No data from "openl" for isbn %s', isbn) 69 | return {} 70 | 71 | # map canonical <- records 72 | return _mapper(isbn, records) 73 | 74 | 75 | def query(isbn): 76 | """Query the openlibrary.org service for metadata.""" 77 | data = wquery(SERVICE_URL.format(isbn=isbn), user_agent=UA) 78 | return _records(isbn, data) 79 | -------------------------------------------------------------------------------- /isbnlib/test/test_data.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """nose tests""" 5 | 6 | from nose.tools import assert_equals, assert_raises 7 | 8 | from ..dev import Metadata, stdmeta 9 | from ..dev._bouth23 import u 10 | 11 | 12 | def test_stdmeta(): 13 | """Test the transformation of raw records into standard metadata.""" 14 | # test stdmeta from data 15 | r = { 16 | 'ISBN-13': u('9780123456789 '), 17 | 'Title': u('Bla. Bla /Title .'), 18 | 'Publisher': u(''), 19 | 'Year': u('2000'), 20 | 'Language': u('en'), 21 | 'Authors': [u('author1. mba'), u('author2 ')], 22 | } 23 | R = { 24 | 'ISBN-13': u('9780123456789'), 25 | 'Title': u('Bla. Bla /Title'), 26 | 'Publisher': u(''), 27 | 'Year': u('2000'), 28 | 'Language': u('en'), 29 | 'Authors': [u('author1. mba'), u('author2')], 30 | } 31 | A = { 32 | 'ISBN-13': u('9780123456789 '), 33 | 'Title': b'Bla. Bla /Title .', 34 | 'Publisher': u(''), 35 | 'Year': b'2000', 36 | 'Language': u('en'), 37 | 'Authors': [u('author1. mba'), u('author2 ')], 38 | } 39 | B = { 40 | 'ISBN-13': u('9780123456789'), 41 | 'Title': u('Bla. Bla /Title .'), 42 | 'Publisher': u(''), 43 | 'Year': u('2000'), 44 | 'Language': u('en'), 45 | 'Authors': u('author1'), 46 | } 47 | assert_equals(stdmeta(r), R) 48 | assert_equals(stdmeta(R), R) 49 | assert_raises(Exception, stdmeta, A) 50 | assert_raises(Exception, stdmeta, B) 51 | 52 | 53 | def test_metaclass(): 54 | """Test the creation of a Metadata class from raw records.""" 55 | R = { 56 | 'ISBN-13': u('9780123456789'), 57 | 'Title': u('Bla. Bla /Title'), 58 | 'Publisher': u(''), 59 | 'Year': u('2000'), 60 | 'Language': u('en'), 61 | 'Authors': [u('author1. mba'), u('author2')], 62 | } 63 | dt = Metadata(R) 64 | assert_equals(dt.value, R) 65 | 66 | 67 | def test_metrge(): 68 | """Test the merging of records.""" 69 | R = { 70 | 'ISBN-13': u('9780123456789'), 71 | 'Title': u('Bla. Bla /Title'), 72 | 'Publisher': u(''), 73 | 'Year': u('2000'), 74 | 'Language': u('en'), 75 | 'Authors': [u('author1. mba'), u('author2')], 76 | } 77 | T = { 78 | 'ISBN-13': u('9780123456789'), 79 | 'Title': u('Bla. Bla /Title'), 80 | 'Publisher': u('Pub House'), 81 | 'Year': u('2000'), 82 | 'Language': u('en'), 83 | 'Authors': [u('author1. mba'), u('author2')], 84 | } 85 | dt = Metadata(R) 86 | dt.merge(T) 87 | assert_equals(dt.value, T) 88 | -------------------------------------------------------------------------------- /.github/workflows/basictests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '15 5 * * *' 8 | 9 | jobs: 10 | basic-tests-linux: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: [3.6, 3.9] 15 | env: 16 | GITHUB_OS: linux 17 | steps: 18 | - uses: actions/checkout@v2.3.5 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v2.2.2 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install -r requirements.txt 27 | - name: Lint with flake8 28 | run: | 29 | pip install flake8 30 | # stop the build if there are Python syntax errors or undefined names 31 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 32 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 33 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 34 | - name: Test with nose 35 | run: | 36 | pip install nose coverage 37 | nosetests -v --with-coverage --cover-package=isbnlib --cover-min-percentage=90 38 | 39 | basic-tests-macos: 40 | runs-on: macos-latest 41 | strategy: 42 | matrix: 43 | python-version: [3.8] 44 | env: 45 | GITHUB_OS: macos 46 | steps: 47 | - uses: actions/checkout@v2.3.5 48 | - name: Set up Python ${{ matrix.python-version }} 49 | uses: actions/setup-python@v2.2.2 50 | with: 51 | python-version: ${{ matrix.python-version }} 52 | - name: Install dependencies 53 | run: | 54 | python -m pip install --upgrade pip 55 | pip install -r requirements.txt 56 | - name: Test with nose 57 | run: | 58 | pip install nose coverage 59 | nosetests -v --with-coverage --cover-package=isbnlib --cover-min-percentage=90 60 | 61 | basic-tests-windows: 62 | runs-on: windows-latest 63 | strategy: 64 | matrix: 65 | python-version: [3.8] 66 | env: 67 | GITHUB_OS: windows 68 | steps: 69 | - uses: actions/checkout@v2.3.5 70 | - name: Set up Python ${{ matrix.python-version }} 71 | uses: actions/setup-python@v2.2.2 72 | with: 73 | python-version: ${{ matrix.python-version }} 74 | - name: Install dependencies 75 | run: | 76 | python -m pip install --upgrade pip 77 | pip install -r requirements.txt 78 | - name: Test with nose 79 | run: | 80 | pip install nose coverage 81 | nosetests -v --with-coverage --cover-package=isbnlib --cover-min-percentage=30 82 | -------------------------------------------------------------------------------- /isbnlib/_goom.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Query the Google Books (JSON API v1) for metadata.""" 3 | 4 | import logging 5 | 6 | try: 7 | from urllib.parse import quote 8 | except ImportError: 9 | from urllib import quote 10 | 11 | from .dev import cache, stdmeta 12 | from .dev._bouth23 import u 13 | from .dev._exceptions import NoDataForSelectorError, RecordMappingError 14 | from .dev.webquery import query as wquery 15 | 16 | UA = 'isbnlib (gzip)' 17 | SERVICE_URL = ( 18 | 'https://www.googleapis.com/books/v1/volumes?q={words}' 19 | '&fields=items/volumeInfo(title,authors,publisher,publishedDate,' 20 | 'language,industryIdentifiers)&maxResults=10') 21 | LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | def _mapper(record): 25 | """Map canonical <- record.""" 26 | # canonical: 27 | # -> ISBN-13, Title, Authors, Publisher, Year, Language 28 | try: 29 | # mapping: canonical <- records 30 | if 'industryIdentifiers' not in record: # pragma: no cover 31 | return {} 32 | canonical = {} 33 | isbn = None 34 | for ident in record['industryIdentifiers']: 35 | if ident['type'] == 'ISBN_13': 36 | isbn = ident['identifier'] 37 | break 38 | if not isbn: # pragma: no cover 39 | return {} 40 | canonical['ISBN-13'] = isbn 41 | canonical['Title'] = record.get('title', u('')).replace(' :', ':') 42 | canonical['Authors'] = record.get('authors', []) 43 | canonical['Publisher'] = record.get('publisher', u('')) 44 | if 'publishedDate' in record and len(record['publishedDate']) >= 4: 45 | canonical['Year'] = record['publishedDate'][0:4] 46 | else: # pragma: no cover 47 | canonical['Year'] = u('') 48 | canonical['Language'] = record.get('language', u('')) 49 | except Exception: # pragma: no cover 50 | raise RecordMappingError(isbn) 51 | # call stdmeta for extra cleaning and validation 52 | return stdmeta(canonical) 53 | 54 | 55 | def _records(words, data): 56 | """Classify (canonically) the parsed data.""" 57 | # put the selected data in records 58 | try: 59 | recs = [d['volumeInfo'] for d in data['items']] 60 | except Exception: # pragma: no cover 61 | LOGGER.debug('NoDataForSelectorError for (%s)', words) 62 | raise NoDataForSelectorError(words) 63 | # map canonical <- records 64 | return [_mapper(r) for r in recs if _mapper(r)] 65 | 66 | 67 | @cache 68 | def query(words): 69 | """Query the Google Books (JSON API v1) for metadata.""" 70 | words.replace(' ', '+') 71 | words = quote(words) 72 | data = wquery(SERVICE_URL.format(words=words), UA) 73 | return _records(words, data) if data else {} 74 | -------------------------------------------------------------------------------- /isbnlib/_editions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Return editions for a given ISBN.""" 3 | 4 | import logging 5 | 6 | from ._core import EAN13, to_isbn13 7 | from ._exceptions import NotRecognizedServiceError, NotValidISBNError 8 | from ._openled import query as _oed 9 | from ._thinged import query as _ted 10 | from ._wikied import query as _wiki 11 | from .dev import cache, vias 12 | 13 | PROVIDERS = ('any', 'merge', 'openl', 'thingl', 'wiki') 14 | LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | # pylint: disable=broad-except 18 | def _fake_provider_any(isbn): 19 | """Fake provider 'any' service.""" 20 | providers = {'wiki': _wiki, 'openl': _oed, 'thingl': _ted} 21 | for provider in providers: 22 | try: 23 | data = providers[provider](isbn) 24 | if len(data) > 1: 25 | return list(data) 26 | except Exception: # pragma: no cover 27 | LOGGER.error( 28 | "Some error on editions 'any' service for %s (%s)!", 29 | isbn, 30 | provider, 31 | ) 32 | continue # pragma: no cover 33 | return [isbn] # pragma: no cover 34 | 35 | 36 | # pylint: disable=broad-except 37 | def _fake_provider_merge(isbn): 38 | """Fake provider 'merge' service.""" 39 | try: # pragma: no cover 40 | named_tasks = (('openl', _oed), ('thingl', _ted), ('wiki', _wiki)) 41 | results = vias.parallel(named_tasks, isbn) 42 | odata = results.get('openl', set()) 43 | tdata = results.get('thingl', set()) 44 | wdata = results.get('wiki', set()) 45 | return list(odata | tdata | wdata) 46 | except Exception: # pragma: no cover 47 | LOGGER.error("Some error on editions 'merge' service for %s!", isbn) 48 | return [isbn] 49 | 50 | 51 | @cache 52 | def get_editions(isbn, service): 53 | """Select the provider.""" 54 | if service == 'merge': 55 | eds = _fake_provider_merge(isbn) 56 | if service == 'any': 57 | eds = _fake_provider_any(isbn) 58 | if service == 'openl': 59 | eds = list(_oed(isbn)) 60 | if service == 'thingl': 61 | eds = list(_ted(isbn)) 62 | if service == 'wiki': 63 | eds = list(_wiki(isbn)) 64 | return list(set(map(to_isbn13, eds))) if eds else [] 65 | 66 | 67 | def editions(isbn, service='merge'): 68 | """Return the list of ISBNs of editions related with this ISBN.""" 69 | isbn = EAN13(isbn) 70 | if not isbn: 71 | LOGGER.critical('%s is not a valid ISBN', isbn) 72 | raise NotValidISBNError(isbn) 73 | 74 | if service not in PROVIDERS: 75 | LOGGER.critical('%s is not a recognized editions provider', service) 76 | raise NotRecognizedServiceError(service) 77 | 78 | return get_editions(isbn, service) 79 | -------------------------------------------------------------------------------- /isbnlib/test/test_rename.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """ 5 | nose tests 6 | """ 7 | 8 | import locale 9 | import os 10 | 11 | from nose.tools import assert_equals 12 | 13 | from .._ext import ren 14 | from ..dev._bouth23 import b2u3 15 | from ..dev.helpers import cwdfiles 16 | 17 | WINDOWS = os.name == 'nt' 18 | ENCODING = locale.getpreferredencoding() 19 | if ENCODING != 'UTF-8': 20 | print( 21 | "Your default locale encoding (%s) doesn't allow unicode filenames!" % ENCODING, 22 | ) 23 | print('=> Some tests could fail.') 24 | 25 | TESTFILE_1 = './ç-deleteme.pdf' if WINDOWS else '/tmp/ç-deleteme.pdf' 26 | TESTFILE_2 = './ç-deleteme-PLEASE.pdf' if WINDOWS else '/tmp/ç-deleteme-PLEASE.pdf' 27 | 28 | # F1 = '9780321534965.pdf' 29 | F1 = '9780872203495.pdf' 30 | F2 = '9781597499644.pdf' 31 | F3 = '9781852330729.pdf' 32 | F4 = '9787500117018.pdf' 33 | F5 = '9789727576807.pdf' 34 | 35 | F6 = 'Campos2011_Emergências obstétricas_9789727576807.pdf' 36 | # F7 = 'Knuth2008_The Art Of Computer Programming_9780321534965.pdf' 37 | # F7a = 'Knuth2008_Introduction To Combinatorial Algorithms And Boolean Functions_9780321534965.pdf' 38 | F7 = 'Plato1997_Complete Works_9780872203495.pdf' 39 | F8 = 'Man2001_Genetic Algorithms Concepts And Designs_9781852330729.pdf' 40 | F9 = "O'Connor2012_Violent Python A Cookbook for Hackers, Forensic Analysts, Penetra_9781597499644.pdf" 41 | F10 = '海明威2007_Lao ren yu hai_9787500117018.pdf' 42 | 43 | F11 = 'myfile.pdf' 44 | 45 | FISBN = [F1, F2, F3, F4, F5] 46 | FFT = [F6, F7, F8, F9, F10] 47 | FILES = FISBN + FFT + [F11] 48 | 49 | 50 | def create_files(files): 51 | os.chdir(os.path.dirname(TESTFILE_1)) 52 | for fn in files: 53 | try: 54 | with open(fn, 'w') as f: 55 | f.write(b2u3('ooo') + b2u3(fn)) 56 | except UnicodeEncodeError: 57 | print( 58 | "Your default locale (%s) doesn't allow non-ascii filenames!" 59 | % locale.CODESET, 60 | ) 61 | 62 | 63 | def delete_files(fnpatt): 64 | os.chdir(os.path.dirname(TESTFILE_1)) 65 | for fn in cwdfiles(fnpatt): 66 | os.remove(fn) 67 | 68 | 69 | def setup_module(): 70 | # create_files([u(TESTFILE_1), u(TESTFILE_2)]) 71 | os.chdir(os.path.dirname(TESTFILE_1)) 72 | # create_files(FISBN + [F11]) 73 | create_files([F1]) 74 | 75 | 76 | def teardown_module(): 77 | delete_files('*.pdf') 78 | 79 | 80 | def test_ren(): 81 | """Test 'high level' ren function.""" 82 | ren(F1) 83 | assert_equals(F7 in cwdfiles('*.pdf'), True) 84 | # assert_equals(F7 in cwdfiles("*.pdf") or F7a in cwdfiles("*.pdf"), True) 85 | # create_files([F5]) 86 | # ren(F5) 87 | # assert_equals('Campos2011_Emergências obstétricas_9789727576807.pdf' in cwdfiles("*.pdf"), True) 88 | -------------------------------------------------------------------------------- /isbnlib/dev/_exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Exceptions for 'isbnlib.dev'. 3 | 4 | The classes in isbnlib.dev should use the exceptions below. 5 | """ 6 | 7 | # TODO(MV) merge these exceptions with the top exceptions on version 4. 8 | 9 | from .._exceptions import ISBNLibException 10 | 11 | 12 | # pylint: disable=super-init-not-called 13 | class ISBNLibDevException(ISBNLibException): 14 | """Base class for isbnlib.dev exceptions. 15 | 16 | This exception should not be raised directly, 17 | only subclasses of this exception should be used! 18 | """ 19 | def __init__(self, msg=None): 20 | if msg: 21 | self.message = '%s (%s)' % (self.message, msg) 22 | 23 | def __str__(self): 24 | return getattr(self, 'message', '') # pragma: no cover 25 | 26 | 27 | class ISBNLibHTTPError(ISBNLibDevException): 28 | """Exception raised for HTTP related errors.""" 29 | 30 | message = 'an HTTP error has ocurred' 31 | 32 | 33 | class ISBNLibURLError(ISBNLibDevException): 34 | """Exception raised for URL related errors.""" 35 | 36 | message = 'an URL error has ocurred' 37 | 38 | 39 | class DataNotFoundAtServiceError(ISBNLibDevException): 40 | """Exception raised when there is no target data from the service.""" 41 | 42 | message = 'the target data was not found at this service' 43 | 44 | 45 | class ServiceIsDownError(ISBNLibDevException): 46 | """Exception raised when the service is not available.""" 47 | 48 | message = 'the service is down (try later)' 49 | 50 | 51 | class DataWrongShapeError(ISBNLibDevException): 52 | """Exception raised when the data hasn't the expected format.""" 53 | 54 | message = "the data hasn't the expected format" 55 | 56 | 57 | class NoDataForSelectorError(ISBNLibDevException): 58 | """Exception raised when there is no data for the selector.""" 59 | 60 | message = 'no data for this selector' 61 | 62 | 63 | class NotValidMetadataError(ISBNLibDevException): 64 | """Exception raised when the metadata hasn't the expected format.""" 65 | 66 | message = "the metadata hasn't the expected format" 67 | 68 | 69 | class ISBNNotConsistentError(ISBNLibDevException): 70 | """Exception raised when the isbn request != isbn response.""" 71 | 72 | message = 'isbn request != isbn response' 73 | 74 | 75 | class RecordMappingError(ISBNLibDevException): 76 | """Exception raised when the mapping records -> canonical doesn't work.""" 77 | 78 | message = "the mapping `canonical <- records` doesn't work" 79 | 80 | 81 | class NoAPIKeyError(ISBNLibDevException): 82 | """Exception raised when the API Key for a service is not found.""" 83 | 84 | message = 'this service needs an API key' 85 | 86 | 87 | # pylint: disable=redefined-builtin 88 | class FileNotFoundError(ISBNLibDevException): 89 | """Exception raised when a given file doesn't exist.""" 90 | 91 | message = "the file wasn't found" 92 | -------------------------------------------------------------------------------- /isbnlib/dev/_helpers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Private helper functions.""" 3 | 4 | import re 5 | from hashlib import md5 6 | 7 | from ._bouth23 import b, s 8 | 9 | 10 | def fake_isbn(title, author='unkown', publisher='unkown', sid=1): 11 | """Produce a fake ISBN from the (title, author, publisher) of the book.""" 12 | key = '%s %s %s' % (title, author, publisher) 13 | # normalize 14 | regex1 = re.compile(r'\?|,|\.|!|\:|;', re.I | re.M | re.S) 15 | regex2 = re.compile(r'\s\s+', re.I | re.M | re.S) 16 | key = regex1.sub(' ', key) 17 | key = regex2.sub(' ', key).strip().lower() 18 | # hash 19 | return (str(sid) + str(int(md5(b(key)).hexdigest()[:10], 16)))[:13] 20 | 21 | 22 | def normalize_space(item): 23 | """Normalize white space. 24 | 25 | Strips leading and trailing white space and replaces sequences of 26 | white space characters with a single space. 27 | """ 28 | item = re.sub(r'\s\s+', ' ', item) 29 | return item.strip() 30 | 31 | 32 | def titlecase(st): 33 | """Format string in 'title case' (for ascii).""" 34 | try: 35 | st.encode('ascii') 36 | return re.sub( 37 | r"[A-Za-z]+('[A-Za-z]+)?", 38 | lambda m: m.group(0)[0].upper() + m.group(0)[1:], 39 | st, 40 | ) 41 | except (UnicodeEncodeError, UnicodeDecodeError): # pragma: no cover 42 | return st 43 | 44 | 45 | def last_first(author): 46 | """Parse an author name into last (name) and first.""" 47 | if ',' in author: 48 | tokens = author.split(',') 49 | last = tokens[0].strip() 50 | first = ' '.join(tokens[1:]).strip().replace(' ', ', ') 51 | else: 52 | tokens = author.split(' ') 53 | last = tokens[-1].strip() 54 | first = ' '.join(tokens[:-1]).strip() 55 | return {'last': last, 'first': first} 56 | 57 | 58 | def unicode_to_utf8tex(utex, filtre=()): 59 | """Replace unicode entities with tex entities and returns utf8 bytes.""" 60 | from .._data.data4tex import unicode_to_tex 61 | 62 | btex = utex.encode('utf-8') 63 | table = { 64 | k.encode('utf-8'): v 65 | for k, v in unicode_to_tex.items() if v not in filtre 66 | } 67 | regex = re.compile(b('|'.join(re.escape(s(k)) for k in table))) 68 | return regex.sub(lambda m: table[m.group(0)], btex) 69 | 70 | 71 | def cutoff_tokens(tokens, cutoff): 72 | """Keep only the tokens with total length <= cutoff.""" 73 | ltokens = [len(t) for t in tokens] 74 | length = 0 75 | stokens = [] 76 | for token, ln in zip(tokens, ltokens): 77 | if length + ln <= cutoff: 78 | length = length + ln 79 | stokens.append(token) 80 | else: 81 | break 82 | return stokens 83 | 84 | 85 | def parse_placeholders(pattern): 86 | """Return a list of placeholders in a pattern.""" 87 | regex = re.compile(r'({[^}]*})') 88 | return regex.findall(pattern) 89 | -------------------------------------------------------------------------------- /isbnlib/_goob.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Query the Google Books (JSON API v1) service for metadata.""" 3 | 4 | import logging 5 | 6 | from .dev import stdmeta 7 | from .dev._bouth23 import u 8 | from .dev._exceptions import ISBNNotConsistentError, RecordMappingError 9 | from .dev.webquery import query as wquery 10 | 11 | UA = 'isbnlib (gzip)' 12 | SERVICE_URL = ( 13 | 'https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn}' 14 | '&fields=items/volumeInfo(title,subtitle,authors,publisher,publishedDate,' 15 | 'language,industryIdentifiers,description,imageLinks)&maxResults=1') 16 | LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | # pylint: disable=broad-except 20 | def _mapper(isbn, records): 21 | """Map: canonical <- records.""" 22 | # canonical: ISBN-13, Title, Authors, Publisher, Year, Language 23 | try: 24 | canonical = {} 25 | canonical['ISBN-13'] = u(isbn) 26 | title = records.get('title', u('')).replace(' :', ':') 27 | subtitle = records.get('subtitle', u('')) 28 | title = title + ' - ' + subtitle if subtitle else title 29 | canonical['Title'] = title 30 | canonical['Authors'] = records.get('authors', [u('')]) 31 | # see issue #64 32 | canonical['Publisher'] = records.get('publisher', u('')).strip('"') 33 | if 'publishedDate' in records and len(records['publishedDate']) >= 4: 34 | canonical['Year'] = records['publishedDate'][0:4] 35 | else: # pragma: no cover 36 | canonical['Year'] = u('') 37 | canonical['Language'] = records.get('language', u('')) 38 | except Exception: # pragma: no cover 39 | LOGGER.debug('RecordMappingError for %s with data %s', isbn, records) 40 | raise RecordMappingError(isbn) 41 | # call stdmeta for extra cleaning and validation 42 | return stdmeta(canonical) 43 | 44 | 45 | def _records(isbn, data): 46 | """Classify (canonically) the parsed data.""" 47 | # put the selected data in records 48 | try: 49 | recs = data['items'][0]['volumeInfo'] 50 | except Exception: # pragma: no cover 51 | # don't raise exception! 52 | LOGGER.debug('No data from "goob" for isbn %s', isbn) 53 | return {} 54 | # consistency check (isbn request = isbn response) 55 | if recs: 56 | ids = recs.get('industryIdentifiers', '') 57 | if u('ISBN_13') in repr(ids) and isbn not in repr( 58 | ids): # pragma: no cover 59 | LOGGER.debug('ISBNNotConsistentError for %s (%s)', isbn, repr(ids)) 60 | raise ISBNNotConsistentError('{0} not in {1}'.format( 61 | isbn, 62 | repr(ids), 63 | )) 64 | else: 65 | return {} # pragma: no cover 66 | # map canonical <- records 67 | return _mapper(isbn, recs) 68 | 69 | 70 | def query(isbn): 71 | """Query the Google Books (JSON API v1) service for metadata.""" 72 | data = wquery(SERVICE_URL.format(isbn=isbn), user_agent=UA) 73 | return _records(isbn, data) 74 | -------------------------------------------------------------------------------- /isbnlib/_ext.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Extra methods.""" 3 | 4 | import re 5 | 6 | from ._core import EAN13 7 | from ._cover import cover as gcover 8 | from ._desc import goo_desc 9 | from ._editions import editions as eds 10 | from ._gwords import goos 11 | from ._infogroup import infogroup 12 | from ._metadata import query 13 | from ._msk import msk 14 | from .dev._bouth23 import b2u3, u 15 | from .dev.helpers import File, cutoff_tokens, last_first 16 | 17 | 18 | def mask(isbn, separator='-'): 19 | """`Mask` a canonical ISBN.""" 20 | return msk(isbn, separator) 21 | 22 | 23 | def meta(isbn, service='default'): 24 | """Get metadata from Google Books ('goob'), Open Library ('openl'), ...""" 25 | return query(isbn, service) if isbn else {} 26 | 27 | 28 | def info(isbn): 29 | """Get language or country assigned to this ISBN.""" 30 | return infogroup(isbn) 31 | 32 | 33 | def editions(isbn, service='merge'): 34 | """Return the list of ISBNs of editions related with this ISBN. 35 | 36 | 'service' can have the values: 37 | 'any', 'merge' (default), 'openl' and 'thingl' 38 | """ 39 | return eds(isbn, service) 40 | 41 | 42 | def isbn_from_words(words): 43 | """Return the most probable ISBN from a list of words.""" 44 | return goos(words) 45 | 46 | 47 | def doi(isbn): 48 | """Return a DOI's ISBN-A from a ISBN-13.""" 49 | try: 50 | value = '10.%s.%s%s/%s%s' % tuple(msk(EAN13(isbn), '-').split('-')) 51 | except TypeError: 52 | return '' 53 | return value 54 | 55 | 56 | def ren(fp): 57 | """Rename a file using metadata from an ISBN in his filename.""" 58 | cfp = File(fp) 59 | isbn = EAN13(cfp.name) 60 | if not isbn: # pragma: no cover 61 | return None 62 | data = meta(isbn) 63 | author = data.get('Authors', u('UNKNOWN')) 64 | if author != u('UNKNOWN'): # pragma: no cover 65 | author = last_first(author[0])['last'] 66 | year = data.get('Year', u('UNKNOWN')) 67 | maxlen = 98 - (20 + len(author) + len(year)) 68 | title = data.get('Title', u('UNKNOWN')) 69 | if title != u('UNKNOWN'): 70 | regex1 = re.compile(r'[.,_!?/\\]') 71 | regex2 = re.compile(r'\s\s+') 72 | title = regex1.sub(' ', title) 73 | title = regex2.sub(' ', title) 74 | title = title.strip() 75 | if title == u('UNKNOWN') or not title: # pragma: no cover 76 | return None 77 | if ' ' in title: # pragma: no cover 78 | tokens = title.split(' ') 79 | stitle = cutoff_tokens(tokens, maxlen) 80 | title = ' '.join(stitle) 81 | isbn13 = data.get('ISBN-13', u('UNKNOWN')) 82 | new_name = '%s%s_%s_%s' % (author, year, title, isbn13) 83 | return cfp.baserename(b2u3(new_name + cfp.ext)) 84 | 85 | 86 | def cover(isbn): 87 | """Get the img urls of the cover of the ISBN.""" 88 | isbn = EAN13(isbn) 89 | return gcover(isbn) if isbn else {} 90 | 91 | 92 | def desc(isbn): 93 | """Return a descripion of the ISBN.""" 94 | isbn = EAN13(isbn) 95 | return goo_desc(isbn) if isbn else '' 96 | -------------------------------------------------------------------------------- /isbnlib/_oclc.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Query the 'classify.oclc.org' service for classifiers.""" 3 | 4 | import logging 5 | import re 6 | 7 | from .dev import cache 8 | from .dev._bouth23 import u 9 | from .dev.webquery import query as wquery 10 | 11 | UA = 'isbnlib (gzip)' 12 | SERVICE_URL = 'http://classify.oclc.org/classify2/Classify?isbn={isbn}&maxRecs=1' 13 | LOGGER = logging.getLogger(__name__) 14 | 15 | RE_OWI = re.compile(r'owi="(.*?)"', re.I | re.M | re.S) 16 | RE_OCLC = re.compile(r'oclc="(.*?)"', re.I | re.M | re.S) 17 | 18 | RE_DDC = re.compile(r'(.*?)', re.I | re.M | re.S) 19 | RE_LCC = re.compile(r'(.*?)', re.I | re.M | re.S) 20 | RE_NSFA = re.compile(r'nsfa="(.*?)"', re.I | re.M | re.S) 21 | RE_SFA = re.compile(r' sfa="(.*?)"', re.I | re.M | re.S) 22 | 23 | RE_HEADINGS = re.compile(r'(.*?)', re.I | re.M | re.S) 24 | RE_FLDS = re.compile(r' ident="(.*?)"', re.I | re.M | re.S) 25 | RE_VALS = re.compile(r'fast">(.*?)', re.I | re.M | re.S) 26 | 27 | 28 | def data_checker(xml): 29 | """Check the response from the service.""" 30 | if not xml: 31 | LOGGER.debug("The service 'oclc' is temporarily down!") 32 | return False 33 | if 'response code="102"' in xml: 34 | LOGGER.debug("The service 'oclc' is temporarily very slow!") 35 | return False 36 | return True 37 | 38 | 39 | def parser(xml): 40 | """Parse the response from the service.""" 41 | if not xml: 42 | return {} # pragma: no cover 43 | 44 | data = {} 45 | 46 | match = RE_OWI.search(u(xml)) 47 | if match: 48 | data['owi'] = match.group(1) 49 | match = RE_OCLC.search(u(xml)) 50 | if match: 51 | data['oclc'] = match.group(1) 52 | 53 | match = RE_LCC.search(u(xml)) 54 | if match: 55 | buf = match.group() 56 | match = RE_SFA.search(buf) 57 | if match: 58 | data['lcc'] = match.group(1) 59 | 60 | match = RE_DDC.search(u(xml)) 61 | if match: 62 | buf = match.group() 63 | match = RE_SFA.search(buf) 64 | if match: 65 | data['ddc'] = match.group(1) 66 | 67 | fast = parser_headings(xml) 68 | if fast: 69 | data['fast'] = fast 70 | 71 | return data 72 | 73 | 74 | # pylint: disable=broad-except 75 | def parser_headings(xmlthing): 76 | """RE parser for classify.oclc service (headings branch).""" 77 | match = RE_HEADINGS.search(u(xmlthing)) 78 | if match: 79 | try: 80 | buf = match.group() 81 | flds = RE_FLDS.findall(buf) 82 | vals = RE_VALS.findall(buf) 83 | return dict(zip(flds, vals)) 84 | except Exception: # pragma: no cover 85 | LOGGER.debug("Bad parsing of 'headings' for 'oclc' service!") 86 | return {} # pragma: no cover 87 | 88 | 89 | @cache 90 | def query_classify(isbn): 91 | """Query the classify.oclc service for classifiers.""" 92 | return (wquery( 93 | SERVICE_URL.format(isbn=isbn), 94 | user_agent=UA, 95 | data_checker=data_checker, 96 | parser=parser, 97 | ) or {}) 98 | -------------------------------------------------------------------------------- /isbnlib/_wiki.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Query the wikipedia.org service for metadata.""" 3 | 4 | import logging 5 | import re 6 | 7 | from .dev import stdmeta 8 | from .dev._bouth23 import u 9 | from .dev._exceptions import RecordMappingError 10 | from .dev.webquery import query as wquery 11 | 12 | UA = 'isbnlib (gzip)' 13 | SERVICE_URL = 'https://en.wikipedia.org/api/rest_v1/data/citation/mediawiki/{isbn}' 14 | LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | # pylint: disable=broad-except 18 | def _mapper(isbn, records): 19 | """Map canonical <- records.""" 20 | # canonical: 21 | # -> ISBN-13, Title, Authors, Publisher, Year, Language 22 | try: 23 | # mapping: canonical <- records 24 | canonical = {} 25 | canonical['ISBN-13'] = u(isbn) 26 | canonical['Title'] = records.get('title', u('')).replace(' :', ':') 27 | # try to handle the inconsistent use of fields by Wikipedia (issue #65)! 28 | try: 29 | authors = [ 30 | ' '.join(sublist) 31 | for sublist in records.get('author', [u('')]) 32 | ] 33 | canonical['Authors'] = [ 34 | author.replace('.', u('')) for author in authors 35 | ] 36 | buf = canonical.get('Authors', []) 37 | if not buf or len(buf[0]) == 0: 38 | raise IndexError 39 | except IndexError: 40 | try: 41 | authors = [ 42 | ' '.join(sublist) 43 | for sublist in records.get('contributor', [u('')]) 44 | ] 45 | canonical['Authors'] = [ 46 | author.replace('.', u('')) for author in authors 47 | ] 48 | except IndexError: 49 | pass 50 | canonical['Publisher'] = records.get('publisher', u('')) or u(' '.join( 51 | [pub for pub in records.get('contributor', [u('')])[0] if pub])) 52 | canonical['Year'] = u('') 53 | strdate = records.get('date', u('')) 54 | if strdate: # pragma: no cover 55 | match = re.search(r'\d{4}', strdate) 56 | if match: 57 | canonical['Year'] = match.group(0) 58 | except Exception: # pragma: no cover 59 | LOGGER.debug('RecordMappingError for %s with data %s', isbn, records) 60 | raise RecordMappingError(isbn) 61 | # call stdmeta for extra cleaning and validation 62 | return stdmeta(canonical) 63 | 64 | 65 | # pylint: disable=broad-except 66 | def _records(isbn, data): 67 | """Classify (canonically) the parsed data.""" 68 | try: 69 | # put the selected data in records 70 | records = data[0] 71 | except Exception: # pragma: no cover 72 | # don't raise exception! 73 | LOGGER.debug('No data from "wikipedia" for isbn %s', isbn) 74 | return {} 75 | 76 | # map canonical <- records 77 | return _mapper(isbn, records) 78 | 79 | 80 | def query(isbn): 81 | """Query the wikipedia.org service for metadata.""" 82 | data = wquery(SERVICE_URL.format(isbn=isbn), user_agent=UA, throttling=0) 83 | return _records(isbn, data) 84 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [xlcnd@outlook.com]. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # isort:skip_file 4 | 5 | # isbnlib -- tools for extracting, cleaning and transforming ISBNs 6 | # Copyright (C) 2014-2021 Alexandre Lima Conde 7 | # SPDX-License-Identifier: LGPL-3.0-or-later 8 | 9 | # This program is free software: you can redistribute it and/or modify 10 | # it under the terms of the GNU Lesser General Public License as published by 11 | # the Free Software Foundation, either version 3 of the License, or 12 | # (at your option) any later version. 13 | 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | 19 | # You should have received a copy of the GNU Lesser General Public License 20 | # along with this program. If not, see . 21 | 22 | from datetime import datetime as dt 23 | from setuptools import setup 24 | from isbnlib import __version__ 25 | 26 | PROJECT_NAME = 'isbnlib' 27 | PROJECT_PACKAGE_NAME = 'isbnlib' 28 | PROJECT_LICENSE = 'LGPL v3' 29 | PROJECT_LICENSE_URL = ( 30 | 'https://github.com/xlcnd/isbnlib/blob/dev/LICENSE-LGPL-3.0.txt') 31 | PROJECT_AUTHOR = 'Alexandre Lima Conde' 32 | PROJECT_COPYRIGHT = ' 2014-{}, {}'.format(dt.now().year, PROJECT_AUTHOR) 33 | PROJECT_URL = 'https://github.com/xlcnd/isbnlib' 34 | PROJECT_EMAIL = 'xlcnd@outlook.com' 35 | PROJECT_VERSION = __version__ 36 | 37 | PROJECT_GITHUB_USERNAME = 'xlcnd' 38 | PROJECT_GITHUB_REPOSITORY = 'isbnlib' 39 | 40 | GITHUB_PATH = '{}/{}'.format( 41 | PROJECT_GITHUB_USERNAME, 42 | PROJECT_GITHUB_REPOSITORY, 43 | ) 44 | GITHUB_URL = 'https://github.com/{}'.format(GITHUB_PATH) 45 | 46 | DOWNLOAD_URL = '{}/archive/{}.zip'.format(GITHUB_URL, 'v' + PROJECT_VERSION) 47 | PROJECT_URLS = { 48 | 'Bug Reports': '{}/issues'.format(GITHUB_URL), 49 | 'Dev Docs': 'https://github.com/xlcnd/isbnlib#info', 50 | 'Forum': 'https://stackoverflow.com/search?tab=newest&q=isbnlib', 51 | 'License': PROJECT_LICENSE_URL, 52 | } 53 | 54 | PYPI_URL = 'https://pypi.org/project/{}/'.format(PROJECT_PACKAGE_NAME) 55 | PYPI_CLASSIFIERS = [ 56 | 'Programming Language :: Python', 57 | 'Programming Language :: Python :: 2', 58 | 'Programming Language :: Python :: 2.7', 59 | 'Programming Language :: Python :: 3', 60 | 'Programming Language :: Python :: 3.6', 61 | 'Programming Language :: Python :: 3.7', 62 | 'Programming Language :: Python :: 3.8', 63 | 'Programming Language :: Python :: 3.9', 64 | 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)', 65 | 'Operating System :: OS Independent', 66 | 'Development Status :: 5 - Production/Stable', 67 | 'Intended Audience :: Developers', 68 | 'Topic :: Text Processing :: General', 69 | 'Topic :: Software Development :: Libraries :: Python Modules', 70 | ] 71 | 72 | PACKAGES = [ 73 | 'isbnlib', 74 | 'isbnlib/dev', 75 | 'isbnlib/_data', 76 | ] 77 | 78 | setup( 79 | name=PROJECT_PACKAGE_NAME, 80 | version=PROJECT_VERSION, 81 | url=PROJECT_URL, 82 | download_url=DOWNLOAD_URL, 83 | project_urls=PROJECT_URLS, 84 | author=PROJECT_AUTHOR, 85 | author_email=PROJECT_EMAIL, 86 | packages=PACKAGES, 87 | license=PROJECT_LICENSE, 88 | description= 89 | 'Extract, clean, transform, hyphenate and metadata for ISBNs (International Standard Book Number).', 90 | long_description=open('README.rst').read(), 91 | keywords= 92 | 'ISBN metadata World_Catalogue Google_Books Wikipedia Open_Library BibTeX EndNote RefWorks MSWord opf BibJSON', 93 | classifiers=PYPI_CLASSIFIERS, 94 | ) 95 | -------------------------------------------------------------------------------- /isbnlib/dev/_data.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Handle metadata objects.""" 3 | 4 | import logging 5 | 6 | from ._bouth23 import type3str, u 7 | from ._exceptions import NotValidMetadataError 8 | from ._helpers import normalize_space, titlecase 9 | 10 | # For now you cannot add custom fields! 11 | FIELDS = ('ISBN-13', 'Title', 'Authors', 'Publisher', 'Year', 'Language') 12 | LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | # pylint: disable=useless-object-inheritance 16 | class Metadata(object): 17 | """Class for metadata objects.""" 18 | 19 | def __init__(self, record=None): 20 | """Initialize attributes.""" 21 | self._content = None 22 | self._set_empty() 23 | if record: # pragma: no cover 24 | self._content.update((k, v) for k, v in list(record.items())) 25 | if not self._validate(): 26 | self._set_empty() 27 | LOGGER.debug(record) 28 | raise NotValidMetadataError() 29 | self.clean() 30 | 31 | @staticmethod 32 | def fields(): # pragma: no cover 33 | """Return a list of value's fields.""" 34 | return list(FIELDS) 35 | 36 | def clean(self, broom=normalize_space, exclude=()): 37 | """Clean fields of value.""" 38 | self._content.update( 39 | (k, broom(v)) 40 | for k, v in list(self._content.items()) 41 | if k != 'Authors' and k not in exclude 42 | ) 43 | if 'Authors' not in exclude: 44 | self._content['Authors'] = [ 45 | broom(i) for i in self._content['Authors'] 46 | ] 47 | self._content['Title'] = self._content['Title'].strip(',.:;-_ ') 48 | if self._content['Language'].lower() in ('en', 'eng', 'english'): 49 | self._content['Title'] = titlecase(self._content['Title']) 50 | 51 | @property 52 | def value(self): 53 | """Get value.""" 54 | return self._content 55 | 56 | @value.setter 57 | def value(self, record): # pragma: no cover 58 | """Set value.""" 59 | self._content.update((k, v) for k, v in list(record.items())) 60 | if not self._validate(): 61 | self._set_empty() 62 | LOGGER.debug(record) 63 | raise NotValidMetadataError() 64 | self.clean() 65 | 66 | @value.deleter 67 | def value(self): # pragma: no cover 68 | """Delete value.""" 69 | self._set_empty() 70 | 71 | def merge( 72 | self, 73 | record, 74 | overwrite=(), 75 | overrule=lambda x: x == u('') or x == [u('')], 76 | ): 77 | """Merge the record with value.""" 78 | # by default do nothing 79 | self._content.update( 80 | (k, v) for k, v in list(record.items()) 81 | if k in overwrite and not overrule(v) or self._content[k] == u('') 82 | ) 83 | if not self._validate(): # pragma: no cover 84 | self._set_empty() 85 | LOGGER.debug(record) 86 | raise NotValidMetadataError() 87 | self.clean() 88 | 89 | def _validate(self): 90 | """Validate value.""" 91 | # 'minimal' check 92 | for k in self._content: 93 | if not isinstance(self._content[k], type3str()) and k != 'Authors': 94 | return False 95 | if not isinstance(self._content['Authors'], list): 96 | return False 97 | return True 98 | 99 | def _set_empty(self): 100 | """Set an empty value record.""" 101 | self._content = dict.fromkeys(list(FIELDS), u('')) 102 | self._content['Authors'] = [u('')] 103 | 104 | 105 | def stdmeta(records): 106 | """Transform data using class Metadata.""" 107 | data = Metadata(records) 108 | return data.value 109 | -------------------------------------------------------------------------------- /isbnlib/dev/_files.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Helper module to work with files.""" 3 | 4 | import fnmatch 5 | import logging 6 | import os 7 | import re 8 | from stat import S_IRGRP, S_IROTH, S_IRUSR, S_IWGRP, S_IWOTH, S_IWUSR 9 | 10 | # pylint: disable=redefined-builtin 11 | from ._exceptions import FileNotFoundError 12 | 13 | MAXLEN = 120 14 | ILLEGAL = r'<>:"/\|?*' 15 | LOGGER = logging.getLogger(__name__) 16 | MODE666 = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH 17 | 18 | 19 | # pylint: disable=useless-object-inheritance 20 | class File(object): 21 | """Easy manipulation of files in the SAME directory.""" 22 | def __init__(self, fp): 23 | """Set and validate the basic properties.""" 24 | if not self.isfile(fp): 25 | raise FileNotFoundError(fp) 26 | self.path = os.path.dirname(fp) or os.getcwd() 27 | self.basename = os.path.basename(fp) 28 | self.name, self.ext = os.path.splitext(self.basename) 29 | self.writable = os.access(fp, os.W_OK) 30 | 31 | def siblings(self): 32 | """Collect files and directories in the same directory.""" 33 | return [f for f in os.listdir(self.path) if f != self.basename] 34 | 35 | @staticmethod 36 | def isfile(path): 37 | """Check if a given path is a file.""" 38 | return os.path.isfile(path) 39 | 40 | @staticmethod 41 | def exists(path): 42 | """Check if a given path is a file or a directory.""" 43 | return os.path.exists(path) 44 | 45 | @staticmethod 46 | def mkwinsafe(name, space=' '): 47 | """Delete most common characters not allowed in Windows filenames.""" 48 | space = space if space not in ILLEGAL else ' ' 49 | name = ''.join(c for c in name 50 | if c not in ILLEGAL).replace(' ', space).strip() 51 | name = re.sub(r'\s\s+', ' ', name) if space == ' ' else name 52 | return name[:MAXLEN] 53 | 54 | @staticmethod 55 | def validate(basename): 56 | """Check for a proper basename.""" 57 | if basename != os.path.basename(basename): 58 | LOGGER.critical('This (%s) is not a basename!', basename) 59 | return False 60 | name, ext = os.path.splitext(basename) 61 | if not name: 62 | LOGGER.critical('Not a valid name (length 0)!') 63 | return False 64 | if not ext: 65 | LOGGER.critical('Not a valid extension (length 0)!') 66 | return False 67 | return True 68 | 69 | def baserename(self, new_basename): 70 | """Rename the file to a 'safe' basename.""" 71 | if not self.validate(new_basename): 72 | return False 73 | name, ext = os.path.splitext(new_basename) 74 | name = self.mkwinsafe(name) 75 | new_basename = name + ext 76 | if new_basename == self.basename: 77 | return True 78 | if new_basename not in self.siblings(): 79 | try: 80 | os.rename(self.basename, new_basename) 81 | except OSError as err: 82 | LOGGER.critical('%s', err) 83 | return False 84 | self.basename = new_basename 85 | self.name = name 86 | self.ext = ext 87 | else: 88 | LOGGER.info( 89 | 'The file (%s) already exist in the directory!', 90 | new_basename, 91 | ) 92 | return True 93 | 94 | @staticmethod 95 | def uxchmod(fp, mode=MODE666): 96 | """Change the mode of the file (default is 0666).""" 97 | return os.chmod(fp, mode) 98 | 99 | 100 | def cwdfiles(pattern='*'): 101 | """List the files in current directory that match a given pattern.""" 102 | return fnmatch.filter(os.listdir('.'), pattern) 103 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | v3.3.3, 2014-06-16 -- Initial release (based in `isbntools 3.3.3)`. 2 | v3.3.4, 2014-06-16 -- Fix small bug with wheel package (based in `isbntools 3.3.3)`. 3 | v3.3.5, 2014-06-22 -- Fix bug in 'notisbn' (based in `isbntools 3.3.4)`. 4 | v3.3.6, 2014-06-30 -- Small improvements and optional ShelveCache. 5 | v3.3.7, 2014-07-07 -- Fix issue #2, better debug logging and expose top exceptions. 6 | v3.3.8, 2014-07-07 -- Fix wrong logging identifier. 7 | v3.3.9, 2014-07-13 -- Mark as not valid ISBN-13 in series not allocated yet. 8 | v3.4.1, 2014-07-16 -- Fix bug #7 and small improvements on the API. 9 | v3.4.2, 2014-08-13 -- Add `doi2tex` and updated data. 10 | v3.4.3, 2015-01-13 -- Better LOOSE regex and allow 'x' in canonical. 11 | v3.4.4, 2015-01-26 -- Helpers sprint and fake_isbn. 12 | v3.4.5, 2015-02-05 -- Better yield for openl and better log model. 13 | v3.4.6, 2015-02-05 -- Fix bug #13. 14 | v3.4.7, 2015-02-11 -- Fix bug #14. 15 | v3.4.8, 2015-02-25 -- Fix bug #16, add 'desc' and 'cover' features. 16 | v3.4.9, 2015-02-27 -- Fix issue #17 and bug #18, data range 20150227. 17 | v3.5.1, 2015-03-10 -- New CoversCache and better unicode printing in Windows. 18 | v3.5.2, 2015-03-15 -- Fix bug #19, relative paths on coverscache and data range 20150310. 19 | v3.5.3, 2015-03-26 -- Add throttling for web services, data range 20150325 and 'in memory' keys cache. 20 | v3.5.4, 2015-04-10 -- Issue #20, fix bugs #21 and #22 and data range 20150401. 21 | v3.5.5, 2015-04-22 -- Add logging to vias, fix bug #23, and data range 20150422. 22 | v3.5.6, 2015-06-03 -- Deprecated 'bouth23' and data range 20150603. 23 | v3.5.7, 2015-11-22 -- Support for py35 and pypy3 and data range 20151118. 24 | v3.5.8, 2016-03-07 -- Issue #28 (closing down of xISBN service). 25 | v3.5.9, 2016-06-17 -- Improved data quality (close #39 and #40) and new cover (close issue #42). 26 | v3.6.1, 2016-06-21 -- Improved data quality (#39 and #40), new cover (#42) and streamlined. 27 | v3.6.2, 2016-09-06 -- Updated data. 28 | v3.6.3, 2016-11-09 -- Updated data. 29 | v3.6.4, 2016-11-12 -- Fix bug #46. 30 | v3.6.5, 2016-12-08 -- Updated data. 31 | v3.6.6, 2017-01-05 -- Fix bug #47. 32 | v3.6.7, 2017-03-11 -- Updated data. 33 | v3.6.8, 2017-03-31 -- New functions: check_digit10 and check_digit13. 34 | v3.7.1, 2017-05-24 -- Fix #43 (bump middle version). 35 | v3.7.2, 2017-06-19 -- Restore xID (improved data quality close #28). 36 | v3.7.3, 2018-01-08 -- Updated data. 37 | v3.7.4, 2018-01-08 -- Add support for CSL-JSON format (close #48). 38 | v3.8.1, 2018-01-24 -- BREAK: stop support for py26, py33 and isbndb. 39 | v3.8.2, 2018-01-24 -- Fix bug 'in_virtual'. 40 | v3.8.3, 2018-01-29 -- Add 'wcat' to editions. 41 | v3.8.4, 2018-03-06 -- Solve issues with non-ASCII searches in 'from_words'. 42 | v3.8.5, 2018-06-14 -- NOT RELEASED. 43 | v3.9.1, 2018-07-02 -- Issue #51 (closing down of xISBN service). 44 | v3.9.2, 2018-09-28 -- Updated data. 45 | v3.9.3, 2018-10-05 -- Make it easier to override 'WEBService.data'. 46 | v3.9.4, 2019-01-18 -- Fix bibjson and updated data. 47 | v3.9.5, 2019-02-10 -- PR #53 and updated data. 48 | v3.9.6, 2019-02-24 -- Performance improvements (close #55). 49 | v3.9.7, 2019-05-06 -- Editions is now cached and consistent return types. 50 | v3.9.8, 2019-05-06 -- Fix bug #58 on 'editions'. 51 | v3.9.9, 2019-10-17 -- Better caching, improved security, fix 'catch all exception'. 52 | v3.9.10, 2019-12-20 -- Fix issues: 61 (close #61) and 62 (close #62). 53 | v3.9.11, 2019-12-20 -- NOT RELEASED. 54 | v3.10.0, 2020-03-23 -- Add a 'classify' service (oclc.org). 55 | v3.10.1, 2020-05-18 -- Add a 'wiki' (wikipedia) provider for metadata and editions. 56 | v3.10.2, 2020-05-26 -- Fix 'goob' and 'wiki' quirks (issue #64 and #65). 57 | v3.10.3, 2020-05-27 -- Delete a (quick debug) print statement. 58 | v3.10.4, 2020-11-17 -- Add 'Isbn' and fix #67. 59 | v3.10.5, 2020-12-31 -- Fix 'csv' and updated data. 60 | v3.10.6, 2021-01-26 -- Fix 'wiki' (close #75). 61 | v3.10.7, 2021-04-12 -- Common url for GB services. 62 | v3.10.8, 2021-05-10 -- New 'Authors' parsing strategy for 'wiki'. 63 | v3.10.9, 2021-05-10 -- TENTATIVE 64 | -------------------------------------------------------------------------------- /isbnlib/registry.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Registry for metadata services, formatters and cache.""" 3 | 4 | import logging 5 | 6 | from pkg_resources import iter_entry_points 7 | 8 | from . import NotValidDefaultFormatterError, NotValidDefaultServiceError 9 | from . import _goob as goob 10 | from . import _openl as openl 11 | from . import _wiki as wiki 12 | from ._imcache import IMCache 13 | from .config import options 14 | from .dev._fmt import _fmtbib 15 | 16 | LOGGER = logging.getLogger(__name__) 17 | 18 | # SERVICES 19 | 20 | services = { 21 | 'default': goob.query, 22 | 'goob': goob.query, 23 | 'openl': openl.query, 24 | 'wiki': wiki.query, 25 | } 26 | 27 | PROVIDERS = () 28 | 29 | 30 | def setdefaultservice(name): 31 | """Set the default service.""" 32 | global services 33 | services['default'] = services[name.lower()] 34 | if name != 'default' and name in services: 35 | services['default'] = services[name.lower()] 36 | else: 37 | LOGGER.critical('Wrong default service') 38 | raise NotValidDefaultServiceError(name) 39 | 40 | 41 | def add_service(name, query): # pragma: no cover 42 | """Add a new service to services.""" 43 | global services 44 | services[name.lower()] = query 45 | 46 | 47 | # FORMATTERS 48 | 49 | bibformatters = { 50 | 'default': lambda x: _fmtbib('labels', x), 51 | 'labels': lambda x: _fmtbib('labels', x), 52 | 'bibtex': lambda x: _fmtbib('bibtex', x), 53 | 'csl': lambda x: _fmtbib('csl', x), 54 | 'csv': lambda x: _fmtbib('csv', x), 55 | 'json': lambda x: _fmtbib('json', x), 56 | 'opf': lambda x: _fmtbib('opf', x), 57 | 'endnote': lambda x: _fmtbib('endnote', x), 58 | 'ris': lambda x: _fmtbib('ris', x), 59 | 'refworks': lambda x: _fmtbib('ris', x), 60 | 'msword': lambda x: _fmtbib('msword', x), 61 | } # pragma: no cover 62 | 63 | BIBFORMATS = () 64 | 65 | 66 | def setdefaultbibformatter(name): 67 | """Set the default formatter.""" 68 | global bibformatters 69 | if name != 'default' and name in bibformatters: 70 | bibformatters['default'] = bibformatters[name.lower()] 71 | else: 72 | LOGGER.critical('Wrong default bibformatter') 73 | raise NotValidDefaultFormatterError(name) 74 | 75 | 76 | def add_bibformatter(name, formatter): # pragma: no cover 77 | """Add a new formatter to formatters.""" 78 | global bibformatters 79 | bibformatters[name] = formatter.lower() 80 | 81 | 82 | # pylint: disable=broad-except 83 | def load_plugins(): # pragma: no cover 84 | """Load plugins with groups: isbnlib.metadata & isbnlib.formatters.""" 85 | # get metadata plugins from entry_points 86 | if options.get('LOAD_METADATA_PLUGINS', True): 87 | try: 88 | for entry in iter_entry_points(group='isbnlib.metadata'): 89 | add_service(entry.name, entry.load()) 90 | except Exception: 91 | LOGGER.critical('Some metadata plugins were not loaded!') 92 | global PROVIDERS 93 | _buf = list(services.keys()) 94 | _buf.remove('default') 95 | PROVIDERS = tuple(sorted(_buf)) 96 | # get formatters from entry_points 97 | if options.get('LOAD_FORMATTER_PLUGINS', True): 98 | try: 99 | for entry in iter_entry_points(group='isbnlib.formatters'): 100 | add_bibformatter(entry.name, entry.load()) 101 | except Exception: 102 | LOGGER.critical('Some formatters plugins were not loaded!') 103 | global BIBFORMATS 104 | _buf = list(bibformatters.keys()) 105 | _buf.remove('labels') 106 | _buf.remove('default') 107 | BIBFORMATS = tuple(sorted(_buf)) 108 | 109 | 110 | # load plugins on import 111 | load_plugins() 112 | del load_plugins 113 | 114 | # CACHE 115 | metadata_cache = IMCache() # should be an instance 116 | 117 | 118 | def set_cache(cache): # pragma: no cover 119 | """Set cache for metadata.""" 120 | global metadata_cache 121 | metadata_cache = cache 122 | -------------------------------------------------------------------------------- /isbnlib/dev/webservice.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Query web services.""" 3 | 4 | import gzip 5 | import logging 6 | from socket import timeout as sockettimeout 7 | 8 | from ..config import options 9 | from ._bouth23 import bstream, s 10 | from ._decorators import imcache 11 | from ._exceptions import ISBNLibHTTPError, ISBNLibURLError, ServiceIsDownError 12 | 13 | # pylint: disable=import-error 14 | # pylint: disable=wrong-import-order 15 | # pylint: disable=no-name-in-module 16 | try: # pragma: no cover 17 | from urllib.error import HTTPError, URLError 18 | from urllib.parse import urlencode 19 | from urllib.request import Request, urlopen 20 | except ImportError: # pragma: no cover 21 | from urllib import urlencode 22 | 23 | from urllib2 import HTTPError, Request, URLError, urlopen 24 | 25 | UA = 'isbnlib (gzip)' 26 | LOGGER = logging.getLogger(__name__) 27 | 28 | 29 | # pylint: disable=too-few-public-methods 30 | # pylint: disable=useless-object-inheritance 31 | class WEBService(object): 32 | """Class to query web services.""" 33 | def __init__(self, url, user_agent=UA, values=None, appheaders=None): 34 | """Initialize main properties.""" 35 | # TODO(use urllib.quote to the non-ascii part?) 36 | if not url.lower().startswith('http'): 37 | LOGGER.critical('Url (%s) not allowed!', url) 38 | raise ISBNLibURLError('Url (%s) not allowed!' % url) 39 | self._url = url 40 | # headers to accept gzipped content 41 | headers = {'Accept-Encoding': 'gzip', 'User-Agent': user_agent} 42 | # add more user provided headers 43 | if appheaders: # pragma: no cover 44 | headers.update(appheaders) 45 | # if 'data' it does a POST request (data must be urlencoded) 46 | data = urlencode(values).encode('utf8') if values else None 47 | self._request = Request(url, data, headers=headers) 48 | 49 | def response(self): 50 | """Check errors on response.""" 51 | # TODO(http 102) 52 | # How to handle "102 http's code"? 53 | # - urlopen doesn't catch a 102 code! 54 | # https://docs.python.org/3/howto/urllib2.html#error-codes 55 | try: 56 | response = urlopen( 57 | self._request, 58 | timeout=options.get('URLOPEN_TIMEOUT'), 59 | ) 60 | LOGGER.debug('Request headers:\n%s', self._request.header_items()) 61 | except HTTPError as e: # pragma: no cover 62 | LOGGER.critical( 63 | 'ISBNLibHTTPError for %s with code %s [%s]', 64 | self._url, 65 | e.code, 66 | e.msg, 67 | ) 68 | if e.code in (401, 403, 429): 69 | raise ISBNLibHTTPError('%s Are you making many requests?' % 70 | e.code) 71 | if e.code in (502, 504): 72 | raise ISBNLibHTTPError('%s Service temporarily unavailable!' % 73 | e.code) 74 | raise ISBNLibHTTPError('(%s) %s' % (e.code, e.msg)) 75 | except URLError as e: # pragma: no cover 76 | LOGGER.critical( 77 | 'ISBNLibURLError for %s with reason %s', 78 | self._url, 79 | e.reason, 80 | ) 81 | raise ISBNLibURLError(e.reason) 82 | except sockettimeout: # pragma: no cover 83 | LOGGER.critical( 84 | 'ServiceIsDownError for %s with reason %s', 85 | self._url, 86 | 'timeout', 87 | ) 88 | raise ServiceIsDownError('service timeout') 89 | return response if response else None 90 | 91 | def data(self): 92 | """Return the uncompressed data.""" 93 | res = self.response() 94 | LOGGER.debug('Response headers:\n%s', res.info()) 95 | if res.info().get('Content-Encoding') == 'gzip': 96 | buf = bstream(res.read()) 97 | f = gzip.GzipFile(fileobj=buf) 98 | data = f.read() 99 | else: # pragma: no cover 100 | data = res.read() 101 | return s(data) 102 | 103 | 104 | @imcache 105 | def query(url, user_agent=UA, values=None, appheaders=None): 106 | """Query to a web service.""" 107 | service = WEBService( 108 | url, 109 | user_agent=user_agent, 110 | values=values, 111 | appheaders=appheaders, 112 | ) 113 | data = service.data() 114 | LOGGER.debug('Raw data from service:\n%s', data) 115 | return data 116 | -------------------------------------------------------------------------------- /isbnlib/dev/_fmt.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """Format canonical in bibliographic formats.""" 5 | 6 | import re 7 | import uuid 8 | from string import Template 9 | 10 | from ._helpers import last_first 11 | 12 | bibtex = r"""@book{$ISBN, 13 | title = {$Title}, 14 | author = {$AUTHORS}, 15 | isbn = {$ISBN}, 16 | year = {$Year}, 17 | publisher = {$Publisher} 18 | }""" 19 | 20 | endnote = r"""%0 Book 21 | %T $Title 22 | %A $AUTHORS 23 | %@ $ISBN 24 | %D $Year 25 | %I $Publisher """ 26 | 27 | ris = r"""TY - BOOK 28 | T1 - $Title 29 | A1 - $AUTHORS 30 | SN - $ISBN 31 | Y1 - $Year 32 | PB - $Publisher 33 | ER - """ 34 | 35 | msword = (r""" 37 | $uid 38 | Book 39 | 40 | $AUTHORS 41 | 42 | 43 | $Title 44 | $Year 45 | 46 | $Publisher 47 | """) 48 | 49 | json = r"""{"type": "book", 50 | "title": "$Title", 51 | "author": [$AUTHORS], 52 | "year": "$Year", 53 | "identifier": [{"type": "ISBN", "id": "$ISBN"}], 54 | "publisher": "$Publisher"}""" 55 | 56 | csl = r"""{"type":"book", 57 | "id":"$ISBN", 58 | "title":"$Title", 59 | "author": [$AUTHORS], 60 | "issued": {"date-parts": [[$Year]]}, 61 | "ISBN":"$ISBN", 62 | "publisher":"$Publisher"}""" 63 | 64 | csv = '"book","$ISBN","$Title","$AUTHORS","$Year","$Publisher"' 65 | 66 | opf = r""" 67 | 68 | 69 | Book 70 | $uid 71 | $ISBN 72 | $Title 73 | $AUTHORS 74 | $Publisher 75 | $Year 76 | isbnlib [http://github.com/xlcnd/isbnlib] 77 | 78 | """ 79 | 80 | labels = r"""Type: BOOK 81 | Title: $Title 82 | Author: $AUTHORS 83 | ISBN: $ISBN 84 | Year: $Year 85 | Publisher: $Publisher""" 86 | 87 | templates = { 88 | 'labels': labels, 89 | 'bibtex': bibtex, 90 | 'endnote': endnote, 91 | 'ris': ris, 92 | 'msword': msword, 93 | 'json': json, 94 | 'csl': csl, 95 | 'csv': csv, 96 | 'opf': opf, 97 | } 98 | 99 | _fmts = list(templates.keys()) 100 | 101 | 102 | def _gen_proc(name, canonical): 103 | if 'ISBN-13' in canonical: 104 | canonical['ISBN'] = canonical.pop('ISBN-13') 105 | canonical['Title'] = canonical.get('Title').replace('"', '') 106 | tpl = templates[name] 107 | return Template(tpl).safe_substitute(canonical) 108 | 109 | 110 | def _spec_proc(name, fmtrec, authors): 111 | """Fix the Authors records.""" 112 | if name not in _fmts: 113 | return 114 | if not authors: 115 | return 116 | if name == 'labels': 117 | AUTHORS = '\nAuthor: '.join(authors) 118 | elif name == 'bibtex': 119 | AUTHORS = ' and '.join(authors) 120 | elif name == 'ris': 121 | AUTHORS = '\nA1 - '.join(authors) 122 | elif name == 'endnote': 123 | AUTHORS = '\n%A '.join(authors) 124 | elif name == 'msword': 125 | fmtrec = fmtrec.replace('$uid', str(uuid.uuid4())) 126 | person = (r'$last' 127 | r'$first') 128 | AUTHORS = '\n'.join( 129 | Template(person).safe_substitute(last_first(a)) for a in authors) 130 | elif name == 'json': 131 | AUTHORS = ', '.join('{"name": "$"}'.replace('$', a) for a in authors) 132 | elif name == 'csl': 133 | AUTHORS = ', '.join('{"literal": "$"}'.replace('$', a) 134 | for a in authors) 135 | elif name == 'csv': 136 | AUTHORS = ', '.join(authors) 137 | elif name == 'opf': 138 | fmtrec = fmtrec.replace('$uid', str(uuid.uuid4())) 139 | creator = (r'$first $last') 141 | AUTHORS = '\n '.join( 142 | Template(creator).safe_substitute(last_first(author)) 143 | for author in authors) 144 | return re.sub(r'\$AUTHORS', AUTHORS, fmtrec) 145 | 146 | 147 | def _fmtbib(fmtname, canonical): 148 | """Return a canonical record in the selected format.""" 149 | return _spec_proc( 150 | fmtname, 151 | _gen_proc(fmtname, canonical), 152 | canonical.get('Authors'), 153 | ) 154 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | `isbnlib` has a very small code base, so it is a good project to begin your 4 | adventure in open-source. 5 | 6 | > **NOTE**: By contributing you agree with the [license terms](LICENSE-LGPL.txt) 7 | (**LGPL v3**) of the project. 8 | 9 | 10 | ## Main Steps 11 | 12 | 1. Make sure you have a [GitHub account](https://github.com/signup/free) 13 | 2. Submit a ticket for your issue or idea (**ONE ticket for each issue or idea**) 14 | ([help](https://www.youtube.com/watch?v=TJlYiMp8FuY)), 15 | on https://github.com/xlcnd/isbnlib/issues, 16 | (if possible wait for some feedback before any serious commitment... :) 17 | 3. **Fork** the repository on GitHub and **clone it locally** 18 | ([help](https://help.github.com/articles/fork-a-repo)). 19 | 4. `pip install -r requirements-dev.txt` (at your local directory). 20 | 5. Do your code... (**remember the code must run on python 2.7, 3.5+ 21 | and be OS independent** It is easier if you start to write in python 3 and then 22 | adapt for python 2) (you will find [Travis](https://travis-ci.org/xlcnd/isbnlib) very handy for 23 | testing with this requirement!) 24 | 6. Write tests for your code using `nose` and put then in the directory `isbnlib/test` 25 | 7. Pass **all tests** and with **coverage > 90%**. 26 | Check the coverage in [Coveralls](https://coveralls.io/r/xlcnd/isbnlib) or locally with the command 27 | `nosetests --with-coverage --cover-package=isbnlib`. 28 | 8. **Check if all requirements are fulfilled**! 29 | 9. **Push** your local changes to GitHub and make there a **pull request** 30 | ([help](https://help.github.com/articles/using-pull-requests/)) 31 | **using `dev` as base branch** (by the way, we follow the *fork & pull* model with this small change). 32 | **VERY IMPORTANT:** Don't put in the same pull request unrelated changes in the code, 33 | make one pull request for each set of related changes! 34 | 35 | > **NOTE**: *Travis*, *coverage*, *flake8* and *pylint*, have already 36 | configuration files adapted to the project. 37 | 38 | ## Style 39 | 40 | Your code **must** be [PEP8](http://legacy.python.org/dev/peps/pep-0008/) compliant 41 | and be concise as possible (use `yapf` then check it with 42 | `flake8` and `pylint`). 43 | 44 | Use doc strings ([PEP257](http://legacy.python.org/dev/peps/pep-0257/)) 45 | for users and comments (**few**) as signposts 46 | for fellow developers. Make your code as clear as possible. 47 | 48 | 49 | ## Red Lines 50 | 51 | **Don't submit pull requests that are only comments to the code that is 52 | already in the repo!** 53 | Don't expect kindness if you do that :) You **can** comment and give 54 | suggestions on the code at the 55 | [issues](https://github.com/xlcnd/isbnlib/issues/5) page. 56 | 57 | **No** doc tests! Remember point 6 above. 58 | 59 | **Don't** submit pull requests without checking points 8 and 9! 60 | 61 | 62 | 63 | ## Suggestions 64 | 65 | Read the code in a structured way at [sourcegraph](https://sourcegraph.com/github.com/xlcnd/isbnlib). 66 | 67 | Goto [issues/enhancement](https://github.com/xlcnd/isbnlib/issues?labels=enhancement&page=1&state=open) 68 | for possible enhancements to the code. 69 | If you have some idea that is not there enter your own. 70 | Select some focused issue and enter some comments on how you plan to tackle it. 71 | 72 | 73 | ## Important 74 | 75 | If you don't have experience with these issues, don't be put off by these requirements, 76 | see them as a learning opportunity. Thanks! 77 | 78 | 79 | 80 | ## Resources (for *newbies*) 81 | 82 | 83 | ### Minimum git & GitHub 84 | 85 | - https://guides.github.com/activities/hello-world/ 86 | - https://guides.github.com/introduction/flow/index.html 87 | - https://www.youtube.com/watch?v=IeW1Irw45hQ 88 | - https://www.youtube.com/watch?v=U8GBXvdmHT4 89 | - https://www.youtube.com/watch?v=9Blbj1HMROU 90 | 91 | 92 | ### More Resources by Topic 93 | 94 | | **Topic** | **Resource** | 95 | |----------------------------:|:------------------------------------------------------------------------| 96 | | fork a repo | https://help.github.com/articles/fork-a-repo | 97 | | pull request | https://help.github.com/articles/using-pull-requests/ | 98 | | git log | https://www.youtube.com/watch?v=U8GBXvdmHT4 | 99 | | local feature branches | https://www.youtube.com/watch?v=ImhZj6tpXLE | 100 | | git & GitHub tips | https://github.com/tiimgreen/github-cheat-sheet | 101 | | | http://cbx33.github.io/gitt/intro.html | 102 | | | http://git-scm.com/documentation | 103 | | | http://gitimmersion.com/ | 104 | | | http://www.youtube.com/playlist?list=PLq0VzNtDZbe9QLq8YCizFN2TVWvlLjrvX | 105 | | nosetests | http://pythontesting.net/framework/nose/nose-introduction/ | 106 | | contributing | https://www.youtube.com/watch?v=IXnNgLmd6BM | 107 | | | http://openhatch.org/missions | 108 | | | http://opensource.com/resources/how-get-started-open-source | 109 | -------------------------------------------------------------------------------- /isbnlib/test/test_core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | 5 | from nose.tools import assert_equals 6 | 7 | from .._core import ( 8 | EAN13, 9 | _check_structure10, 10 | _check_structure13, 11 | canonical, 12 | check_digit10, 13 | check_digit13, 14 | clean, 15 | ean13, 16 | get_canonical_isbn, 17 | get_isbnlike, 18 | is_isbn10, 19 | is_isbn13, 20 | notisbn, 21 | to_isbn10, 22 | to_isbn13, 23 | ) 24 | from .data4tests import ISBNs 25 | 26 | # nose tests 27 | 28 | 29 | def test_check_digit10(): 30 | """Test check digit algo for ISBN-10.""" 31 | assert_equals(check_digit10('082649752'), '7') 32 | assert_equals(check_digit10('585270001'), '0') 33 | assert_equals(check_digit10('08264975X'), '') 34 | assert_equals(check_digit10('08264975'), '') 35 | 36 | 37 | def test_check_digit13(): 38 | """Test check digit algo for ISBN-13.""" 39 | assert_equals(check_digit13('978082649752'), '9') 40 | assert_equals(check_digit13('97808264975'), '') 41 | assert_equals(check_digit13('97808264975X'), '') 42 | 43 | 44 | def test__check_structure10(): 45 | """Test structure detection for ISBN-10.""" 46 | assert_equals(_check_structure10('0826497527'), True) 47 | assert_equals(_check_structure10('0826497X27'), True) # isbnlike! 48 | assert_equals(_check_structure10('0826497XI7'), False) 49 | 50 | 51 | def test__check_structure13(): 52 | """Test structure detection for ISBN-13.""" 53 | assert_equals(_check_structure13('9780826497529'), True) 54 | assert_equals(_check_structure13('978082649752X'), False) 55 | 56 | 57 | def test_is_isbn10(): 58 | """Test detection and validation for ISBN-10.""" 59 | assert_equals(is_isbn10('0826497527'), True) 60 | assert_equals(is_isbn10('isbn 0-8264-9752-7'), True) 61 | assert_equals(is_isbn10('0826497520'), False) 62 | assert_equals(is_isbn10('954430603X'), True) 63 | 64 | 65 | def test_is_isbn13(): 66 | """Test detection and validation for ISBN-13.""" 67 | assert_equals(is_isbn13('9780826497529'), True) 68 | assert_equals(is_isbn13('9791090636071'), True) 69 | assert_equals(is_isbn13('isbn 979-10-90636-07-1'), True) 70 | assert_equals(is_isbn13('9780826497520'), False) 71 | assert_equals(is_isbn13('9700000000000'), False) 72 | assert_equals(is_isbn13('9000000000000'), False) 73 | assert_equals(is_isbn13('9710000000000'), False) 74 | 75 | 76 | def test_to_isbn10(): 77 | """Test transformation of ISBN to ISBN-10.""" 78 | assert_equals(to_isbn10('9780826497529'), '0826497527') 79 | assert_equals(to_isbn10('0826497527'), '0826497527') 80 | assert_equals(to_isbn10('9780826497520'), '') # ISBN13 not valid 81 | assert_equals(to_isbn10('9790826497529'), '') 82 | assert_equals(to_isbn10('97808264975X3'), '') 83 | assert_equals(to_isbn10('978-826497'), '') # (bug #14) 84 | assert_equals(to_isbn10('isbn 0-8264-9752-7'), '0826497527') 85 | assert_equals(to_isbn10('isbn 979-10-90636-07-1'), '') 86 | assert_equals(to_isbn10('isbn 978-0-8264-9752-9'), '0826497527') 87 | assert_equals(to_isbn10('asdadv isbn 978-0-8264-9752-9'), '0826497527') 88 | 89 | 90 | def test_to_isbn13(): 91 | """Test transformation of ISBN to ISBN-13.""" 92 | assert_equals(to_isbn13('0826497527'), '9780826497529') 93 | assert_equals(to_isbn13('9780826497529'), '9780826497529') 94 | assert_equals(to_isbn13('0826497520'), '') # ISBN10 not valid 95 | assert_equals(to_isbn13('08X6497527'), '') 96 | assert_equals(to_isbn13('91-43-01019-9'), '9789143010190') # (bug #14) 97 | assert_equals(to_isbn13('isbn 91-43-01019-9'), '9789143010190') 98 | assert_equals(to_isbn13('asd isbn 979-10-90636-07-1 blabla'), '9791090636071') 99 | 100 | 101 | def test_clean(): 102 | """Test the cleaning of ISBN-like strings.""" 103 | assert_equals(clean(' 978.0826.497529'), '9780826497529') 104 | assert_equals(clean('ISBN: 9791090636071'), 'ISBN 9791090636071') 105 | assert_equals(clean('978,0826497520'), '9780826497520') 106 | 107 | 108 | def test_notisbn(): 109 | """Test the impossibility of extracting valid ISBN from ISBN-like strings.""" 110 | assert_equals(notisbn('0826497527'), False) 111 | assert_equals(notisbn('0826497520'), True) 112 | assert_equals(notisbn('9780826497529', level='strict'), False) 113 | assert_equals(notisbn('9426497529', level='strict'), True) 114 | assert_equals(notisbn('978082649752', level='strict'), True) 115 | assert_equals(notisbn('978082649752', level='loose'), True) 116 | assert_equals(notisbn('9780826400001', level='loose'), False) 117 | assert_equals(notisbn('9780826400001', level='strict'), True) 118 | assert_equals(notisbn('9780826400001', level='badlevel'), None) 119 | assert_equals(notisbn('978 9426497529'), True) 120 | assert_equals(notisbn('9789426497529'), True) 121 | assert_equals(notisbn('979 10 9063607 1'), False) 122 | assert_equals(notisbn('9780826497520'), True) 123 | 124 | 125 | def test_get_isbnlike(): 126 | """Test the extraction of ISBN-like strings.""" 127 | assert_equals(len(get_isbnlike(ISBNs)), 78) 128 | assert_equals(len(get_isbnlike(ISBNs, 'normal')), 78) 129 | assert_equals(len(get_isbnlike(ISBNs, 'strict')), 69) 130 | assert_equals(len(get_isbnlike(ISBNs, 'loose')), 81) 131 | assert_equals(get_isbnlike(ISBNs, 'e'), []) 132 | 133 | 134 | def test_get_canonical_isbn(): 135 | """Test the extraction of canonical ISBN from ISBN-like string.""" 136 | assert_equals(get_canonical_isbn('0826497527', output='bouth'), '0826497527') 137 | assert_equals(get_canonical_isbn('0826497527'), '0826497527') 138 | assert_equals(get_canonical_isbn('0826497527', output='isbn10'), '0826497527') 139 | assert_equals(get_canonical_isbn('0826497527', output='isbn13'), '9780826497529') 140 | assert_equals( 141 | get_canonical_isbn('ISBN 0826497527', output='isbn13'), '9780826497529', 142 | ) 143 | assert_equals(get_canonical_isbn('ISBN 0826497527', output='NOOPTION'), '') 144 | assert_equals(get_canonical_isbn('0826497520'), '') 145 | assert_equals(get_canonical_isbn('9780826497529'), '9780826497529') 146 | assert_equals(get_canonical_isbn('9780826497520'), '') 147 | assert_equals(get_canonical_isbn('OSX 9780826497529.pdf'), '9780826497529') 148 | 149 | 150 | def test_canonical(): 151 | """Test the extraction of 'only numbers and X' from ISBN-like string.""" 152 | assert_equals(canonical('ISBN 9789720404427'), '9789720404427') 153 | assert_equals(canonical('ISBN-9780826497529'), '9780826497529') 154 | assert_equals(canonical('ISBN9780826497529'), '9780826497529') 155 | assert_equals(canonical('isbn9780826497529'), '9780826497529') 156 | assert_equals(canonical('isbn 0826497527'), '0826497527') 157 | assert_equals(canonical('954430603x'), '954430603X') 158 | assert_equals(canonical('95443060x3'), '') 159 | assert_equals(canonical('0000000000'), '') 160 | assert_equals(canonical('000000000X'), '') 161 | assert_equals(canonical('0000000000000'), '') 162 | assert_equals(canonical('0000000'), '') 163 | assert_equals(canonical(''), '') 164 | 165 | 166 | def test_ean13(): 167 | """Test the extraction and validation of EAN13 from ISBN-like string.""" 168 | assert_equals(ean13('ISBN 9789720404427'), '') 169 | assert_equals(ean13('ISBN 9789720404428'), '9789720404428') 170 | assert_equals(EAN13('ISBN-9780826497529'), '9780826497529') 171 | assert_equals(ean13('ISBN9780826497529'), '9780826497529') 172 | assert_equals(EAN13('isbn9780826497529'), '9780826497529') 173 | assert_equals(EAN13('isbn 0826497527'), '9780826497529') 174 | assert_equals(ean13('9700000000000'), '') 175 | assert_equals(EAN13('9000000000000'), '') 176 | assert_equals(EAN13('9710000000000'), '') 177 | -------------------------------------------------------------------------------- /LICENSE-LGPL-3.0.txt: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /isbnlib/_data/data4info.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8:noqa 3 | # pylint:skip-file 4 | # isort:skip_file 5 | # fmt:off 6 | # Produced by 'isbntools-dev'@'2021-10-28T13:31:56+00:00' 7 | 8 | # WARNING 9 | # THIS FILE WAS PRODUCED BY TOOLS THAT AUTOMATICALLY 10 | # GATHER THE RELEVANT INFORMATION FROM SEVERAL SOURCES 11 | # DON'T EDIT IT MANUALLY! 12 | 13 | 14 | countries={'978-0':'English language','978-1':'English language','978-2':'French language','978-3':'German language','978-4':'Japan','978-5':'former U.S.S.R','978-600':'Iran','978-601':'Kazakhstan','978-602':'Indonesia','978-603':'Saudi Arabia','978-604':'Vietnam','978-605':'Turkey','978-606':'Romania','978-607':'Mexico','978-608':'North Macedonia','978-609':'Lithuania','978-611':'Thailand','978-612':'Peru','978-613':'Mauritius','978-614':'Lebanon','978-615':'Hungary','978-616':'Thailand','978-617':'Ukraine','978-618':'Greece','978-619':'Bulgaria','978-620':'Mauritius','978-621':'Philippines','978-622':'Iran','978-623':'Indonesia','978-624':'Sri Lanka','978-625':'Turkey','978-626':'Taiwan','978-627':'Pakistan','978-628':'Colombia','978-65':'Brazil','978-7': "China, People's Republic", '978-80':'former Czechoslovakia','978-81':'India','978-82':'Norway','978-83':'Poland','978-84':'Spain','978-85':'Brazil','978-86':'former Yugoslavia','978-87':'Denmark','978-88':'Italy','978-89':'Korea, Republic','978-90':'Netherlands','978-91':'Sweden','978-92':'International NGO Publishers and EU Organizations','978-93':'India','978-94':'Netherlands','978-950':'Argentina','978-951':'Finland','978-952':'Finland','978-953':'Croatia','978-954':'Bulgaria','978-955':'Sri Lanka','978-956':'Chile','978-957':'Taiwan','978-958':'Colombia','978-959':'Cuba','978-960':'Greece','978-961':'Slovenia','978-962':'Hong Kong, China','978-963':'Hungary','978-964':'Iran','978-965':'Israel','978-966':'Ukraine','978-967':'Malaysia','978-968':'Mexico','978-969':'Pakistan','978-970':'Mexico','978-971':'Philippines','978-972':'Portugal','978-973':'Romania','978-974':'Thailand','978-975':'Turkey','978-976':'Caribbean Community','978-977':'Egypt','978-978':'Nigeria','978-979':'Indonesia','978-980':'Venezuela','978-981':'Singapore','978-982':'South Pacific','978-983':'Malaysia','978-984':'Bangladesh','978-985':'Belarus','978-986':'Taiwan','978-987':'Argentina','978-988':'Hong Kong, China','978-989':'Portugal','978-9912':'Tanzania','978-9913':'Uganda','978-9914':'Kenya','978-9915':'Uruguay','978-9916':'Estonia','978-9917':'Bolivia','978-9918':'Malta','978-9919':'Mongolia','978-9920':'Morocco','978-9921':'Kuwait','978-9922':'Iraq','978-9923':'Jordan','978-9924':'Cambodia','978-9925':'Cyprus','978-9926':'Bosnia and Herzegovina','978-9927':'Qatar','978-9928':'Albania','978-9929':'Guatemala','978-9930':'Costa Rica','978-9931':'Algeria','978-9932': "Lao People's Democratic Republic", '978-9933':'Syria','978-9934':'Latvia','978-9935':'Iceland','978-9936':'Afghanistan','978-9937':'Nepal','978-9938':'Tunisia','978-9939':'Armenia','978-9940':'Montenegro','978-9941':'Georgia','978-9942':'Ecuador','978-9943':'Uzbekistan','978-9944':'Turkey','978-9945':'Dominican Republic','978-9946':'Korea, P.D.R.','978-9947':'Algeria','978-9948':'United Arab Emirates','978-9949':'Estonia','978-9950':'Palestine','978-9951':'Kosova','978-9952':'Azerbaijan','978-9953':'Lebanon','978-9954':'Morocco','978-9955':'Lithuania','978-9956':'Cameroon','978-9957':'Jordan','978-9958':'Bosnia and Herzegovina','978-9959':'Libya','978-9960':'Saudi Arabia','978-9961':'Algeria','978-9962':'Panama','978-9963':'Cyprus','978-9964':'Ghana','978-9965':'Kazakhstan','978-9966':'Kenya','978-9967':'Kyrgyz Republic','978-9968':'Costa Rica','978-9970':'Uganda','978-9971':'Singapore','978-9972':'Peru','978-9973':'Tunisia','978-9974':'Uruguay','978-9975':'Moldova','978-9976':'Tanzania','978-9977':'Costa Rica','978-9978':'Ecuador','978-9979':'Iceland','978-9980':'Papua New Guinea','978-9981':'Morocco','978-9982':'Zambia','978-9983':'Gambia','978-9984':'Latvia','978-9985':'Estonia','978-9986':'Lithuania','978-9987':'Tanzania','978-9988':'Ghana','978-9989':'North Macedonia','978-99901':'Bahrain','978-99902':'Reserved Agency','978-99903':'Mauritius','978-99904':'Curaçao','978-99905':'Bolivia','978-99906':'Kuwait','978-99908':'Malawi','978-99909':'Malta','978-99910':'Sierra Leone','978-99911':'Lesotho','978-99912':'Botswana','978-99913':'Andorra','978-99914':'International NGO Publishers','978-99915':'Maldives','978-99916':'Namibia','978-99917':'Brunei Darussalam','978-99918':'Faroe Islands','978-99919':'Benin','978-99920':'Andorra','978-99921':'Qatar','978-99922':'Guatemala','978-99923':'El Salvador','978-99924':'Nicaragua','978-99925':'Paraguay','978-99926':'Honduras','978-99927':'Albania','978-99928':'Georgia','978-99929':'Mongolia','978-99930':'Armenia','978-99931':'Seychelles','978-99932':'Malta','978-99933':'Nepal','978-99934':'Dominican Republic','978-99935':'Haiti','978-99936':'Bhutan','978-99937':'Macau','978-99938':'Srpska, Republic of','978-99939':'Guatemala','978-99940':'Georgia','978-99941':'Armenia','978-99942':'Sudan','978-99943':'Albania','978-99944':'Ethiopia','978-99945':'Namibia','978-99946':'Nepal','978-99947':'Tajikistan','978-99948':'Eritrea','978-99949':'Mauritius','978-99950':'Cambodia','978-99951':'Reserved Agency','978-99952':'Mali','978-99953':'Paraguay','978-99954':'Bolivia','978-99955':'Srpska, Republic of','978-99956':'Albania','978-99957':'Malta','978-99958':'Bahrain','978-99959':'Luxembourg','978-99960':'Malawi','978-99961':'El Salvador','978-99962':'Mongolia','978-99963':'Cambodia','978-99964':'Nicaragua','978-99965':'Macau','978-99966':'Kuwait','978-99967':'Paraguay','978-99968':'Botswana','978-99969':'Oman','978-99970':'Haiti','978-99971':'Myanmar','978-99972':'Faroe Islands','978-99973':'Mongolia','978-99974':'Bolivia','978-99975':'Tajikistan','978-99976':'Srpska, Republic of','978-99977':'Rwanda','978-99978':'Mongolia','978-99979':'Honduras','978-99980':'Bhutan','978-99981':'Macau','978-99982':'Benin','978-99983':'El Salvador','978-99985':'Tajikistan','978-99986':'Myanmar','978-99987':'Luxembourg','978-99988':'Sudan','979-10':'France','979-11':'Korea, Republic','979-12':'Italy','979-8':'United States'} 15 | identifiers=(('978-0','978-1','978-2','978-3','978-4','978-5','978-7','979-8'),('978-65','978-80','978-81','978-82','978-83','978-84','978-85','978-86','978-87','978-88','978-89','978-90','978-91','978-92','978-93','978-94','979-10','979-11','979-12'),('978-600','978-601','978-602','978-603','978-604','978-605','978-606','978-607','978-608','978-609','978-611','978-612','978-613','978-614','978-615','978-616','978-617','978-618','978-619','978-620','978-621','978-622','978-623','978-624','978-625','978-626','978-627','978-628','978-950','978-951','978-952','978-953','978-954','978-955','978-956','978-957','978-958','978-959','978-960','978-961','978-962','978-963','978-964','978-965','978-966','978-967','978-968','978-969','978-970','978-971','978-972','978-973','978-974','978-975','978-976','978-977','978-978','978-979','978-980','978-981','978-982','978-983','978-984','978-985','978-986','978-987','978-988','978-989'),('978-9912','978-9913','978-9914','978-9915','978-9916','978-9917','978-9918','978-9919','978-9920','978-9921','978-9922','978-9923','978-9924','978-9925','978-9926','978-9927','978-9928','978-9929','978-9930','978-9931','978-9932','978-9933','978-9934','978-9935','978-9936','978-9937','978-9938','978-9939','978-9940','978-9941','978-9942','978-9943','978-9944','978-9945','978-9946','978-9947','978-9948','978-9949','978-9950','978-9951','978-9952','978-9953','978-9954','978-9955','978-9956','978-9957','978-9958','978-9959','978-9960','978-9961','978-9962','978-9963','978-9964','978-9965','978-9966','978-9967','978-9968','978-9970','978-9971','978-9972','978-9973','978-9974','978-9975','978-9976','978-9977','978-9978','978-9979','978-9980','978-9981','978-9982','978-9983','978-9984','978-9985','978-9986','978-9987','978-9988','978-9989'),('978-99901','978-99902','978-99903','978-99904','978-99905','978-99906','978-99908','978-99909','978-99910','978-99911','978-99912','978-99913','978-99914','978-99915','978-99916','978-99917','978-99918','978-99919','978-99920','978-99921','978-99922','978-99923','978-99924','978-99925','978-99926','978-99927','978-99928','978-99929','978-99930','978-99931','978-99932','978-99933','978-99934','978-99935','978-99936','978-99937','978-99938','978-99939','978-99940','978-99941','978-99942','978-99943','978-99944','978-99945','978-99946','978-99947','978-99948','978-99949','978-99950','978-99951','978-99952','978-99953','978-99954','978-99955','978-99956','978-99957','978-99958','978-99959','978-99960','978-99961','978-99962','978-99963','978-99964','978-99965','978-99966','978-99967','978-99968','978-99969','978-99970','978-99971','978-99972','978-99973','978-99974','978-99975','978-99976','978-99977','978-99978','978-99979','978-99980','978-99981','978-99982','978-99983','978-99985','978-99986','978-99987','978-99988')) 16 | RDDATE='20211028' 17 | -------------------------------------------------------------------------------- /isbnlib/_core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # isbnlib - tools for extracting, cleaning and transforming ISBNs 4 | # Copyright (C) 2014-2021 Alexandre Lima Conde 5 | # SPDX-License-Identifier: LGPL-3.0-or-later 6 | 7 | # This program is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program. If not, see . 19 | """isbnlib main file for ISBN manipulation. 20 | 21 | Tools for extracting, cleaning, transforming and validating ISBN ids. 22 | """ 23 | 24 | import logging 25 | import re 26 | 27 | LOGGER = logging.getLogger(__name__) 28 | 29 | RE_ISBN10 = re.compile(r'ISBN\x20(?=.{13}$)\d{1,5}([- ])\d{1,7}' 30 | r'\1\d{1,6}\1(\d|X)$|[- 0-9X]{10,16}') 31 | RE_ISBN13 = re.compile(r'97[89]{1}(?:-?\d){10,16}|97[89]{1}[- 0-9]{10,16}') 32 | RE_STRICT = re.compile( 33 | r'^(?:ISBN(?:-1[03])?:? )?(?=[0-9X]{10}$|' 34 | r'(?=(?:[0-9]+[- ]){3})' 35 | r'[- 0-9X]{13}$|97[89][0-9]{10}$|' 36 | r'(?=(?:[0-9]+[- ]){4})' 37 | r'[- 0-9]{17}$)(?:97[89][- ]?)?[0-9]{1,5}' 38 | r'[- ]?[0-9]+[- ]?[0-9]+[- ]?[0-9X]$', 39 | re.I | re.M | re.S, 40 | ) 41 | RE_NORMAL = re.compile( 42 | r'97[89]{1}(?:-?\d){10}|\d{9}[0-9X]{1}|' 43 | r'[-0-9X]{10,16}', 44 | re.I | re.M | re.S, 45 | ) 46 | RE_LOOSE = re.compile(r'[- 0-9X]{10,19}', re.I | re.M | re.S) 47 | ISBN13_PREFIX = '978' 48 | LEGAL = '0123456789xXisbnISBN- ' 49 | 50 | 51 | # pylint: disable=broad-except 52 | def check_digit10(firstninedigits): 53 | """Check sum ISBN-10.""" 54 | # minimum checks 55 | if len(firstninedigits) != 9: 56 | return '' 57 | try: 58 | int(firstninedigits) 59 | except Exception: # pragma: no cover 60 | return '' 61 | # checksum 62 | val = sum( 63 | (i + 2) * int(x) for i, x in enumerate(reversed(firstninedigits))) 64 | remainder = int(val % 11) 65 | if remainder == 0: 66 | tenthdigit = 0 67 | else: 68 | tenthdigit = 11 - remainder 69 | if tenthdigit == 10: 70 | tenthdigit = 'X' 71 | return str(tenthdigit) 72 | 73 | 74 | # pylint: disable=broad-except 75 | def check_digit13(firsttwelvedigits): 76 | """Check sum ISBN-13.""" 77 | # minimum checks 78 | if len(firsttwelvedigits) != 12: 79 | return '' 80 | try: 81 | int(firsttwelvedigits) 82 | except Exception: # pragma: no cover 83 | return '' 84 | # checksum 85 | val = sum( 86 | (i % 2 * 2 + 1) * int(x) for i, x in enumerate(firsttwelvedigits)) 87 | thirteenthdigit = 10 - int(val % 10) 88 | if thirteenthdigit == 10: 89 | thirteenthdigit = '0' 90 | return str(thirteenthdigit) 91 | 92 | 93 | def _check_structure10(isbn10like): 94 | """Check structure of an ISBN-10.""" 95 | return bool(re.match(RE_ISBN10, isbn10like)) 96 | 97 | 98 | def _check_structure13(isbn13like): 99 | """Check structure of an ISBN-13.""" 100 | return bool(re.match(RE_ISBN13, isbn13like)) 101 | 102 | 103 | def is_isbn10(isbn10): 104 | """Validate as ISBN-10.""" 105 | isbn10 = canonical(isbn10) 106 | if len(isbn10) != 10: 107 | return False # pragma: no cover 108 | return bool(not check_digit10(isbn10[:-1]) != isbn10[-1]) 109 | 110 | 111 | def is_isbn13(isbn13): 112 | """Validate as ISBN-13.""" 113 | isbn13 = canonical(isbn13) 114 | if len(isbn13) != 13: 115 | return False # pragma: no cover 116 | if isbn13[0:3] not in ('978', '979'): 117 | return False 118 | return bool(not check_digit13(isbn13[:-1]) != isbn13[-1]) 119 | 120 | 121 | def to_isbn10(isbn13): 122 | """Transform isbn-13 to isbn-10.""" 123 | isbn13 = canonical(isbn13) 124 | # Check prefix 125 | if isbn13[:3] != ISBN13_PREFIX: 126 | return isbn13 if len(isbn13) == 10 and is_isbn10(isbn13) else '' 127 | if not is_isbn13(isbn13): 128 | return '' 129 | isbn10 = isbn13[3:] 130 | check = check_digit10(isbn10[:-1]) 131 | # Change check digit 132 | return isbn10[:-1] + check if check else '' 133 | 134 | 135 | def to_isbn13(isbn10): 136 | """Transform isbn-10 to isbn-13.""" 137 | isbn10 = canonical(isbn10) 138 | if len(isbn10) == 13 and is_isbn13(isbn10): 139 | return isbn10 140 | if not is_isbn10(isbn10): 141 | return '' 142 | isbn13 = ISBN13_PREFIX + isbn10[:-1] 143 | check = check_digit13(isbn13) 144 | return isbn13 + check if check else '' 145 | 146 | 147 | def canonical(isbnlike): 148 | """Keep only numbers and X.""" 149 | numb = [c for c in isbnlike if c in '0123456789Xx'] 150 | if numb and numb[-1] == 'x': 151 | numb[-1] = 'X' 152 | isbn = ''.join(numb) 153 | # Filter some special cases 154 | if (isbn and len(isbn) not in (10, 13) 155 | or isbn in ('0000000000', '0000000000000', '000000000X') 156 | or isbn.find('X') not in (9, -1) or isbn.find('x') != -1): 157 | return '' 158 | return isbn 159 | 160 | 161 | def clean(isbnlike): 162 | """Clean ISBN (only legal characters).""" 163 | cisbn = [c for c in isbnlike if c in LEGAL] 164 | buf = re.sub(r'\s*-\s*', '-', ''.join(cisbn)) 165 | return re.sub(r'\s+', ' ', buf).strip() 166 | 167 | 168 | def notisbn(isbnlike, level='strict'): 169 | """Check with the goal to invalidate isbn-like. 170 | 171 | level: 172 | 'strict' when certain they are not ISBNs (default) 173 | 'loose' only filters obvious NO ISBNs 174 | 175 | """ 176 | if level not in ('strict', 'loose'): # pragma: no cover 177 | LOGGER.error('level as no option %s', level) 178 | return None 179 | isbnlike = canonical(isbnlike) 180 | if len(isbnlike) not in (10, 13): 181 | return True 182 | if level != 'strict': 183 | return False 184 | if len(isbnlike) == 10: 185 | return not is_isbn10(isbnlike) 186 | return not is_isbn13(isbnlike) 187 | 188 | 189 | def get_isbnlike(text, level='normal'): 190 | """Extract all substrings that seem like ISBNs. 191 | 192 | level: 193 | strict almost as certain they are ISBNs 194 | normal (default) 195 | loose catch many as possible 196 | 197 | """ 198 | if level == 'normal': # pragma: no cover 199 | text = text.replace('-97', '- 97') 200 | isbnlike = RE_NORMAL 201 | elif level == 'strict': 202 | isbnlike = RE_STRICT 203 | elif level == 'loose': 204 | isbnlike = RE_LOOSE 205 | else: 206 | LOGGER.error('level as no option %s', level) 207 | return [] 208 | return isbnlike.findall(text) 209 | 210 | 211 | def get_canonical_isbn(isbnlike, output='bouth'): 212 | """Extract ISBNs and transform them to the canonical form. 213 | 214 | output: 215 | isbn10 216 | isbn13 217 | bouth (default) 218 | 219 | """ 220 | if output not in ('bouth', 'isbn10', 'isbn13'): # pragma: no cover 221 | LOGGER.error('output as no option %s', output) 222 | return '' 223 | 224 | regex = RE_NORMAL 225 | 226 | match = regex.search(isbnlike) 227 | if match: 228 | # Get only canonical characters 229 | cisbn = canonical(match.group()) 230 | if not cisbn: 231 | return '' 232 | # Split into a list 233 | chars = list(cisbn) 234 | # Remove the last digit from `chars` and assign it to `last` 235 | last = chars.pop() 236 | buf = ''.join(chars) 237 | 238 | if len(chars) == 9: 239 | # Compute the ISBN-10 checksum digit 240 | check = check_digit10(buf) 241 | else: 242 | # Compute the ISBN-13 checksum digit 243 | check = check_digit13(buf) 244 | 245 | # If checksum OK return a `canonical` ISBN 246 | if str(check) == last: 247 | if output == 'bouth': 248 | return cisbn 249 | if output == 'isbn10': 250 | return cisbn if len(cisbn) == 10 else to_isbn10(cisbn) 251 | return to_isbn13(cisbn) if len(cisbn) == 10 else cisbn 252 | return '' 253 | 254 | 255 | def ean13(isbnlike): 256 | """Transform an `isbnlike` string in an EAN number (canonical ISBN-13).""" 257 | ib = canonical(isbnlike) 258 | if len(ib) == 13: 259 | return ib if is_isbn13(ib) else '' 260 | if len(ib) == 10: 261 | return to_isbn13(ib) if is_isbn10(ib) else '' 262 | return '' 263 | 264 | 265 | # Alias 266 | EAN13 = ean13 267 | GTIN13 = ean13 268 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | .. image:: https://coveralls.io/repos/github/xlcnd/isbnlib/badge.svg?branch=master 3 | :target: https://coveralls.io/github/xlcnd/isbnlib?branch=master 4 | :alt: Coverage Status 5 | 6 | .. image:: https://github.com/xlcnd/isbnlib/workflows/tests/badge.svg 7 | :target: https://github.com/xlcnd/isbnlib/actions 8 | :alt: Built Status 9 | 10 | .. image:: https://img.shields.io/github/issues/xlcnd/isbnlib/bug.svg?label=bugs&style=flat 11 | :target: https://github.com/xlcnd/isbnlib/labels/bug 12 | :alt: Bugs 13 | 14 | .. image:: https://img.shields.io/pypi/dm/isbnlib.svg?style=flat 15 | :target: https://pypi.org/project/isbnlib/ 16 | :alt: PYPI Downloads 17 | 18 | 19 | 20 | Info 21 | ==== 22 | 23 | ``isbnlib`` is a (pure) python library that provides several 24 | useful methods and functions to validate, clean, transform, hyphenate and 25 | get metadata for ISBN strings. 26 | 27 | 28 | ISBN 29 | ---- 30 | 31 | The official form of an ISBN is something like ``ISBN 979-10-90636-07-1``. However for most 32 | applications only the numbers are important, you can always 'mask' them if you need (see below). 33 | This library works mainly with 'striped' ISBNs (only digits and X) like '0826497527'. You can 34 | strip an ISBN-like string by using ``canonical(isbnlike)``. You can 35 | 'mask' the ISBN by using ``mask(isbn)``. So in the examples below, when you see 'isbn' 36 | in the argument, it is a 'striped' ISBN, when the argument is an 'isbnlike' it is a string 37 | like ``ISBN 979-10-90636-07-1`` or even something dirty like ``asdf 979-10-90636-07-1 bla bla``. 38 | 39 | Two important concepts: **valid ISBN** should be an ISBN that was built according with the rules, 40 | this is distinct from **issued ISBN** that is an ISBN that was already issued to a publisher 41 | (this is the usage of the libraries and most of the web services). 42 | However *isbn.org*, probably by legal reasons, merges the two! 43 | So, according to *isbn-international.org*, '9786610326266' is not valid (because the block 978-66... 44 | has not been issued yet, however if you use ``is_isbn13('9786610326266')`` you will get ``True`` 45 | (because '9786610326266' follows the rules of an ISBN). But the situation is even murkier, 46 | try ``meta('9786610326266')`` and you will see that this ISBN was already used! 47 | 48 | If possible, work with ISBNs in the isbn-13 format (since 2007, only are issued ISBNs 49 | in the isbn-13 format). You can always convert isbn-10 to isbn-13, but **not** the reverse (read this_). 50 | Read more about ISBN at isbn-international.org_ or wikipedia_. 51 | 52 | 53 | 54 | Main Functions 55 | -------------- 56 | 57 | ``is_isbn10(isbn10like)`` 58 | Validates as ISBN-10. 59 | 60 | ``is_isbn13(isbn13like)`` 61 | Validates as ISBN-13. 62 | 63 | ``to_isbn10(isbn13)`` 64 | Transforms isbn-13 to isbn-10. 65 | 66 | ``to_isbn13(isbn10)`` 67 | Transforms isbn-10 to isbn-13. 68 | 69 | ``canonical(isbnlike)`` 70 | Keeps only digits and X. You will get strings like `9780321534965` and `954430603X`. 71 | 72 | ``clean(isbnlike)`` 73 | Cleans ISBN (only legal characters). 74 | 75 | ``notisbn(isbnlike, level='strict')`` 76 | Check with the goal to invalidate isbn-like. 77 | 78 | ``get_isbnlike(text, level='normal')`` 79 | Extracts all substrings that seem like ISBNs (very useful for scraping). 80 | 81 | ``get_canonical_isbn(isbnlike, output='bouth')`` 82 | Extracts ISBNs and transform them to the canonical form. 83 | 84 | ``ean13(isbnlike)`` 85 | Transforms an `isbnlike` string into an EAN13 number (validated canonical ISBN-13). 86 | 87 | ``info(isbn)`` 88 | Gets the language or country assigned to this ISBN. 89 | 90 | ``mask(isbn, separator='-')`` 91 | `Mask` (hyphenate) a canonical ISBN. 92 | 93 | ``meta(isbn, service='default')`` 94 | Gives you the main metadata associated with the ISBN. As `service` parameter you can use: 95 | ``'goob'`` uses the **Google Books service** (**no key is needed**) and 96 | **is the default option**, 97 | ``'wiki'`` uses the **wikipedia.org** api (**no key is needed**), 98 | ``'openl'`` uses the **OpenLibrary.org** api (**no key is needed**). 99 | You can enter API keys 100 | with ``config.add_apikey(service, apikey)`` (see example below). 101 | The output can be formatted as ``bibtex``, ``csl`` (CSL-JSON), ``msword``, ``endnote``, ``refworks``, 102 | ``opf`` or ``json`` (BibJSON) bibliographic formats with ``registry.bibformatters``. 103 | Now, you can extend the functionality of this function by adding pluggins, more metadata 104 | providers or new bibliographic formatters (check_ for available pluggins). 105 | 106 | ``editions(isbn, service='merge')`` 107 | Returns the list of ISBNs of editions related with this ISBN. By default 108 | uses 'merge' (merges 'openl', 'thingl' and 'wiki'), but other providers are available: 109 | 'openl' uses **Open Library**, 'thingl' (uses the service ThingISBN from **LibraryThing**), 110 | 'wiki' (uses the service Citation from **Wikipedia**) 111 | and 'any' (first tries 'wiki', if no data, then 'openl' or 'thingl'). 112 | 113 | ``isbn_from_words(words)`` 114 | Returns the most probable ISBN from a list of words (for your geographic area). 115 | 116 | ``goom(words)`` 117 | Returns a list of references from **Google Books multiple references**. 118 | 119 | ``classify(isbn)`` 120 | Returns a dictionary of **classifiers** for a canonical ISBN. For the meaning of these classifiers see OCLC_. 121 | Most of the data in the underlying service are for books in english. 122 | 123 | ``doi(isbn)`` 124 | Returns a DOI's ISBN-A from a ISBN-13. 125 | 126 | ``doi2tex(DOI)`` 127 | Returns metadata formatted as BibTeX for a given DOI. 128 | 129 | ``ren(filename)`` 130 | Renames a file using metadata from an ISBN in his filename. 131 | 132 | ``desc(isbn)`` 133 | Returns a small description of the book. 134 | *Almost all data available are for US books!* 135 | 136 | ``cover(isbn)`` 137 | Returns a dictionary with the url for cover. 138 | *Almost all data available are for US books!* 139 | 140 | 141 | See files test_core_ and test_ext_ for **a lot of examples**. 142 | 143 | 144 | Install 145 | ======= 146 | 147 | 148 | From the command line, enter (in some cases you have to precede the 149 | command with ``sudo``): 150 | 151 | 152 | .. code-block:: bash 153 | 154 | $ pip install isbnlib 155 | 156 | 157 | If you use linux systems, you can install using your distribution package 158 | manager (all major distributions have packages ``python-isbnlib`` 159 | and ``python3-isbnlib``), however (usually) are **very old and don't work well anymore**! 160 | 161 | 162 | 163 | For Devs 164 | ======== 165 | 166 | 167 | API's Main Namespaces 168 | --------------------- 169 | 170 | In the namespace ``isbnlib`` you have access to the **core functions**: 171 | ``is_isbn10``, ``is_isbn13``, ``to_isbn10``, ``to_isbn13``, ``canonical``, 172 | ``clean``, ``notisbn``, ``get_isbnlike``, ``get_canonical_isbn``, ``mask``, 173 | ``info``, ``check_digit10``, ``check_digit13``, ``doi`` and ``ean13``. 174 | 175 | In addition, you have access to **metadata functions**, namely: 176 | ``meta``, ``editions``, ``ren``, ``desc``, ``cover``, 177 | ``goom``, ``classify``, ``doi2tex`` and ``isbn_from_words``. 178 | 179 | The exceptions raised by these methods can all be caught using ``ISBNLibException``. 180 | 181 | 182 | You can extend the lib by using the classes and functions exposed in 183 | namespace ``isbnlib.dev``, namely: 184 | 185 | * ``WEBService`` a class that handles the access to web 186 | services (just by passing an url) and supports ``gzip``. 187 | You can subclass it to extend the functionality... but 188 | probably you don't need to use it! It is used in the next class. 189 | 190 | * ``WEBQuery`` a class that uses ``WEBService`` to retrieve and parse 191 | data from a web service. You can build a new provider of metadata 192 | by subclassing this class. 193 | His main methods allow passing custom 194 | functions (*handlers*) that specialize them to specific needs (``data_checker`` and 195 | ``parser``). It implements a **throttling mechanism** with a default rate of 196 | one call per second per service. 197 | 198 | * ``Metadata`` a class that structures, cleans and 'validates' records of 199 | metadata. His method ``merge`` allows to implement a simple merging 200 | procedure for records from different sources. The main features of this class, can be 201 | implemented by a call to the ``stdmeta`` function instead! 202 | 203 | * ``vias`` exposes several functions to put calls to services, just by passing the name and 204 | a pointer to the service's ``query`` function. 205 | ``vias.parallel`` allows to put threaded calls. 206 | You can use ``vias.serial`` to make serial calls and 207 | ``vias.multi`` to use several cores. The default is ``vias.serial``. 208 | 209 | The exceptions raised by these methods can all be caught using ``ISBNLibDevException``. 210 | You **shouldn't raise** this exception in your code, only raise the specific exceptions 211 | exposed in ``isbnlib.dev`` whose name ends in Error. 212 | 213 | 214 | In ``isbnlib.dev.helpers`` you can find several methods, that we found very useful, some of then 215 | are only used in ``isbntools`` (*an app and framework* that uses ``isbnlib``). 216 | 217 | 218 | With ``isbnlib.config`` you can read and set configuration options: 219 | change timeouts with ``seturlopentimeout`` and ``setthreadstimeout``, 220 | access api keys with ``apikeys`` and add new one with ``add_apikey``, 221 | access and set generic and user-defined options with ``options.get('OPTION1')`` and ``set_option``. 222 | 223 | 224 | Finally, from ``isbnlib.registry`` you can change the metadata service to be used by default 225 | (``setdefaultservice``), 226 | add a new service (``add_service``), access bibliographic formatters for metadata (``bibformatters``), 227 | set the default formatter (``setdefaultbibformatter``), add new formatters (``add_bibformatter``) and 228 | set a new cache (``set_cache``) (e.g. to switch off the cache ``set_cache(None)``). 229 | The cache only works for calls through metadata functions. These changes only work for the 'current session', 230 | so should be done always before calling other methods. 231 | 232 | 233 | Let us concretize these points with a small example. 234 | 235 | Suppose you want a small script to get metadata using ``Open Library`` formatted in BibTeX. 236 | 237 | A minimal script would be: 238 | 239 | 240 | .. code-block:: python 241 | 242 | from isbnlib import meta 243 | from isbnlib.registry import bibformatters 244 | 245 | SERVICE = "openl" 246 | 247 | # now you can use the service 248 | isbn = "9780446310789" 249 | bibtex = bibformatters["bibtex"] 250 | print(bibtex(meta(isbn, SERVICE))) 251 | 252 | 253 | 254 | 255 | Plugins 256 | ------- 257 | 258 | You can extend the functionality of the library by adding plugins (for now, just 259 | new metadata providers or new bibliographic formatters). 260 | 261 | For available plugins check_ here. 262 | 263 | After install, your plugin will blend transparently in ``isbnlib`` (you will have more options in ``meta`` and ``bibformatters``). 264 | 265 | If you want to develop a plugin, start with this template_ and follow the instructions there. For inspiration take a look at goob_. 266 | 267 | 268 | 269 | Patterns of Usage 270 | ----------------- 271 | 272 | The library implements a very simple API with sensible defaults, but there are cases 273 | that need your attention (see case 3 below). 274 | 275 | 276 | 277 | A. You only need **core functions**: 278 | 279 | 280 | .. code-block:: python 281 | 282 | # import the core functions you need 283 | from isbnlib import canonical, is_isbn10, is_isbn13 284 | 285 | isbn = canonical("978-0446310789") 286 | if is_isbn13(isbn): 287 | ... 288 | ... 289 | 290 | 291 | B. You need also **metadata functions**, with **default config**: 292 | 293 | 294 | .. code-block:: python 295 | 296 | from isbnlib import canonical, meta, description 297 | 298 | isbn = canonical("978-0446310789") 299 | data = meta(isbn) 300 | ... 301 | 302 | C. You need also **metadata functions**, with **special config**: 303 | 304 | *Lets suppose you need to add an api key for a metadata plugin 305 | and change the cache too*. 306 | 307 | 308 | .. code-block:: python 309 | 310 | from myapp.utils import MyCache 311 | 312 | # import the functions you need, plus 'config' and 'registry' 313 | from isbnlib import canonical, config, meta, registry 314 | 315 | # you should use 'config' first 316 | config.add_apikey("isbndb", "kjshdfkjahsdflkjh") 317 | 318 | # then 'registry' 319 | registry.set_cache(MyCache()) 320 | 321 | # Only now you should use metadata functions 322 | # (there are no adaptions for core functions, 323 | # so they can be used at any moment) 324 | isbn = canonical("978-0446310789") 325 | data = meta(isbn, service="isbndb") 326 | ... 327 | 328 | 329 | D. You want to build a **plugin** or use **isbnlib.dev** in your code: 330 | 331 | You should study very carefully the **public** methods in ``dir(isbnlib.dev)``. 332 | 333 | 334 | 335 | Caveats 336 | ------- 337 | 338 | 339 | 1. These classes are optimized for one-call to services and not for batch calls. 340 | 341 | 2. If you inspect the library, you will see that there are a lot of private modules 342 | (their name starts with '_'). These modules **should not** be accessed directly since, 343 | with high probability, your program will break with a further version of the library! 344 | 345 | 346 | 347 | Projects using *isbnlib* 348 | ------------------------ 349 | 350 | **isbntools** https://github.com/xlcnd/isbntools 351 | 352 | **isbnsrv** https://github.com/xlcnd/isbnsrv 353 | 354 | **Open Library** https://github.com/internetarchive/openlibrary 355 | 356 | **NYPL Library Simplified** https://github.com/NYPL-Simplified 357 | 358 | **Manubot** https://github.com/manubot 359 | 360 | **Spreads** https://github.com/DIYBookScanner/spreads 361 | 362 | 363 | 364 | See the full list here_. 365 | 366 | 367 | 368 | Help 369 | ---- 370 | 371 | 372 | If you need help, please take a look at github_ or post a question on 373 | stackoverflow_ . 374 | 375 | 376 | 377 | .. _github: https://github.com/xlcnd/isbnlib/discussions 378 | 379 | .. _range: https://www.isbn-international.org/range_file_generation 380 | 381 | .. _isbntools: https://pypi.python.org/pypi/isbntools 382 | 383 | .. _sourcegraph: http://bit.ly/ISBNLib_srcgraph 384 | 385 | .. _readthedocs: http://bit.ly/ISBNLib_rtd 386 | 387 | .. _stackoverflow: http://stackoverflow.com/search?tab=newest&q=isbnlib 388 | 389 | .. _test_core: https://github.com/xlcnd/isbnlib/blob/master/isbnlib/test/test_core.py 390 | 391 | .. _test_ext: https://github.com/xlcnd/isbnlib/blob/master/isbnlib/test/test_ext.py 392 | 393 | .. _isbn-international.org: https://www.isbn-international.org/content/what-isbn 394 | 395 | .. _wikipedia: http://en.wikipedia.org/wiki/International_Standard_Book_Number 396 | 397 | .. _python-future.org: http://python-future.org/compatible_idioms.html 398 | 399 | .. _issue: https://github.com/xlcnd/isbnlib/issues/28 400 | 401 | .. _check: https://pypi.python.org/pypi?%3Aaction=search&term=isbnlib_&submit=search 402 | 403 | .. _template: https://github.com/xlcnd/isbnlib/blob/dev/PLUGIN.zip 404 | 405 | .. _goob: https://github.com/xlcnd/isbnlib/blob/dev/isbnlib/_goob.py 406 | 407 | .. _search: https://pypi.python.org/pypi?%3Aaction=search&term=isbnlib&submit=search 408 | 409 | .. _51: https://github.com/xlcnd/isbnlib/issues/51 410 | 411 | .. _here: https://github.com/xlcnd/isbnlib/network/dependents?package_id=UGFja2FnZS01MjIyODAxMQ%3D%3D 412 | 413 | .. _OCLC: http://classify.oclc.org/classify2/ 414 | 415 | .. _this: https://bisg.org/news/479346/New-979-ISBN-Prefixes-Expected-in-2020.htm 416 | --------------------------------------------------------------------------------