├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── README.md ├── make.bat └── source │ ├── _static │ ├── directory_structure.PNG │ └── geofabric_templates.PNG │ ├── alternates_template.rst │ ├── class_item_template.rst │ ├── conf.py │ ├── download_jinja_templates.rst │ ├── download_templates │ ├── alternates.html │ ├── index.html │ ├── instance.html │ ├── page.html │ └── register.html │ ├── example_renderer_view_usage.rst │ ├── example_usage.rst │ ├── exceptions.rst │ ├── exempler_custom_renderer_example.py │ ├── index.rst │ ├── indices_and_tables.rst │ ├── installation.rst │ ├── register_of_registers_renderer.rst │ ├── register_renderer.rst │ ├── register_template.rst │ ├── renderer.rst │ ├── requirements.rst │ ├── ror_setup.rst │ └── view.rst ├── images ├── blocks.png ├── instance-ASGS.png └── instance-GNAF.png ├── pyLDAPI-250.png ├── pyLDAPI.svg ├── pyldapi ├── __init__.py ├── data.py ├── exceptions.py ├── helpers.py ├── profile.py ├── renderer.py ├── renderer_container.py └── templates │ ├── alt.html │ ├── mem.html │ └── page.html ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── alt_template.py ├── example.py ├── renderer_container.py ├── test_conneg_by_p.py └── test_renderer.py └── upload-to-PyPI.txt /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.pyc 3 | */*.pyc 4 | *.pyo 5 | 6 | # PyCharm 7 | /.idea/ 8 | /.vscode/ 9 | 10 | # Mac 11 | .DS_Store 12 | 13 | #Dolphin 14 | .directory 15 | 16 | # log files 17 | *.log 18 | 19 | # PyPI distribution 20 | build/ 21 | dist/ 22 | *.egg 23 | *.egg-info/ 24 | 25 | # Virtualenv 26 | /venv/ 27 | 28 | # generated cofc.ttl 29 | cofc.ttl 30 | 31 | # the config 32 | _config/__init__.py 33 | 34 | .pytest_cache/ 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2002-2021, RDFLib Team 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | recursive-include docs *.txt -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. note:: 2 | 3 | This API is replaced by `Prez `_ . 4 | 5 | 6 | |pyLDAPI Logo| 7 | 8 | .. |pyLDAPI Logo| image:: https://github.com/RDFLib/pyLDAPI/raw/master/pyLDAPI-250.png 9 | :target: https://github.com/RDFLib/pyLDAPI/raw/master/pyLDAPI-250.png 10 | 11 | Welcome to pyLDAPI 12 | ================== 13 | 14 | The Python Linked Data API (pyLDAPI) is: 15 | 16 | *A very small module to add Linked Data API functionality to a Python FastAPI installation*. 17 | 18 | |PyPI version| 19 | 20 | .. |PyPI version| image:: https://badge.fury.io/py/pyldapi.svg 21 | :target: https://badge.fury.io/py/pyldapi 22 | 23 | What is it? 24 | ----------- 25 | 26 | This module contains a small Python module which is intended to be added (imported) into a `FastAPI `_ (v4.x +) or `Python Flask `_ (v3.x) installation to add a small library of ``Renderer`` classes which can be used to handle requests and return responses in a manner consistent with `Linked Data `__ principles of operation. 27 | 28 | The intention is to make it easy to "Linked Data-enable" web APIs. 29 | 30 | An API using this module will get: 31 | 32 | * an *alt profile* for each endpoint that uses a ``Renderer`` class to return responses that the API delivers 33 | * this is a *profile*, or *view* of the resource that lists all other available profiles 34 | * a *Register of Registers* 35 | * a start-up function that auto-generates a Register of Registers is run when the API is launched. 36 | * a basic, over-writeable template for Registers' HTML & RDF 37 | * all of the functionality defined by the W3C's `Content Negotiation by Profile `_ specification 38 | * to allow for requests of content that conform to data specifications and profiles 39 | 40 | The main parts of pyLDAPI are as follows: 41 | 42 | |blocks| 43 | 44 | .. |blocks| image:: images/blocks.png 45 | :width: 250 46 | :alt: Block diagram of pyLDAPI's main parts 47 | 48 | Web requests arrive at a Web Server, such as *Apache* or *nginx*, which then forwards (some of) them on to *FastAPI*, a Python web framework. FastAPI calls Python functions for web requests defined in a request/function mapping and may call pyLDAPI elements. FastAPI need not call pyLDAPI for all requests, just as Apache/nginx need not forward all web request to FastAPI. pyLDAPI may then draw on any Python data source, such as database APIs, and uses the *rdflib* Python module to formulate RDF responses. 49 | 50 | Definitions 51 | ----------- 52 | 53 | Alt Profile 54 | ~~~~~~~~~~~ 55 | The *model view* that lists all other views. This API uses the definition of *Alternate Profiles Data Model as an OWL ontology* presented at `https://www.w3.org/TR/dx-prof-conneg/#altr-owl `_. 56 | 57 | Linked Data Principles 58 | ~~~~~~~~~~~~~~~~~~~~~~ 59 | The principles of making things available over the internet in both human and machine-readable forms. Codified by the World Wide Web Consortium. See `https://www.w3.org/standards/semanticweb/data `_. 60 | 61 | Model View 62 | ~~~~~~~~~~ 63 | A set of properties of a Linked Data object codified according to a standard or profile of a standard. 64 | 65 | Object 66 | ~~~~~~ 67 | Any individual thing delivered according to *Linked Data* principles. 68 | 69 | Register 70 | ~~~~~~~~ 71 | A simple listing of URIs of objects, delivered according to *Linked Data principles*. 72 | 73 | Register of Registers 74 | ~~~~~~~~~~~~~~~~~~~~~ 75 | A *register* that lists all other registers which this API provides. 76 | 77 | 78 | 79 | pyLDAPI in action 80 | ----------------- 81 | 82 | * Register of Media Types 83 | * `https://w3id.org/mediatype/ `_ 84 | 85 | * Linked Data version of the Geocoded National Address File 86 | * `http://linked.data.gov.au/dataset/gnaf `_ 87 | 88 | |gnaf| 89 | 90 | Parts of the GNAF implementation 91 | 92 | .. |gnaf| image:: images/instance-GNAF.png 93 | :width: 250 94 | :alt: Block diagram of the GNAF implementation 95 | 96 | * Geoscience Australia's Sites, Samples Surveys Linked Data API 97 | * `http://pid.geoscience.gov.au/sample/ `_ 98 | 99 | * Linked Data version of the Australian Statistical Geography Standard product 100 | * `http://linked.data.gov.au/dataset/asgs `_ 101 | 102 | |asgs| 103 | 104 | Parts of the ASGS implementation 105 | 106 | .. |asgs| image:: images/instance-ASGS.png 107 | :width: 250 108 | :alt: Block diagram of the ASGS implementation 109 | 110 | Documentation 111 | ------------- 112 | 113 | Detailed documentation can be found at `https://pyldapi.readthedocs.io/ `_ 114 | 115 | 116 | Licence 117 | ------- 118 | 119 | This is licensed under GNU General Public License (GPL) v3.0. See the `LICENSE deed `_ for more details. 120 | 121 | 122 | Contact 123 | ------- 124 | 125 | Dr Nicholas Car (lead) 126 | ~~~~~~~~~~~~~~~~~~~~~~ 127 | | *Data Systems Architect* 128 | | `SURROUND Australia Pty Ltd `_ 129 | | `nicholas.car@surroundaustralia.com `_ 130 | | `https://orcid.org/0000-0002-8742-7730 `_ 131 | 132 | Ashley Sommer (senior developer) 133 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 134 | | *Informatics Software Engineer* 135 | | `CSIRO Land and Water `_ 136 | | `ashley.sommer@csiro.au `_ 137 | 138 | 139 | Related work 140 | ------------ 141 | 142 | `pyLDAPI Client `_ 143 | 144 | * *A Simple helper library for consuming registers, indexes, and instances of classes exposed via a pyLDAPI endpoint.* 145 | 146 | 147 | Changelog 148 | --------- 149 | **4.x** 150 | 151 | * Version 4+ uses FastAPI, not Flask. For Flask, use <=3.11 152 | 153 | **3.11** 154 | 155 | * tokens applied to Representations in Alternate View profile, not Profiles 156 | 157 | **3.0** 158 | 159 | * Content Negotiation specification by Profile supported 160 | * replaced all references to "format" with "Media Type" and "view" with "profile" 161 | * renamed class View to Profile 162 | * added unit tests for all profile functions 163 | * added unit tests for main ConnegP functions 164 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Building the Documentation 2 | * Ensure all requirements are met in the **requirements.txt** file. 3 | * Using the command line/terminal, `cd` into **docs/**`. 4 | * Perform `make clean` 5 | * Perform `make html` 6 | * With python3 installed, run `python -m http.server 5000` in the **docs/build/html/** directory. 7 | * Go to `localhost:5000` to see the result. 8 | 9 | # Seeing changes while editing 10 | Simply use `make html` to update the textual changes and refresh the browser. 11 | 12 | Note: To see changes for the toctree, the documents must be generated again. In this instance, one would do as follows: 13 | * `ctrl + c` to stop the http server 14 | * `make clean` 15 | * `make html` 16 | * `python -m http.server 5000` to start the server again 17 | 18 | # PyCharm optional but recommended settings 19 | * https://www.jetbrains.com/help/pycharm/documenting-source-code.html 20 | * https://www.jetbrains.com/help/pycharm/restructured-text.html 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/_static/directory_structure.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RDFLib/pyLDAPI/c9be295b4ed772437d1a3c31b0de2c28140c4c20/docs/source/_static/directory_structure.PNG -------------------------------------------------------------------------------- /docs/source/_static/geofabric_templates.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RDFLib/pyLDAPI/c9be295b4ed772437d1a3c31b0de2c28140c4c20/docs/source/_static/geofabric_templates.PNG -------------------------------------------------------------------------------- /docs/source/alternates_template.rst: -------------------------------------------------------------------------------- 1 | Alternates template 2 | =================== 3 | 4 | Example of a generic alternates template: 5 | 6 | .. code-block:: HTML 7 | 8 | {% extends "layout.html" %} 9 | {% block content %} 10 |

{{ register_name }} Linked Data API

11 | {% if class_uri %} 12 |

Alternates view of a {{ class_uri }}

13 | {% else %} 14 |

Alternates view

15 | {% endif %} 16 | {% if instance_uri %} 17 |

Instance {{ instance_uri }}

18 | {% endif %} 19 |

Default profile: {{ default_profile_token }}

20 | 21 | 22 | {% for v, vals in profiles.items() %} 23 | 24 | 25 | 31 | 32 | 33 | 34 | {% endfor %} 35 |
ViewFormatsView Desc.View Namespace
{{ v }} 26 | {% for f in vals['formats'] %} 27 | {{ f }} 28 | {% if loop.index != vals['formats']|length %}
{% endif %} 29 | {% endfor %} 30 |
{{ vals['namespace'] }}{{ vals['comment'] }}
36 | {% endblock %} 37 | 38 | The alternates profile template is shared for both a Register's alternates profile as well as a class instance item's alternates profile. In any case, since a :class:`.RegisterRenderer` class and a :ref:`example-renderer-reference` class both inherit from the base class :class:`.Renderer`, then they can both easily render the alternates profile by calling the base class' :func:`pyldapi.Renderer.render_alternates_profile` method. One distinct difference is that pyLDAPI will handle the alternates profile automatically for a :class:`.RegisterRenderer` whereas a :ref:`example-renderer-reference` will have to explicitly call the :func:`pyldapi.Renderer.render_alternates_profile`. 39 | 40 | Example usage for a :ref:`example-renderer-reference`: 41 | 42 | .. code-block:: python 43 | :linenos: 44 | :emphasize-lines: 7 45 | 46 | # context: inside a 'custom' Renderer class which inherits from pyldapi.Renderer 47 | 48 | # this is an implementation of the abstract render() of the base class Renderer 49 | def render(self): 50 | # ... 51 | if self.profile == 'alternates': 52 | return self.render_alternates_profile() # render the alternates profile for this class instance 53 | # ... 54 | -------------------------------------------------------------------------------- /docs/source/class_item_template.rst: -------------------------------------------------------------------------------- 1 | Class template 2 | ============== 3 | 4 | Example of a class item template customised for the `mediatypes dataset`_: 5 | 6 | .. _mediatypes dataset: https://github.com/nicholascar/mediatypes-dataset 7 | 8 | .. code-block:: HTML 9 | 10 | {% extends "layout.html" %} 11 | 12 | {% block content %} 13 |

{{ mediatype }}

14 |

{{ request.values.get('uri') }}

15 |

Source:

16 | 19 | {% if deets['contributors'] is not none %} 20 |

Contributors:

21 |
    22 | {% for contributor in deets['contributors'] %} 23 |
  • {{ contributor }}
  • 24 | {% endfor %} 25 |
26 | {% endif %} 27 |

Other profiles, formats and languages:

28 | 29 | {% endblock %} 30 | 31 | Variables used by the class instance template: 32 | 33 | This will be called within a custom Renderer class' :func:`render`. See :ref:`example-renderer-reference`. 34 | 35 | .. code-block:: python 36 | 37 | return render_template( 38 | 'mediatype-en.html', # the class item template 39 | deets=deets, # a python dict containing keys *label* and *contributors* to its respective values. 40 | mediatype=mediatype # the mediatype class instance item name 41 | ) -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | # import os 16 | # import sys 17 | # sys.path.insert(0, os.path.abspath('.')) 18 | 19 | import sys 20 | import os 21 | 22 | # set the sys path to include the root directory of the project (since we only run the commands in pyLDAPI/docs/) 23 | sys.path.insert(0, os.path.abspath('../..')) 24 | 25 | # -- Project information ----------------------------------------------------- 26 | 27 | project = 'pyLDAPI' 28 | copyright = '2018, CSIRO Land and Water' 29 | author = 'CSIRO Land and Water' 30 | 31 | # The short X.Y version 32 | version = '' 33 | # The full version, including alpha/beta/rc tags 34 | release = '2.0.13' 35 | 36 | 37 | # -- General configuration --------------------------------------------------- 38 | 39 | # If your documentation needs a minimal Sphinx version, state it here. 40 | # 41 | # needs_sphinx = '1.0' 42 | 43 | # Add any Sphinx extension module names here, as strings. They can be 44 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 45 | # ones. 46 | extensions = [ 47 | 'sphinx.ext.autodoc', 48 | 'sphinx.ext.intersphinx', 49 | 'sphinx.ext.viewcode', 50 | ] 51 | 52 | # intersphinx mappings 53 | 54 | 55 | # Add any paths that contain templates here, relative to this directory. 56 | templates_path = ['_templates'] 57 | 58 | # The suffix(es) of source filenames. 59 | # You can specify multiple suffix as a list of string: 60 | # 61 | # source_suffix = ['.rst', '.md'] 62 | source_suffix = '.rst' 63 | 64 | # The master toctree document. 65 | master_doc = 'index' 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | # 70 | # This is also used if you do content translation via gettext catalogs. 71 | # Usually you set "language" from the command line for these cases. 72 | language = None 73 | 74 | # List of patterns, relative to source directory, that match files and 75 | # directories to ignore when looking for source files. 76 | # This pattern also affects html_static_path and html_extra_path. 77 | exclude_patterns = [] 78 | 79 | # The name of the Pygments (syntax highlighting) style to use. 80 | pygments_style = None 81 | 82 | 83 | # -- Options for HTML output ------------------------------------------------- 84 | 85 | # The theme to use for HTML and HTML Help pages. See the documentation for 86 | # a list of builtin themes. 87 | # 88 | html_theme = 'sphinx_rtd_theme' 89 | 90 | # Theme options are theme-specific and customize the look and feel of a theme 91 | # further. For a list of options available for each theme, see the 92 | # documentation. 93 | # 94 | # html_theme_options = {} 95 | 96 | # Add any paths that contain custom static files (such as style sheets) here, 97 | # relative to this directory. They are copied after the builtin static files, 98 | # so a file named "default.css" will overwrite the builtin "default.css". 99 | html_static_path = ['_static'] 100 | 101 | # Custom sidebar templates, must be a dictionary that maps document names 102 | # to template names. 103 | # 104 | # The default sidebars (for documents that don't match any pattern) are 105 | # defined by theme itself. Builtin themes are using these templates by 106 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 107 | # 'searchbox.html']``. 108 | # 109 | # html_sidebars = {} 110 | 111 | 112 | # -- Options for HTMLHelp output --------------------------------------------- 113 | 114 | # Output file base name for HTML help builder. 115 | htmlhelp_basename = 'pyLDAPIdoc' 116 | 117 | 118 | # -- Options for LaTeX output ------------------------------------------------ 119 | 120 | latex_elements = { 121 | # The paper size ('letterpaper' or 'a4paper'). 122 | # 123 | # 'papersize': 'letterpaper', 124 | 125 | # The font size ('10pt', '11pt' or '12pt'). 126 | # 127 | # 'pointsize': '10pt', 128 | 129 | # Additional stuff for the LaTeX preamble. 130 | # 131 | # 'preamble': '', 132 | 133 | # Latex figure (float) alignment 134 | # 135 | # 'figure_align': 'htbp', 136 | } 137 | 138 | # Grouping the document tree into LaTeX files. List of tuples 139 | # (source start file, target name, title, 140 | # author, documentclass [howto, manual, or own class]). 141 | latex_documents = [ 142 | (master_doc, 'pyLDAPI.tex', 'pyLDAPI Documentation', 143 | 'CSIRO Land and Water', 'manual'), 144 | ] 145 | 146 | 147 | # -- Options for manual page output ------------------------------------------ 148 | 149 | # One entry per manual page. List of tuples 150 | # (source start file, name, description, authors, manual section). 151 | man_pages = [ 152 | (master_doc, 'pyldapi', 'pyLDAPI Documentation', 153 | [author], 1) 154 | ] 155 | 156 | 157 | # -- Options for Texinfo output ---------------------------------------------- 158 | 159 | # Grouping the document tree into Texinfo files. List of tuples 160 | # (source start file, target name, title, author, 161 | # dir menu entry, description, category) 162 | texinfo_documents = [ 163 | (master_doc, 'pyLDAPI', 'pyLDAPI Documentation', 164 | author, 'pyLDAPI', 'One line description of project.', 165 | 'Miscellaneous'), 166 | ] 167 | 168 | 169 | # -- Options for Epub output ------------------------------------------------- 170 | 171 | # Bibliographic Dublin Core info. 172 | epub_title = project 173 | 174 | # The unique identifier of the text. This can be a ISBN number 175 | # or the project homepage. 176 | # 177 | # epub_identifier = '' 178 | 179 | # A unique identification for the text. 180 | # 181 | # epub_uid = '' 182 | 183 | # A list of files that should not be packed into the epub file. 184 | epub_exclude_files = ['search.html'] 185 | 186 | 187 | # -- Extension configuration ------------------------------------------------- 188 | 189 | # -- Options for intersphinx extension --------------------------------------- 190 | 191 | # Example configuration for intersphinx: refer to the Python standard library. 192 | intersphinx_mapping = {'https://docs.python.org/': None} -------------------------------------------------------------------------------- /docs/source/download_jinja_templates.rst: -------------------------------------------------------------------------------- 1 | .. _`demo-jinja-templates`: 2 | 3 | Download Jinja Templates 4 | ======================== 5 | 6 | This page contains a few general templates that are likely to be used in a pyLDAPI instance. They are provided to ease the initial development efforts with pyLDAPI. 7 | 8 | All Jinja2 templates should use the Jinja2 :code:`extends` keyword to extend the generic :code:`page.html` to reduce duplicated HTML code. 9 | 10 | Page 11 | ---- 12 | 13 | This template contains the persistent HTML code like the product's logo, the navigation bar and the footer. All other persistent things should go in this template. 14 | 15 | :download:`page.html ` 16 | 17 | 18 | Index 19 | ----- 20 | 21 | The home page of the pyLDAPI instance. Add whatever you like to this page. 22 | 23 | :download:`index.html ` 24 | 25 | 26 | Register 27 | -------- 28 | 29 | The register template lists all the items in a register. 30 | 31 | :download:`register.html ` 32 | 33 | 34 | Instance 35 | -------- 36 | 37 | The instance template presents the basic metadata of an instance item. 38 | 39 | :download:`instance.html ` 40 | 41 | 42 | Alternates 43 | ---------- 44 | 45 | The alternates template renders a list of alternate views and formats for a register or instance item. 46 | 47 | :download:`alternates.html ` -------------------------------------------------------------------------------- /docs/source/download_templates/alternates.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% block content %} 4 |

Alternates View

5 |

Instance {{ uri }}

6 |

Default view: {{ default_view_token }}

7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% for token, vals in views.items() %} 17 | 18 | 19 | 20 | 25 | 30 | 31 | {% if vals['namespace'] is not none %} 32 | 33 | {% endif %} 34 | 35 | {% endfor %} 36 |
TokenNameFormatsLanguagesDescriptionNamespace
{{ token }}{{ vals['label'] }} 21 | {% for f in vals['formats'] %} 22 | {{ f }}
23 | {% endfor %} 24 |
26 | {% for l in vals['languages'] %} 27 | {{ l }}
28 | {% endfor %} 29 |
{{ vals['comment'] }}{{ vals['namespace'] }}
37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /docs/source/download_templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% block content %} 4 |

System Home

5 | 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /docs/source/download_templates/instance.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% block content %} 4 |

Register

5 |

Of {{ register_item_type_string }}

6 |

7 | 8 | 9 | 22 | 40 | 41 |
10 |

Instances

11 | 20 | 21 |
23 |

Alternate views

24 |

Different views of this register are at its Alternate views.

25 |

Automated Pagination

26 |

To paginate this register, use the query string arguments 'page' for the page number and 'per_page' for the number of {{ register_class }} per page.

27 |

HTTP Link headers of first, prev, next & last are given to indicate URIs to the first, a previous, a next and the last page.

28 |

Example, for Page #7 with 50 {{ register_class }} per page:

29 |
30 | {{  request.base_url }}?page=7&per_page=500
31 |                 
32 |

The Link header would contain:

33 |
34 | Link:   <{{ request.base_url }}?per_page=50> rel="first",
35 |         <{{ request.base_url }}?per_page=50&page=6> rel="prev",
36 |         <{{ request.base_url }}?per_page=50&page=8> rel="next",
37 |         <{{ request.base_url }}?per_page=50&page=10> rel="last"
38 |                 
39 |
42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /docs/source/download_templates/page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ title }} 6 | 7 | 8 | 9 |
10 | 25 | 26 |
27 | {% block content %}{% endblock %} 28 |
29 | 32 |
33 | 34 | 35 | -------------------------------------------------------------------------------- /docs/source/download_templates/register.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% block content %} 4 |

Register

5 |

Of {{ register_item_type_string }}

6 |

7 | 8 | 9 | 22 | 40 | 41 |
10 |

Instances

11 | 20 | 21 |
23 |

Alternate views

24 |

Different views of this register are at its Alternate views.

25 |

Automated Pagination

26 |

To paginate this register, use the query string arguments 'page' for the page number and 'per_page' for the number of {{ register_class }} per page.

27 |

HTTP Link headers of first, prev, next & last are given to indicate URIs to the first, a previous, a next and the last page.

28 |

Example, for Page #7 with 50 {{ register_class }} per page:

29 |
30 | {{  request.base_url }}?page=7&per_page=500
31 |                 
32 |

The Link header would contain:

33 |
34 | Link:   <{{ request.base_url }}?per_page=50> rel="first",
35 |         <{{ request.base_url }}?per_page=50&page=6> rel="prev",
36 |         <{{ request.base_url }}?per_page=50&page=8> rel="next",
37 |         <{{ request.base_url }}?per_page=50&page=10> rel="last"
38 |                 
39 |
42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /docs/source/example_renderer_view_usage.rst: -------------------------------------------------------------------------------- 1 | .. _example-renderer-reference: 2 | 3 | Custom Renderer 4 | =============== 5 | 6 | In this example, we are creating a custom :class:`.Renderer` class by inheritance to cater for a `media type`_ instance. More information about this code can be found at this repository_. 7 | 8 | * The interest for :class:`.View` declarations are on lines |view linenos|. 9 | 10 | * On line |init linenos|, we pass we call the :code:`__init__()` of the super class, passing in the list of :class:`.View` objects and some other arguments. 11 | 12 | * Lines |render linenos| demonstrate how to implement the abstract :func:`pyldapi.Renderer.render` and how it works in tandem with the list of :class:`.View` objects. 13 | 14 | 15 | .. _repository: https://github.com/nicholascar/mediatypes-dataset 16 | .. _media type: https://www.iana.org/assignments/media-types/media-types.xml 17 | 18 | .. note:: The focus here is to demonstrate how to create a custom :class:`.Renderer` class, defining a custom :code:`render()` method and defining a list of :class:`.View` objects. 19 | 20 | .. literalinclude:: exempler_custom_renderer_example.py 21 | :linenos: 22 | :emphasize-lines: 10-19, 20-25, 27-57 23 | 24 | .. shame I can't use the replace directive in directive parameters. :( 25 | 26 | .. |view linenos| replace:: 10-19 27 | .. |init linenos| replace:: 20-25 28 | .. |render linenos| replace:: 27-57 -------------------------------------------------------------------------------- /docs/source/example_usage.rst: -------------------------------------------------------------------------------- 1 | A Toy pyLDAPI Example Usage 2 | =========================== 3 | 4 | .. warning:: TODO: Explain the import statements and the example code. 5 | 6 | Here is a very simple example usage of pyLDAPI. 7 | 8 | .. literalinclude:: ../../example.py 9 | 10 | -------------------------------------------------------------------------------- /docs/source/exceptions.rst: -------------------------------------------------------------------------------- 1 | Exceptions 2 | ========== 3 | 4 | .. automodule:: pyldapi.exceptions 5 | :members: -------------------------------------------------------------------------------- /docs/source/exempler_custom_renderer_example.py: -------------------------------------------------------------------------------- 1 | from flask import Response, render_template 2 | from SPARQLWrapper import SPARQLWrapper, JSON 3 | from rdflib import Graph, URIRef, Namespace, RDF, RDFS, XSD, OWL, Literal 4 | from pyldapi import Renderer, Profile 5 | import _conf as conf 6 | 7 | 8 | class MediaTypeRenderer(Renderer): 9 | def __init__(self, request, instance_uri): 10 | profiles = { 11 | 'mt': Profile( 12 | 'Mediatype View', 13 | 'Basic properties of a Media Type, as recorded by IANA', 14 | ['text/html'] + Renderer.RDF_MEDIA_TYPES, 15 | 'text/turtle', 16 | languages=['en', 'pl'], 17 | uri='http://test.linked.data.gov.au/def/mt#' 18 | ) 19 | } 20 | super(MediaTypeRenderer, self).__init__( 21 | request, 22 | instance_uri, 23 | profiles, 24 | 'mt' 25 | ) 26 | 27 | def render(self): 28 | if hasattr(self, 'vf_error'): 29 | return Response(self.vf_error, status=406, mimetype='text/plain') 30 | else: 31 | if self.profile == 'alternates': 32 | return self._render_alternates_profile() 33 | elif self.profile == 'mt': 34 | if self.format in Renderer.RDF_MEDIA_TYPES: 35 | rdf = self._get_instance_rdf() 36 | if rdf is None: 37 | return Response('No triples contain that URI as subject', status=404, mimetype='text/plain') 38 | else: 39 | return Response(rdf, mimetype=self.format) 40 | else: # only the HTML format left 41 | deets = self._get_instance_details() 42 | if deets is None: 43 | return Response('That URI yielded no data', status=404, mimetype='text/plain') 44 | else: 45 | mediatype = self.instance_uri.replace('%2B', '+').replace('%2F', '/').split('/mediatype/')[1] 46 | if self.language == 'pl': 47 | return render_template( 48 | 'mediatype-pl.html', 49 | deets=deets, 50 | mediatype=mediatype 51 | ) 52 | else: 53 | return render_template( 54 | 'mediatype-en.html', 55 | deets=deets, 56 | mediatype=mediatype 57 | ) 58 | 59 | def _get_instance_details(self): 60 | sparql = SPARQLWrapper(conf.SPARQL_QUERY_URI, returnFormat=JSON) 61 | q = ''' 62 | PREFIX rdfs: 63 | PREFIX dct: 64 | SELECT * 65 | WHERE {{ 66 | <{0[uri]}> rdfs:label ?label . 67 | OPTIONAL {{ <{0[uri]}> dct:contributor ?contributor . }} 68 | }} 69 | '''.format({'uri': self.instance_uri}) 70 | sparql.setQuery(q) 71 | d = sparql.query().convert() 72 | d = d.get('results').get('bindings') 73 | if d is None or len(d) < 1: # handle no result 74 | return None 75 | 76 | label = '' 77 | contributors = [] 78 | for r in d: 79 | label = str(r.get('label').get('value')) 80 | contributors.append(str(r.get('contributor').get('value'))) 81 | 82 | return { 83 | 'label': label, 84 | 'contributors': contributors 85 | } 86 | 87 | def _get_instance_rdf(self): 88 | deets = self._get_instance_details() 89 | 90 | g = Graph() 91 | DCT = Namespace('http://purl.org/dc/terms/') 92 | g.bind('dct', DCT) 93 | me = URIRef(self.instance_uri) 94 | g.add((me, RDF.type, DCT.FileFormat)) 95 | g.add(( 96 | me, 97 | OWL.sameAs, 98 | URIRef(self.instance_uri.replace('https://w3id.org/mediatype/', 'https://www.iana.org/assignments/media-types/')) 99 | )) 100 | g.add((me, RDFS.label, Literal(deets.get('label'), datatype=XSD.string))) 101 | source = 'https://www.iana.org/assignments/media-types/' + self.instance_uri.replace('%2B', '+').replace('%2F', '/').split('/mediatype/')[1] 102 | g.add((me, DCT.source, URIRef(source))) 103 | if deets.get('contributors') is not None: 104 | for contributor in deets.get('contributors'): 105 | g.add((me, DCT.contributor, URIRef(contributor))) 106 | 107 | if self.format in ['application/rdf+json', 'application/json']: 108 | return g.serialize(format='json-ld') 109 | else: 110 | return g.serialize(format=self.format) 111 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. pyLDAPI documentation master file, created by 2 | sphinx-quickstart on Tue Nov 13 12:36:34 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | 7 | ===================== 8 | pyLDAPI Documentation 9 | ===================== 10 | 11 | .. include:: ../../README.rst 12 | 13 | 14 | .. toctree:: 15 | :maxdepth: 1 16 | :caption: Getting Started 17 | 18 | requirements 19 | installation 20 | indices_and_tables 21 | 22 | 23 | .. toctree:: 24 | :maxdepth: 1 25 | :caption: Jinja2 Templates 26 | 27 | register_template 28 | alternates_template 29 | class_item_template 30 | download_jinja_templates 31 | 32 | 33 | .. toctree:: 34 | :maxdepth: 1 35 | :caption: API 36 | 37 | renderer 38 | view 39 | register_renderer 40 | register_of_registers_renderer 41 | ror_setup 42 | exceptions 43 | 44 | 45 | .. toctree:: 46 | :maxdepth: 1 47 | :caption: Examples 48 | 49 | example_renderer_view_usage 50 | example_usage 51 | 52 | 53 | -------------------------------------------------------------------------------- /docs/source/indices_and_tables.rst: -------------------------------------------------------------------------------- 1 | Indices and tables 2 | ================== 3 | 4 | * :ref:`genindex` 5 | * :ref:`modindex` 6 | * :ref:`search` 7 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | =============== 3 | 4 | .. attention:: See :ref:`requirements-reference` before getting started and make sure the requirements are met. 5 | 6 | To install, use Python's PyPI by invoking :code:`pip install pyldapi` on the command line interface. 7 | 8 | Now download the set of :ref:`demo-jinja-templates` and put them into a directory called :code:`view/templates` in your Flask project. 9 | 10 | 11 | Recommended project structure 12 | ----------------------------- 13 | 14 | We recommend a project structure as follows: 15 | 16 | .. image:: _static/directory_structure.PNG 17 | 18 | As shown in the image above, we recommend *this* model-view-controller architectural pattern for the project structure to maximise separation of concerns. The image above was taken from this_ repository. 19 | 20 | .. _this: https://github.com/CSIRO-enviro-informatics/sss-api 21 | 22 | The **controller** directory 23 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 24 | 25 | The **controller** directory is used to declare the Flask routes to your python functions. 26 | 27 | 28 | The **model** directory 29 | ~~~~~~~~~~~~~~~~~~~~~~~ 30 | 31 | The **model** directory is used to declare the Python files that manage the data of the API. 32 | 33 | 34 | The **view** directory 35 | ~~~~~~~~~~~~~~~~~~~~~~ 36 | 37 | The **view** directory contains the static content as well as the *required* Jinja2 templates for this API. -------------------------------------------------------------------------------- /docs/source/register_of_registers_renderer.rst: -------------------------------------------------------------------------------- 1 | Register of Registers Renderer (RoR) 2 | ==================================== 3 | 4 | .. note:: To use this, ensure :func:`pyldapi.setup` is called before calling Flask's :code:`app.run()`. See :ref:`setup-reference` for more information. 5 | 6 | .. autoclass:: pyldapi.RegisterOfRegistersRenderer 7 | :members: __init__ 8 | 9 | Example Usage 10 | ------------- 11 | 12 | A Flask route at the root of the application serving the *Register of Registers* page to the client. 13 | 14 | .. code-block:: python 15 | 16 | @app.route('/') 17 | def index(): 18 | cofc = RegisterOfRegistersRenderer( 19 | request, 20 | API_BASE, 21 | "Register of Registers", 22 | "A register of all of my registers.", 23 | "./cofc.ttl" 24 | ) 25 | return cofc.render() 26 | -------------------------------------------------------------------------------- /docs/source/register_renderer.rst: -------------------------------------------------------------------------------- 1 | Register Renderer 2 | ================= 3 | 4 | .. autoclass:: pyldapi.RegisterRenderer 5 | :members: __init__, render 6 | 7 | 8 | Example Usage 9 | ------------- 10 | This example contains a Flask route :code:`/sample/` which maps to the *Register of Samples*. 11 | We use the :class:`.RegisterRenderer` to create the Register and return a response back to the client. 12 | The code of interest is highlighted on lines 20-30. 13 | 14 | .. code-block:: python 15 | :linenos: 16 | :emphasize-lines: 20-30 17 | 18 | @classes.route('/sample/') 19 | def samples(): 20 | """ 21 | The Register of Samples 22 | :return: HTTP Response 23 | """ 24 | 25 | # get the total register count from the XML API 26 | try: 27 | r = requests.get(conf.XML_API_URL_TOTAL_COUNT) 28 | no_of_items = int(r.content.decode('utf-8').split('')[1].split('')[0]) 29 | 30 | page = request.values.get('page') if request.values.get('page') is not None else 1 31 | per_page = request.values.get('per_page') if request.values.get('per_page') is not None else 20 32 | items = _get_items(page, per_page, "IGSN") 33 | except Exception as e: 34 | print(e) 35 | return Response('The Samples Register is offline', mimetype='text/plain', status=500) 36 | 37 | r = pyldapi.RegisterRenderer( 38 | request, 39 | request.url, 40 | 'Sample Register', 41 | 'A register of Samples', 42 | items, 43 | [conf.URI_SAMPLE_CLASS], 44 | no_of_items 45 | ) 46 | 47 | return r.render() -------------------------------------------------------------------------------- /docs/source/register_template.rst: -------------------------------------------------------------------------------- 1 | Members template 2 | ================= 3 | 4 | Example of a generic register template: 5 | 6 | .. code-block:: HTML 7 | 8 | {% extends "layout.html" %} 9 | {% block content %} 10 |

{{ label }}

11 |

Register View

12 | {% for class in contained_item_classes %} 13 |

Of {{ class }} class items

14 | {% endfor %} 15 | 16 | 17 | 33 | 51 | 52 |
18 |

Items in this Register

19 | 28 | {% if pagination.links %} 29 |
Paging
30 | {% endif %} 31 | {{ pagination.links }} 32 |
34 |

Alternate profiles

35 |

Different profiles of this register are listed at its Alternate profiles page.

36 |

Automated Pagination

37 |

To page through these items, use the query string arguments 'page' for the page number and 'per_page' for the number of items per page. HTTP Link headers of first, prev, next & last indicate URIs to the first, a previous, a next and the last page.

38 |

Example:

39 |
40 |                         {{ request.base_url }}?page=7&per_page=50
41 |                     
42 |

Assuming 500 items, this request would result in a response with the following Link header:

43 |
44 |                         Link:   <{{ request.base_url }}?per_page=50> rel="first",
45 |                             <{{ request.base_url }}?per_page=50&page=6> rel="prev",
46 |                             <{{ request.base_url }}?per_page=50&page=8> rel="next",
47 |                             <{{ request.base_url }}?per_page=50&page=10> rel="last"
48 |                     
49 |

If you want to page the whole collection, you should start at first and follow the link headers until you reach last or until there is no last link given. You shouldn't try to calculate each page query string argument yourself.

50 |
53 | {% endblock %} 54 | 55 | Variables used by the register template: 56 | 57 | .. code-block:: python 58 | 59 | render_template( 60 | self.register_template or 'register.html', # the register template to use 61 | uri=self.uri, # the URI requested 62 | label=self.label, # The label of the Register 63 | comment=self.comment, # A description of the Register 64 | contained_item_classes=self.contained_item_classes, # The list of URI strings of each distinct class of item contained in this Register 65 | register_items=self.register_items, # The class items in this Register 66 | page=self.page, # The page number of this current Register's instance 67 | per_page=self.per_page, # The number of class items per page. Default is 20 68 | first_page=self.first_page, # deprecated, use pagination instead 69 | prev_page=self.prev_page, # deprecated, use pagination instead 70 | next_page=self.next_page, # deprecated, use pagination instead 71 | last_page=self.last_page, # deprecated, use pagination instead 72 | super_register=self.super_register, # A super-Register URI for this register. Can be within this API or external 73 | pagination=pagination # pagination object from module flask_paginate 74 | ) 75 | 76 | See :class:`.RegisterRenderer` for an example on how to render the register profile. 77 | -------------------------------------------------------------------------------- /docs/source/renderer.rst: -------------------------------------------------------------------------------- 1 | Renderer 2 | ======== 3 | 4 | .. autoclass:: pyldapi.Renderer 5 | :members: __init__, _render_alternates_view, render 6 | 7 | Example Implementation of :func:`pyldapi.Renderer.render` 8 | --------------------------------------------------------- 9 | 10 | .. code-block:: python 11 | 12 | # context: a custom Renderer class which inherits from pyldapi.Renderer 13 | 14 | def render(self): 15 | if self.site_no is None: 16 | return Response('Site {} not found.'.format(self.site_no), status=404, mimetype='text/plain') 17 | if self.view == 'alternates': 18 | # call the base class' render alternates view method 19 | return self._render_alternates_view() 20 | elif self.view == 'pdm': 21 | # render the view with the token 'pdm' as text/html 22 | if self.format == 'text/html': 23 | # you need to define your own self.export_html() 24 | return self.export_html(model_view=self.view) 25 | else: 26 | # you need to define your own self.export_rdf() 27 | return Response(self.export_rdf(self.view, self.format), mimetype=self.format) 28 | elif self.view == 'nemsr': 29 | # you need to define your own self.export_nemsr_geojson() 30 | return self.export_nemsr_geojson() 31 | 32 | The example code determines the response based on the set *view* and *format* of the object. 33 | 34 | .. seealso:: See :ref:`example-renderer-reference` for implementation details for :class:`.Renderer`. -------------------------------------------------------------------------------- /docs/source/requirements.rst: -------------------------------------------------------------------------------- 1 | .. _requirements-reference: 2 | 3 | Requirements 4 | ============ 5 | 6 | .. note:: To use the pyLDAPI module, a set of requirements must be met for the tool to work correctly. 7 | 8 | 9 | Jinja2 Templates 10 | ---------------- 11 | 12 | Register 13 | ~~~~~~~~ 14 | 15 | A :code:`members.html` template is required to deliver a register of items. 16 | 17 | 18 | Alternates 19 | ~~~~~~~~~~ 20 | 21 | An :code:`alternates.html` template is required to deliver an *alternates view* of a register or instance of a class. Alternatively, you can specify a different template for the alternates view by passing an optional argument to the :func:`pyldapi.Renderer.__init__` as :code:`alternates_template=`. 22 | 23 | 24 | Class 25 | ~~~~~ 26 | 27 | A template for each class item in the dataset is required to render a class item. 28 | 29 | Example: The online LD API for the Geofabric at `geofabricld.net`_ is exposing three class types, *Catchment*, *River Region* and *Drainage Division*. You can see in the image below showcasing the templates used for this API. 30 | 31 | .. _geofabricld.net: http://geofabricld.net 32 | 33 | .. image:: _static/geofabric_templates.PNG 34 | 35 | .. note:: These are of course not the only Jinja2 templates that you will have. Other ones may include something like the API's home page, about page, etc. You can also see that there are more than one template for a specific class type in the image above. These different templates with *geof* and *hyf* are the different views for the specific class item. See :class:`.View` for more information. 36 | 37 | .. seealso:: See also the template information under the **Jinja2 Templates** section of the documentation for more information in regards to what variables are required to pass in to the required templates. -------------------------------------------------------------------------------- /docs/source/ror_setup.rst: -------------------------------------------------------------------------------- 1 | .. _setup-reference: 2 | 3 | RoR Setup 4 | ========= 5 | 6 | .. autofunction:: pyldapi.setup 7 | 8 | Example Usage 9 | ------------- 10 | 11 | .. code-block:: python 12 | :linenos: 13 | :emphasize-lines: 8 14 | 15 | from flask import Flask 16 | from pyldapi import setup as pyldapi_setup 17 | 18 | API_BASE = 'http://127.0.0.1:8081' 19 | app = Flask(__name__) 20 | 21 | if __name__ == "__main__": 22 | pyldapi_setup(app, '.', API_BASE) 23 | app.run("127.0.0.1", 8081, debug=True, threaded=True, use_reloader=False) -------------------------------------------------------------------------------- /docs/source/view.rst: -------------------------------------------------------------------------------- 1 | View 2 | ==== 3 | 4 | .. autoclass:: pyldapi.View 5 | :members: __init__ 6 | 7 | Example Usage 8 | ------------- 9 | 10 | A dictionary of views: 11 | 12 | .. code-block:: python 13 | 14 | views = { 15 | 'csirov3': View( 16 | 'CSIRO IGSN View', 17 | 'An XML-only metadata schema for descriptive elements of IGSNs', 18 | ['text/xml'], 19 | 'text/xml', 20 | namespace='https://confluence.csiro.au/display/AusIGSN/CSIRO+IGSN+IMPLEMENTATION' 21 | ), 22 | 23 | 'prov': View( 24 | 'PROV View', 25 | "The W3C's provenance data model, PROV", 26 | ["text/html", "text/turtle", "application/rdf+xml", "application/rdf+json"], 27 | "text/turtle", 28 | namespace="http://www.w3.org/ns/prov/" 29 | ), 30 | } 31 | 32 | A dictionary of views are generally intialised in the constructor of a specialised *ClassRenderer*. 33 | This ClassRenderer inherits from :class:`.Renderer` 34 | 35 | .. seealso:: See :ref:`example-renderer-reference` for more information. -------------------------------------------------------------------------------- /images/blocks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RDFLib/pyLDAPI/c9be295b4ed772437d1a3c31b0de2c28140c4c20/images/blocks.png -------------------------------------------------------------------------------- /images/instance-ASGS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RDFLib/pyLDAPI/c9be295b4ed772437d1a3c31b0de2c28140c4c20/images/instance-ASGS.png -------------------------------------------------------------------------------- /images/instance-GNAF.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RDFLib/pyLDAPI/c9be295b4ed772437d1a3c31b0de2c28140c4c20/images/instance-GNAF.png -------------------------------------------------------------------------------- /pyLDAPI-250.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RDFLib/pyLDAPI/c9be295b4ed772437d1a3c31b0de2c28140c4c20/pyLDAPI-250.png -------------------------------------------------------------------------------- /pyLDAPI.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 12 | 13 | 14 | 20 | 26 | 34 | 35 | 43 | 46 | 49 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /pyldapi/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: latin-1 -*- 2 | 3 | from pyldapi.exceptions import ProfilesMediatypesException, PagingError 4 | from pyldapi.renderer import Renderer 5 | from pyldapi.renderer_container import ContainerRenderer, ContainerOfContainersRenderer 6 | from pyldapi.profile import Profile 7 | from pyldapi.helpers import setup 8 | from pyldapi.data import RDF_MEDIATYPES, RDF_FILE_EXTS, MEDIATYPE_NAMES 9 | 10 | __version__ = '4.2' 11 | 12 | __all__ = [ 13 | 'Renderer', 14 | 'ContainerRenderer', 15 | 'ContainerOfContainersRenderer', 16 | 'Profile', 17 | 'ProfilesMediatypesException', 18 | 'PagingError', 19 | 'setup', 20 | '__version__', 21 | 'RDF_MEDIATYPES', 22 | 'RDF_FILE_EXTS', 23 | 'MEDIATYPE_NAMES' 24 | ] 25 | -------------------------------------------------------------------------------- /pyldapi/data.py: -------------------------------------------------------------------------------- 1 | MEDIATYPE_NAMES = { 2 | "text/html": "HTML", 3 | "text/turtle": "Turtle", 4 | "application/rdf+xml": "RDF/XML", 5 | "application/ld+json": "JSON-LD", 6 | "application/json": "JSON", 7 | "application/n-triples": "N-triples", 8 | } 9 | RDF_MEDIATYPES = [ 10 | "text/turtle", 11 | "application/rdf+xml", 12 | "application/ld+json", 13 | "application/n-triples", 14 | ] 15 | RDF_FILE_EXTS = { 16 | "text/turtle": "ttl", 17 | "application/rdf+xml": "rdf", 18 | "application/ld+json": "jsonld", 19 | "application/n-triples": "nt", 20 | "text/n3": "n3", 21 | } 22 | -------------------------------------------------------------------------------- /pyldapi/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class ProfilesMediatypesException(ValueError): 5 | """ 6 | TODO: Ashley add docstring for documentation 7 | """ 8 | pass 9 | 10 | 11 | class CofCTtlError(RuntimeError): 12 | """ 13 | TODO: Ashley add docstring for documentation 14 | """ 15 | pass 16 | 17 | 18 | class PagingError(ValueError): 19 | """ 20 | TODO: Ashley add docstring for documentation 21 | """ 22 | pass 23 | 24 | -------------------------------------------------------------------------------- /pyldapi/helpers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import logging 4 | from rdflib import Graph 5 | from pyldapi.exceptions import CofCTtlError 6 | 7 | 8 | def setup(app, api_home_dir, api_uri): 9 | """ 10 | This is used to set up the :class:`.RegisteC of CegistersRenderer` for this pyLDAPI instance. 11 | 12 | .. note:: This must run before Flask's :func:`app.run` like this: :code:`pyldapi.setup(app, '.', conf.URI_BASE)`. See the example below. 13 | 14 | :param app: The Flask app containing this pyLDAPI instance. 15 | :type app: :class:`flask.Flask` 16 | :param api_home_dir: The path of the API's hom directory. 17 | :type api_home_dir: str 18 | :param api_uri: The URI base of the API. 19 | :type api_uri: str 20 | :return: None 21 | :rtype: None 22 | """ 23 | return _make_cofc_rdf(app, api_home_dir, api_uri) 24 | 25 | 26 | def _make_cofc_rdf(app, api_home_dir, api_uri): 27 | """ 28 | The setup function that creates the Register of Registers. 29 | 30 | Do not call from outside setup 31 | :param app: the Flask app containing this LDAPI 32 | :type app: Flask app 33 | :param api_home_dir: The path of the API's hom directory. 34 | :type api_home_dir: str 35 | :param api_uri: URI base of the API 36 | :type api_uri: string 37 | :return: none 38 | :rtype: None 39 | """ 40 | from time import sleep 41 | cofc_file_path = os.path.join(api_home_dir, 'cofc.ttl') 42 | try: 43 | os.remove(cofc_file_path) 44 | except FileNotFoundError: 45 | pass 46 | sleep(1) # to ensure that this occurs after the Flask boot 47 | g = Graph() 48 | # get the RDF for each Register, extract the bits we need, write them to graph g 49 | for rule in app.url_map.iter_rules(): 50 | # no containers can have a Flask variable in their path and they must end in / 51 | if '<' in str(rule) or not str(rule).endswith('/') or str(rule) == '/': 52 | pass 53 | else: 54 | # make the container profile URI for each possible container 55 | try: 56 | endpoint_func = app.view_functions[rule.endpoint] 57 | except (AttributeError, KeyError): 58 | continue 59 | 60 | try: 61 | dummy_request_uri = 'http://localhost:5000' + str(rule) + \ 62 | '?_profile=mem&_format=text/turtle&page=1&per_page=1' 63 | test_context = app.test_request_context(dummy_request_uri) 64 | with test_context: 65 | resp = endpoint_func() 66 | g = g + Graph().parse(data=resp.response[0].decode('utf-8'), format='turtle') 67 | except CofCTtlError: # usually an C of C renderer cannot find its cofc.ttl. 68 | continue 69 | except Exception as e: 70 | raise e 71 | 72 | # get all the child Container from the in-memory graph 73 | # which is the set of all the Container end point's first pages of Container profile content 74 | q = ''' 75 | CONSTRUCT {{ 76 | ?uri a rdf:Bag ; 77 | rdfs:label ?label . 78 | ?parent a rdf:Bag ; 79 | rdfs:label ?parent_label ; 80 | rdfs:member ?uri . 81 | }} 82 | WHERE {{ 83 | ?uri a rdf:Bag ; 84 | rdfs:label ?label . 85 | OPTIONAL {{ 86 | ?parent rdfs:label ?parent_label ; 87 | rdfs:member ?uri . 88 | }} 89 | }} 90 | ORDER BY ?label 91 | '''.format(api_uri) 92 | gg = Graph() 93 | for r in g.query(q): 94 | gg.add(r) 95 | 96 | # serialise gg 97 | with open(cofc_file_path, 'w') as f: 98 | f.write(gg.serialize(format='text/turtle').decode('utf-8')) 99 | 100 | 101 | def _filter_members_graph(container_uri, r, g): 102 | if 'text/turtle' in r.headers.get('Content-Type'): 103 | logging.debug('{} is a register '.format(container_uri)) 104 | # it is a valid endpoint returning RDF (turtle) so... 105 | # import all its content into the in-memory graph 106 | g2 = Graph().parse(data=r.data.decode('utf-8'), format='text/turtle') 107 | # extract out only the Register details 108 | # make a query to get all the vars we need 109 | q = ''' 110 | CONSTRUCT {{ 111 | <{0}> a rdf:Bag ; 112 | rdfs:label ?label ; 113 | rdfs:comment ?comment ; 114 | reg:subregister ?subregister . 115 | }} 116 | WHERE {{ 117 | <{0}> a rdf:Bag ; 118 | rdfs:label ?label ; 119 | rdfs:comment ?comment ; 120 | }} 121 | '''.format(container_uri) 122 | 123 | g += g2.query(q) 124 | return True 125 | else: 126 | logging.debug( 127 | '{} returns no RDF'.format(container_uri)) 128 | return False # no RDF (turtle) response from endpoint so not register 129 | 130 | 131 | # def get_filtered_register_graph(register_uri, g): 132 | # """ 133 | # Gets a filtered version (label, comment, contained item classes & subregisters only) of the each register for the 134 | # Register of Registers 135 | # 136 | # :param register_uri: the public URI of the register 137 | # :type register_uri: string 138 | # :param g: the rdf graph to append registers to 139 | # :type g: Graph 140 | # :return: True if ok, else False 141 | # :rtype: boolean 142 | # """ 143 | # import requests 144 | # from pyldapi.exceptions import ViewsFormatsException 145 | # assert isinstance(g, Graph) 146 | # logging.debug('assessing register candidate ' + register_uri.replace('?_profile=reg&_format=text/turtle', '')) 147 | # try: 148 | # r = requests.get(register_uri) 149 | # except ViewsFormatsException as e: 150 | # return False # ignore these exceptions as are just a result of requesting a profile/format combo of something like a page 151 | # if r.status_code == 200: 152 | # return _filter_register_graph(register_uri.replace('?_profile=reg&_format=text/turtle', ''), r, g) 153 | # logging.debug('{} returns no HTTP 200'.format(register_uri)) 154 | # return False # no valid response from endpoint so not register 155 | 156 | 157 | # resp.format in Renderer.RDF_MIMETYPES: 158 | # from pyldapi.rendered import Rendered.RDF_MIMETYPES -------------------------------------------------------------------------------- /pyldapi/profile.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | class Profile: 3 | """ 4 | A class containing elements for a Linked Data 'model view', 5 | including MIME type 'mediatypes'. 6 | 7 | The syntax for mediatypes can be found at iana org: https://www.iana.org/assignments/media-types/media-types.xhtml 8 | 9 | Example of common mediatypes and languages as a list: 10 | 11 | .. code-block:: python 12 | 13 | mediatypes = ['text/html', 'text/turtle', 'application/rdf+xml', 'application/rdf+json'] 14 | languages = ['en', 'pl'] # 'en' for English and 'pl' for Polish. 15 | 16 | """ 17 | def __init__( 18 | self, 19 | uri, 20 | label, 21 | comment, 22 | mediatypes, 23 | default_mediatype, 24 | languages=None, 25 | default_language='en', 26 | ): 27 | """ 28 | Constructor 29 | 30 | :param label: The view label. 31 | :type label: str 32 | :param comment: The comment describing the view. 33 | :type comment: str 34 | :param mediatypes: The list of mediatypes according to iana org. 35 | :type mediatypes: list 36 | :param default_mediatype: The default mediatype according to iana org. 37 | :type default_mediatype: str 38 | :param languages: A list of languages as strings. 39 | :type languages: list 40 | :param default_language: The default language, by default it is 'en' English. 41 | :type default_language: str 42 | :param uri: The namespace URI for the *profile* view. 43 | :type uri: str 44 | """ 45 | self.label = label 46 | self.comment = comment 47 | self.mediatypes = mediatypes 48 | self.default_mediatype = default_mediatype 49 | self.languages = languages if languages is not None else ['en'] 50 | self.default_language = default_language 51 | self.uri = uri 52 | -------------------------------------------------------------------------------- /pyldapi/renderer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from abc import ABCMeta 3 | 4 | from fastapi import Response 5 | from fastapi.responses import JSONResponse 6 | from fastapi.templating import Jinja2Templates 7 | 8 | from rdflib import Graph, Namespace, URIRef, BNode, Literal 9 | from rdflib.namespace import PROF, RDF, RDFS, XSD 10 | from rdflib.namespace import DCTERMS 11 | from pyldapi.profile import Profile 12 | from pyldapi.exceptions import ProfilesMediatypesException 13 | import re 14 | import connegp 15 | from .data import MEDIATYPE_NAMES, RDF_MEDIATYPES 16 | 17 | templates = Jinja2Templates(directory="templates") 18 | 19 | 20 | class Renderer(object, metaclass=ABCMeta): 21 | """ 22 | Abstract class as a parent for classes that validate the profiles & mediatypes for an API-delivered resource (typically 23 | either registers or objects) and also creates an 'alternates profile' for them, based on all available profiles & mediatypes. 24 | """ 25 | 26 | def __init__(self, 27 | request, 28 | instance_uri, 29 | profiles, 30 | default_profile_token, 31 | ): 32 | """ 33 | Constructor 34 | 35 | :param request: Flask request object that triggered this class object's creation. 36 | :type request: :class:`flask.request` 37 | :param instance_uri: The URI that triggered this API endpoint (can be via redirects but the main URI is needed). 38 | :type instance_uri: str 39 | :param profiles: A dictionary of profiles available for this resource. 40 | :type profiles: dict (of :class:`.View` class objects) 41 | :param default_profile_token: The ID of the default profile (key of a profile in the dictionary of :class: 42 | `.Profile` objects) 43 | :type default_profile_token: str (a key in profiles) 44 | :param alternates_template: The Jinja2 template to use for rendering the HTML *alternates view*. If None, then 45 | it will default to try and use a template called :code:`alternates.html`. 46 | :type alternates_template: str 47 | 48 | .. seealso:: See the :class:`.View` class on how to create a dictionary of profiles. 49 | 50 | """ 51 | self.vf_error = None 52 | self.request = request 53 | self.instance_uri = instance_uri 54 | 55 | # ensure alternates token isn't hogged by user 56 | for k, v in profiles.items(): 57 | if k == 'alternates': 58 | self.vf_error = 'You must not manually add a profile with token \'alternates\' as this is auto-created.' 59 | 60 | self.profiles = profiles 61 | # auto-add in an Alternates profile 62 | self.profiles['alt'] = Profile( 63 | 'http://www.w3.org/ns/dx/conneg/altr', # the ConnegP URI for Alt Rep Data Model 64 | 'Alternate Representations', 65 | 'The representation of the resource that lists all other representations (profiles and Media Types)', 66 | ['text/html', 'application/json'] + RDF_MEDIATYPES, 67 | 'text/html', 68 | languages=['en'], # default 'en' only for now 69 | ) 70 | self.profile = None 71 | 72 | # ensure that the default profile is actually a given profile 73 | if default_profile_token == "alternates": 74 | self.vf_error = 'You cannot specify \'alternates\' as the default profile.' 75 | 76 | # ensure the default profile is in the list of profiles 77 | if default_profile_token not in self.profiles.keys(): 78 | self.vf_error = 'The profile token you specified ({}) for the default profile ' \ 79 | 'is not in the list of profiles you supplied ({})'\ 80 | .format(default_profile_token, ', '.join(self.profiles.keys())) 81 | 82 | self.default_profile_token = default_profile_token 83 | 84 | # get profile & mediatype for this request, flag any errors but do not except out 85 | self.profile = self._get_profile() 86 | self.mediatype = self._get_mediatype() 87 | self.language = self._get_language() 88 | 89 | # make headers only if there's no error 90 | if self.vf_error is None: 91 | self.headers = dict() 92 | self.headers['Link'] = '<' + self.profiles[self.profile].uri + '>; rel="profile"' 93 | self.headers['Content-Type'] = self.mediatype 94 | self.headers['Content-Language'] = self.language 95 | 96 | self.headers['Link'] += ', ' + self._make_header_link_tokens() 97 | self.headers['Link'] += ', ' + self._make_header_link_list_profiles() 98 | 99 | # Issue 18 - https://github.com/RDFLib/pyLDAPI/issues/18 100 | # Enable CORS for browser client consumption 101 | self.headers['Access-Control-Allow-Origin'] = '*' 102 | 103 | # 104 | # getting request's preferences 105 | # 106 | 107 | # TODO: wrap all the input parsing functions in try/except block pushing errors to vf_error 108 | 109 | def _get_profiles_from_qsa(self): 110 | """ 111 | Reads either _profile or _view Query String Argument and returns a list of Profile tokens 112 | in ascending preference order 113 | Ref: https://www.w3.org/TR/dx-prof-conneg/#qsa-getresourcebyprofile 114 | :return: List of URIs of accept profiles in descending request order 115 | :rtype: list 116 | """ 117 | # try QSAa and, if we have any, return them only 118 | profiles_string = self.request.query_params.get('_view', self.request.query_params.get('_profile')) 119 | # profiles_string = None # TODO: Change request from Flask to FastAPI 120 | if profiles_string is not None: 121 | pqsa = connegp.ProfileQsaParser(profiles_string) 122 | if pqsa.valid: 123 | profiles = [] 124 | for profile in pqsa.profiles: 125 | if profile['profile'].startswith('<'): 126 | # convert this valid URI/URN to a token 127 | for token, profile in self.profiles.items(): 128 | if profile.uri == profile['profile'].strip('<>'): 129 | profiles.append(token) 130 | else: 131 | # it's already a token so just add it 132 | profiles.append(profile['profile']) 133 | if len(profiles) > 0: 134 | return profiles 135 | 136 | return None 137 | 138 | def _get_profiles_from_http(self): 139 | """ 140 | Reads an Accept-Profile HTTP header and returns a list of Profile tokens in descending weighted order 141 | Ref: https://www.w3.org/TR/dx-prof-conneg/#http-getresourcebyprofile 142 | :return: List of URIs of accept profiles in descending request order 143 | :rtype: list 144 | """ 145 | if self.request.headers.get('Accept-Profile') is not None: 146 | try: 147 | ap = connegp.AcceptProfileHeaderParser(self.request.headers.get('Accept-Profile')) 148 | if ap.valid: 149 | profiles = [] 150 | for p in ap.profiles: 151 | # convert this valid URI/URN to a token 152 | for token, profile in self.profiles.items(): 153 | if profile.uri == p['profile']: 154 | profiles.append(token) 155 | if len(profiles) == 0: 156 | return None 157 | else: 158 | return profiles 159 | else: 160 | return None 161 | except Exception: 162 | msg = 'You have requested a profile using an Accept-Profile header that is incorrectly formatted.' 163 | raise ProfilesMediatypesException(msg) 164 | else: 165 | return None 166 | 167 | def _get_available_profiles(self): 168 | uris = {} 169 | for token, profile in self.profiles.items(): 170 | uris[profile.uri] = token 171 | 172 | return uris 173 | 174 | def _get_profile(self): 175 | # if we get a profile from QSA, use that 176 | profiles_requested = self._get_profiles_from_qsa() 177 | 178 | # if not, try HTTP 179 | if profiles_requested is None: 180 | profiles_requested = self._get_profiles_from_http() 181 | 182 | # if still no profile, return None 183 | if profiles_requested is None: 184 | return self.default_profile_token 185 | 186 | # if we have a result from QSA or HTTP, got through each in order and see if there's an available 187 | # profile for that token, return first one 188 | profiles_available = self._get_available_profiles() 189 | for profile in profiles_requested: 190 | for k, v in profiles_available.items(): 191 | if profile == v: 192 | return v # return the profile token 193 | 194 | # if no match found, should never o 195 | return self.default_profile_token 196 | 197 | def _get_mediatypes_from_qsa(self): 198 | """Returns a list of Media Types from QSA 199 | :return: list 200 | """ 201 | qsa_mediatypes = self.request.query_params.get('_format', self.request.query_params.get('_mediatype', None)) 202 | # qsa_mediatypes = None 203 | if qsa_mediatypes is not None: 204 | qsa_mediatypes = str(qsa_mediatypes).replace(' ', '+').split(',') 205 | # if the internal mediatype is requested, return the default 206 | if qsa_mediatypes[0] == "_internal": 207 | return [self.profiles[self.profile].default_mediatype] 208 | else: 209 | return qsa_mediatypes 210 | else: 211 | return None 212 | 213 | def _get_mediatypes_from_http(self): 214 | """Returns a list of Media Type tokens from an Accept HTTP header in descending weighted order 215 | :return: List of URIs of accept profiles in descending request order 216 | :rtype: list 217 | """ 218 | if hasattr(self.request, 'headers'): 219 | if self.request.headers.get('Accept') is not None: 220 | try: 221 | # Chrome breaking Accept header variable by adding v=b3 222 | # Issue https://github.com/RDFLib/pyLDAPI/issues/21 223 | mediatypes_string = re.sub('v=(.*);', '', self.request.headers['Accept']) 224 | 225 | # split the header into individual URIs, with weights still attached 226 | mediatypes = mediatypes_string.split(',') 227 | 228 | # remove \s 229 | mediatypes = [x.strip() for x in mediatypes] 230 | 231 | # split off any weights and sort by them with default weight = 1 232 | mediatypes = [ 233 | (float(x.split(';')[1].replace('q=', '')) 234 | if ";q=" in x else 1, x.split(';')[0]) for x in mediatypes 235 | ] 236 | 237 | # sort profiles by weight, heaviest first 238 | mediatypes.sort(reverse=True) 239 | 240 | # return only the orderd list of mediatypes, not weights 241 | return[x[1] for x in mediatypes] 242 | except Exception: 243 | raise ProfilesMediatypesException( 244 | 'You have requested a Media Type using an Accept header that is incorrectly formatted.') 245 | 246 | return None 247 | 248 | def _get_available_mediatypes(self): 249 | return self.profiles[self.profile].mediatypes 250 | 251 | def _get_mediatype(self): 252 | mediatypes_requested = self._get_mediatypes_from_qsa() 253 | if mediatypes_requested is None: 254 | mediatypes_requested = self._get_mediatypes_from_http() 255 | 256 | # no Media Types requested so return default 257 | if mediatypes_requested is None: 258 | return self.profiles[self.profile].default_mediatype 259 | 260 | # iterate through requested Media Types until a valid one is found 261 | mediatypes_available = self._get_available_mediatypes() 262 | for mediatype in mediatypes_requested: 263 | if mediatype in mediatypes_available: 264 | return mediatype 265 | 266 | # no valid Media Type is found so return default 267 | return self.profiles[self.profile].default_mediatype 268 | 269 | def _get_languages_from_qsa(self): 270 | """Returns a list of Languages from QSA 271 | :return: list 272 | """ 273 | # languages = self.request.values.get('_lang') 274 | languages = None 275 | if languages is not None: 276 | languages = str(languages).replace(' ', '_').replace('+', '_').split(',') 277 | # if the internal mediatype is requested, return the default 278 | if languages == "_internal": 279 | return self.profiles[self.profile].default_language 280 | else: 281 | return languages 282 | 283 | return None 284 | 285 | def _get_languages_from_http(self): 286 | """ 287 | Reads an Accept HTTP header and returns an array of Media Type string in descending weighted order 288 | :return: List of URIs of accept profiles in descending request order 289 | :rtype: list 290 | """ 291 | if hasattr(self.request, 'headers'): 292 | if self.request.headers.get('Accept-Language') is not None: 293 | try: 294 | # split the header into individual URIs, with weights still attached 295 | languages = self.request.headers['Accept-Language'].split(',') 296 | # remove \s 297 | languages = [x.strip() for x in languages] 298 | 299 | # split off any weights and sort by them with default weight = 1 300 | languages = [ 301 | (float(x.split(';')[1].replace('q=', '')) 302 | if len(x.split(';')) == 2 else 1, x.split(';')[0]) for x in languages 303 | ] 304 | 305 | # sort profiles by weight, heaviest first 306 | languages.sort(reverse=True) 307 | 308 | # return only the orderd list of languages, not weights 309 | return[x[1] for x in languages] 310 | except Exception: 311 | raise ProfilesMediatypesException( 312 | 'You have requested a language using an Accept-Language header that is incorrectly formatted.') 313 | 314 | return None 315 | 316 | def _get_available_languages(self): 317 | return self.profiles[self.profile].languages 318 | 319 | def _get_language(self): 320 | languages_requested = self._get_languages_from_qsa() 321 | if languages_requested is None: 322 | languages_requested = self._get_languages_from_http() 323 | 324 | # no Media Types requested so return default 325 | if languages_requested is None: 326 | return self.profiles[self.profile].default_language 327 | 328 | # iterate through requested Media Types until a valid one is found 329 | languages_available = self._get_available_mediatypes() 330 | for language in languages_requested: 331 | if language in languages_available: 332 | return language 333 | 334 | # no valid Media Type is found so return default 335 | return self.profiles[self.profile].default_language 336 | 337 | # end getting request's preferences 338 | 339 | # 340 | # making response headers 341 | # 342 | def _make_header_link_tokens(self): 343 | individual_links = [] 344 | link_header_template = '; rel="type"; token="{}"; anchor=<{}>, ' 345 | 346 | for token, profile in self.profiles.items(): 347 | individual_links.append(link_header_template.format(token, profile.uri)) 348 | 349 | return ''.join(individual_links).rstrip(', ') 350 | 351 | def _make_header_link_list_profiles(self): 352 | individual_links = [] 353 | for token, profile in self.profiles.items(): 354 | # create an individual Link statement per Media Type 355 | for mediatype in profile.mediatypes: 356 | # set the rel="self" just for this profile & mediatype 357 | if mediatype != '_internal': 358 | if token == self.profile and mediatype == self.profiles[self.profile].default_mediatype: 359 | rel = 'self' 360 | else: 361 | rel = 'alternate' 362 | 363 | individual_links.append( 364 | '<{}?_profile={}&_mediatype={}>; rel="{}"; type="{}"; profile="{}", '.format( 365 | self.instance_uri, 366 | token, 367 | mediatype, 368 | rel, 369 | mediatype, 370 | profile.uri) 371 | ) 372 | 373 | # append to, or create, Link header 374 | return ''.join(individual_links).rstrip(', ') 375 | 376 | # end making response headers 377 | 378 | # 379 | # making response content 380 | # 381 | def _generate_alt_profiles_rdf(self): 382 | # Alt R Data Model as per https://www.w3.org/TR/dx-prof-conneg/#altr 383 | g = Graph() 384 | ALTR = Namespace('http://www.w3.org/ns/dx/conneg/altr#') 385 | g.bind('altr', ALTR) 386 | 387 | g.bind('dct', DCTERMS) 388 | 389 | g.bind('prof', PROF) 390 | 391 | instance_uri = URIRef(self.instance_uri) 392 | 393 | # for each Profile, lis it via its URI and give annotations 394 | for token, p in self.profiles.items(): 395 | profile_uri = URIRef(p.uri) 396 | g.add((profile_uri, RDF.type, PROF.Profile)) 397 | g.add((profile_uri, RDFS.label, Literal(p.label, datatype=XSD.string))) 398 | g.add((profile_uri, RDFS.comment, Literal(p.comment, datatype=XSD.string))) 399 | 400 | # for each Profile and Media Type, create a Representation 401 | for token, p in self.profiles.items(): 402 | for mt in p.mediatypes: 403 | if not str(mt).startswith('_'): # ignore Media Types like `_internal` 404 | rep = BNode() 405 | g.add((rep, RDF.type, ALTR.Representation)) 406 | g.add((rep, DCTERMS.conformsTo, URIRef(p.uri))) 407 | g.add((rep, URIRef(DCTERMS + 'format'), Literal(mt))) 408 | g.add((rep, PROF.hasToken, Literal(token, datatype=XSD.token))) 409 | 410 | # if this is the default format for the Profile, say so 411 | if mt == p.default_mediatype: 412 | g.add((rep, ALTR.isProfilesDefault, Literal(True, datatype=XSD.boolean))) 413 | 414 | # link this representation to the instances 415 | g.add((instance_uri, ALTR.hasRepresentation, rep)) 416 | 417 | # if this is the default Profile and the default Media Type, set it as the instance's default Rep 418 | if token == self.default_profile_token and mt == p.default_mediatype: 419 | g.add((instance_uri, ALTR.hasDefaultRepresentation, rep)) 420 | 421 | return g 422 | 423 | def _make_rdf_response(self, graph, mimetype=None, headers=None, delete_graph=True): 424 | if headers is None: 425 | headers = self.headers 426 | 427 | response_text = graph.serialize(format=mimetype or "text/turtle") 428 | 429 | if delete_graph: 430 | # destroy the triples in the triplestore, then delete the triplestore 431 | # this helps to prevent a memory leak in rdflib 432 | graph.store.remove((None, None, None)) 433 | graph.destroy({}) 434 | del graph 435 | 436 | return Response( 437 | response_text, 438 | media_type=mimetype, 439 | headers=headers 440 | ) 441 | 442 | def _render_alt_profile_html( 443 | self, 444 | additional_alt_template_context=None, 445 | alt_template_context_replace=False 446 | ): 447 | """Renders the Alternates Profile in HTML 448 | 449 | If an alt_template value (str, file name) is given, a template of that name will be looked for in the app's 450 | template directory, else alt.html will be used. 451 | 452 | If a dictionary is given for additional_alt_template_context, this will either replace the standard template 453 | context, if alt_template_context_replace is True, or just be added to it, if alt_template_context_replace is 454 | False 455 | 456 | :param alt_template: the name of the template to use 457 | :type alt_template: str (file name, within the application's templates directory) 458 | :param additional_alt_template_context: Additional or complete context for the template 459 | :type additional_alt_template_context: dict 460 | :param alt_template_context_replace: To replace (True) or add to (False) the standard template context 461 | :type alt_template_context_replace: bool 462 | :return: a rendered template (HTML) 463 | :rtype: TemplateResponse 464 | """ 465 | profiles = {} 466 | for token, profile in self.profiles.items(): 467 | profiles[token] = { 468 | 'label': str(profile.label), 'comment': str(profile.comment), 469 | 'mediatypes': tuple(f for f in profile.mediatypes if not f.startswith('_')), 470 | 'default_mediatype': str(profile.default_mediatype), 471 | 'languages': profile.languages if profile.languages is not None else ['en'], 472 | 'default_language': str(profile.default_language), 473 | 'uri': str(profile.uri) 474 | } 475 | 476 | _template_context = { 477 | 'uri': self.instance_uri, 478 | 'default_profile_token': self.default_profile_token, 479 | 'profiles': profiles, 480 | 'mediatype_names': MEDIATYPE_NAMES, 481 | 'request': self.request 482 | } 483 | if additional_alt_template_context is not None and isinstance(additional_alt_template_context, dict): 484 | if alt_template_context_replace: 485 | _template_context = additional_alt_template_context 486 | else: 487 | _template_context.update(additional_alt_template_context) 488 | 489 | return templates.TemplateResponse("alt.html", 490 | context=_template_context, 491 | headers=self.headers) 492 | 493 | def _render_alt_profile_rdf(self): 494 | g = self._generate_alt_profiles_rdf() 495 | return self._make_rdf_response(g) 496 | 497 | def _render_alt_profile_json(self): 498 | return JSONResponse( 499 | content={ 500 | 'uri': self.instance_uri, 501 | 'profiles': list(self.profiles.keys()), 502 | 'default_profile': self.default_profile_token 503 | }, 504 | media_type='application/json', 505 | headers=self.headers 506 | ) 507 | 508 | def _render_alt_profile( 509 | self, 510 | additional_alt_template_context=None, 511 | alt_template_context_replace=False 512 | ): 513 | """ 514 | Return a Flask Response object depending on the value assigned to :code:`self.mediatype`. 515 | 516 | :return: A Flask Response object 517 | :rtype: :class:`flask.Response` 518 | """ 519 | if self.mediatype == 'text/html': 520 | return self._render_alt_profile_html( 521 | additional_alt_template_context, 522 | alt_template_context_replace 523 | ) 524 | elif self.mediatype in RDF_MEDIATYPES: 525 | return self._render_alt_profile_rdf() 526 | else: # application/json 527 | return self._render_alt_profile_json() 528 | 529 | def render( 530 | self, 531 | alt_template: str = "alt.html", 532 | additional_alt_template_context=None, 533 | alt_template_context_replace=False 534 | ): 535 | """ 536 | Use the received profile and mediatype to create a response back to the client. 537 | 538 | TODO: Ashley, are you able to update this description with your new changes please? 539 | What is the method for rendering other profiles now? - Edmond 540 | 541 | This is an abstract method. 542 | 543 | .. note:: The :class:`pyldapi.Renderer.render` requires you to implement your own business logic to render 544 | custom responses back to the client using :func:`flask.render_template` or :class:`flask.Response` object. 545 | """ 546 | 547 | # if there's been an error with the request, return that 548 | if self.vf_error is not None: 549 | return Response(self.vf_error, status=400, media_type='text/plain') 550 | elif self.profile == 'alt' or self.profile == 'alternates': 551 | return self._render_alt_profile( 552 | additional_alt_template_context, 553 | alt_template_context_replace 554 | ) 555 | return None 556 | 557 | # end making response content 558 | -------------------------------------------------------------------------------- /pyldapi/renderer_container.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from pathlib import Path 3 | 4 | from fastapi import Response 5 | from fastapi.responses import JSONResponse 6 | from fastapi.templating import Jinja2Templates 7 | 8 | from rdflib import Graph, Namespace, URIRef, Literal, RDF, RDFS 9 | from pyldapi.renderer import Renderer 10 | from pyldapi.profile import Profile 11 | from pyldapi.exceptions import ProfilesMediatypesException, CofCTtlError 12 | from .data import RDF_MEDIATYPES, MEDIATYPE_NAMES 13 | 14 | templates = Jinja2Templates(directory="templates") 15 | 16 | 17 | class ContainerRenderer(Renderer): 18 | """ 19 | Specific implementation of the abstract Renderer for displaying Register information 20 | """ 21 | DEFAULT_ITEMS_PER_PAGE = 100 22 | 23 | def __init__(self, 24 | request, 25 | instance_uri, 26 | label, 27 | comment, 28 | parent_container_uri, 29 | parent_container_label, 30 | members, 31 | members_total_count, 32 | *args, 33 | profiles=None, 34 | default_profile_token=None, 35 | super_register=None, 36 | page_size_max=1000): 37 | """ 38 | Constructor 39 | 40 | :param request: The Flask request object triggering this class object's creation. 41 | :type request: :class:`.flask.request` 42 | :param instance_uri: The URI requested. 43 | :type instance_uri: str 44 | :param label: The label of the Register. 45 | :type label: str 46 | :param comment: A description of the Register. 47 | :type comment: str 48 | :param members: The items within this register as a list of URI strings or tuples with string elements 49 | like (URI, label). They can also be tuples like (URI, URI, label) if you want to manually specify an item's 50 | class. 51 | :type members: list 52 | :param contained_item_classes: The list of URI strings of each distinct class of item contained in this 53 | Register. 54 | :type contained_item_classes: list 55 | :param members_total_count: The total number of items in this Register (not of a page but the register as a 56 | whole). 57 | :type members_total_count: int 58 | :param profiles: A dictionary of named :class:`.View` objects available for this Register, apart from 'reg' 59 | which is auto-created. 60 | :type profiles: dict 61 | :param default_profile_token: The ID of the default :class:`.View` (key of a profile in the list of Views). 62 | :type default_profile_token: str 63 | :param super_register: A super-Register URI for this register. Can be within this API or external. 64 | :type super_register: str 65 | :param members_template: The Jinja2 template to use for rendering the HTML profile of the register. If None, 66 | then it will default to try and use a template called :code:`mem.html`. 67 | :type members_template: str or None 68 | :param per_page: Number of items to show per page if not specified in request. If None, then it will default to 69 | RegisterRenderer.DEFAULT_ITEMS_PER_PAGE. 70 | :type per_page: int or None 71 | """ 72 | self.instance_uri = instance_uri 73 | 74 | if profiles is None: 75 | profiles = {} 76 | for k, v in profiles.items(): 77 | if k == 'mem': 78 | raise ProfilesMediatypesException( 79 | 'You must not manually add a profile with token \'mem\' as this is auto-created' 80 | ) 81 | profiles.update({ 82 | 'mem': Profile( 83 | 'https://w3id.org/profile/mem', 84 | 'Members Profile', 85 | 'A very basic RDF data model-only profile that lists the sub-items (members) of collections (rdf:Bag)', 86 | ['text/html'] + RDF_MEDIATYPES, 87 | 'text/html' 88 | ) 89 | }) 90 | if default_profile_token is None: 91 | default_profile_token = 'mem' 92 | 93 | super(ContainerRenderer, self).__init__( 94 | request, 95 | instance_uri, 96 | profiles, 97 | default_profile_token 98 | ) 99 | if self.vf_error is None: 100 | self.label = label 101 | self.comment = comment 102 | self.parent_container_uri = parent_container_uri 103 | self.parent_container_label = parent_container_label 104 | if members is not None: 105 | self.members = members 106 | else: 107 | self.members = [] 108 | self.members_total_count = members_total_count 109 | 110 | if request.query_params.get("per_page"): 111 | self.per_page = int(request.query_params.get("per_page")) 112 | else: 113 | self.per_page = ContainerRenderer.DEFAULT_ITEMS_PER_PAGE 114 | if request.query_params.get("page"): 115 | self.page = int(request.query_params.get("page")) 116 | else: 117 | self.page = 1 118 | 119 | self.super_register = super_register 120 | self.page_size_max = page_size_max 121 | self.paging_error = self._paging() 122 | 123 | def _paging(self): 124 | # calculate last page 125 | self.last_page = int(round(self.members_total_count / self.per_page, 0)) + 1 # same as math.ceil() 126 | 127 | # if we've gotten the last page value successfully, we can choke if someone enters a larger value 128 | if self.page > self.last_page: 129 | return 'You must enter either no value for page or an integer <= {} which is the last page number.'\ 130 | .format(self.last_page) 131 | 132 | if self.per_page > self.page_size_max: 133 | return 'You must choose a page size <= {}'.format(self.page_size_max) 134 | 135 | # set up Link headers 136 | links = list() 137 | # signalling this is an LDP Resource 138 | links.append('; rel="type"') 139 | # signalling that this is, in fact, a Resource described in pages 140 | links.append('; rel="type"') 141 | 142 | # other Query String Arguments 143 | other_qsas = [x + "=" + self.request.query_params[x] for x in self.request.query_params if x not in ["page", "per_page"]] 144 | if len(other_qsas) > 0: 145 | other_qsas_str = "&".join(other_qsas) + "&" 146 | else: 147 | other_qsas_str = '' 148 | 149 | # always add a link to first 150 | self.first_page = 1 151 | links.append( 152 | '<{}?{}per_page={}&page=1>; rel="first"'.format( 153 | self.instance_uri, 154 | other_qsas_str, 155 | self.per_page 156 | ) 157 | ) 158 | 159 | # if this isn't the first page, add a link to "prev" 160 | if self.page > 1: 161 | self.prev_page = self.page - 1 162 | links.append('<{}?per_page={}&page={}>; rel="prev"'.format( 163 | self.instance_uri, 164 | self.per_page, 165 | self.prev_page 166 | )) 167 | else: 168 | self.prev_page = None 169 | 170 | # if this isn't the last page, add a link to next 171 | if self.page < self.last_page: 172 | self.next_page = self.page + 1 173 | links.append( 174 | '<{}?{}per_page={}&page={}>; rel="next"'.format( 175 | self.instance_uri, 176 | other_qsas_str, 177 | self.per_page, 178 | self.next_page 179 | ) 180 | ) 181 | else: 182 | self.next_page = None 183 | 184 | # always add a link to last 185 | links.append( 186 | '<{}?{}per_page={}&page={}>; rel="last"'.format( 187 | self.instance_uri, 188 | other_qsas_str, 189 | self.per_page, self.last_page 190 | ) 191 | ) 192 | 193 | self.headers['Link'] += ', ' + ', '.join(links) 194 | 195 | return None 196 | 197 | def render( 198 | self, 199 | additional_alt_template_context=None, 200 | alt_template_context_replace=False, 201 | additional_mem_template_context=None, 202 | mem_template_context_replace=False 203 | ): 204 | """ 205 | Renders the register profile. 206 | 207 | :return: A Flask Response object. 208 | :rtype: :py:class:`flask.Response` 209 | """ 210 | response = super(ContainerRenderer, self).render( 211 | additional_alt_template_context=additional_alt_template_context, 212 | alt_template_context_replace=alt_template_context_replace 213 | ) 214 | if response is None and self.profile == 'mem': 215 | if self.paging_error is None: 216 | if self.mediatype == 'text/html': 217 | return self._render_mem_profile_html( 218 | additional_mem_template_context, 219 | mem_template_context_replace 220 | ) 221 | elif self.mediatype in RDF_MEDIATYPES: 222 | return self._render_mem_profile_rdf() 223 | else: 224 | return self._render_mem_profile_json() 225 | else: # there is a paging error (e.g. page > last_page) 226 | return Response(self.paging_error, status_code=400, media_type='text/plain') 227 | return response 228 | 229 | def _render_mem_profile_html( 230 | self, 231 | additional_mem_template_context=None, 232 | mem_template_context_replace=False 233 | ): 234 | _template_context = { 235 | 'uri': self.instance_uri, 236 | 'label': self.label, 237 | 'comment': self.comment, 238 | 'parent_container_uri': self.parent_container_uri, 239 | 'parent_container_label': self.parent_container_label, 240 | 'members': self.members, 241 | 'page': self.page, 242 | 'per_page': self.per_page, 243 | 'first_page': self.first_page, 244 | 'prev_page': self.prev_page, 245 | 'next_page': self.next_page, 246 | 'last_page': self.last_page, 247 | 'mediatype_names': MEDIATYPE_NAMES, 248 | 'request': self.request 249 | } 250 | if additional_mem_template_context is not None and isinstance(additional_mem_template_context, dict): 251 | if mem_template_context_replace: 252 | _template_context = additional_mem_template_context 253 | else: 254 | _template_context.update(additional_mem_template_context) 255 | 256 | return templates.TemplateResponse("mem.html", 257 | context=_template_context, 258 | headers=self.headers) 259 | 260 | def _generate_mem_profile_rdf(self): 261 | g = Graph() 262 | 263 | LDP = Namespace('http://www.w3.org/ns/ldp#') 264 | g.bind('ldp', LDP) 265 | 266 | XHV = Namespace('https://www.w3.org/1999/xhtml/vocab#') 267 | g.bind('xhv', XHV) 268 | 269 | u = URIRef(self.instance_uri) 270 | g.add((u, RDF.type, RDF.Bag)) 271 | g.add((u, RDFS.label, Literal(self.label))) 272 | g.add((u, RDFS.comment, Literal(self.comment, lang='en'))) 273 | for member in self.members: 274 | if "uri" in member: 275 | member_uri = URIRef(member["uri"]) 276 | g.add((u, RDFS.member, member_uri)) 277 | g.add((member_uri, RDFS.label, Literal(member["title"]))) 278 | elif isinstance(member, tuple): 279 | member_uri = URIRef(member[0]) 280 | g.add((u, RDFS.member, member_uri)) 281 | g.add((member_uri, RDFS.label, Literal(member[1]))) 282 | else: 283 | g.add((u, RDFS.member, URIRef(member))) 284 | 285 | # other Query String Arguments 286 | other_qsas = [x + "=" + self.request.query_params[x] for x in self.request.query_params if x not in ["page", "per_page"]] 287 | if len(other_qsas) > 0: 288 | other_qsas_str = "&".join(other_qsas) + "&" 289 | else: 290 | other_qsas_str = '' 291 | 292 | page_uri_str = "{}?{}per_page={}&page={}".format(self.instance_uri, other_qsas_str, self.per_page, self.page) 293 | page_uri_str_nonum = "{}?{}per_page={}&page=".format(self.instance_uri, other_qsas_str, self.per_page) 294 | page_uri = URIRef(page_uri_str) 295 | 296 | # pagination 297 | # this page 298 | g.add((page_uri, RDF.type, LDP.Page)) 299 | g.add((page_uri, LDP.pageOf, u)) 300 | 301 | # links to other pages 302 | g.add((page_uri, XHV.first, URIRef(page_uri_str_nonum + '1'))) 303 | g.add((page_uri, XHV.last, URIRef(page_uri_str_nonum + str(self.last_page)))) 304 | 305 | if self.page != 1: 306 | g.add((page_uri, XHV.prev, URIRef(page_uri_str_nonum + str(self.page - 1)))) 307 | 308 | if self.page != self.last_page: 309 | g.add((page_uri, XHV.next, URIRef(page_uri_str_nonum + str(self.page + 1)))) 310 | 311 | if self.parent_container_uri is not None: 312 | g.add((URIRef(self.parent_container_uri), RDF.Bag, u)) 313 | g.add((URIRef(self.parent_container_uri), RDFS.member, u)) 314 | if self.parent_container_label is not None: 315 | g.add((URIRef(self.parent_container_uri), RDFS.label, Literal(self.parent_container_label))) 316 | return g 317 | 318 | def _render_mem_profile_rdf(self): 319 | g = self._generate_mem_profile_rdf() 320 | return self._make_rdf_response(g) 321 | 322 | def _render_mem_profile_json(self): 323 | return JSONResponse( 324 | content={ 325 | 'uri': self.instance_uri, 326 | 'label': self.label, 327 | 'comment': self.comment, 328 | 'profiles': list(self.profiles.keys()), 329 | 'default_profile': self.default_profile_token, 330 | 'register_items': self.members 331 | }, 332 | media_type='application/json', 333 | headers=self.headers 334 | ) 335 | 336 | 337 | class ContainerOfContainersRenderer(ContainerRenderer): 338 | """ 339 | Specialised implementation of the :class:`.RegisterRenderer` for displaying Register of Registers information. 340 | 341 | This sub-class auto-fills many of the :class:`.RegisterRenderer` options. 342 | """ 343 | 344 | def __init__(self, request, instance_uri, label, comment, profiles, cofc_file_path, default_profile_token='mem'): 345 | """ 346 | Constructor 347 | 348 | :param request: The Flask request object triggering this class object's creation. 349 | :type request: :class:`flask.request` 350 | :param instance_uri: The URI requested. 351 | :type instance_uri: str 352 | :param label: The label of the Register. 353 | :type label: str 354 | :param comment: A description of the Register. 355 | :type comment: str 356 | :param cofc_file_path: The path to the Register of Registers RDF file (used in API setup). 357 | :type cofc_file_path: str 358 | """ 359 | super(ContainerOfContainersRenderer, self).__init__( 360 | request, 361 | instance_uri, 362 | label, 363 | comment, 364 | None, 365 | None, 366 | [], # will be replaced further down 367 | 0, # will be replaced further down 368 | profiles=profiles, 369 | default_profile_token=default_profile_token, 370 | ) 371 | self.members = [] 372 | 373 | # find things (Containers) within the C of C from cofc.ttl 374 | try: 375 | with open(cofc_file_path, 'rb') as file: 376 | g = Graph().parse(file=file, format='turtle') 377 | assert g, "Could not parse the CofC RDF file." 378 | except FileNotFoundError: 379 | raise CofCTtlError() 380 | except AssertionError: 381 | raise CofCTtlError() 382 | 383 | q = ''' 384 | SELECT ?uri ?label 385 | WHERE {{ 386 | # the URIs and labels of all the things of type rdf:Bag that are within (rdfs:member) the CofC 387 | ?uri a rdf:Bag ; 388 | rdfs:label ?label . 389 | <{register_uri}> rdfs:member ?uri . 390 | }} 391 | '''.format(**{'register_uri': instance_uri}) 392 | for r in g.query(q): 393 | self.members.append((r['uri'], r['label'])) 394 | 395 | self.register_total_count = len(self.members) 396 | -------------------------------------------------------------------------------- /pyldapi/templates/alt.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% block content %} 4 |

Alternate Profiles

5 |

Instance: {{ uri }}

6 |

Default Profile: {{ default_profile_token }}

7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {% for token, vals in profiles.items() %} 18 | 19 | 20 | 21 | 29 | 34 | 35 | {% if vals['namespace'] is not none %} 36 | 37 | {% endif %} 38 | 39 | {% endfor %} 40 |
TokenNameFormatsLanguagesDescriptionNamespace
{{ token }}{{ vals['label'] }} 22 | {% for f in vals['mediatypes'] -%} 23 | {% if f in mediatype_names -%} 24 | {{ mediatype_names[f] }}
25 | {% else -%} 26 | {{ f }}
27 | {% endif %}{% endfor %} 28 |
30 | {%- for l in vals['languages'] %} 31 | {{ l }}
32 | {% endfor -%} 33 |
{{ vals['comment'] }}{{ vals['namespace'] }}
41 |
42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /pyldapi/templates/mem.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |

{{ label }}

7 |
8 |
9 |
10 |

Alternates Profiles

11 |

Different views of and formats:

12 |

13 | Alternate Profiles 14 | ?Different Media Types (HTML, text, RDF, JSON etc.) and different information model views, profiles, are available for this resource. 15 |

16 |
17 |
18 |
19 |
20 |
URI
21 |
{{ uri }}
22 | {% if comment is not none %} 23 |
Description
24 |
{{ comment|safe }}
25 | {% endif %} 26 | {% if parent_container_uri is not none %} 27 |
Parent Container
28 | {% if parent_container_label is not none %} 29 |
{{ parent_container_uri }}
30 | {% else %} 31 |
{{ parent_container_label }}
32 | {% endif %} 33 | {% endif %} 34 |
35 |
36 |
37 |

Members

38 |
    39 | {%- for member in members %} 40 |
  • {{ member[1] }}
  • 41 | {%- endfor %} 42 |
43 |
44 |
45 |
46 |

Filter

47 |
48 | 49 | 50 | ?A simple text matching filter of the list content to the left 51 |
52 |
53 | 54 |
55 |
56 |
57 |
58 |
59 |
60 | 82 | {% endblock %} 83 | -------------------------------------------------------------------------------- /pyldapi/templates/page.html: -------------------------------------------------------------------------------- 1 | {% if title is defined %} 2 |

{{ title }}

3 | {% else %} 4 |

No Title

5 | {% endif %} 6 | {% block content %}{% endblock %} -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx_rtd_theme 3 | pytest 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi 2 | rdflib>=6.0.0 3 | connegp 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: latin-1 -*- 3 | import codecs 4 | import re 5 | import os 6 | from setuptools import setup, find_packages 7 | 8 | 9 | def open_local(paths, mode='r', encoding='utf8'): 10 | path = os.path.join( 11 | os.path.abspath(os.path.dirname(__file__)), 12 | *paths 13 | ) 14 | return codecs.open(path, mode, encoding) 15 | 16 | 17 | with open_local(['pyldapi', '__init__.py'], encoding='latin1') as fp: 18 | try: 19 | version = re.findall(r"^__version__ = '([^']+)'\r?$", 20 | fp.read(), re.M)[0] 21 | except IndexError: 22 | raise RuntimeError('Unable to determine version.') 23 | 24 | with open_local(['README.rst']) as readme: 25 | long_description = readme.read() 26 | 27 | with open_local(['requirements.txt']) as req: 28 | install_requires = req.read().split("\n") 29 | 30 | setup( 31 | name='pyldapi', 32 | packages=find_packages(), 33 | package_dir={"pyldapi": "pyldapi"}, 34 | package_data={ 35 | "pyldapi": ["templates/*"], 36 | }, 37 | include_package_data=True, 38 | version=version, 39 | description='A very small module to add Linked Data API functionality to ' 40 | 'a Python FastAPI or Flask (v3.x) installation', 41 | author='Nicholas Car', 42 | author_email='nicholas.car@surroundaustralia.com', 43 | url='https://github.com/RDFLib/pyLDAPI', 44 | download_url='https://github.com/RDFLib/pyLDAPI' 45 | '/archive/v{:s}.tar.gz'.format(version), 46 | license='LICENSE.txt', 47 | keywords=['Linked Data', 'Semantic Web', 'FastAPI', 'Python', 'API', 'RDF'], 48 | long_description=long_description, 49 | classifiers=[ 50 | 'Development Status :: 4 - Beta', 51 | 'Topic :: Utilities', 52 | 'License :: OSI Approved :: ' 53 | 'GNU General Public License v3 or later (GPLv3+)', 54 | 'Intended Audience :: Developers', 55 | 'Natural Language :: English', 56 | 'Programming Language :: Python :: 3', 57 | 'Programming Language :: Python :: 3 :: Only', 58 | 'Programming Language :: Python :: 3.4', 59 | 'Programming Language :: Python :: 3.5', 60 | 'Programming Language :: Python :: 3.6', 61 | 'Programming Language :: Python :: 3.7', 62 | 'Programming Language :: Python :: Implementation :: CPython', 63 | 'Programming Language :: Python :: Implementation :: PyPy', 64 | 'Topic :: Software Development :: Libraries :: Python Modules', 65 | ], 66 | project_urls={ 67 | 'Bug Reports': 'https://github.com/RDFLib/pyLDAPI/issues', 68 | 'Source': 'https://github.com/RDFLib/pyLDAPI/', 69 | }, 70 | install_requires=install_requires, 71 | ) 72 | 73 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import pyldapi -------------------------------------------------------------------------------- /tests/alt_template.py: -------------------------------------------------------------------------------- 1 | from pyldapi import Renderer, Profile 2 | from pyldapi.data import RDF_MEDIATYPES 3 | from fastapi.requests import Request 4 | 5 | 6 | # Mock FastAPI / Starlette Request object 7 | req = Request({"type": "http", "query_string": None, "headers": {}}) 8 | 9 | # dummy profile to use 10 | sdo = Profile( 11 | uri="https://schema.org", 12 | label="schema.org", 13 | comment="Schema.org is a collaborative, community activity with a mission to create, maintain, and promote schemas " 14 | "for structured data on the Internet, on web pages, in email messages, and beyond.", 15 | mediatypes=RDF_MEDIATYPES, 16 | default_mediatype="text/turtle", 17 | languages=["en"], 18 | default_language="en", 19 | ) 20 | 21 | r = Renderer(req, "http://example.com", {"sdo": sdo}, "alt") 22 | txt = r._render_alt_profile_html( 23 | alt_template="alt.html", 24 | additional_alt_template_context={"stuff": ["one", "two"], "title": "Dummy Title"} 25 | ) 26 | print(txt.body.decode()) 27 | -------------------------------------------------------------------------------- /tests/example.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from flask import Flask, Response, request, render_template 4 | from pyldapi import setup as pyldapi_setup 5 | from pyldapi import RegisterOfRegistersRenderer, RegisterRenderer, Renderer, Profile 6 | 7 | API_BASE = 'http://127.0.0.1:8081' 8 | 9 | cats = [ 10 | { 11 | 'name': 'Jonny', 12 | 'breed': 'DomesticShorthair', 13 | 'age': 10, 14 | 'color': 'tortoiseshell', 15 | }, { 16 | 'name': 'Sally', 17 | 'breed': 'Manx', 18 | 'age': 3, 19 | 'color': 'brown', 20 | }, { 21 | 'name': 'Spud', 22 | 'breed': 'Persian', 23 | 'age': 7, 24 | 'color': 'grey', 25 | } 26 | ] 27 | 28 | dogs = [ 29 | { 30 | 'name': 'Rex', 31 | 'breed': 'Dachshund', 32 | 'age': 7, 33 | 'color': 'brown', 34 | }, { 35 | 'name': 'Micky', 36 | 'breed': 'Alsatian', 37 | 'age': 3, 38 | 'color': 'black', 39 | } 40 | ] 41 | 42 | MyPetView = Profile( 43 | 'PetView', 44 | 'A profile of my pet.', 45 | ['text/html', 'application/json'], 46 | 'text/html', 47 | uri='http://example.org/def/mypetprofile') 48 | 49 | app = Flask(__name__) 50 | 51 | 52 | class PetRenderer(Renderer): 53 | def __init__(self, request, instance_uri, instance, pet_html_template, **kwargs): 54 | self.profiles = {'mypetprofile': MyPetView} 55 | self.default_view_token = 'mypetview' 56 | super(PetRenderer, self).__init__( 57 | request, instance_uri, self.views, self.default_view_token, **kwargs) 58 | self.instance = instance 59 | self.pet_html_template = pet_html_template 60 | 61 | def _render_mypetview(self): 62 | self.headers['Profile'] = 'http://example.org/def/mypetview' 63 | if self.format == 'application/json': 64 | return Response(json.dumps(self.instance), 65 | mimetype='application/json', status=200) 66 | elif self.format == 'text/html': 67 | return Response(render_template(self.pet_html_template, **self.instance)) 68 | 69 | # All `Renderer` subclasses _must_ implement render 70 | def render(self): 71 | response = super(PetRenderer, self).render() 72 | if not response and self.profile == 'mypetview': 73 | response = self._render_mypetview() 74 | else: 75 | raise NotImplementedError(self.profile) 76 | return response 77 | 78 | 79 | @app.route('/id/dog/') 80 | def dog_instance(dog_id): 81 | instance = None 82 | for d in dogs: 83 | if d['name'] == dog_id: 84 | instance = d 85 | break 86 | if instance is None: 87 | return Response('Not Found', status=404) 88 | renderer = PetRenderer(request, request.base_url, instance, 'dog.html') 89 | return renderer.render() 90 | 91 | 92 | @app.route('/id/cat/') 93 | def cat_instance(cat_id): 94 | instance = None 95 | for c in cats: 96 | if c['name'] == cat_id: 97 | instance = c 98 | break 99 | if instance is None: 100 | return Response('Not Found', status=404) 101 | renderer = PetRenderer(request, request.base_url, instance, 'cat.html') 102 | return renderer.render() 103 | 104 | 105 | @app.route('/cats') 106 | def cats_reg(): 107 | cat_items = [('http://example.com/id/cat/{}'.format(i['name']), i['name']) for i in cats] 108 | r = RegisterRenderer(request, 109 | API_BASE + '/cats', 110 | 'Cats Register', 111 | 'A complete register of my cats.', 112 | cat_items, 113 | ['http://example.com/Cat'], 114 | len(cat_items), 115 | super_register=API_BASE + '/' 116 | ) 117 | return r.render() 118 | 119 | @app.route('/dogs') 120 | def dogs_reg(): 121 | dog_items = [('http://example.com/id/dog/{}'.format(i['name']), i['name']) for i in dogs] 122 | r = RegisterRenderer(request, 123 | API_BASE + '/dogs', 124 | 'Dogs Register', 125 | 'A complete register of my dogs.', 126 | dog_items, 127 | ['http://example.com/Dog'], 128 | len(dog_items), 129 | super_register=API_BASE + '/', 130 | register_template='members.html', 131 | alternates_template='alternates.html' 132 | ) 133 | return r.render() 134 | 135 | 136 | @app.route('/') 137 | def index(): 138 | cofc = RegisterOfRegistersRenderer(request, 139 | API_BASE, 140 | 'Register of Registers', 141 | 'A register of all of my registers.', 142 | './cofc.ttl' 143 | ) 144 | return cofc.render() 145 | 146 | 147 | if __name__ == '__main__': 148 | pyldapi_setup(app, '..', API_BASE) 149 | app.run('127.0.0.1', 8081, debug=True, threaded=True, use_reloader=False) 150 | -------------------------------------------------------------------------------- /tests/renderer_container.py: -------------------------------------------------------------------------------- 1 | from pyldapi import ContainerRenderer, Profile 2 | from pyldapi.data import RDF_MEDIATYPES 3 | from fastapi.requests import Request 4 | 5 | 6 | req = Request({"type": "http", "query_string": None, "headers": {}}) 7 | 8 | sdo = Profile( 9 | uri="https://schema.org", 10 | label="schema.org", 11 | comment="Schema.org is a collaborative, community activity with a mission to create, maintain, and promote schemas " 12 | "for structured data on the Internet, on web pages, in email messages, and beyond.", 13 | mediatypes=RDF_MEDIATYPES, 14 | default_mediatype="text/turtle", 15 | languages=["en"], 16 | default_language="en", 17 | ) 18 | 19 | cr = ContainerRenderer( 20 | req, 21 | "http://example.com", 22 | "Dummy Label", 23 | "Dummy Comment", 24 | None, 25 | None, 26 | [ 27 | ("http://example.com/one", "One"), 28 | ("http://example.com/two", "Two"), 29 | ("http://example.com/three", "Three"), 30 | ("http://example.com/four", "Four"), 31 | ("http://example.com/five", "Five"), 32 | ], 33 | 60, 34 | None, 35 | profiles={"sdo": sdo}, 36 | default_profile_token="sdo", 37 | ) 38 | 39 | txt = cr._render_mem_profile_html() 40 | print(txt.body.decode()) -------------------------------------------------------------------------------- /tests/test_conneg_by_p.py: -------------------------------------------------------------------------------- 1 | from pyldapi import Renderer, Profile 2 | 3 | 4 | # Mock class 5 | class Request: 6 | pass 7 | 8 | 9 | class MockRenderer(Renderer): 10 | def render(self): 11 | # use the now gotten profile & format to create a response 12 | pass 13 | 14 | 15 | def setup(): 16 | # profiles for testing 17 | global profiles 18 | profiles = { 19 | 'agor': Profile( 20 | 'http://linked.data.gov.au/def/agor', 21 | 'AGOR Profile', 22 | 'A profile of organisations according to the Australian Government Organisations Register', 23 | ['text/html'] + Renderer.RDF_MEDIA_TYPES, 24 | 'text/turtle', 25 | ), 26 | 'fake': Profile( 27 | 'http://fake.com', 28 | 'Fake Profile', 29 | 'A fake Profile for testing', 30 | ['text/xml'], 31 | 'text/xml', 32 | ), 33 | 'other': Profile( 34 | 'http://other.com', 35 | 'Another Testing Profile', 36 | 'Another profile for testing', 37 | ['text/html', 'text/xml'], 38 | 'text/html', 39 | ) 40 | # 'alternates' # included by default 41 | # 'all' # included by default 42 | } 43 | 44 | # this tests Accept-Profile selection of 'fake' profile 45 | mr = Request() 46 | mr.url = 'http://whocares.com' 47 | mr.values = {} 48 | mr.headers = { 49 | 'Accept-Profile': 'http://nothing.com ,' # ignored - broken, no <> 50 | 'http://nothing-else.com,' # ignored - broken, no <> 51 | '; q=0.9, ' # not available 52 | '; q=0.1, ' # available but lower weight 53 | '; q=0.2', # should be this 54 | 'Accept': 'text/turtle' 55 | } 56 | 57 | # this tests QSA selection of 'alternates' profile 58 | mr2 = Request() 59 | mr2.url = 'http://whocares.com' 60 | mr2.values = {'_profile': 'alternates'} 61 | mr2.headers = {} 62 | 63 | global r 64 | r = MockRenderer( 65 | mr, 66 | 'http://whocares.com', 67 | profiles, 68 | 'agor' 69 | ) 70 | 71 | global r2 72 | r2 = MockRenderer( 73 | mr2, 74 | 'http://whocares.com', 75 | profiles, 76 | 'agor' 77 | ) 78 | 79 | 80 | def test_content_profile(): 81 | expected = '; rel="profile"' 82 | actual = r.headers.get('Link') 83 | assert actual.startswith(expected), \ 84 | 'test_list_profiles() test 1: Link (current profile) expected to be {}, was {}'.format( 85 | expected, 86 | actual 87 | ) 88 | 89 | 90 | def test_list_profiles(): 91 | expected = \ 92 | '; rel="profile", ' \ 93 | '; rel="type"; token="agor"; anchor=, ' \ 94 | '; rel="type"; token="fake"; anchor=, ' \ 95 | '; rel="type"; token="other"; anchor=, ' \ 96 | '; rel="type"; token="alt"; anchor=, ' \ 97 | \ 98 | '; rel="alternate"; type="text/html"; profile="http://linked.data.gov.au/def/agor", ' \ 99 | '; rel="alternate"; type="text/turtle"; profile="http://linked.data.gov.au/def/agor", ' \ 100 | '; rel="alternate"; type="application/rdf+xml"; profile="http://linked.data.gov.au/def/agor", ' \ 101 | '; rel="alternate"; type="application/ld+json"; profile="http://linked.data.gov.au/def/agor", ' \ 102 | '; rel="alternate"; type="text/n3"; profile="http://linked.data.gov.au/def/agor", ' \ 103 | '; rel="alternate"; type="application/n-triples"; profile="http://linked.data.gov.au/def/agor", ' \ 104 | '; rel="alternate"; type="text/xml"; profile="http://fake.com", ' \ 105 | '; rel="alternate"; type="text/html"; profile="http://other.com", ' \ 106 | '; rel="alternate"; type="text/xml"; profile="http://other.com", ' \ 107 | '; rel="alternate"; type="text/html"; profile="http://www.w3.org/ns/dx/conneg/altr", ' \ 108 | '; rel="alternate"; type="application/json"; profile="http://www.w3.org/ns/dx/conneg/altr", ' \ 109 | '; rel="alternate"; type="text/turtle"; profile="http://www.w3.org/ns/dx/conneg/altr", ' \ 110 | '; rel="alternate"; type="application/rdf+xml"; profile="http://www.w3.org/ns/dx/conneg/altr", ' \ 111 | '; rel="alternate"; type="application/ld+json"; profile="http://www.w3.org/ns/dx/conneg/altr", ' \ 112 | '; rel="alternate"; type="text/n3"; profile="http://www.w3.org/ns/dx/conneg/altr", ' \ 113 | '; rel="alternate"; type="application/n-triples"; profile="http://www.w3.org/ns/dx/conneg/altr"' 114 | actual = r.headers.get('Link') 115 | assert actual == expected, \ 116 | 'test_list_profiles() test 1:\nLink (current profile) expected to be\n{},\nwas\n{}'.format( 117 | expected, 118 | actual 119 | ) 120 | 121 | 122 | if __name__ == '__main__': 123 | setup() 124 | test_content_profile() 125 | test_list_profiles() 126 | 127 | print('Passed all tests') 128 | -------------------------------------------------------------------------------- /tests/test_renderer.py: -------------------------------------------------------------------------------- 1 | from pyldapi import Renderer, Profile 2 | 3 | 4 | # Mock class 5 | class Request: 6 | pass 7 | 8 | 9 | class MockRenderer(Renderer): 10 | def render(self): 11 | # use the now gotten view & format to create a response 12 | pass 13 | 14 | 15 | def setup(): 16 | # profiles for testing 17 | global profiles 18 | profiles = { 19 | 'agor': Profile( 20 | 'http://linked.data.gov.au/def/agor', 21 | 'AGOR Profile', 22 | 'A profile of organisations according to the Australian Government Organisations Register', 23 | ['text/html'] + Renderer.RDF_MEDIA_TYPES, 24 | 'text/turtle' 25 | ), 26 | 'fake': Profile( 27 | 'http://fake.com', 28 | 'Fake Profile', 29 | 'A fake Profile for testing', 30 | ['text/xml'], 31 | 'text/xml' 32 | ), 33 | 'other': Profile( 34 | 'http://other.com', 35 | 'Another Testing Profile', 36 | 'Another profile for testing', 37 | ['text/html', 'text/xml'], 38 | 'text/html' 39 | ) 40 | # 'alt' # included by default 41 | } 42 | 43 | # this tests Accept-Profile selection of 'fake' profile 44 | mr = Request() 45 | mr.url = 'http://whocares.com' 46 | mr.values = {} 47 | mr.headers = { 48 | 'Accept-Profile': 'http://nothing.com ,' # ignored - broken, no <> 49 | 'http://nothing-else.com,' # ignored - broken, no <> 50 | '; q=0.9, ' # not available 51 | '; q=0.1, ' # available but lower weight 52 | '; q=0.2', # should be this 53 | 'Accept': 'text/turtle' 54 | } 55 | 56 | # this tests QSA selection of 'alt' profile 57 | mr2 = Request() 58 | mr2.url = 'http://whocares.com' 59 | mr2.values = {'_profile': 'alt'} 60 | mr2.headers = {} 61 | 62 | global r 63 | r = MockRenderer( 64 | mr, 65 | 'http://whocares.com', 66 | profiles, 67 | 'agor' 68 | ) 69 | 70 | global r2 71 | r2 = MockRenderer( 72 | mr2, 73 | 'http://whocares.com', 74 | profiles, 75 | 'agor' 76 | ) 77 | 78 | 79 | def test_get_profiles_from_http(): 80 | expected = ['fake', 'agor'] 81 | actual = r._get_profiles_from_http() 82 | assert actual == expected, \ 83 | 'r failed _get_profiles_from_http() test 1. Got {}, expected {}'.format(actual, expected) 84 | 85 | expected = None 86 | actual = r2._get_profiles_from_http() 87 | assert actual == expected, \ 88 | 'r2 failed _get_profiles_from_http() test 2. Got {}, expected {}'.format(actual, expected) 89 | 90 | 91 | def test_get_profiles_from_qsa(): 92 | expected = None 93 | actual = r._get_profiles_from_qsa() 94 | assert actual == expected, \ 95 | 'r failed _get_profiles_from_qsa() test 1. Got {}, expected {}'.format(actual, expected) 96 | 97 | expected = ['alt'] 98 | actual = r2._get_profiles_from_qsa() 99 | assert actual == expected, \ 100 | 'r2 failed _get_profiles_from_qsa() test 2. Got {}, expected {}'.format(actual, expected) 101 | 102 | 103 | def test_get_available_profiles(): 104 | expected = {'agor', 'alt', 'fake', 'other'} 105 | actual = set(r._get_available_profiles().values()) 106 | assert actual == expected, \ 107 | 'r failed test_get_available_profiles() test 1. Got {}, expected {}'.format(actual, expected) 108 | 109 | 110 | def test_get_profile(): 111 | expected = 'fake' 112 | actual = r._get_profile() 113 | assert actual == expected, \ 114 | 'r failed test_get_profile() test 1. Got {}, expected {}'.format(actual, expected) 115 | 116 | expected = 'alt' 117 | actual = r2._get_profile() 118 | assert actual == expected, \ 119 | 'r2 failed test_get_profile() test 2. Got {}, expected {}'.format(actual, expected) 120 | 121 | # testing the return of default ('agor') when no existing profiles are quested for 122 | mr3 = Request() 123 | mr3.url = 'http://whocares.com' 124 | mr3.values = {} 125 | mr3.headers = { 126 | 'Accept-Profile': '; q=0.9, ' 127 | '; q=0.1', 128 | 'Accept': 'text/turtle' 129 | } 130 | 131 | global profiles 132 | r3 = MockRenderer( 133 | mr3, 134 | 'http://whocares.com', 135 | profiles, 136 | 'agor' 137 | ) 138 | 139 | expected = 'agor' # default, since requests gets no legit profile 140 | actual = r3._get_profile() 141 | assert actual == expected, \ 142 | 'r failed test_get_profile() test 3. Got {}, expected {}'.format(actual, expected) 143 | 144 | 145 | def test_get_mediatype(): 146 | mr4 = Request() 147 | mr4.url = 'http://whocares.com' 148 | mr4.values = {'_mediatype': 'text/turtle;q=0.5,application/rdf+xml,application/json+ld;q=0.6'} 149 | mr4.headers = {} 150 | 151 | r4 = MockRenderer( 152 | mr4, 153 | 'http://whocares.com', 154 | profiles, 155 | 'agor' # default view 156 | ) 157 | expected = 'application/rdf+xml' 158 | actual = r4._get_mediatype() 159 | assert actual == expected, \ 160 | 'r4 failed test_get_mediatype() test 1. Got {}, expected {}'.format(actual, expected) 161 | 162 | 163 | if __name__ == '__main__': 164 | setup() 165 | test_get_profiles_from_http() 166 | test_get_profiles_from_qsa() 167 | test_get_available_profiles() 168 | test_get_profile() 169 | test_get_mediatype() 170 | 171 | print('Passed all tests') 172 | -------------------------------------------------------------------------------- /upload-to-PyPI.txt: -------------------------------------------------------------------------------- 1 | # 1. rm -r build dist pyldapi.egg-info 2 | # ?? python setup.py clean --all 3 | 4 | # 2. change version no in pyldapi/__init__.py 5 | 6 | # 3. $ git commit 7 | 8 | # 4. $ git tag 9 | 10 | # 5. $ git push 11 | $ git push --tags 12 | 13 | # 6. $ python setup.py sdist bdist_wheel 14 | 15 | # 7. $ twine upload dist/* --------------------------------------------------------------------------------