diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..0056d36 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,56 @@ +name: "CodeQL" + +on: + push: + branches: [ main, develop ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ main, develop ] + schedule: + - cron: '34 21 * * 1' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more: + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..620005d --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,45 @@ +# This workflow will upload a Python Package when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + # normal behavior: run when a new release is created + release: + types: [published] + # allow running manually on main + workflow_dispatch: + branches: [main] + +permissions: + contents: read + +jobs: + pypi-publish: + name: Upload release to PyPI + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/viapy/ + permissions: + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml new file mode 100644 index 0000000..3d9ab20 --- /dev/null +++ b/.github/workflows/unit_tests.yml @@ -0,0 +1,55 @@ +name: unit tests + +on: + push: # run on every push or PR to any branch + pull_request: + schedule: # run automatically on main branch each Tuesday at 11am + - cron: "0 16 * * 2" + +jobs: + python-unit: + name: Python unit tests + runs-on: ubuntu-latest + strategy: + matrix: + python: ["3.9", "3.10", "3.11", "3.12"] + django: [0, "3.2", "4.0", "4.1", "4.2", "5.0"] + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + + # Base python cache on the hash of pyproject.toml with requirements + # if the file changes, the cache is invalidated. + - name: Cache pip + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: pip-${{ hashFiles('pyproject.toml') }} + restore-keys: | + pip-${{ hashFiles('pyproject.toml') }} + pip- + + - name: Install package with dependencies + run: | + if [ "${{ matrix.django }}" -gt "0"]; then pip install -q Django==${{ matrix.django }} '.[django_test]'; fi + pip install . + pip install '.[test]' + pip install codecov + + - name: Setup test settings + run: | + cp ci/testsettings.py testsettings.py + python -c "import uuid; print('SECRET_KEY = \'%s\'' % uuid.uuid4())" >> testsettings.py + + - name: Run pytest + env: + SOLR_VERSION: "${{ matrix.solr }}" + run: py.test --cov=viapy --cov-report=xml + + - name: Upload test coverage to Codecov + uses: codecov/codecov-action@v3 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 1a1494d..0000000 --- a/.travis.yml +++ /dev/null @@ -1,31 +0,0 @@ -language: python -python: -- 3.5 -- 3.6 -env: -- DJANGO="" -- DJANGO=1.11 -- DJANGO=2.0 -- DJANGO=2.1 -- DJANGO=2.2 -- DJANGO=3.0 -jobs: - exclude: - - python: 3.5 - env: DJANGO=3.0 -install: -- pip install -e . -- pip install -e '.[test]' -- if [ ! -z $DJANGO ]; then pip install -q Django==$DJANGO; fi -- if [ $DJANGO != '' ]; then pip install django-autocomplete-light pytest-django; - fi -- pip install codecov -- cp ci/testsettings.py testsettings.py -- python -c "import uuid; print('SECRET_KEY = \'%s\'' % uuid.uuid4())" >> testsettings.py -script: -- py.test --cov=viapy -after_success: -- codecov -notifications: - slack: - secure: fGFC9zMgsSTUlFFtS4mmUyFq/eZLABF2Q9VmfIWOdZjHBLu9pXrX7x4trrmG6+ZMDf0tLIyyO6Wa8W+zz6xkhpW+bhdMVnDJomLmLfc/ZbavdkY1LGM+Dj6CDMtPIU26z1y8PmxhpWd/uO0JT6QtYqkfmMy6OaK7BU4NCXse3HYD434UBvce30x2w2Q9JQHDSWvSMqP17vdJMLmpmQl4Nl5gjduR8sqe/itxwlvShxwBJjjQjZSAcCUNqjSSQNjdaLM7hdu6byQhVCbuEi7IQMXvSSPTkyQpIvTAoXJQ/SoYOxmI6fA5vHNO8sSO3yTJPpZCm+KzjE8wJEun8lPkd7vExrw7iwVXPtCeL6PE/9k8ax1lTn68Sc+FdGlDgiHqZ/2b5/btuuhsjY3DibPDmbJy7C42cO0YvvCkUaY2NlbWn59p92NBcttyVUJ8fSE/iJZmIOqHsfREVzB4liOwpQYiNe9LB4WxmbCFssxMvIqR5+86n0bdV4dDu+u+9IibwQhfOVliGaoDnt8sEc/ESrBwvPcev55nJcuag1gnjQAk3azCKbYrMUe3+c6DquiwQpQmY/uIaQhuILeEyEZSlcCjB9wrs7hbdnH5ZVNsmGe7ZrgR+0ySwdZrQl0FxYgCXMIQyTSkMJnlZy7U544HBlYFXvX2UcVIMLCH268yorM= diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3944472..f202872 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,6 +3,14 @@ CHANGELOG ========= +0.3 +--- + +* Handle negative years when parsing birth and death dates +* Now tested on python 3.9 through 3.12 +* Now tested against Django 3.2 through 5.0 +* Migrate continous integration to GitHub Actions + 0.2 --- diff --git a/README.rst b/README.rst index 1b89ff2..23c2342 100644 --- a/README.rst +++ b/README.rst @@ -13,8 +13,8 @@ Authority File) data and APIs. **viapy** provides optional Django integration; this currently includes a django-autocomplete-light lookup view and a VIAF url widget. -.. image:: https://travis-ci.org/Princeton-CDH/viapy.svg - :target: https://travis-ci.org/Princeton-CDH/viapy +.. image:: https://github.com/Princeton-CDH/viapy/actions/workflows/unit_tests.yml/badge.svg + :target: https://github.com/Princeton-CDH/viapy/actions/workflows/unit_tests.yml :alt: Build status .. image:: https://codecov.io/gh/Princeton-CDH/viapy/branch/master/graph/badge.svg @@ -65,7 +65,7 @@ Include the viapy urls at the desired base url with the namespace:: urlpatterns = [ ... - url(r'^viaf/', include('viapy.urls', namespace='viaf')), + path(r'viaf/', include('viapy.urls', namespace='viaf')), ... ] @@ -79,15 +79,14 @@ This git repository uses `git flow`_ branching conventions. Initial setup and installation: -- Recommended: create and activate a python 3.5 virtualenv:: +- Recommended: create and activate a python 3.11 virtualenv:: - virtualenv viapy -p python3.5 + python3 -m venv viapy source viapy/bin/activate -- pip install the package with its python dependencies:: +- pip install the package with all development and test dependencies:: - pip install -e . - pip install -e ".[django]"" + pip install -e ".[dev]"" Unit Testing @@ -115,7 +114,7 @@ Documentation Documentation is generated using `sphinx `_. To generate documentation, first install development requirements:: - pip install -e ".[docs]" + pip install -e ".[dev]" Then build the documentation using the customized make file in the `docs` directory:: @@ -133,7 +132,7 @@ License **viapy** is distributed under the Apache 2.0 License. -©2017 Trustees of Princeton University. Permission granted via +©2024 Trustees of Princeton University. Permission granted via Princeton Docket #18-3449-1 for distribution online under a standard Open Source license. Ownership rights transferred to Rebecca Koeser provided software is distributed online via open source. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..02c4397 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,59 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "viapy" +description = "Python module for interacting with VIAF data & APIs" +authors = [ + {name = "Center for Digital Humanities at Princeton", email = "cdh@princeton.edu"}, +] +requires-python = ">=3.9" +readme = "README.rst" +license = {text = "Apache-2"} +classifiers = [ + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: 3.2", + "Framework :: Django :: 4.0", + "Framework :: Django :: 4.1", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dynamic = ["version"] +dependencies = [ + "requests", "rdflib", "cached_property", "attrdict3" +] + +[project.urls] +#Documentation = "https://readthedocs.org" +Repository = "https://github.com/Princeton-CDH/viapy" +Changelog = "https://github.com/Princeton-CDH/viapy/blob/main/CHANGELOG.rst" + +[tool.hatch.version] +path = "viapy/__init__.py" + +[project.optional-dependencies] +test = [ + "pytest", + "pytest-cov" +] +django = ["django>=3.2", "django-autocomplete-light"] +django_test = ["pytest-django", "viapy[django]"] +docs = ["sphinx"] +test_all = ["viapy[test]", "viapy[django_test]"] +dev = ["pre-commit", "viapy[django]", "viapy[test_all]", "viapy[docs]"] + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "testsettings" \ No newline at end of file diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 3b8b73f..0000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -DJANGO_SETTINGS_MODULE=testsettings diff --git a/setup.py b/setup.py deleted file mode 100644 index 9b2bc50..0000000 --- a/setup.py +++ /dev/null @@ -1,56 +0,0 @@ -import os -from setuptools import find_packages, setup - -from viapy import __version__ - -with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: - README = readme.read() - -# allow setup.py to be run from any path -os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) - -REQUIREMENTS = ['requests', 'rdflib', 'cached_property', 'attrdict'] -TEST_REQUIREMENTS = ['pytest', 'pytest-cov'] -DJANGO_REQS = ['django>=1.11,<3.1', 'django-autocomplete-light'] -DJANGO_TEST_REQS = ['pytest-django'] - - -setup( - name='viapy', - version=__version__, - packages=find_packages(), - include_package_data=True, - license='Apache License, Version 2.0', - description='Python module for interacting with VIAF data & APIs', - long_description=README, - url='https://github.com/Princeton-CDH/viapy', - install_requires=REQUIREMENTS, - setup_requires=['pytest-runner'], - tests_require=TEST_REQUIREMENTS, - extras_require={ - 'django': DJANGO_REQS, - 'test': TEST_REQUIREMENTS, - 'test_all': TEST_REQUIREMENTS + DJANGO_REQS + DJANGO_TEST_REQS, - 'docs': ['sphinx'] - }, - author='CDH @ Princeton', - author_email='digitalhumanities@princeton.edu', - classifiers=[ - 'Environment :: Web Environment', - 'Framework :: Django', - 'Framework :: Django :: 1.11', - 'Framework :: Django :: 2.0', - 'Framework :: Django :: 2.1', - 'Framework :: Django :: 2.2', - 'Framework :: Django :: 3.0', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], -) diff --git a/sphinx-docs/conf.py b/sphinx-docs/conf.py index de4b33b..05de731 100644 --- a/sphinx-docs/conf.py +++ b/sphinx-docs/conf.py @@ -25,9 +25,9 @@ import sys import django -sys.path.insert(0, os.path.abspath('.')) +sys.path.insert(0, os.path.abspath(".")) -os.environ['DJANGO_SETTINGS_MODULE'] = 'docsettings' +os.environ["DJANGO_SETTINGS_MODULE"] = "docsettings" django.setup() from viapy import __version__ @@ -43,29 +43,29 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', - 'sphinx.ext.coverage', - 'sphinx.ext.viewcode', - 'sphinx.ext.githubpages' + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.coverage", + "sphinx.ext.viewcode", + "sphinx.ext.githubpages", ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ".rst" # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = 'viapy' -copyright = '2017, CDH @ Princeton' -author = 'CDH @ Princeton' +project = "viapy" +copyright = "2017, CDH @ Princeton" +author = "CDH @ Princeton" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -81,15 +81,15 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False @@ -100,7 +100,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'alabaster' +html_theme = "alabaster" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -108,18 +108,19 @@ # # html_theme_options = {} html_theme_options = { - 'description': 'VIAF via Python', - 'github_user': 'Princeton-CDH', - 'github_repo': 'viapy', - 'travis_button': True, - 'codecov_button': True, + "description": "VIAF via Python", + "github_user": "Princeton-CDH", + "github_repo": "viapy", + "travis_button": True, + "codecov_button": True, } # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +# currently unused +# html_static_path = ["_static"] # Custom sidebar templates, must be a dictionary that maps document names # to template names. @@ -127,12 +128,12 @@ # This is required for the alabaster theme # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars html_sidebars = { - '**': [ - 'about.html', - 'navigation.html', + "**": [ + "about.html", + "navigation.html", # 'relations.html', # needs 'show_related': True theme option to display - 'searchbox.html', - 'sidebar_footer.html', + "searchbox.html", + "sidebar_footer.html", ] } @@ -140,7 +141,7 @@ # -- Options for HTMLHelp output ------------------------------------------ # Output file base name for HTML help builder. -htmlhelp_basename = 'viapydoc' +htmlhelp_basename = "viapydoc" # -- Options for LaTeX output --------------------------------------------- @@ -149,15 +150,12 @@ # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # # 'preamble': '', - # Latex figure (float) alignment # # 'figure_align': 'htbp', @@ -167,8 +165,7 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'viapy.tex', 'viapy Documentation', - 'CDH @ Princeton', 'manual'), + (master_doc, "viapy.tex", "viapy Documentation", "CDH @ Princeton", "manual"), ] @@ -176,10 +173,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'viapy', 'viapy Documentation', - [author], 1) -] +man_pages = [(master_doc, "viapy", "viapy Documentation", [author], 1)] # -- Options for Texinfo output ------------------------------------------- @@ -188,11 +182,23 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'viapy', 'viapy Documentation', - author, 'viapy', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "viapy", + "viapy Documentation", + author, + "viapy", + "One line description of project.", + "Miscellaneous", + ), ] # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'https://docs.python.org/': None} +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "django": ( + "http://docs.djangoproject.com/en/dev/", + "http://docs.djangoproject.com/en/dev/_objects/", + ), +} diff --git a/viapy/__init__.py b/viapy/__init__.py index f255f68..493f741 100644 --- a/viapy/__init__.py +++ b/viapy/__init__.py @@ -1,7 +1 @@ -__version_info__ = (0, 2, 0, None) - - -# Dot-connect all but the last. Last is dash-connected if not None. -__version__ = '.'.join([str(i) for i in __version_info__[:-1]]) -if __version_info__[-1] is not None: - __version__ += ('-%s' % (__version_info__[-1],)) +__version__ = "0.3.0" diff --git a/viapy/api.py b/viapy/api.py index a871d28..0499084 100644 --- a/viapy/api.py +++ b/viapy/api.py @@ -11,7 +11,7 @@ logger = logging.getLogger(__name__) -SCHEMA_NS = Namespace('http://schema.org/') +SCHEMA_NS = Namespace("http://schema.org/") class ViafAPI(object): @@ -33,20 +33,26 @@ def uri_from_id(cls, viaf_id): return "%s/%s" % (cls.uri_base, viaf_id) def suggest(self, term): - '''Query autosuggest API. Returns a list of results, or an - empty list if no suggestions are found or if something went wrong''' + """Query autosuggest API. Returns a list of results, or an + empty list if no suggestions are found or if something went wrong""" # 'viaf/AutoSuggest?query=[searchTerms]&callback[optionalCallbackName] - autosuggest_url = '%s/AutoSuggest' % self.api_base - response = requests.get(autosuggest_url, - params={'query': term}, - headers={'accept': 'application/json'}) - logger.debug('autosuggest \'%s\': %s %s, %0.2f', - term, response.status_code, response.reason, - response.elapsed.total_seconds()) + autosuggest_url = "%s/AutoSuggest" % self.api_base + response = requests.get( + autosuggest_url, + params={"query": term}, + headers={"accept": "application/json"}, + ) + logger.debug( + "autosuggest '%s': %s %s, %0.2f", + term, + response.status_code, + response.reason, + response.elapsed.total_seconds(), + ) if response.status_code == requests.codes.ok: - return response.json().get('result', None) or [] + return response.json().get("result", None) or [] # if there was an http error, raise it response.raise_for_status() @@ -54,23 +60,27 @@ def suggest(self, term): return [] def search(self, query): - '''Query VIAF seach interface. Returns a list of :class:`SRUItem` + """Query VIAF seach interface. Returns a list of :class:`SRUItem` :param query: CQL query in viaf syntax (e.g., ``cql.any all "term"``) - ''' - search_url = '%s/search' % self.api_base + """ + search_url = "%s/search" % self.api_base params = { - 'query': query, - 'httpAccept': 'application/json', - 'maximumRecords': 50, # TODO: configurable ? + "query": query, + "httpAccept": "application/json", + "maximumRecords": 50, # TODO: configurable ? # sort by number of holdings (default sort on web search) # - so better known names show up first - 'sortKeys': 'holdingscount' + "sortKeys": "holdingscount", } response = requests.get(search_url, params=params) - logger.debug('search \'%s\': %s %s, %0.2f', - params['query'], response.status_code, response.reason, - response.elapsed.total_seconds()) + logger.debug( + "search '%s': %s %s, %0.2f", + params["query"], + response.status_code, + response.reason, + response.elapsed.total_seconds(), + ) if response.status_code == requests.codes.ok: data = SRUResult(response.json()) if data.total_results: @@ -83,23 +93,24 @@ def _find_type(self, fltr, value): return self.search('%s all "%s"' % (fltr, value)) def find_person(self, name): - '''Search VIAF for local.personalNames''' - return self._find_type('local.personalNames', name) + """Search VIAF for local.personalNames""" + return self._find_type("local.personalNames", name) def find_corporate(self, name): - '''Search VIAF for local.corporateNames''' - return self._find_type('local.corporateNames', name) + """Search VIAF for local.corporateNames""" + return self._find_type("local.corporateNames", name) def find_place(self, name): - '''Search VIAF for local.geographicNames''' - return self._find_type('local.geographicNames', name) + """Search VIAF for local.geographicNames""" + return self._find_type("local.geographicNames", name) class ViafEntity(object): - '''Object for working with a single VIAF entity. + """Object for working with a single VIAF entity. :param viaf_id: viaf identifier (either integer or uri) - ''' + """ + def __init__(self, viaf_id): try: int(viaf_id) @@ -111,94 +122,98 @@ def __init__(self, viaf_id): @property def uriref(self): - '''VIAF URI reference as instance of :class:`rdflib.URIRef`''' + """VIAF URI reference as instance of :class:`rdflib.URIRef`""" return rdflib.URIRef(self.uri) @cached_property def rdf(self): - '''VIAF data for this entity as :class:`rdflib.Graph`''' + """VIAF data for this entity as :class:`rdflib.Graph`""" start = time.time() graph = rdflib.Graph() graph.parse(self.uri) - logger.debug('Loaded VIAF RDF %s: %0.2f sec', - self.uri, time.time() - start) + logger.debug("Loaded VIAF RDF %s: %0.2f sec", self.uri, time.time() - start) return graph # person-specific properties @property def birthdate(self): - '''schema birthdate as :class:`rdflib.Literal`''' + """schema birthdate as :class:`rdflib.Literal`""" return self.rdf.value(self.uriref, SCHEMA_NS.birthDate) @property def deathdate(self): - '''schema deathdate as :class:`rdflib.Literal`''' + """schema deathdate as :class:`rdflib.Literal`""" return self.rdf.value(self.uriref, SCHEMA_NS.deathDate) @property def birthyear(self): - '''birth year''' + """birth year""" if self.birthdate: return self.year_from_isodate(str(self.birthdate)) @property def deathyear(self): - '''death year''' + """death year""" if self.deathdate: return self.year_from_isodate(str(self.deathdate)) # utility method for date parsing @classmethod def year_from_isodate(cls, date): - '''Return just the year portion of an ISO8601 date. Expects - a string, returns an integer''' - return int(date.split('-')[0]) + """Return just the year portion of an ISO8601 date. Expects + a string, returns an integer. Supports negative dates.""" + negative = False + # if the date starts with a dash, strip off before trying to split + if date.startswith("-"): + date = date[1:] + negative = True + value = int(date.split("-")[0]) + if negative: + return -value + return value class SRUResult(object): - '''SRU search result object, for use with :meth:`ViafAPI.search`.''' + """SRU search result object, for use with :meth:`ViafAPI.search`.""" def __init__(self, data): - self._data = data.get('searchRetrieveResponse', {}) + self._data = data.get("searchRetrieveResponse", {}) @cached_property def total_results(self): - '''number of records matching the query''' - return int(self._data.get('numberOfRecords', 0)) + """number of records matching the query""" + return int(self._data.get("numberOfRecords", 0)) @cached_property def records(self): - '''list of results as :class:`SRUItem`.''' - return [SRUItem(d['record']) for d in self._data.get('records', [])] + """list of results as :class:`SRUItem`.""" + return [SRUItem(d["record"]) for d in self._data.get("records", [])] class SRUItem(AttrMap): - '''Single item returned by a SRU search, for use with - :meth:`ViafAPI.search` and :class:`SRUResult`.''' + """Single item returned by a SRU search, for use with + :meth:`ViafAPI.search` and :class:`SRUResult`.""" @property def uri(self): - '''VIAF URI for this result''' - return self.recordData.Document['@about'] + """VIAF URI for this result""" + return self.recordData.Document["@about"] @property def viaf_id(self): - '''VIAF numeric identifier''' + """VIAF numeric identifier""" return self.recordData.viafID - @property def nametype(self): - '''type of name (personal, corporate, title, etc)''' + """type of name (personal, corporate, title, etc)""" return self.recordData.nameType @property def label(self): - '''first main heading for this item''' + """first main heading for this item""" try: return self.recordData.mainHeadings.data[0].text except KeyError: return self.recordData.mainHeadings.data.text - - diff --git a/viapy/test_urls.py b/viapy/test_urls.py index af0149c..d5a3511 100644 --- a/viapy/test_urls.py +++ b/viapy/test_urls.py @@ -1,12 +1,12 @@ """Test URL configuration for viapy """ try: - from django.conf.urls import url, include + from django.urls import path, include from viapy import urls as viapy_urls urlpatterns = [ - url(r'^viaf/', include(viapy_urls, namespace='viaf')), + path(r"viaf/", include(viapy_urls, namespace="viaf")), ] except ImportError: diff --git a/viapy/tests/test_api.py b/viapy/tests/test_api.py index feaaef1..a5dbc47 100644 --- a/viapy/tests/test_api.py +++ b/viapy/tests/test_api.py @@ -7,106 +7,109 @@ from viapy.api import ViafAPI, ViafEntity, SRUResult, SRUItem -FIXTURES_PATH = os.path.join(os.path.dirname(__file__), 'fixtures') +FIXTURES_PATH = os.path.join(os.path.dirname(__file__), "fixtures") class TestViafAPI(object): - def test_get_uri(self): - assert ViafAPI.uri_from_id('1234') == \ - 'http://viaf.org/viaf/1234' + assert ViafAPI.uri_from_id("1234") == "http://viaf.org/viaf/1234" # numeric id should also work - assert ViafAPI.uri_from_id(1234) == \ - 'http://viaf.org/viaf/1234' + assert ViafAPI.uri_from_id(1234) == "http://viaf.org/viaf/1234" - @patch('viapy.api.requests') + @patch("viapy.api.requests") def test_suggest(self, mockrequests): viaf = ViafAPI() mockrequests.codes = requests.codes # Check that query with no matches still returns an empty list - mock_result = {'query': 'notanauthor', 'result': None} + mock_result = {"query": "notanauthor", "result": None} mockrequests.get.return_value.status_code = requests.codes.ok mockrequests.get.return_value.json.return_value = mock_result - assert viaf.suggest('notanauthor') == [] + assert viaf.suggest("notanauthor") == [] mockrequests.get.assert_called_with( - 'https://www.viaf.org/viaf/AutoSuggest', - headers={'accept': 'application/json'}, - params={'query': 'notanauthor'}) + "https://www.viaf.org/viaf/AutoSuggest", + headers={"accept": "application/json"}, + params={"query": "notanauthor"}, + ) # valid (abbreviated) response - mock_result['result'] = [{ - "term": "Austen, Jane, 1775-1817", - "displayForm": "Austen, Jane, 1775-1817", - "recordID": "102333412" - }] + mock_result["result"] = [ + { + "term": "Austen, Jane, 1775-1817", + "displayForm": "Austen, Jane, 1775-1817", + "recordID": "102333412", + } + ] mockrequests.get.return_value.json.return_value = mock_result - assert viaf.suggest('austen') == mock_result['result'] + assert viaf.suggest("austen") == mock_result["result"] # bad status code on the response - should still return an empty list mockrequests.get.return_value.status_code = requests.codes.forbidden - assert viaf.suggest('test') == [] + assert viaf.suggest("test") == [] - - @patch('viapy.api.requests') + @patch("viapy.api.requests") def test_search(self, mockrequests): viaf = ViafAPI() mockrequests.codes = requests.codes - sru_fixture = os.path.join(FIXTURES_PATH, 'sru_search.json') + sru_fixture = os.path.join(FIXTURES_PATH, "sru_search.json") with open(sru_fixture) as srufile: mock_result = json.load(srufile) mockrequests.get.return_value.status_code = requests.codes.ok mockrequests.get.return_value.json.return_value = mock_result - results = viaf.search('stephen benet') + results = viaf.search("stephen benet") assert isinstance(results, list) assert isinstance(results[0], SRUItem) mockrequests.get.assert_called_with( - 'https://www.viaf.org/viaf/search', + "https://www.viaf.org/viaf/search", # headers={'accept': 'application/json'}, - params={'query': 'stephen benet', 'httpAccept': 'application/json', - 'maximumRecords': 50, 'sortKeys': 'holdingscount'}) + params={ + "query": "stephen benet", + "httpAccept": "application/json", + "maximumRecords": 50, + "sortKeys": "holdingscount", + }, + ) # sample empty result mockrequests.get.return_value.json.return_value = { - 'searchRetrieveResponse': { - 'version': "1.1", - 'numberOfRecords': "0", - 'resultSetIdleTime': "1" + "searchRetrieveResponse": { + "version": "1.1", + "numberOfRecords": "0", + "resultSetIdleTime": "1", } } - results = viaf.search('stephen benet') + results = viaf.search("stephen benet") assert not results def test_find_person(self): viaf = ViafAPI() - term = 'stephen benet' - with patch.object(viaf, 'search') as mocksearch: + term = "stephen benet" + with patch.object(viaf, "search") as mocksearch: viaf.find_person(term) mocksearch.assert_called_with('local.personalNames all "%s"' % term) def test_find_corporate(self): viaf = ViafAPI() - term = 'library of congress' - with patch.object(viaf, 'search') as mocksearch: + term = "library of congress" + with patch.object(viaf, "search") as mocksearch: viaf.find_corporate(term) mocksearch.assert_called_with('local.corporateNames all "%s"' % term) def test_find_place(self): viaf = ViafAPI() - term = 'princeton' - with patch.object(viaf, 'search') as mocksearch: + term = "princeton" + with patch.object(viaf, "search") as mocksearch: viaf.find_place(term) mocksearch.assert_called_with('local.geographicNames all "%s"' % term) class TestViafEntity(object): - test_id = 102333412 - test_uri = 'http://viaf.org/viaf/102333412' - rdf_fixture = os.path.join(FIXTURES_PATH, '102333412_rdf.xml') + test_uri = "http://viaf.org/viaf/102333412" + rdf_fixture = os.path.join(FIXTURES_PATH, "102333412_rdf.xml") def test_init(self): # numeric id (either int or string should work) @@ -122,38 +125,39 @@ def test_uriref(self): ent = ViafEntity(self.test_uri) assert ent.uriref == rdflib.URIRef(self.test_uri) - @patch('viapy.api.rdflib') + @patch("viapy.api.rdflib") def test_rdf(self, mockrdflib): ent = ViafEntity(self.test_uri) assert ent.rdf == mockrdflib.Graph.return_value # should initialize a graph and parse uri data mockrdflib.Graph.assert_called_with() - mockrdflib.Graph.return_value.parse.assert_called_with( - self.test_uri) + mockrdflib.Graph.return_value.parse.assert_called_with(self.test_uri) def test_properties(self): # use viaf id matching fixture rdf file - ent = ViafEntity('89599270') + ent = ViafEntity("89599270") # load fixture test_rdf = rdflib.Graph() test_rdf.parse(self.rdf_fixture) # patch fixture in to ViafEntity rdf property - with patch.object(ViafEntity, 'rdf', new=test_rdf): - assert str(ent.birthdate) == '69' - assert str(ent.deathdate) == '140' + with patch.object(ViafEntity, "rdf", new=test_rdf): + assert str(ent.birthdate) == "69" + assert str(ent.deathdate) == "140" assert ent.birthyear == 69 assert ent.deathyear == 140 def test_year_from_isodate(self): - assert ViafEntity.year_from_isodate('2001') == 2001 - assert ViafEntity.year_from_isodate('2002-01') == 2002 - assert ViafEntity.year_from_isodate('2004-03-05') == 2004 + assert ViafEntity.year_from_isodate("2001") == 2001 + assert ViafEntity.year_from_isodate("2002-01") == 2002 + assert ViafEntity.year_from_isodate("2004-03-05") == 2004 + # negative years, e.g. Aeschylus + assert ViafEntity.year_from_isodate("-525") == -525 def test_sru_result(): # test SRUResult class properties - sru_fixture = os.path.join(FIXTURES_PATH, 'sru_search.json') + sru_fixture = os.path.join(FIXTURES_PATH, "sru_search.json") with open(sru_fixture) as srufile: sru_data = json.load(srufile) sru_res = SRUResult(sru_data) @@ -165,17 +169,21 @@ def test_sru_result(): def test_sru_item(): # test SRUItem class - sru_fixture = os.path.join(FIXTURES_PATH, 'sru_search.json') + sru_fixture = os.path.join(FIXTURES_PATH, "sru_search.json") with open(sru_fixture) as srufile: sru_data = json.load(srufile) sru_item = SRUResult(sru_data).records[0] assert sru_item.uri == "http://viaf.org/viaf/888145424579886830405/" assert sru_item.viaf_id == "888145424579886830405" assert sru_item.nametype == "UniformTitleExpression" - assert sru_item.label == "Benét, Stephen Vincent, 1898-1943. | John Brown's Body | Russian 1979" + assert ( + sru_item.label + == "Benét, Stephen Vincent, 1898-1943. | John Brown's Body | Russian 1979" + ) # label when data is a list - sru_item.recordData.mainHeadings.data = [ - sru_item.recordData.mainHeadings.data - ] - assert sru_item.label == "Benét, Stephen Vincent, 1898-1943. | John Brown's Body | Russian 1979" \ No newline at end of file + sru_item.recordData.mainHeadings.data = [sru_item.recordData.mainHeadings.data] + assert ( + sru_item.label + == "Benét, Stephen Vincent, 1898-1943. | John Brown's Body | Russian 1979" + ) diff --git a/viapy/urls.py b/viapy/urls.py index bc51938..8e35c1f 100644 --- a/viapy/urls.py +++ b/viapy/urls.py @@ -1,15 +1,23 @@ -from django.conf.urls import url +from django.urls import path from viapy.views import ViafLookup, ViafSearch -app_name = 'viapy' +app_name = "viapy" urlpatterns = [ - url(r'^suggest/$', ViafLookup.as_view(), name='suggest'), - url(r'^suggest/person/$', ViafLookup.as_view(), - {'nametype': 'personal'}, name='person-suggest'), - url(r'^search/$', ViafSearch.as_view(), name='search'), - url(r'^search/person/$', ViafSearch.as_view(), - {'nametype': 'personal'}, name='person-search'), + path("suggest/", ViafLookup.as_view(), name="suggest"), + path( + "suggest/person/", + ViafLookup.as_view(), + {"nametype": "personal"}, + name="person-suggest", + ), + path("search/", ViafSearch.as_view(), name="search"), + path( + "search/person/", + ViafSearch.as_view(), + {"nametype": "personal"}, + name="person-search", + ), ] diff --git a/viapy/widgets.py b/viapy/widgets.py index 3332c92..885c6f3 100644 --- a/viapy/widgets.py +++ b/viapy/widgets.py @@ -3,8 +3,8 @@ class ViafWidget(autocomplete.Select2): - '''Custom autocomplete select widget that displays VIAF id as a link. - Extends :class:`dal.autocomplete.Select2`.''' + """Custom autocomplete select widget that displays VIAF id as a link. + Extends :class:`dal.autocomplete.Select2`.""" def render(self, name, value, renderer=None, attrs=None): # select2 filters based on existing choices (non-existent here), @@ -13,7 +13,6 @@ def render(self, name, value, renderer=None, attrs=None): self.choices = [(value, value)] widget = super(ViafWidget, self).render(name, value, attrs) return mark_safe( - '%s


%s

' % \ - (widget, value or '', value or '')) - - + '%s


%s

' + % (widget, value or "", value or "") + )