36 Commits

Author SHA1 Message Date
Daniel Bauer
c4b43d6ee5 bump version 2024-11-25 15:41:03 +01:00
Daniel Bauer
d153962c64 update README 2024-11-25 15:40:42 +01:00
Daniel Bauer
9f1b7cb59f make linter happy 2024-11-25 15:40:12 +01:00
Daniel Bauer
69862a4134 refactored unit tests 2024-11-25 15:39:12 +01:00
Daniel Bauer
b803d5b106 more unit tests for HTML middleware 2024-11-25 15:26:53 +01:00
Daniel Bauer
8d8a92e5d5 add middleware to add signposting links into html head 2024-11-22 12:04:15 +01:00
Daniel Bauer
06d745e4a4 more documentation on examples 2024-11-22 11:53:00 +01:00
Daniel Bauer
7a414e5772 refactoring on the readme 2024-11-22 10:49:44 +01:00
Daniel Bauer
d11f2246df readme about parseing JSON-LD manually 2024-11-22 10:41:51 +01:00
Daniel Bauer
f2a803106f add RO-Crate parsing to readme 2024-11-22 10:13:42 +01:00
Daniel Bauer
6b1ea1403c Create Signposting from RO-Crate (#2)
* improvate jsonld parsing to be compatible with ro-crate

* refactoring

* example with RO-Crate

* bump version

---------

Co-authored-by: Daniel Bauer <daniel.bauer@senckenberg.de>
2024-11-21 15:40:46 +01:00
Daniel Bauer
f97c33e418 update TODOs 2024-11-21 10:44:00 +01:00
Daniel Bauer
0f27560015 fix pip install example 2024-11-19 08:28:22 +01:00
Daniel Bauer
7b93eae884 Update pyproject.toml 2024-11-18 17:23:53 +01:00
Daniel Bauer
0cd321112f Extract signposts from jsonld (#1)
* use rdflib to extract signposts from jsonld

* use rdflib to extract signposts from jsonld

* linting

* unit tests for jsonld middleware

* update readme

* update flake pipeline

---------

Co-authored-by: Daniel Bauer <daniel.bauer@senckenberg.de>
2024-11-11 13:02:55 +01:00
Daniel Bauer
bbf0527733 more agressive lint checking in pipeline 2024-11-11 09:45:26 +01:00
Daniel Bauer
8b09f4feb5 linting 2024-11-11 09:40:52 +01:00
Daniel Bauer
01affafd7a requirements file for example 2024-11-08 17:47:09 +01:00
Daniel Bauer
76e791914d update example to use signposting.Signpost 2024-11-08 17:44:48 +01:00
Daniel Bauer
cbd621435c Update README.md 2024-11-01 13:39:08 +01:00
Daniel Bauer
8c548d2392 add todos to readme 2024-11-01 13:17:56 +01:00
Daniel Bauer
1bc2b60609 update readme 2024-11-01 13:12:17 +01:00
Daniel Bauer
aafd404f90 add license to pyproject.toml 2024-11-01 13:10:07 +01:00
Daniel Bauer
2014a4f024 update readme 2024-11-01 13:09:19 +01:00
Daniel Bauer
31fcf221f0 add type test 2024-11-01 13:06:22 +01:00
Daniel Bauer
bf2604e303 bump version 2024-11-01 13:03:33 +01:00
Daniel Bauer
2d3dafbacc use signposting package 2024-11-01 13:02:56 +01:00
Daniel Bauer
076273d014 fix tests cannot find main package 2024-11-01 12:50:21 +01:00
daniel
93738b046f move folder 2024-10-27 22:24:45 +01:00
daniel
a863272c7e add simple example 2024-10-27 22:21:00 +01:00
daniel
bbd07d3fab update gitignore 2024-10-27 22:20:02 +01:00
daniel
695c167d32 fix import in README.md 2024-10-27 22:17:03 +01:00
Daniel Bauer
0a31aa21ff remove debug statement 2024-08-20 16:07:16 +02:00
Daniel Bauer
aa5850b0bd Update README.md 2024-08-15 23:21:21 +02:00
Daniel Bauer
b18a48cf7e update publish script 2024-08-15 23:17:26 +02:00
Daniel Bauer
7a421160b2 bump version 2024-08-15 23:15:23 +02:00
26 changed files with 1585 additions and 116 deletions

3
.flake8 Normal file
View File

@@ -0,0 +1,3 @@
[flake8]
max-line-length = 99
exclude = .git, __pycache__, .pytest_cache, .venv, venv, build

View File

@@ -32,10 +32,7 @@ jobs:
python -m pip install -e '.[dev]'
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
flake8 . --count --max-line-length=127 --statistics
- name: Test with pytest
run: |
pytest

View File

@@ -29,7 +29,12 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build
python -m pip install build
- name: Test
run: |
python -m pip install .
python -m pip install -e '.[dev]'
pytest
- name: Build package
run: python -m build
- name: Publish package

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ dist
.idea
build
__pycache__
*.sqlite3

3
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,3 @@
- repo: https://github.com/pycqa/flake8
hooks:
- id: flake8

153
README.md
View File

@@ -1,26 +1,84 @@
# FAIR signposting for Django
[![Python package](https://github.com/dnlbauer/django-signposting/actions/workflows/python-package.yml/badge.svg)](https://github.com/dnlbauer/django-signposting/actions/workflows/python-package.yml)
# FAIR signposting middleware for Django
`django_signposting` is a Django middleware library that facilitates the addition of
FAIR signposting headers to HTTP responses.
This middleware helps in making your data more FAIR (Findable, accessible, interoperable, reuseable) by
embedding signposting headers in responses, guiding clients to relevant resources linked to the response content.
Based on the [Signposting](https://github.com/stain/signposting) library.
## Features
- Automatically adds signposting headers to HTTP responses.
- Signposts can be added manually or automatically be parsed from JSON-LD/schema.org
- Supports multiple relation types with optional media type specification.
- Easily integrable with existing Django applications.
### Signposts are formatted and added as Link headers
```bash
curl -I http://localhost:8000
HTTP/2 200
...
link: <https://schema.org/Dataset> ; rel="type" ,
<https://orcid.org/0000-0001-9447-460X> ; rel="author" ,
<https://example.com/download.zip> ; rel="item" ; type="application/zip"
```
## Installation
```bash
pip install django_signposting
pip install django-signposting
```
## Usage
### 1. Add Middleware
### Automatic parsing of JSON-LD
Add the middleware to your Django project's `MIDDLEWARE` setting in `settings.py`:
The library can automatically add signposts to web pages that already make
metadata available via JSON-LD in HTML via `<script type="application/ld+json">` tags, i.e.:
```HTML
<!DOCTYPE html>
<html>
<head>
<script type="application/ld+json">{"@context": "https://schema.org", "@type": ["WebSite", "Dataset"], "author": {"@type": "Person", "name": "Daniel Bauer", "url": "https://orcid.org/0000-0001-9447-460X"}, "description": "A dataset of things.", "hasPart": [{"@type": "ImageObject", "encodingFormat": "image/png", "url": "http://example.com/image.png"}, {"@type": "ImageObject", "encodingFormat": "image/png", "url": "http://example.com/image2.png"}], "license": {"@type": "CreativeWork", "name": "CC BY 4.0", "url": "https://creativecommons.org/licenses/by/4.0/"}, "name": "My Dataset", "sameAs": [{"@type": "MediaObject", "contentUrl": "https://example.com/download.zip", "encodingFormat": "application/zip"}, {"@type": "MediaObject", "contentUrl": "https://example.com/metadata.json"}], "url": "https://example.com"}</script>
</head>
<body>
<p>Hello, world!</p>
</body>
</html>
```
To enable automatic parsing of JSON-LD, add the following middleware classes to your Django project's MIDDLEWARE setting in settings.py:
```python
MIDDLEWARE = [
...,
'django_signposting.middleware.SignpostingMiddleware',
'django_signposting.middleware.JsonLdSignpostingParserMiddleware',
...,
]
```
This extracts supported metadatad properties from JSON-LD and adds the corresponding signposting headers to the `HttpResponse` automatically.
Automatic parsing is compatible with extensions that provide JSON-LD as part of a web page, such as [django-json-ld](https://pypi.org/project/django-json-ld/).
It can also extract signposts from rich metadata descriptions of datasets such as detached [RO-Crates](https://www.researchobject.org/ro-crate) (see [example views](./example/example/views.py)).
> Note: The middleware order is important! Place `SignpostingMiddleware` before `JsonLdSignpostingParserMiddleware` to ensure proper extraction and processing of JSON-LD content.
## Alternative approaches
### Manual signposting
For cases where there is no embedded JSON-LD, or you want to specify additional headers manually, you can use the `add_signposts` utility function.
This still requires the `SignpostingMiddleware` to inject the links into
the response:
1. **Add Middleware**: Add the `SignpostingMiddleware` to your Django project's `MIDDLEWARE` setting in `settings.py`:
```python
MIDDLEWARE = [
@@ -30,60 +88,79 @@ MIDDLEWARE = [
]
```
### 2. Add Signposts to your Views
You can add signposting headers in your Django views using the provided `add_signposts` utility function.
Here's how you can use it:
2. **Add Signposts to your Views:** Use the `add_signposts` utility function:
```python
from django.http import HttpResponse
from django_signposting.util import add_signposts
from django_signposting.utils import add_signposts
from signposting import Signpost, LinkRel
def my_view(request):
response = HttpResponse("Hello, world!")
# Add signpostings as string
add_signposts(response,
type="https://schema.org/Dataset",
author="https://orcid.org/0000-0001-9447-460X")
add_signposts(
response,
Signpost(LinkRel.type, "https://schema.org/Dataset"),
Signpost(LinkRel.author, "https://orcid.org/0000-0001-9447-460X")
Signpost(LinkRel.item, "https://example.com/download.zip", "application/zip")
)
return response
```
Multiple links with the same link type can be added as lists and the content type of a link
can be defined by using tuples:
### Manual parsing of JSON-LD
If you have metadata in JSON-LD available, but it is not rendered as part of the response, you can still parse it and add the signposting links manually:
```python
from django.http import HttpResponse
from django_signposting.util import add_signposts
from django_signposting.utils import add_signposts, jsonld_to_signposts
def my_view(request):
response = HttpResponse("Hello, world!")
# Add signpostings as string
add_signposts(response,
type="https://schema.org/Dataset",
author="https://orcid.org/0000-0001-9447-460X",
item=[
("https://example.com/image.png", "image/png"),
("https://example.com/download.zip", "application/zip")
])
return response
response = HttpResponse("Hello World")
json_ld = {
"@context": "http://schema.org/",
"@graph": [
...
]
}
signposts = jsonld_to_signposts(json_ld)
add_signposts(response, **signposts)
```
### 3. Signposts are formatted and added as Link headers by the middleware:
### Add signposts as HTML links instead of headers
```bash
curl -I https://example.com
HTTP/2 200
...
link: <https://schema.org/Dataset> ; rel="type" ,
<https://orcid.org/0000-0001-9447-460X> ; rel="author" ,
<https://example.com/image.png> ; rel="item" ; type="application/json+ld"
In cases where you want to include signposting directly in the HTML response rather than as HTTP headers
(for example because headers are overwritten by a proxy or something else),
you can use the `HtmlSignpostingMiddleware` instead of `SignpostingMiddleware`.
This middleware automatically adds <link> elements to the HTML <head> section based
on the detected signposting metadata and produces output similar to the one shown here:
```HTML
<!DOCTYPE html>
<html>
<head>
(...)
<link href="http://schema.org/WebSite" rel="type"/>
<link href="http://schema.org/Dataset" rel="type"/>
<link href="https://orcid.org/0000-0001-9447-460X" rel="author"/>
<link href="https://creativecommons.org/licenses/by/4.0/" rel="license"/>
<link href="https://example.com" rel="cite-as"/>
<link href="https://example.com/download.zip" rel="describedby" type="application/zip"/>
<link href="https://example.com/metadata.json" rel="describedby"/>
<link href="http://example.com/image.png" rel="item" type="image/png"/>
<link href="http://example.com/image2.png" rel="item" type="image/png"/>
</head>
...
</html>
```
## TODO
- [ ] Add support for link sets
- [ ] Add support for specifying profile extension attribute
## License
Licensed under the MIT License.
Licensed under the MIT License.

View File

@@ -1,9 +1,15 @@
from typing import Callable
from django.http import HttpRequest, HttpResponse
from signposting import Signpost
from bs4 import BeautifulSoup
import json
from django.utils.deprecation import MiddlewareMixin
from django.conf import settings
from .utils import jsonld_to_signposts
class SignpostingMiddleware:
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
@@ -16,25 +22,85 @@ class SignpostingMiddleware:
if not hasattr(response, "_signposts"):
return response
self._add_signposts(response, response._signposts)
return response
def _add_signposts(self, response: HttpResponse, typed_links: dict[str, list[str|tuple[str, str]]]):
""" Adds signposting headers to the respones.
def _add_signposts(self, response: HttpResponse, signposts: list[Signpost]):
"""Adds signposting headers to the respones.
params:
response - the response object
typed_links - a map of relation types to a list of corresponding links. Each link can be a link or a tuple of link and media type.
signposts - a list of Signposts
"""
link_snippets = []
for relation_type in typed_links.keys():
links = typed_links.get(relation_type, [])
for link in links:
if isinstance(link, tuple) and len(link) > 1:
link_snippets.append(f'<{link[0]}> ; rel="{relation_type}" ; type="{link[1]}"')
else:
link_snippets.append(f'<{link}> ; rel="{relation_type}"')
for signpost in signposts:
link_snippets.append(f'<{signpost.target}> ; rel="{signpost.rel}"')
if signpost.type:
link_snippets[-1] += f' ; type="{signpost.type}"'
response["Link"] = " , ".join(link_snippets)
class HtmlSignpostingMiddleware(SignpostingMiddleware):
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
response = self.get_response(request)
if not hasattr(response, "_signposts"):
return response
# Adding Signposts via HTML is only supported for HTML responses
if not response.headers["Content-Type"].startswith("text/html"):
return response
content = response.content.decode("utf-8")
soup = BeautifulSoup(content, "html.parser")
# Html should have a root element
if not soup.html:
raise Exception("Could not find HTML root element")
# Add head element if not present
if not soup.head:
head_tag = soup.new_tag("head")
soup.html.insert(0, head_tag)
# BUild links for each signpost and add them to the html
for signpost in response._signposts:
link_tag = soup.new_tag("link")
link_tag["rel"] = signpost.rel
link_tag["href"] = signpost.target
if signpost.type:
link_tag["type"] = signpost.type
soup.head.append(link_tag)
# Override the original content with the new HTML
response.content = soup.prettify().encode("utf-8")
response["Content-Length"] = len(response.content)
return response
class JsonLdSignpostingParserMiddleware(MiddlewareMixin):
def process_response(
self, request: HttpRequest, response: HttpResponse
) -> HttpResponse:
if not getattr(settings, "SIGNPOSTING_PARSE_JSONLD", True):
return response
if response.get("Content-Type", "").startswith("text/html"):
soup = BeautifulSoup(response.content, "html.parser")
for script in soup.find_all("script", type="application/ld+json"):
try:
jsonld = json.loads(script.string)
signposts = jsonld_to_signposts(jsonld)
response._signposts = signposts
except json.JSONDecodeError as e:
print(e)
continue
return response

View File

@@ -0,0 +1,150 @@
from rdflib import Graph
from rdflib.query import Result
def root_element_query(g: Graph) -> Result:
return g.query("""
PREFIX schema: <http://schema.org/>
SELECT DISTINCT ?rootElement
WHERE {
# has a type
?rootElement a ?type ;
# has a license or an author or a creator
(schema:license | schema:author | schema:creator ) ?value .
# No incoming edges for ?rootElement
FILTER NOT EXISTS {
?incoming ?p ?rootElement .
# also allow incoming edges that link entities BACK to the dataset
FILTER(?p != schema:isPartOf)
FILTER(?p != schema:mainEntityOfPage)
FILTER(?p != schema:recordedIn)
FILTER(?p != schema:exampleOfWork)
FILTER(?p != schema:includedInDataCatalogue)
FILTER(?p != schema:subjectOf)
FILTER(?p != schema:dataset)
# allow incoming edges from ro-crate-metadata.json
FILTER(CONTAINS(?incoming, "ro-crate-metadata.json")) .
}
}
LIMIT 1
""")
def type_query(g: Graph, rootElement: str) -> Graph:
return g.query(
"""
PREFIX schema: <?context>
SELECT DISTINCT ?type
WHERE {
?rootElement a ?type .
}
""",
initBindings={"rootElement": rootElement},
)
def author_query(g: Graph, rootElement: str):
return g.query(
"""
PREFIX schema: <http://schema.org/>
SELECT ?author_id ?identifier ?url
WHERE {
?rootElement schema:author ?author .
BIND(?author AS ?author_id) # Get the @id of the author
OPTIONAL { ?author schema:identifier ?identifier }
OPTIONAL { ?author schema:url ?url }
}
LIMIT 1
""",
initBindings={"rootElement": rootElement},
)
def license_query(g: Graph, rootElement: str):
return g.query(
"""
PREFIX schema: <http://schema.org/>
SELECT ?element_id ?identifier ?url
WHERE {
?rootElement schema:license ?element .
BIND(?element AS ?element_id) # Get the @id of the element
OPTIONAL { ?element schema:identifier ?identifier }
OPTIONAL { ?element schema:url ?url }
}
LIMIT 1
""",
initBindings={"rootElement": rootElement},
)
def cite_query(g: Graph, rootElement: str):
return g.query(
"""
PREFIX schema: <http://schema.org/>
SELECT ?element_id ?identifier ?url
WHERE {
?rootElement schema:url ?element .
BIND(?element AS ?element_id) # Get the @id of the element
OPTIONAL { ?element schema:identifier ?identifier }
OPTIONAL { ?element schema:url ?url }
}
LIMIT 1
""",
initBindings={"rootElement": rootElement},
)
def sameas_query(g: Graph, rootElement: str):
return g.query(
"""
PREFIX schema: <http://schema.org/>
SELECT ?element_id ?contentUrl ?identifier ?url ?encoding
WHERE {
?rootElement schema:sameAs ?element .
BIND(?element AS ?element_id) # Get the @id of the element
OPTIONAL { ?element schema:contentUrl ?contentUrl }
OPTIONAL { ?element schema:identifier ?identifier }
OPTIONAL { ?element schema:url ?url }
OPTIONAL { ?element schema:encodingFormat ?encoding }
}
""",
initBindings={"rootElement": rootElement},
)
def item_query(g: Graph, rootElement: str):
return g.query(
"""
PREFIX schema: <http://schema.org/>
SELECT ?element_id ?identifier ?url ?contentUrl ?encoding
WHERE {
?rootElement schema:hasPart ?element .
#VALUES ?element_type { schema:MediaObject schema:Dataset }
#?element a ?element_type .
BIND(?element AS ?element_id) # Get the @id of the element
OPTIONAL { ?element schema:contentUrl ?contentUrl }
OPTIONAL { ?element schema:identifier ?identifier }
OPTIONAL { ?element schema:url ?url }
OPTIONAL { ?element schema:encodingFormat ?encoding }
}
""",
initBindings={"rootElement": rootElement},
)

View File

@@ -1,23 +1,86 @@
import json
from django.http import HttpResponse
from rdflib import Graph
from signposting import Signpost, LinkRel
import re
from django_signposting import sparql
def add_signposts(response: HttpResponse, **kwargs):
def add_signposts(response: HttpResponse, *args: Signpost):
""" Adds signposting headers to the responses.
params:
response - the response object
kwargs - a map of relation types to a list of corresponding links. Each link can be a link or a tuple of link and media type.
args - a list of signposts to add to this resposnse.
"""
if not hasattr(response, '_signposts'):
response._signposts = {}
response._signposts = []
for key in kwargs.keys():
for signpost in args:
if signpost not in response._signposts:
response._signposts.append(signpost)
values = kwargs[key]
if isinstance(values, str) or isinstance(values, tuple):
values = [values]
if key not in response._signposts:
response._signposts[key] = values
else:
response._signposts[key] += values
def select_url(elements: tuple[str, ...]) -> str | None:
def is_url(url: str) -> bool:
url_pattern = re.compile(
r"^(https?|ftp)://" # protocol
r"(?:(?:[a-zA-Z0-9-_]+\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,6})" # domain
r"(?::\d{1,5})?" # optional port
r"(?:/.*)?$" # path
)
return bool(url_pattern.match(url))
for elem in elements[::-1]:
if is_url(str(elem)):
return str(elem)
return None
def jsonld_to_signposts(jsonld: dict) -> []:
signposts = []
# TODO use jsonld context in query as prefix
g = Graph().parse(data=json.dumps(jsonld), format="json-ld")
rootElement = next(iter(sparql.root_element_query(g)), None)
if rootElement:
rootElement = rootElement[0]
else:
print("No root element found")
return {}
types = sparql.type_query(g, rootElement)
for type in types:
signposts.append(Signpost(LinkRel.type, str(type[0])))
authors = sparql.author_query(g, rootElement)
for author in authors:
author = select_url(author)
if author:
signposts.append(Signpost(LinkRel.author, author))
license = next(iter(sparql.license_query(g, rootElement)), [])
license = select_url(license)
if license:
signposts.append(Signpost(LinkRel.license, license))
citations = sparql.cite_query(g, rootElement)
for citation in citations:
citation = select_url(citation)
if citation:
signposts.append(Signpost(LinkRel.cite_as, citation))
sameas = sparql.sameas_query(g, rootElement)
for sa in sameas:
sa_media_type = sa[-1]
sa = select_url(sa[:-1])
if sa:
signposts.append(Signpost(LinkRel.describedby, sa, sa_media_type))
items = sparql.item_query(g, rootElement)
for item in items:
item_media_type = item[-1]
item = select_url(item[:-1])
if item:
signposts.append(Signpost(LinkRel.item, item, item_media_type))
return signposts

View File

16
example/example/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for example project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings')
application = get_asgi_application()

129
example/example/settings.py Normal file
View File

@@ -0,0 +1,129 @@
"""
Django settings for example project.
Generated by 'django-admin startproject' using Django 5.1.2.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-0_!deorp-j4ng0d$4#jrwcbw#@#)a5qf)_wgxq%or7ic4v3euc'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_json_ld',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django_signposting.middleware.SignpostingMiddleware',
# 'django_signposting.middleware.HtmlSignpostingMiddleware',
'django_signposting.middleware.JsonLdSignpostingParserMiddleware',
]
ROOT_URLCONF = 'example.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
BASE_DIR / 'example/templates',
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'example.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Password validation
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.1/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.1/howto/static-files/
STATIC_URL = 'static/'
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
{% load render_json_ld from json_ld %}
{% render_json_ld sd %}
</head>
<body>
<p>Hello, world!</p>
</body>
</html>

25
example/example/urls.py Normal file
View File

@@ -0,0 +1,25 @@
"""
URL configuration for simple project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.1/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.urls import path
from . import views
urlpatterns = [
path("", views.SimpleView.as_view()),
path("jsonld", views.JsonLdView.as_view()),
path("ro-crate", views.rocrate.as_view()),
]

138
example/example/views.py Normal file
View File

@@ -0,0 +1,138 @@
from django.http import HttpResponse
from django.shortcuts import render
from django.views import View
from django_json_ld.views import JsonLdContextMixin
from signposting import LinkRel, Signpost
from django_signposting.utils import add_signposts, jsonld_to_signposts
class SimpleView(View):
""" A simple view that adds signposting headers to the response."""
def get(self, request):
response = HttpResponse("Hello, world!")
# Add signpostings as string
add_signposts(
response,
Signpost(LinkRel.type, "http://schema.org/Dataset"),
Signpost(LinkRel.author, "https://orcid.org/0000-0001-9447-460X"),
)
return response
class JsonLdView(JsonLdContextMixin, View):
""" A view that automatically adds signposting headers to the response based on
the JSON-LD metadata in the response."""
sd = {
"@context": "https://schema.org",
"@type": ["WebSite", "Dataset"],
"name": "My Dataset",
"description": "A dataset of things.",
"url": "https://example.com",
"sameAs": [
{
"@type": "MediaObject",
"contentUrl": "https://example.com/download.zip",
"encodingFormat": "application/zip",
},
{
"@type": "MediaObject",
"contentUrl": "https://example.com/metadata.json",
},
],
"author": {
"@type": "Person",
"name": "Daniel Bauer",
"url": "https://orcid.org/0000-0001-9447-460X",
},
"license": {
"@type": "CreativeWork",
"name": "CC BY 4.0",
"url": "https://creativecommons.org/licenses/by/4.0/",
},
"hasPart": [
{
"@type": "ImageObject",
"url": "http://example.com/image.png",
"encodingFormat": "image/png",
},
{
"@type": "ImageObject",
"url": "http://example.com/image2.png",
"encodingFormat": "image/png",
},
],
}
def get(self, request):
return render(request, "jsonld.html", context={"sd": self.sd})
class rocrate(JsonLdContextMixin, View):
""" A view that adds signposting headers from the metadata of a detached RO-Crate."""
def get(self, request):
import json
import os
import tempfile
# Build an RO-Crate and serve its preview
from rocrate.model.person import Person
from rocrate.model.creativework import CreativeWork
from rocrate.rocrate import ROCrate
with tempfile.TemporaryDirectory() as d:
crate = ROCrate(gen_preview=True)
crate.add_file(
"http://example.com/test.pdf",
properties={"name": "test file", "encodingFormat": "application/pdf"},
)
author = crate.add(
Person(
crate,
"https://orcid.org/0000-0001-9447-460X",
properties={"name": "Daniel Bauer"},
)
)
license = crate.add(
CreativeWork(
crate,
"https://spdx.org/licenses/CC0-1.0",
properties={
"@id": "https://spdx.org/licenses/CC0-1.0",
"@type": "CreativeWork",
"name": "CC0-1.0",
"description": "Creative Commons Zero v1.0 Universal",
},
)
)
sameAs = crate.add(
CreativeWork(
crate,
"https://example.com/ro-crate-metadata.json",
properties={"encodingFormat": "application/ld+json"},
)
)
crate.root_dataset["name"] = "My Dataset"
crate.root_dataset["description"] = "A dataset of things."
crate.root_dataset["author"] = author
crate.root_dataset["creator"] = author
crate.root_dataset["license"] = license
crate.root_dataset["url"] = "https://example.com"
crate.root_dataset["sameAs"] = sameAs
crate.write(d)
# Extract JSON-LD from RO-Crate metadata and build signposts from it
metadata = open(os.path.join(d, "ro-crate-metadata.json"), "r").read()
metadata = json.loads(metadata)
signposts = jsonld_to_signposts(metadata)
preview = open(os.path.join(d, "ro-crate-preview.html"), "r").read()
response = HttpResponse(preview)
add_signposts(response, *signposts)
return response

16
example/example/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for example project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings')
application = get_wsgi_application()

22
example/manage.py Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

18
example/requirements.txt Normal file
View File

@@ -0,0 +1,18 @@
asgiref==3.8.1
beautifulsoup4==4.12.3
certifi==2024.8.30
charset-normalizer==3.4.0
django==5.1.3
django-json-ld==0.0.5
django-signposting==0.10.0
httplink==0.2.0
idna==3.10
pyparsing==3.2.0
rdflib==7.1.1
requests==2.32.3
rfc3987==1.3.8
signposting==0.9.9
soupsieve==2.6
sqlparse==0.5.1
urllib3==2.2.3
rocrate==0.11.0

View File

@@ -7,13 +7,17 @@ name = "django_signposting"
authors = [
{name = "Daniel Bauer", email = "github@dbauer.me"}
]
description = "Add FAIR signpostings to response headers"
version = "0.0.2"
description = "Add FAIR signpostings to response headers in Django"
version = "0.11.0"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"Django>=3.0",
"beautifulsoup4>=4.12.3",
"django>=3.0",
"signposting>=0.9.9",
"rdflib>=7.1.1",
]
license = {file= "LICENSE"}
[project.urls]
Homepage = "https://github.com/dnlbauer/django-signposting"

0
tests/__init__.py Normal file
View File

View File

@@ -1,7 +1,5 @@
import pytest
from django.conf import settings
def pytest_sessionstart(session):
print("test")
settings.configure()

View File

@@ -0,0 +1,112 @@
from bs4 import BeautifulSoup
from django.http import HttpResponse, JsonResponse
from django_signposting.middleware import HtmlSignpostingMiddleware
from signposting import LinkRel, Signpost
import pytest
def assert_links_exist(response: HttpResponse, signposts: list[Signpost] = None):
if signposts is None:
signposts = response._signposts
soup = BeautifulSoup(response.content.decode("utf-8"), "html.parser")
for signpost in signposts:
links = soup.find_all("link", href=signpost.target, rel=signpost.rel)
assert len(links) == 1
assert len(soup.find_all("link")) == len(signposts)
def test_middleware_no_signposting():
response = HttpResponse("<html><head></head><body></body></html>")
response.status_code = 200
middleware = HtmlSignpostingMiddleware(lambda request: response)
response = middleware(None)
assert_links_exist(response, [])
def test_middleware_no_html():
response = JsonResponse({"hello": "world"})
response.status_code = 200
response._signpost = [Signpost(LinkRel.author, "http://example.com")]
middleware = HtmlSignpostingMiddleware(lambda request: response)
middleware(None)
assert_links_exist(response, [])
def test_middleware_malformed_html():
response = HttpResponse("Hello world")
response.status_code = 200
response.content_type = "text/html"
response._signposts = [Signpost(LinkRel.author, "http://example.com")]
middleware = HtmlSignpostingMiddleware(lambda request: response)
with pytest.raises(Exception):
middleware(None)
def test_middleware_signposting_without_head():
response = HttpResponse("<html><body></body></html>")
response.status_code = 200
response._signposts = [Signpost(LinkRel.author, "http://example.com")]
middleware = HtmlSignpostingMiddleware(lambda request: response)
middleware(None)
assert_links_exist(response)
def test_middleware_signposting():
response = HttpResponse("<html><head></head><body></body></html>")
response.status_code = 200
response._signposts = [Signpost(LinkRel.author, "http://example.com")]
middleware = HtmlSignpostingMiddleware(lambda request: response)
middleware(None)
assert_links_exist(response)
def test_middleware_multiple_signposts():
response = HttpResponse("<html><head></head><body></body></html>")
response.status_code = 200
response._signposts = [
Signpost(LinkRel.author, "http://example.com"),
Signpost(LinkRel.author, "http://example2.com"),
Signpost(LinkRel.cite_as, "http://example3.com"),
]
middleware = HtmlSignpostingMiddleware(lambda request: response)
response = middleware(None)
assert_links_exist(response)
def test_middleware_signpost_with_content_type():
response = HttpResponse("<html><head></head><body></body></html>")
response.status_code = 200
response._signposts = [Signpost(LinkRel.item, "http://example.com", "text/json")]
middleware = HtmlSignpostingMiddleware(lambda request: response)
response = middleware(None)
assert_links_exist(response)
def test_middleware_ignore_error_responses():
response = HttpResponse("<html><head></head><body></body></html>")
response.status_code = 400
response._signposts = [Signpost(LinkRel.author, "http://example.com")]
middleware = HtmlSignpostingMiddleware(lambda request: response)
response = middleware(None)
assert_links_exist(response)
def test_middleware_type_link():
response = HttpResponse("<html><head></head><body></body></html>")
response.status_code = 200
response._signposts = [Signpost(LinkRel.type, "http://schema.org/Dataset")]
middleware = HtmlSignpostingMiddleware(lambda request: response)
response = middleware(None)
assert_links_exist(response)

View File

@@ -0,0 +1,270 @@
import json
from django.http import HttpRequest, HttpResponse
from django_signposting.middleware import JsonLdSignpostingParserMiddleware
from signposting import Signpost, LinkRel
def jsonld_test_runner(jsonld, expected_signposts):
response = HttpResponse(f"""
<html><head>
<script type="application/ld+json">{json.dumps(jsonld)}</script>
</head>
<body></body>
</html>
""")
response.status_code = 200
middleware = JsonLdSignpostingParserMiddleware(lambda request: response)
response = middleware(HttpRequest())
for expected_signpost in expected_signposts:
assert expected_signpost in response._signposts
assert len(expected_signposts) == len(response._signposts)
def test_jsonld_signposting_basic():
jsonld_test_runner(
{
"@context": "http://schema.org",
"@type": "WebPage",
"author": {"url": "http://example.com/author"},
"url": "http://example.com/url",
"license": {"identifier": "http://example.com/license"},
"hasPart": [
{
"url": "http://example.com/image1.jpg",
},
{
"url": "http://example.com/image2.jpg",
},
],
"sameAs": [
{
"url": "http://example.com/metadata.json",
},
{
"url": "http://example.com/metadata.xml",
},
],
},
[
Signpost(LinkRel.type, "http://schema.org/WebPage"),
Signpost(LinkRel.author, "http://example.com/author"),
Signpost(LinkRel.cite_as, "http://example.com/url"),
Signpost(LinkRel.license, "http://example.com/license"),
Signpost(LinkRel.item, "http://example.com/image1.jpg"),
Signpost(LinkRel.item, "http://example.com/image2.jpg"),
Signpost(LinkRel.describedby, "http://example.com/metadata.json"),
Signpost(LinkRel.describedby, "http://example.com/metadata.xml"),
],
)
def test_jsonld_empty_signposting():
jsonld_test_runner({}, [])
def test_jsonld_signposting_media_types():
jsonld_test_runner(
{
"@context": "http://schema.org",
"@type": "WebPage",
"author": {"url": "http://example.com/author"},
"url": "http://example.com/url",
"license": {"identifier": "http://example.com/license"},
"hasPart": [
{
"url": "http://example.com/image1.jpg",
"encodingFormat": "image/jpeg",
},
{"url": "http://example.com/image2.jpg", "encodingFormat": "image/png"},
],
"sameAs": [
{
"url": "http://example.com/metadata.json",
"encodingFormat": "application/json",
},
{
"url": "http://example.com/metadata.xml",
"encodingFormat": "application/xml",
},
],
},
[
Signpost(LinkRel.type, "http://schema.org/WebPage"),
Signpost(LinkRel.author, "http://example.com/author"),
Signpost(LinkRel.cite_as, "http://example.com/url"),
Signpost(LinkRel.license, "http://example.com/license"),
Signpost(LinkRel.item, "http://example.com/image1.jpg", "image/jpeg"),
Signpost(LinkRel.item, "http://example.com/image2.jpg", "image/png"),
Signpost(
LinkRel.describedby,
"http://example.com/metadata.json",
"application/json",
),
Signpost(
LinkRel.describedby,
"http://example.com/metadata.xml",
"application/xml",
),
],
)
def test_jsonld_signposting_property_precedence():
jsonld_test_runner(
{
"@context": "http://schema.org",
"@type": "WebPage",
"author": {
"identifier": "http://example.com/author-ident",
"url": "http://example.com/author-url",
},
"hasPart": [
{
"identifier": "http://example.com/image1-identifier.jpg",
"url": "http://example.com/image1-url.jpg",
"contentUrl": "http://example.com/image1-content.jpg",
},
{
"identifier": "http://example.com/image2-identifier.jpg",
"url": "http://example.com/image2-url.jpg",
},
],
},
[
Signpost(LinkRel.type, "http://schema.org/WebPage"),
Signpost(LinkRel.author, "http://example.com/author-url"),
Signpost(LinkRel.item, "http://example.com/image1-content.jpg"),
Signpost(LinkRel.item, "http://example.com/image2-url.jpg"),
],
)
def test_jsonld_signposting_ids():
jsonld_test_runner(
{
"@context": "http://schema.org",
"@graph": [
{
"@type": "WebPage",
"author": {"@id": "http://example.com/author"},
"url": "http://example.com/url",
"license": {"@id": "http://example.com/license"},
"hasPart": [
{
"@id": "http://example.com/image1.jpg",
},
],
"sameAs": [
{
"@id": "http://example.com/metadata.json",
},
],
},
{"@id": "http://example.com/author", "@type": "Person"},
{"@id": "http://example.com/license"},
{
"@id": "http://example.com/image1.jpg",
"encodingFormat": "image/jpeg",
},
{"@id": "http://example.com/metadata.json"},
],
},
[
Signpost(LinkRel.type, "http://schema.org/WebPage"),
Signpost(LinkRel.author, "http://example.com/author"),
Signpost(LinkRel.cite_as, "http://example.com/url"),
Signpost(LinkRel.license, "http://example.com/license"),
Signpost(LinkRel.item, "http://example.com/image1.jpg", "image/jpeg"),
Signpost(LinkRel.describedby, "http://example.com/metadata.json"),
],
)
def test_jsonld_signposting_ids_with_url():
jsonld_test_runner(
{
"@context": "http://schema.org",
"@graph": [
{
"@type": "WebPage",
"author": {"@id": "http://example.com/author"},
},
{
"@id": "http://example.com/author",
"url": "http://example.com/realAuthorUrl",
},
],
},
[
Signpost(LinkRel.type, "http://schema.org/WebPage"),
Signpost(LinkRel.author, "http://example.com/realAuthorUrl"),
],
)
def test_jsonld_signposting_multiple_types():
jsonld_test_runner(
{
"@context": "http://schema.org",
"@type": "Dataset",
"author": "http://example.com/author",
},
[
Signpost(LinkRel.type, "http://schema.org/Dataset"),
Signpost(LinkRel.author, "http://example.com/author"),
],
)
def test_jsonld_signposting_rocrate():
jsonld_test_runner(
{
"@context": "http://w3id.org/ro/crate/1.1/context",
"@graph": [
{
"@type": "CreativeWork",
"@id": "ro-crate-metadata.json",
"conformsTo": {"@id": "http://w3id.org/ro/crate/1.1"},
"about": {"@id": "http://example.com/myCrate"}
},
{
"@type": "Dataset",
"@id": "http://example.com/myCrate",
"author": {"@id": "http://example.com/author"},
"datePublished": "2024",
"name": "Test crate",
"description": "This is a detached test create",
"license": {"@id": "http://example.com/license"},
"hasPart": [
{"@id": "http://example.com/file"}
]
},
{
"@id": "http://example.com/author",
"@type": "Person",
"name": "Daniel Bauer"
},
{
"@id": "http://example.com/license",
"@type": "CreativeWork",
"name": "Test License"
},
{
"@id": "http://example.com/file",
"@type": "File",
"contentUrl": "http://example.com/image1.jpg",
"encodingFormat": "image/jpeg"
}
]
},
[
Signpost(LinkRel.type, "http://schema.org/Dataset"),
Signpost(LinkRel.author, "http://example.com/author"),
Signpost(LinkRel.license, "http://example.com/license"),
Signpost(LinkRel.item, "http://example.com/image1.jpg", "image/jpeg"),
],
)

View File

@@ -1,5 +1,6 @@
from django.http import HttpResponse
from django_signposting.middleware import SignpostingMiddleware
from signposting import LinkRel, Signpost
def test_middleware_no_signposting():
@@ -14,7 +15,9 @@ def test_middleware_no_signposting():
def test_middleware_signposting():
response = HttpResponse()
response.status_code = 200
response._signposts = {"author": ["http://example.com"]}
response._signposts = [
Signpost(LinkRel.author, "http://example.com")
]
middleware = SignpostingMiddleware(lambda request: response)
response = middleware(None)
@@ -24,15 +27,11 @@ def test_middleware_signposting():
def test_middleware_multiple_signposts():
response = HttpResponse()
response.status_code = 200
response._signposts = {
"author": [
"http://example.com",
"http://example2.com"
],
"cite-as": [
"http://example3.com"
]
}
response._signposts = [
Signpost(LinkRel.author, "http://example.com"),
Signpost(LinkRel.author, "http://example2.com"),
Signpost(LinkRel.cite_as, "http://example3.com"),
]
middleware = SignpostingMiddleware(lambda request: response)
response = middleware(None)
@@ -45,24 +44,34 @@ def test_middleware_multiple_signposts():
def test_middleware_signpost_with_content_type():
response = HttpResponse()
response.status_code = 200
response._signposts = {
"item": [
("http://example.com", "test/json"),
]
}
response._signposts = [
Signpost(LinkRel.item, "http://example.com", "text/json")
]
middleware = SignpostingMiddleware(lambda request: response)
response = middleware(None)
assert response.headers["Link"] == '<http://example.com> ; rel="item" ; type="test/json"'
assert response.headers["Link"] == '<http://example.com> ; rel="item" ; type="text/json"'
def test_middleware_ignore_error_responses():
response = HttpResponse()
response.status_code = 400
response._signposts = {
"author": ["https://example.com"]
}
response._signposts = [
Signpost(LinkRel.author, "http://example.com")
]
middleware = SignpostingMiddleware(lambda request: response)
response = middleware(None)
assert "Link" not in response.headers
def test_middleware_type_link():
response = HttpResponse()
response.status_code = 200
response._signposts = [
Signpost(LinkRel.type, "http://schema.org/Dataset")
]
middleware = SignpostingMiddleware(lambda request: response)
response = middleware(None)
assert response.headers["Link"] == '<http://schema.org/Dataset> ; rel="type"'

View File

@@ -1,47 +1,37 @@
from django.http import HttpResponse
from django_signposting.utils import add_signposts
from signposting import Signpost, LinkRel
def test_add_signpost():
response = HttpResponse()
add_signposts(response, item="http://example.com")
add_signposts(response, Signpost(LinkRel.item, "http://example.com"))
assert response._signposts["item"] == ["http://example.com"]
assert len(response._signposts) == 1
def test_add_multiple_signposts():
response = HttpResponse()
add_signposts(response,
item="http://example.com",
author=["https://example2.com", "https://example3.com"]
Signpost(LinkRel.item, "http://example.com"),
Signpost(LinkRel.author, "https://example2.com"),
Signpost(LinkRel.author, "https://example3.com"),
)
assert response._signposts == {
"item": ["http://example.com"],
"author": [
"https://example2.com",
"https://example3.com",
]
}
assert len(response._signposts) == 3
def test_add_signposts_with_content_type():
def test_add_signpost_call_multiple_times():
response = HttpResponse()
add_signposts(response,
item=("http://example.com", "text/json"),
author=["https://example2.com", "https://example3.com"]
)
add_signposts(response, Signpost(LinkRel.item, "http://example.com"))
add_signposts(response, Signpost(LinkRel.item, "http://example2.com"))
assert response._signposts == {
"item": [("http://example.com", "text/json")],
"author": [
"https://example2.com",
"https://example3.com",
]
}
assert len(response._signposts) == 2
def test_add_signposts_from_dict():
def test_add_signpost_duplicate():
response = HttpResponse()
add_signposts(response, **{"cite-as": ["https://example.com"]})
assert response._signposts["cite-as"] == ["https://example.com"]
add_signposts(response, Signpost(LinkRel.item, "http://example.com"))
add_signposts(response, Signpost(LinkRel.item, "http://example.com"))
assert len(response._signposts) == 1

346
uv.lock generated Normal file
View File

@@ -0,0 +1,346 @@
version = 1
requires-python = ">=3.10"
[[package]]
name = "asgiref"
version = "3.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 },
]
[[package]]
name = "beautifulsoup4"
version = "4.12.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/ca/824b1195773ce6166d388573fc106ce56d4a805bd7427b624e063596ec58/beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051", size = 581181 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/fe/e8c672695b37eecc5cbf43e1d0638d88d66ba3a44c4d321c796f4e59167f/beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed", size = 147925 },
]
[[package]]
name = "certifi"
version = "2024.8.30"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 },
]
[[package]]
name = "charset-normalizer"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/8b/825cc84cf13a28bfbcba7c416ec22bf85a9584971be15b21dd8300c65b7f/charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", size = 196363 },
{ url = "https://files.pythonhosted.org/packages/23/81/d7eef6a99e42c77f444fdd7bc894b0ceca6c3a95c51239e74a722039521c/charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", size = 125639 },
{ url = "https://files.pythonhosted.org/packages/21/67/b4564d81f48042f520c948abac7079356e94b30cb8ffb22e747532cf469d/charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", size = 120451 },
{ url = "https://files.pythonhosted.org/packages/c2/72/12a7f0943dd71fb5b4e7b55c41327ac0a1663046a868ee4d0d8e9c369b85/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", size = 140041 },
{ url = "https://files.pythonhosted.org/packages/67/56/fa28c2c3e31217c4c52158537a2cf5d98a6c1e89d31faf476c89391cd16b/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", size = 150333 },
{ url = "https://files.pythonhosted.org/packages/f9/d2/466a9be1f32d89eb1554cf84073a5ed9262047acee1ab39cbaefc19635d2/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", size = 142921 },
{ url = "https://files.pythonhosted.org/packages/f8/01/344ec40cf5d85c1da3c1f57566c59e0c9b56bcc5566c08804a95a6cc8257/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", size = 144785 },
{ url = "https://files.pythonhosted.org/packages/73/8b/2102692cb6d7e9f03b9a33a710e0164cadfce312872e3efc7cfe22ed26b4/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", size = 146631 },
{ url = "https://files.pythonhosted.org/packages/d8/96/cc2c1b5d994119ce9f088a9a0c3ebd489d360a2eb058e2c8049f27092847/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", size = 140867 },
{ url = "https://files.pythonhosted.org/packages/c9/27/cde291783715b8ec30a61c810d0120411844bc4c23b50189b81188b273db/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", size = 149273 },
{ url = "https://files.pythonhosted.org/packages/3a/a4/8633b0fc1a2d1834d5393dafecce4a1cc56727bfd82b4dc18fc92f0d3cc3/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", size = 152437 },
{ url = "https://files.pythonhosted.org/packages/64/ea/69af161062166b5975ccbb0961fd2384853190c70786f288684490913bf5/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", size = 150087 },
{ url = "https://files.pythonhosted.org/packages/3b/fd/e60a9d9fd967f4ad5a92810138192f825d77b4fa2a557990fd575a47695b/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", size = 145142 },
{ url = "https://files.pythonhosted.org/packages/6d/02/8cb0988a1e49ac9ce2eed1e07b77ff118f2923e9ebd0ede41ba85f2dcb04/charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", size = 94701 },
{ url = "https://files.pythonhosted.org/packages/d6/20/f1d4670a8a723c46be695dff449d86d6092916f9e99c53051954ee33a1bc/charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", size = 102191 },
{ url = "https://files.pythonhosted.org/packages/9c/61/73589dcc7a719582bf56aae309b6103d2762b526bffe189d635a7fcfd998/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", size = 193339 },
{ url = "https://files.pythonhosted.org/packages/77/d5/8c982d58144de49f59571f940e329ad6e8615e1e82ef84584c5eeb5e1d72/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", size = 124366 },
{ url = "https://files.pythonhosted.org/packages/bf/19/411a64f01ee971bed3231111b69eb56f9331a769072de479eae7de52296d/charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", size = 118874 },
{ url = "https://files.pythonhosted.org/packages/4c/92/97509850f0d00e9f14a46bc751daabd0ad7765cff29cdfb66c68b6dad57f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", size = 138243 },
{ url = "https://files.pythonhosted.org/packages/e2/29/d227805bff72ed6d6cb1ce08eec707f7cfbd9868044893617eb331f16295/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", size = 148676 },
{ url = "https://files.pythonhosted.org/packages/13/bc/87c2c9f2c144bedfa62f894c3007cd4530ba4b5351acb10dc786428a50f0/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", size = 141289 },
{ url = "https://files.pythonhosted.org/packages/eb/5b/6f10bad0f6461fa272bfbbdf5d0023b5fb9bc6217c92bf068fa5a99820f5/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", size = 142585 },
{ url = "https://files.pythonhosted.org/packages/3b/a0/a68980ab8a1f45a36d9745d35049c1af57d27255eff8c907e3add84cf68f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", size = 144408 },
{ url = "https://files.pythonhosted.org/packages/d7/a1/493919799446464ed0299c8eef3c3fad0daf1c3cd48bff9263c731b0d9e2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", size = 139076 },
{ url = "https://files.pythonhosted.org/packages/fb/9d/9c13753a5a6e0db4a0a6edb1cef7aee39859177b64e1a1e748a6e3ba62c2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", size = 146874 },
{ url = "https://files.pythonhosted.org/packages/75/d2/0ab54463d3410709c09266dfb416d032a08f97fd7d60e94b8c6ef54ae14b/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", size = 150871 },
{ url = "https://files.pythonhosted.org/packages/8d/c9/27e41d481557be53d51e60750b85aa40eaf52b841946b3cdeff363105737/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", size = 148546 },
{ url = "https://files.pythonhosted.org/packages/ee/44/4f62042ca8cdc0cabf87c0fc00ae27cd8b53ab68be3605ba6d071f742ad3/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", size = 143048 },
{ url = "https://files.pythonhosted.org/packages/01/f8/38842422988b795220eb8038745d27a675ce066e2ada79516c118f291f07/charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", size = 94389 },
{ url = "https://files.pythonhosted.org/packages/0b/6e/b13bd47fa9023b3699e94abf565b5a2f0b0be6e9ddac9812182596ee62e4/charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", size = 101752 },
{ url = "https://files.pythonhosted.org/packages/d3/0b/4b7a70987abf9b8196845806198975b6aab4ce016632f817ad758a5aa056/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", size = 194445 },
{ url = "https://files.pythonhosted.org/packages/50/89/354cc56cf4dd2449715bc9a0f54f3aef3dc700d2d62d1fa5bbea53b13426/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", size = 125275 },
{ url = "https://files.pythonhosted.org/packages/fa/44/b730e2a2580110ced837ac083d8ad222343c96bb6b66e9e4e706e4d0b6df/charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", size = 119020 },
{ url = "https://files.pythonhosted.org/packages/9d/e4/9263b8240ed9472a2ae7ddc3e516e71ef46617fe40eaa51221ccd4ad9a27/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", size = 139128 },
{ url = "https://files.pythonhosted.org/packages/6b/e3/9f73e779315a54334240353eaea75854a9a690f3f580e4bd85d977cb2204/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", size = 149277 },
{ url = "https://files.pythonhosted.org/packages/1a/cf/f1f50c2f295312edb8a548d3fa56a5c923b146cd3f24114d5adb7e7be558/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", size = 142174 },
{ url = "https://files.pythonhosted.org/packages/16/92/92a76dc2ff3a12e69ba94e7e05168d37d0345fa08c87e1fe24d0c2a42223/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", size = 143838 },
{ url = "https://files.pythonhosted.org/packages/a4/01/2117ff2b1dfc61695daf2babe4a874bca328489afa85952440b59819e9d7/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", size = 146149 },
{ url = "https://files.pythonhosted.org/packages/f6/9b/93a332b8d25b347f6839ca0a61b7f0287b0930216994e8bf67a75d050255/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", size = 140043 },
{ url = "https://files.pythonhosted.org/packages/ab/f6/7ac4a01adcdecbc7a7587767c776d53d369b8b971382b91211489535acf0/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", size = 148229 },
{ url = "https://files.pythonhosted.org/packages/9d/be/5708ad18161dee7dc6a0f7e6cf3a88ea6279c3e8484844c0590e50e803ef/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", size = 151556 },
{ url = "https://files.pythonhosted.org/packages/5a/bb/3d8bc22bacb9eb89785e83e6723f9888265f3a0de3b9ce724d66bd49884e/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", size = 149772 },
{ url = "https://files.pythonhosted.org/packages/f7/fa/d3fc622de05a86f30beea5fc4e9ac46aead4731e73fd9055496732bcc0a4/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", size = 144800 },
{ url = "https://files.pythonhosted.org/packages/9a/65/bdb9bc496d7d190d725e96816e20e2ae3a6fa42a5cac99c3c3d6ff884118/charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", size = 94836 },
{ url = "https://files.pythonhosted.org/packages/3e/67/7b72b69d25b89c0b3cea583ee372c43aa24df15f0e0f8d3982c57804984b/charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", size = 102187 },
{ url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 },
{ url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 },
{ url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 },
{ url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 },
{ url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 },
{ url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 },
{ url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 },
{ url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 },
{ url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 },
{ url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 },
{ url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 },
{ url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 },
{ url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 },
{ url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 },
{ url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 },
{ url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "django"
version = "5.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/85/ba2c2b83ba8b95354f99ed8344405d9571109ce0175028876209d6b93fba/Django-5.1.3.tar.gz", hash = "sha256:c0fa0e619c39325a169208caef234f90baa925227032ad3f44842ba14d75234a", size = 10698518 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/f6/88ed57e1b3ed54ff18c1da352aecbd6f51784c3e642d97586b61f050f5b1/Django-5.1.3-py3-none-any.whl", hash = "sha256:8b38a9a12da3ae00cb0ba72da985ec4b14de6345046b1e174b1fd7254398f818", size = 8276180 },
]
[[package]]
name = "django-signposting"
version = "0.10.2"
source = { editable = "." }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "django" },
{ name = "rdflib" },
{ name = "signposting" },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "beautifulsoup4", specifier = ">=4.12.3" },
{ name = "django", specifier = ">=3.0" },
{ name = "pytest", marker = "extra == 'dev'" },
{ name = "rdflib", specifier = ">=7.1.1" },
{ name = "signposting", specifier = ">=0.9.9" },
]
[[package]]
name = "exceptiongroup"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 },
]
[[package]]
name = "httplink"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/3f/d43dbecbec06fe5654b85b2f5f562c877586dfcde587dd4954d9eeb07f76/httplink-0.2.0-py3-none-any.whl", hash = "sha256:f4688080f92306dbb3faa71ea8232e1e8cb4a4a7f543bc9663b05dca8a2820c1", size = 5290 },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
]
[[package]]
name = "isodate"
version = "0.7.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320 },
]
[[package]]
name = "packaging"
version = "24.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
]
[[package]]
name = "pluggy"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
]
[[package]]
name = "pyparsing"
version = "3.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/d5/e5aeee5387091148a19e1145f63606619cb5f20b83fccb63efae6474e7b2/pyparsing-3.2.0.tar.gz", hash = "sha256:cbf74e27246d595d9a74b186b810f6fbb86726dbf3b9532efb343f6d7294fe9c", size = 920984 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/ec/2eb3cd785efd67806c46c13a17339708ddc346cbb684eade7a6e6f79536a/pyparsing-3.2.0-py3-none-any.whl", hash = "sha256:93d9577b88da0bbea8cc8334ee8b918ed014968fd2ec383e868fb8afb1ccef84", size = 106921 },
]
[[package]]
name = "pytest"
version = "8.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 },
]
[[package]]
name = "rdflib"
version = "7.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "isodate", marker = "python_full_version < '3.11'" },
{ name = "pyparsing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/b6/4cee25689c7a3dfafeb481d358e74696fe3a8543da4cb2c1b71c0cb6583d/rdflib-7.1.1.tar.gz", hash = "sha256:164de86bd3564558802ca983d84f6616a4a1a420c7a17a8152f5016076b2913e", size = 4864216 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/7d/9add7b22a4e695c4f3011ed084ecf725e1f03954b9fa071f1766890b4989/rdflib-7.1.1-py3-none-any.whl", hash = "sha256:e590fa9a2c34ba33a667818b5a84be3fb8a4d85868f8038f17912ec84f912a25", size = 562443 },
]
[[package]]
name = "requests"
version = "2.32.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
]
[[package]]
name = "rfc3987"
version = "1.3.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/14/bb/f1395c4b62f251a1cb503ff884500ebd248eed593f41b469f89caa3547bd/rfc3987-1.3.8.tar.gz", hash = "sha256:d3c4d257a560d544e9826b38bc81db676890c79ab9d7ac92b39c7a253d5ca733", size = 20700 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/d4/f7407c3d15d5ac779c3dd34fbbc6ea2090f77bd7dd12f207ccf881551208/rfc3987-1.3.8-py2.py3-none-any.whl", hash = "sha256:10702b1e51e5658843460b189b185c0366d2cf4cff716f13111b0ea9fd2dce53", size = 13377 },
]
[[package]]
name = "signposting"
version = "0.9.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "httplink" },
{ name = "requests" },
{ name = "rfc3987" },
]
sdist = { url = "https://files.pythonhosted.org/packages/75/68/3697204ad23a2654793a75a8d1fb386be9f2a8c1219771716742b46801c1/signposting-0.9.9.tar.gz", hash = "sha256:193b5f912290e7763782e7e8d1e423edb9cf6cca80f2864f45a4a3c67f8c5b87", size = 39913 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/bc/abc7f282b7cf809bb3f9cc43e599c02c2a645682cdd9fcabc72cab82ace1/signposting-0.9.9-py3-none-any.whl", hash = "sha256:8e9575ddbac52a0acbec9aeaf83edeadb3a4ff512f4563ec8903f7616a38eaaa", size = 34992 },
]
[[package]]
name = "soupsieve"
version = "2.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/ce/fbaeed4f9fb8b2daa961f90591662df6a86c1abf25c548329a86920aedfb/soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb", size = 101569 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186 },
]
[[package]]
name = "sqlparse"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/73/82/dfa23ec2cbed08a801deab02fe7c904bfb00765256b155941d789a338c68/sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e", size = 84502 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/a5/b2860373aa8de1e626b2bdfdd6df4355f0565b47e51f7d0c54fe70faf8fe/sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4", size = 44156 },
]
[[package]]
name = "tomli"
version = "2.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/35/b9/de2a5c0144d7d75a57ff355c0c24054f965b2dc3036456ae03a51ea6264b/tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed", size = 16096 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/db/ce8eda256fa131af12e0a76d481711abe4681b6923c27efb9a255c9e4594/tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", size = 13237 },
]
[[package]]
name = "typing-extensions"
version = "4.12.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
]
[[package]]
name = "tzdata"
version = "2024.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e1/34/943888654477a574a86a98e9896bae89c7aa15078ec29f490fef2f1e5384/tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc", size = 193282 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586 },
]
[[package]]
name = "urllib3"
version = "2.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 },
]