├── requirements.txt ├── tests ├── notification.nt ├── notification.ttl ├── notification.json ├── notification1.json ├── inbox.ttl ├── inbox.jsonld ├── inbox_expanded.jsonld ├── inbox.nt ├── test_base.py ├── test_sender.py └── test_consumer.py ├── ldnlib ├── __init__.py ├── consumer.py ├── sender.py └── base.py ├── .github ├── dependabot.yml └── workflows │ └── ci-config.yml ├── .gitignore ├── setup.py ├── examples ├── sender.py └── consumer.py ├── README.rst └── LICENSE /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | flake8 3 | rdflib 4 | rdflib-jsonld 5 | requests 6 | coveralls 7 | -------------------------------------------------------------------------------- /tests/notification.nt: -------------------------------------------------------------------------------- 1 | . 2 | -------------------------------------------------------------------------------- /ldnlib/__init__.py: -------------------------------------------------------------------------------- 1 | from .sender import Sender 2 | from .consumer import Consumer 3 | 4 | __all__ = ['Sender', 'Consumer'] 5 | -------------------------------------------------------------------------------- /tests/notification.ttl: -------------------------------------------------------------------------------- 1 | @prefix skos: . 2 | 3 | skos:prefLabel "First notification" . 4 | -------------------------------------------------------------------------------- /tests/notification.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context" : { 3 | "schema" : "http://schema.org/", 4 | "creator" : { "@id" : "schema:creator", "@type" : "@id" } 5 | }, 6 | "creator" : "http://example.org/user" 7 | } 8 | -------------------------------------------------------------------------------- /tests/notification1.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context" : { 3 | "schema" : "http://schema.org/", 4 | "creator" : { "@id" : "schema:creator", "@type" : "@id" } 5 | }, 6 | "@id" : "http://example.org/inbox/1" , 7 | "creator" : "http://example.org/user" 8 | } 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: monthly 12 | 13 | -------------------------------------------------------------------------------- /tests/inbox.ttl: -------------------------------------------------------------------------------- 1 | @prefix ldp: . 2 | @prefix skos: . 3 | 4 | skos:prefLabel "Test Inbox" ; 5 | ldp:contains , , 6 | , , 7 | . 8 | -------------------------------------------------------------------------------- /tests/inbox.jsonld: -------------------------------------------------------------------------------- 1 | { 2 | "@context": { 3 | "ldp": "http://www.w3.org/ns/ldp#", 4 | "contains": { 5 | "@id": "ldp:contains", 6 | "@type": "@id" 7 | } 8 | }, 9 | "@id": "http://example.org/inbox", 10 | "contains": [ 11 | "http://example.org/inbox/1", 12 | "http://example.org/inbox/2", 13 | "http://example.org/inbox/3", 14 | "http://example.org/inbox/4", 15 | "http://example.org/inbox/5" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tests/inbox_expanded.jsonld: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "@id": "http://example.org/inbox", 4 | "http://www.w3.org/ns/ldp#contains": [ 5 | { 6 | "@id": "http://example.org/inbox/1" 7 | }, 8 | { 9 | "@id": "http://example.org/inbox/2" 10 | }, 11 | { 12 | "@id": "http://example.org/inbox/3" 13 | }, 14 | { 15 | "@id": "http://example.org/inbox/4" 16 | }, 17 | { 18 | "@id": "http://example.org/inbox/5" 19 | } 20 | ] 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /tests/inbox.nt: -------------------------------------------------------------------------------- 1 | "Test Inbox" . 2 | . 3 | . 4 | . 5 | . 6 | . 7 | 8 | -------------------------------------------------------------------------------- /.github/workflows/ci-config.yml: -------------------------------------------------------------------------------- 1 | name: Python Linked Data Notifications 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ['3.8', '3.9', '3.10'] 17 | 18 | steps: 19 | - uses: actions/checkout@v5 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v6 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install -r requirements.txt 29 | 30 | - name: Link with flake8 31 | run: flake8 . --count --show-source --statistics 32 | 33 | - name: Test with pytest 34 | run: python setup.py test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | -------------------------------------------------------------------------------- /ldnlib/consumer.py: -------------------------------------------------------------------------------- 1 | from rdflib import Graph, URIRef 2 | import requests 3 | 4 | import json 5 | 6 | from .base import BaseLDN 7 | 8 | 9 | class Consumer(BaseLDN): 10 | 11 | def __init__(self, **kwargs): 12 | super(self.__class__, self).__init__(**kwargs) 13 | 14 | def notifications(self, inbox, **kwargs): 15 | """ 16 | Retrieve all of the notification IRIs from an ldp:inbox as a list. 17 | """ 18 | headers = kwargs.pop("headers", dict()) 19 | if 'accept' not in headers: 20 | headers['accept'] = kwargs.pop("accept", self.accept_headers) 21 | 22 | r = requests.get(inbox, headers=headers, **kwargs) 23 | r.raise_for_status() 24 | g = Graph().parse(data=r.text, format=self.content_type_to_mime_type( 25 | r.headers['content-type'])) 26 | return [str(o) for (s, o) in g[:URIRef(self.LDP_CONTAINS)]] 27 | 28 | def notification(self, iri, **kwargs): 29 | """ 30 | Retrieve a single LDN notification and decode into a Python object. 31 | """ 32 | headers = kwargs.pop("headers", dict()) 33 | if 'accept' not in headers: 34 | headers['accept'] = kwargs.pop("accept", self.accept_headers) 35 | 36 | r = requests.get(iri, headers=headers, **kwargs) 37 | r.raise_for_status() 38 | mime_type = self.content_type_to_mime_type(r.headers['content-type']) 39 | if mime_type == self.JSON_LD: 40 | return r.json() 41 | else: 42 | g = Graph().parse(data=r.text, format=mime_type) 43 | return json.loads(g.serialize(format="json-ld")) 44 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | 5 | from setuptools import setup 6 | from setuptools.command.test import test as TestCommand 7 | 8 | 9 | class PyTest(TestCommand): 10 | user_options = [('pytest-args=', 'a', "Arguments to pass into py.test")] 11 | 12 | def initialize_options(self): 13 | TestCommand.initialize_options(self) 14 | self.pytest_args = [] 15 | 16 | def finalize_options(self): 17 | TestCommand.finalize_options(self) 18 | self.test_args = [] 19 | self.test_suite = True 20 | 21 | def run_tests(self): 22 | import pytest 23 | 24 | errno = pytest.main(self.pytest_args) 25 | sys.exit(errno) 26 | 27 | 28 | with open('README.rst', 'r') as f: 29 | readme = f.read() 30 | 31 | setup(name='py-ldnlib', 32 | version='0.1.3', 33 | description='Python-based linked data notification libraries', 34 | long_description=readme, 35 | long_description_content_type="text/x-rst", 36 | author='Aaron Coburn', 37 | author_email='acoburn@apache.org', 38 | maintainer='Aaron Coburn', 39 | maintainer_email='acoburn@apache.org', 40 | classifiers=[ 41 | "Programming Language :: Python", 42 | "Programming Language :: Python :: 3", 43 | "License :: OSI Approved :: Apache Software License", 44 | "Topic :: Internet :: WWW/HTTP"], 45 | url='https://github.com/trellis-ldp/py-ldnlib', 46 | packages=['ldnlib'], 47 | tests_require=['pytest'], 48 | cmdclass={'test': PyTest}, 49 | install_requires=[ 50 | 'requests', 51 | 'rdflib', 52 | 'rdflib-jsonld']) 53 | -------------------------------------------------------------------------------- /examples/sender.py: -------------------------------------------------------------------------------- 1 | import ldnlib 2 | 3 | import argparse 4 | 5 | if __name__ == "__main__": 6 | 7 | parser = argparse.ArgumentParser(description="""For a provided web 8 | resource, discover an ldp:inbox and POST the provided RDF to the 9 | receiver, if one exists""") 10 | parser.add_argument("target", help="The IRI of the target web resource") 11 | parser.add_argument("filename", help="The filename of the JSON-LD message") 12 | parser.add_argument("--target_username", 13 | help="The username for the target resource") 14 | parser.add_argument("--target_password", 15 | help="The password for the target resource") 16 | parser.add_argument("--inbox_username", 17 | help="The username for the inbox resource") 18 | parser.add_argument("--inbox_password", 19 | help="The password for the inbox resource") 20 | parser.add_argument("--allow_local_inbox", type=bool, default=False, 21 | help="Whether to allow a local inbox address") 22 | 23 | args = parser.parse_args() 24 | 25 | target_auth = None 26 | if args.target_username and args.target_password: 27 | target_auth = (args.target_username, args.target_password) 28 | 29 | inbox_auth = None 30 | if args.inbox_username and args.inbox_password: 31 | inbox_auth = (args.inbox_username, args.inbox_password) 32 | 33 | sender = ldnlib.Sender(allow_localhost=args.allow_local_inbox) 34 | 35 | inbox = sender.discover(args.target, auth=target_auth) 36 | if inbox is not None: 37 | with open(args.filename, 'r') as f: 38 | sender.send(inbox, f.read(), auth=inbox_auth) 39 | print("Added message") 40 | else: 41 | print("Sorry, no inbox defined for the resource") 42 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock, patch 3 | 4 | from ldnlib.base import BaseLDN 5 | 6 | 7 | class TestBase(unittest.TestCase): 8 | 9 | @patch('requests.head') 10 | def test_discover_head(self, mock_head): 11 | inbox = "http://example.org/inbox" 12 | mock_res = Mock() 13 | mock_res.links = {"http://www.w3.org/ns/ldp#inbox": {"url": inbox}} 14 | 15 | mock_head.return_value = mock_res 16 | 17 | ldn = BaseLDN() 18 | self.assertEqual(ldn.discover("http://example.org/resource"), inbox) 19 | 20 | @patch('requests.get') 21 | @patch('requests.head') 22 | def test_discover_get(self, mock_head, mock_get): 23 | inbox = "http://example.org/inbox" 24 | links = {"type": {"url": "http://www.w3.org/ns/ldp#Container"}} 25 | headers = {"content-type": "application/n-triples"} 26 | 27 | mock_res1 = Mock() 28 | mock_res1.links = links 29 | mock_res1.headers = headers 30 | mock_head.return_value = mock_res1 31 | 32 | mock_res2 = Mock() 33 | mock_res2.links = links 34 | mock_res2.headers = headers 35 | mock_res2.text = " " + \ 36 | " " + \ 37 | " .\n" 38 | mock_get.return_value = mock_res2 39 | 40 | ldn = BaseLDN() 41 | self.assertEqual(ldn.discover("http://example.org/resource"), inbox) 42 | 43 | def test_content_type(self): 44 | ldn = BaseLDN() 45 | self.assertEqual("application/ld+json", 46 | ldn.content_type_to_mime_type( 47 | "application/ld+json ; " + 48 | "profile=\"http://example.org/profile.json\"")) 49 | self.assertEqual("text/turtle", 50 | ldn.content_type_to_mime_type( 51 | "text/turtle;charset=utf-8")) 52 | -------------------------------------------------------------------------------- /tests/test_sender.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch 3 | from rdflib import Graph 4 | import json 5 | 6 | from ldnlib import Sender 7 | 8 | 9 | class TestSender(unittest.TestCase): 10 | 11 | INBOX = "http://example.org/inbox" 12 | HEADERS = {"content-type": "application/ld+json"} 13 | 14 | @patch('requests.post') 15 | def test_send_string(self, mock_post): 16 | data = None 17 | with open("tests/notification.json", "r") as f: 18 | data = f.read() 19 | 20 | Sender().send(self.INBOX, data) 21 | self.assertEqual(str, type(data)) 22 | mock_post.assert_called_once_with(self.INBOX, data=data, 23 | headers=self.HEADERS) 24 | 25 | @patch('requests.post') 26 | def test_send_dict(self, mock_post): 27 | data = None 28 | with open("tests/notification.json", "r") as f: 29 | data = json.loads(f.read()) 30 | 31 | Sender().send(self.INBOX, data) 32 | self.assertEqual(dict, type(data)) 33 | mock_post.assert_called_once_with(self.INBOX, data=json.dumps(data), 34 | headers=self.HEADERS) 35 | 36 | @patch('requests.post') 37 | def test_send_list(self, mock_post): 38 | data = None 39 | with open("tests/notification.json", "r") as f: 40 | data = json.loads("[" + f.read() + "]") 41 | 42 | Sender().send(self.INBOX, data) 43 | self.assertEqual(list, type(data)) 44 | mock_post.assert_called_once_with(self.INBOX, data=json.dumps(data), 45 | headers=self.HEADERS) 46 | 47 | @patch('requests.post') 48 | def test_send_graph(self, mock_post): 49 | data = Graph().parse("tests/notification.nt", format="ntriples") 50 | 51 | Sender().send(self.INBOX, data) 52 | self.assertEqual(Graph, type(data)) 53 | mock_post.assert_called_once_with(self.INBOX, data=data.serialize( 54 | format="application/ld+json"), headers=self.HEADERS) 55 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | Python-based Linked Data Notifications libraries 3 | ================================================ 4 | 5 | 6 | .. image:: https://github.com/trellis-ldp/py-ldnlib/workflows/Python%20Linked%20Data%20Notifications/badge.svg 7 | :target: https://github.com/trellis-ldp/py-ldnlib 8 | :alt: Build Status 9 | 10 | 11 | .. image:: https://badge.fury.io/py/py-ldnlib.svg 12 | :target: https://badge.fury.io/py/py-ldnlib 13 | :alt: Version 14 | 15 | 16 | This is an implementation of a python3-based `Linked Data Notification `_ sender and consumer. 17 | 18 | Installing 19 | ---------- 20 | 21 | ``pip install py-ldnlib`` 22 | 23 | Adding an LDN sender to your code 24 | --------------------------------- 25 | 26 | A simple LDN Sender could be written as: 27 | 28 | .. code-block:: 29 | 30 | import ldnlib 31 | 32 | sender = ldnlib.Sender() 33 | 34 | inbox = sender.discover(target_resource) 35 | 36 | if inbox is not None: 37 | sender.send(inbox, data) 38 | 39 | The ``data`` value may be a string, a dictionary, a list or an ``rdflib``\ -based Graph. 40 | 41 | Adding an LDN consumer to your code 42 | ----------------------------------- 43 | 44 | A simple LDN Consumer could be written as: 45 | 46 | .. code-block:: 47 | 48 | import ldnlib 49 | 50 | consumer = ldnlib.Consumer() 51 | 52 | inbox = consumer.discover(target_resource) 53 | 54 | if inbox is not None: 55 | for iri in consumer.notifications(inbox): 56 | // fetch the notification as a Python dictionary 57 | notification = consumer.notification(iri) 58 | 59 | Authentication 60 | -------------- 61 | 62 | If the target-resource or inbox-resource requires authentication, an ``auth`` tuple may be supplied: 63 | 64 | .. code-block:: 65 | 66 | import ldnlib 67 | 68 | sender = ldnlib.Sender() 69 | 70 | inbox = sender.discover(target_resource, auth=(username, password)) 71 | 72 | if inbox is not None: 73 | sender.send(inbox, data, auth=(username, password)) 74 | 75 | Maintainer 76 | ---------- 77 | 78 | `Aaron Coburn `_ 79 | -------------------------------------------------------------------------------- /examples/consumer.py: -------------------------------------------------------------------------------- 1 | import ldnlib 2 | 3 | import argparse 4 | import json 5 | 6 | if __name__ == "__main__": 7 | 8 | parser = argparse.ArgumentParser(description="""For a provided web 9 | resource, discover an ldp:inbox and GET the notifications from 10 | that inbox, if one exists""") 11 | parser.add_argument("target", help="The IRI of the target web resource") 12 | parser.add_argument("--target_username", 13 | help="The username for the target resource") 14 | parser.add_argument("--target_password", 15 | help="The password for the target resource") 16 | parser.add_argument("--inbox_username", 17 | help="The username for the inbox resource") 18 | parser.add_argument("--inbox_password", 19 | help="The password for the inbox resource") 20 | parser.add_argument("--allow_local_inbox", type=bool, default=False, 21 | help="Whether to allow a local inbox address") 22 | 23 | args = parser.parse_args() 24 | 25 | target_auth = None 26 | if args.target_username and args.target_password: 27 | target_auth = (args.target_username, args.target_password) 28 | 29 | inbox_auth = None 30 | if args.inbox_username and args.inbox_password: 31 | inbox_auth = (args.inbox_username, args.inbox_password) 32 | 33 | consumer = ldnlib.Consumer() 34 | 35 | inbox = consumer.discover(args.target, auth=target_auth) 36 | if inbox is not None: 37 | print("Found inbox: {}".format(inbox)) 38 | notifications = consumer.notifications(inbox, auth=inbox_auth) 39 | print("Found {0} notifications: {1}".format(len(notifications), 40 | " ".join(notifications))) 41 | 42 | for iri in notifications: 43 | print("") 44 | print("IRI: {}".format(iri)) 45 | notification = consumer.notification(iri, auth=inbox_auth) 46 | print("Notification: {}".format(json.dumps(notification, 47 | ensure_ascii=False))) 48 | else: 49 | print("Sorry, no inbox defined for the resource") 50 | -------------------------------------------------------------------------------- /ldnlib/sender.py: -------------------------------------------------------------------------------- 1 | from rdflib import Graph 2 | import requests 3 | 4 | import ipaddress 5 | import json 6 | import socket 7 | from urllib.parse import urlparse 8 | 9 | from .base import BaseLDN 10 | 11 | 12 | class Sender(BaseLDN): 13 | 14 | def __init__(self, **kwargs): 15 | super(self.__class__, self).__init__(**kwargs) 16 | self.allow_localhost = kwargs.get('allow_localhost', False) 17 | 18 | def __accept_post_options(self, inbox, **kwargs): 19 | r = requests.options(inbox, **kwargs) 20 | if r.status_code == requests.codes.ok and 'accept-post' in r.headers: 21 | if self.JSON_LD in r.headers['accept-post']: 22 | return self.JSON_LD 23 | 24 | for content_type in r.headers['accept-post'].split(','): 25 | return self.content_type_to_mime_type(content_type) 26 | 27 | def __is_localhost(self, inbox): 28 | return ipaddress.ip_address(socket.gethostbyname( 29 | urlparse(inbox).hostname)).is_loopback 30 | 31 | def __post_message(self, inbox, data, content_type, **kwargs): 32 | if self.allow_localhost or not self.__is_localhost(inbox): 33 | headers = kwargs.pop("headers", dict()) 34 | headers['content-type'] = content_type 35 | r = requests.post(inbox, data=data, headers=headers, **kwargs) 36 | r.raise_for_status() 37 | else: 38 | raise ValueError("Invalid local inbox.") 39 | 40 | def send(self, inbox, data, **kwargs): 41 | """Send the provided data to an inbox.""" 42 | if isinstance(data, dict) or isinstance(data, list): 43 | self.__post_message(inbox, json.dumps(data), self.JSON_LD, 44 | **kwargs) 45 | elif isinstance(data, str): 46 | self.__post_message(inbox, data, self.JSON_LD, **kwargs) 47 | elif isinstance(data, Graph): 48 | ct = self.__accept_post_options(inbox, **kwargs) or self.JSON_LD 49 | self.__post_message(inbox, data.serialize(format=ct), ct, 50 | **kwargs) 51 | else: 52 | raise TypeError( 53 | "You cannot send data of type {}.".format(type(data))) 54 | -------------------------------------------------------------------------------- /ldnlib/base.py: -------------------------------------------------------------------------------- 1 | from rdflib import Graph, URIRef 2 | import requests 3 | 4 | 5 | class BaseLDN(object): 6 | 7 | ACCEPT_HEADERS = ("application/ld+json; q=1.0," 8 | "text/turtle; q=0.9," 9 | "application/xml+rdf; q=0.5") 10 | JSON_LD = "application/ld+json" 11 | LDP_INBOX = "http://www.w3.org/ns/ldp#inbox" 12 | LDP_CONTAINS = "http://www.w3.org/ns/ldp#contains" 13 | 14 | def __init__(self, **kwargs): 15 | self.accept_headers = kwargs.get('accept_headers', self.ACCEPT_HEADERS) 16 | 17 | def __discover_head(self, target, **kwargs): 18 | r = requests.head(target, **kwargs) 19 | r.raise_for_status() 20 | if self.LDP_INBOX in r.links: 21 | return r.links[self.LDP_INBOX].get('url') 22 | 23 | def __discover_get(self, target, **kwargs): 24 | r = requests.get(target, **kwargs) 25 | r.raise_for_status() 26 | # TODO -- check for HTML 27 | g = Graph().parse(data=r.text, format=self.content_type_to_mime_type( 28 | r.headers['content-type'])) 29 | 30 | for (subject, inbox) in g[:URIRef(self.LDP_INBOX)]: 31 | return str(inbox) 32 | 33 | def content_type_to_mime_type(self, content_type): 34 | """ 35 | A utility method to convert a content-type header into a 36 | mime-type string. 37 | """ 38 | return content_type.split(";")[0].strip() 39 | 40 | def discover(self, target, **kwargs): 41 | """Discover the inbox for a resource.""" 42 | headers = kwargs.pop("headers", dict()) 43 | if 'accept' not in headers: 44 | headers['accept'] = kwargs.pop("accept", self.accept_headers) 45 | allow_redirects = kwargs.pop('allow_redirects', True) 46 | 47 | inbox = self.__discover_head(target, headers=headers, 48 | allow_redirects=allow_redirects, 49 | **kwargs) 50 | if inbox is None: 51 | return self.__discover_get(target, headers=headers, 52 | allow_redirects=allow_redirects, 53 | **kwargs) 54 | else: 55 | return inbox 56 | -------------------------------------------------------------------------------- /tests/test_consumer.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | from unittest.mock import Mock, patch 4 | 5 | from ldnlib import Consumer 6 | 7 | 8 | class TestConsumer(unittest.TestCase): 9 | 10 | @patch('requests.get') 11 | def test_notifications_ntriples(self, mock_get): 12 | mock_res = Mock() 13 | mock_res.headers = {"content-type": "application/n-triples"} 14 | with open("tests/inbox.nt", "r") as f: 15 | mock_res.text = f.read() 16 | 17 | mock_get.return_value = mock_res 18 | 19 | notifications = Consumer().notifications("http://example.org/inbox") 20 | 21 | self.assertEqual(5, len(notifications)) 22 | self.assertTrue("http://example.org/inbox/1" in notifications) 23 | self.assertTrue("http://example.org/inbox/2" in notifications) 24 | self.assertTrue("http://example.org/inbox/3" in notifications) 25 | self.assertTrue("http://example.org/inbox/4" in notifications) 26 | self.assertTrue("http://example.org/inbox/5" in notifications) 27 | 28 | @patch('requests.get') 29 | def test_notifications_jsonld_compacted(self, mock_get): 30 | mock_res = Mock() 31 | mock_res.headers = {"content-type": "application/ld+json"} 32 | with open("tests/inbox.jsonld", "r") as f: 33 | mock_res.text = f.read() 34 | 35 | mock_get.return_value = mock_res 36 | 37 | notifications = Consumer().notifications("http://example.org/inbox") 38 | 39 | self.assertEqual(5, len(notifications)) 40 | self.assertTrue("http://example.org/inbox/1" in notifications) 41 | self.assertTrue("http://example.org/inbox/2" in notifications) 42 | self.assertTrue("http://example.org/inbox/3" in notifications) 43 | self.assertTrue("http://example.org/inbox/4" in notifications) 44 | self.assertTrue("http://example.org/inbox/5" in notifications) 45 | 46 | @patch('requests.get') 47 | def test_notifications_jsonld_expanded(self, mock_get): 48 | mock_res = Mock() 49 | mock_res.headers = {"content-type": "application/ld+json"} 50 | with open("tests/inbox_expanded.jsonld", "r") as f: 51 | mock_res.text = f.read() 52 | 53 | mock_get.return_value = mock_res 54 | 55 | notifications = Consumer().notifications("http://example.org/inbox") 56 | 57 | self.assertEqual(5, len(notifications)) 58 | self.assertTrue("http://example.org/inbox/1" in notifications) 59 | self.assertTrue("http://example.org/inbox/2" in notifications) 60 | self.assertTrue("http://example.org/inbox/3" in notifications) 61 | self.assertTrue("http://example.org/inbox/4" in notifications) 62 | self.assertTrue("http://example.org/inbox/5" in notifications) 63 | 64 | @patch('requests.get') 65 | def test_notifications_turtle(self, mock_get): 66 | mock_res = Mock() 67 | mock_res.headers = {"content-type": "text/turtle; charset=utf-8"} 68 | with open("tests/inbox.ttl", "r") as f: 69 | mock_res.text = f.read() 70 | 71 | mock_get.return_value = mock_res 72 | 73 | notifications = Consumer().notifications("http://example.org/inbox") 74 | 75 | self.assertEqual(5, len(notifications)) 76 | self.assertTrue("http://example.org/inbox/1" in notifications) 77 | self.assertTrue("http://example.org/inbox/2" in notifications) 78 | self.assertTrue("http://example.org/inbox/3" in notifications) 79 | self.assertTrue("http://example.org/inbox/4" in notifications) 80 | self.assertTrue("http://example.org/inbox/5" in notifications) 81 | 82 | @patch('requests.get') 83 | def test_notification_turtle(self, mock_get): 84 | mock_res = Mock() 85 | mock_res.headers = {"content-type": "text/turtle; charset=utf-8"} 86 | with open("tests/notification.ttl", "r") as f: 87 | mock_res.text = f.read() 88 | 89 | mock_get.return_value = mock_res 90 | 91 | notification = Consumer().notification("http://example.org/inbox/1") 92 | self.assertTrue(1, len(notification)) 93 | self.assertTrue("@id" in notification[0]) 94 | self.assertEqual("http://example.org/inbox/1", notification[0]["@id"]) 95 | 96 | prefLabel = "http://www.w3.org/2004/02/skos/core#prefLabel" 97 | self.assertTrue(prefLabel in notification[0]) 98 | self.assertEqual("First notification", 99 | notification[0][prefLabel][0]["@value"]) 100 | 101 | @patch('requests.get') 102 | def test_notification_jsonld(self, mock_get): 103 | mock_res = Mock() 104 | mock_res.headers = {"content-type": "application/ld+json"} 105 | with open("tests/notification1.json", "r") as f: 106 | attrs = {'json.return_value': json.loads("[" + f.read() + "]")} 107 | mock_res.configure_mock(**attrs) 108 | 109 | mock_get.return_value = mock_res 110 | 111 | notification = Consumer().notification("http://example.org/inbox/1") 112 | self.assertTrue(1, len(notification)) 113 | self.assertTrue("@id" in notification[0]) 114 | self.assertEqual("http://example.org/inbox/1", notification[0]["@id"]) 115 | self.assertTrue("creator" in notification[0]) 116 | self.assertEqual("http://example.org/user", 117 | notification[0]["creator"]) 118 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------