diff --git a/.dockerignore b/.dockerignore index 5509ba4..4d5b6c5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -24,9 +24,7 @@ htmlcov/ .idea/ # Docs site build artifacts -docs/node_modules/ -docs/.astro/ -docs/dist/ +site/ # Runtime spool cache **/spool/ diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index cddd0ed..990ddd1 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -5,11 +5,11 @@ on: branches: [main] paths: - 'docs/**' + - 'mkdocs.yml' workflow_run: workflows: ["Run tests"] types: [completed] branches: [main] - workflow_dispatch: permissions: contents: read @@ -21,7 +21,8 @@ jobs: runs-on: ubuntu-latest if: > github.event_name != 'workflow_run' || - github.event.workflow_run.conclusion == 'success' + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push') steps: - uses: actions/checkout@v4 @@ -30,7 +31,7 @@ jobs: uses: actions/download-artifact@v4 with: name: coverage-report - path: docs/public/coverage + path: docs/coverage github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} @@ -39,13 +40,21 @@ jobs: uses: actions/download-artifact@v4 with: name: coverage-badge - path: docs/public/coverage + path: docs/coverage github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} - - uses: withastro/action@v5 + - uses: actions/setup-python@v5 with: - path: ./docs + python-version: "3.13" + + - run: pip install mkdocs-material "mkdocs<2" + + - run: mkdocs build --strict + + - uses: actions/upload-pages-artifact@v3 + with: + path: site deploy: needs: build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e8d7b6..ddcd376 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,7 @@ jobs: runs-on: ubuntu-latest if: > github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && contains(github.event.workflow_run.head_commit.message, '[release]') permissions: contents: write @@ -54,4 +55,4 @@ jobs: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} run: | uv build - uv publish --token $UV_PUBLISH_TOKEN + uv publish --check-url https://pypi.org/simple/ --token $UV_PUBLISH_TOKEN diff --git a/.gitignore b/.gitignore index 2b28c9e..793c3eb 100644 --- a/.gitignore +++ b/.gitignore @@ -19,9 +19,11 @@ htmlcov/ .idea/ # Starlight/Astro -docs/node_modules/ -docs/.astro/ -docs/dist/ +site/ # saved files spool/ + +# claude +CLAUDE.md +.claude/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index af5ab1d..9bc2345 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ USER appuser # Install dependencies first for better layer caching (frozen = exact lockfile) COPY --chown=appuser:appuser pyproject.toml uv.lock README.md ./ -RUN uv sync --frozen --no-dev --no-install-project +RUN uv sync --frozen --no-dev --no-install-project --extra server # Application code COPY --chown=appuser:appuser . . diff --git a/LICENSE.md b/LICENSE.md index d764181..8e3cb7f 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ ISC License -Copyright (c) 2025 Valentina Pasqual, Sivio Peroni +Copyright (c) 2025 Valentina Pasqual, Silvio Peroni Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above diff --git a/README.md b/README.md index 917f824..7f5cf64 100644 --- a/README.md +++ b/README.md @@ -4,25 +4,31 @@ [![Coverage](https://opencitations.github.io/lode/coverage/coverage-badge.svg)](https://opencitations.github.io/lode/coverage/) [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](https://opensource.org/licenses/ISC) -LODE 2.0 is an open source service that automatically extracts entities from semantic artefacts (OWL, RDF/RDFS, SKOS) and renders them, together with their textual definitions, as a human-readable HTML page designed for browsing and navigation through embedded links. This new reengineered version of LODE is maintained by OpenCitations. +Live Owl Documentation Enviroment (LODE) is an open source service that automatically extracts entities from semantic artefacts (OWL, RDF/RDFS, SKOS) and renders them, together with their textual definitions, as a human-readable HTML page designed for browsing and navigation through embedded links. This new reengineered version of LODE is maintained by OpenCitations. + +**Try it online:** a hosted instance of the web service is available at https://lode.opencitations.net/, with no installation needed. + ## Installation ```bash -pip install lode +pip install oc-lode + +# with the web service (`lode serve`) +pip install "oc-lode[server]" ``` -## Usage + ## Usage -```python -from lode import example_function +```bash +# Build static documentation from an ontology URL or a local file +lode build --url --read-as [--out ./docs] [--lang en] [--artefact-name index] [--imported] [--closure] +lode build --file --read-as [--out ./docs] -result = example_function() -print(result) +# Run the web service locally (requires the [server] extra) +lode serve --port 8000 ``` - - ## Documentation Full documentation is available at: https://opencitations.github.io/lode/ @@ -74,14 +80,47 @@ Replace `latest` with the desired version tag. The app will be available at `localhost:8080`. - - -{% endif %} - {% if v.type == 'Statement' and v.nested %} -
- {% set main = v.nested.main %} - {% if main and main.value and main.value.text %} -
{{ main.predicate }}: {{ render_value(main.value, depth + 1) }}
- {% endif %} - {% if v.nested.siblings %} - {% for s in v.nested.siblings %} -
{{ s.predicate }}: {{ render_value(s.value, depth + 1) }}
- {% endfor %} - {% endif %} -
- {% else %} - {% set v_type = v.type.lower() if v.type else '' %} - {% set is_md = ('str' in v_type) or ('literal' in v_type) %} - {% set dep_class = 'text-decoration-line-through text-muted' if v.is_deprecated else '' %} - {% set dep_title = ' -- Deprecated ' if v.is_deprecated else '' %} - - {% if v.link %} - {% set precise_key = v.link ~ '|' ~ v_type %} - {% if v.is_external %} - {{ v.text }} - {% elif precise_key in anchor_map %} - {{ v.text }} - {% elif v.link in anchor_map %} - {{ v.text }} - {% elif is_static %} - {{ v.text }} - {% else %} - {{ v.text }} - {% endif %} - - {% if v_type in type_map %} - {{ type_map[v_type].abb }} - {% endif %} - - {% else %} -
-
- {{ v.text }} -
- {% if is_md and v.lan %} - {{ v.lan }} - {% endif %} -
- {% endif %} - {% endif %} -{% endmacro %} +{% from '_value.html' import render_value with context %}
diff --git a/lode/templates/_card_skos.html b/lode/templates/_card_skos.html new file mode 100644 index 0000000..d43effd --- /dev/null +++ b/lode/templates/_card_skos.html @@ -0,0 +1,50 @@ +
+
+
+
+

+ {{ item.label }}c +

+ {% if section.id %} + + back to ToC or {{ section.title }} ToC + + {% endif %} +
+ + {{ item.type }} + +
+
+ IRI: {{ item.uri }} +
+
+ +
+ {% if item.definition %} +

{{ item.definition }}

+ {% endif %} + + {% if item.semantic_sections %} + {% for sem_section in item.semantic_sections %} +
+ {{ sem_section.title }} + + {% for concept in sem_section.concepts %} + {% if concept.external %} + {{ concept.label }} + {% else %} + {{ concept.label }}c + {% endif %} + {% if not loop.last %},{% endif %} + {% endfor %} + +
+ {% endfor %} + {% endif %} + + {% if not item.definition and not item.semantic_sections %} +

No definition or semantic relations defined.

+ {% endif %} +
+
diff --git a/lode/templates/_value.html b/lode/templates/_value.html new file mode 100644 index 0000000..1c10ed9 --- /dev/null +++ b/lode/templates/_value.html @@ -0,0 +1,55 @@ +{# Shared renderer for a resolved value: literals, links, restrictions and nested statements. #} +{% macro render_value(v, depth=0, full_link=false) %} + {% if v.type == 'Statement' and v.nested %} +
+ {% set main = v.nested.main %} + {% if main and main.value and main.value.text %} +
{{ main.predicate }}:{{ render_value(main.value, depth + 1, full_link) }}
+ {% endif %} + {% if v.nested.siblings %} + {% for s in v.nested.siblings %} +
{{ s.predicate }}:{{ render_value(s.value, depth + 1, full_link) }}
+ {% endfor %} + {% endif %} +
+ {% else %} + {% set v_type = v.type.lower() if v.type else '' %} + {% set is_md = ('str' in v_type) or ('literal' in v_type) %} + {% set dep_class = 'text-decoration-line-through text-muted' if v.is_deprecated else '' %} + {% set dep_title = ' -- Deprecated ' if v.is_deprecated else '' %} + + {% if v.link %} + {% set precise_key = v.link ~ '|' ~ v_type %} + {% if v.is_external %} + {{ v.link if full_link and v_type == 'resource' else v.text }} + {% elif precise_key in anchor_map %} + {{ v.text }} + {% elif v.link in anchor_map %} + {{ v.text }} + {% elif is_static %} + {{ v.text }} + {% else %} + {{ v.text }} + {% endif %} + + {% if v_type in type_map %} + {{ type_map[v_type].abb }} + {% endif %} + + {% else %} + {# inline inside a nested statement so "name: value" stays on one line #} + {% set tag = 'span' if depth else 'div' %} + <{{ tag }}> + <{{ tag }} class="markdown-with-badge {{ dep_class }} {% if is_md %}render-markdown inline-markdown{% endif %}" + title="{{ v.link }}{{dep_title}}" + {% if is_md %}data-md="{{ v.text | e }}"{% endif %}> + {{ v.text }} + + {% if is_md and v.lan %} + {{ v.lan }} + {% endif %} + + {% endif %} + {% endif %} +{% endmacro %} diff --git a/lode/templates/index.html b/lode/templates/index.html index 3140436..2daf65e 100644 --- a/lode/templates/index.html +++ b/lode/templates/index.html @@ -25,9 +25,9 @@
-
+
-
+
@@ -57,7 +57,7 @@
-
+
@@ -155,7 +155,7 @@
-
+
@@ -169,7 +169,7 @@
-
+

LODE 2.0 is an open source service that automatically extracts entities from semantic artefacts (OWL, RDF/RDFS, SKOS) and renders them, together with their textual definitions, as a human-readable HTML page designed for browsing and @@ -203,6 +203,7 @@

Endpoints

Parameters

+
@@ -279,6 +280,7 @@

Parameters

+

Examples

@@ -340,7 +342,14 @@

Reporting bugs

LODE 2.0 is developed in the open. Open an issue at github.com/opencitations/lode/issues.

-
+
+ + -
- - \ No newline at end of file diff --git a/lode/templates/skos_viewer.html b/lode/templates/skos_viewer.html deleted file mode 100644 index a512bf1..0000000 --- a/lode/templates/skos_viewer.html +++ /dev/null @@ -1,245 +0,0 @@ - - - - - - LODE 2.0 - SKOS Viewer - - - - - -
-
-

LODE 2.0

-

SKOS Vocabulary Documentation

-
- - {% if error %} - - - {% else %} - - {% if grouped_view and sections %} - -
-
-
Table of Contents
-
- {% for section in sections %} - - {{ section.title }} - {{ section.entities|length }} - - {% endfor %} -
-
-
- - - {% for section in sections %} -
-

- {{ section.title }} - {{ section.entities|length }} -

- - -
-
-

Jump to:

-
- {% for item in section.entities %} - - {{ item.label }} - - {% endfor %} -
-
-
- - - {% for item in section.entities %} -
-
-
- - - {{ item.type }} - -
-
- IRI: {{ item.uri }} -
-
- -
- {% if item.definition %} -

{{ item.definition }}

- {% endif %} - - {% if item.semantic_sections %} - {% for sem_section in item.semantic_sections %} -
- {{ sem_section.title }} - - {% for concept in sem_section.concepts %} - {% if concept.external %} - {{ concept.label }} - {% else %} - {{ concept.label }}c - {% endif %} - {% if not loop.last %},{% endif %} - {% endfor %} - -
- {% endfor %} - {% endif %} - - {% if not item.definition and not item.semantic_sections %} -

No definition or semantic relations defined.

- {% endif %} -
-
- {% endfor %} -
- {% endfor %} - - {% endif %} - - {% if single_resource %} - - {% endif %} - - {% endif %} -
- - - - \ No newline at end of file diff --git a/lode/templates/viewer.html b/lode/templates/viewer.html index a7393b7..5f45a95 100644 --- a/lode/templates/viewer.html +++ b/lode/templates/viewer.html @@ -46,6 +46,9 @@ {% set md_types = ['Literal', 'Str', 'literal', 'str', 'string'] %} + {# after the sets above: "with context" only sees variables defined before the import #} + {% from '_value.html' import render_value with context %} + {% include '_toc_sidebar.html' %} {% if not single_resource %} @@ -67,18 +70,7 @@
{% endif %} -
- {% if warnings %} -
- {% for w in warnings %} - - {% endfor %} -
- {% endif %} - +
{% if metadata %} {% if single_resource %} {% set current = entities[0] if entities else None %} @@ -91,7 +83,7 @@

- {{ current.uri if current else '' }} + {{ current.uri if current else '' }} @@ -127,7 +119,7 @@

- {{ metadata.uri[0].link }} + {{ metadata.uri[0].link }} @@ -157,6 +149,8 @@

{% endif %}

+ {% elif item.type == 'Statement' and item.nested %} + {{ render_value(item, 0, true) }} {% else %} {% endif %} @@ -259,7 +253,7 @@

{% for item in section.entities %} - {% include '_entity_card.html' %} + {% include card_template %} {% endfor %}

@@ -284,7 +278,7 @@

← Back to full documentation + ← Back to full documentation {% else %} ← Back to full documentation {% endif %} @@ -295,6 +289,37 @@

+ {% if warnings %} +
+ + {% endif %} +

+ \ No newline at end of file diff --git a/lode/viewer/__init__.py b/lode/viewer/__init__.py index 73fabd5..1511a1c 100644 --- a/lode/viewer/__init__.py +++ b/lode/viewer/__init__.py @@ -1,4 +1,4 @@ -# viewer/__init__.py +"""Viewer layer: renders the model as view data for the HTML templates.""" from .base_viewer import BaseViewer from .owl_viewer import OwlViewer @@ -14,7 +14,10 @@ def get_viewer(read_as: str, reader): - """Factory per il viewer corretto in base al formato.""" + """Return the viewer that matches the artefact format (owl, rdf/rdfs, skos). + + An unknown format falls back to the generic BaseViewer. + """ key = read_as.lower() if key == 'owl': return OwlViewer(reader) diff --git a/lode/viewer/base_viewer.py b/lode/viewer/base_viewer.py index 2593ecc..18f1fbb 100644 --- a/lode/viewer/base_viewer.py +++ b/lode/viewer/base_viewer.py @@ -1,42 +1,83 @@ -# base_viewer.py +"""Generic viewer: turns model instances into the dictionaries the HTML templates render. + +Format-specific viewers (OWL, RDF, SKOS) extend it with their table of contents +and display names. It also serializes the provenance of each entity. +""" import hashlib import re from typing import Dict, List, Optional, Tuple + from lode.models import Literal, Model, Resource, Statement -import urllib.parse from rdflib import Graph, URIRef, BNode, Literal as RDFlibLiteral -from rdflib.namespace import RDF, OWL from lode.reader.provenance_graph import ProvenanceGraph from lode.helpers.rdf import iri_local_name -SERIALIZATION_FORMATS = [ - {"label": "Turtle", "fmt": "turtle", "mime": "text/turtle", "ext": "ttl", "icon": "bi-filetype-raw"}, - {"label": "RDF/XML", "fmt": "xml", "mime": "application/rdf+xml", "ext": "rdf", "icon": "bi-file-code"}, - {"label": "JSON-LD", "fmt": "json-ld", "mime": "application/ld+json", "ext": "jsonld", "icon": "bi-filetype-json"}, - {"label": "N-Triples", "fmt": "nt", "mime": "application/n-triples", "ext": "nt", "icon": "bi-file-text"}, -] - -QUAD_SERIALIZATION_FORMATS = [ - {"label": "TriG", "fmt": "trig", "mime": "application/trig", "ext": "trig", "icon": "bi-filetype-raw"}, - {"label": "JSON-LD", "fmt": "json-ld", "mime": "application/ld+json", "ext": "jsonld", "icon": "bi-filetype-json"}, - {"label": "N-Quads", "fmt": "nquads", "mime": "application/n-quads", "ext": "nq", "icon": "bi-file-text"}, -] +from lode.viewer.helpers import ( + QUAD_SERIALIZATION_FORMATS, + SERIALIZATION_FORMATS, + annotation_sort_key, + clean_name, + is_likely_ontology, + load_viewer_config, + safe_serialize, +) -def formats_for(graph): - return QUAD_SERIALIZATION_FORMATS if isinstance(graph, ProvenanceGraph) else SERIALIZATION_FORMATS class BaseViewer: - """Base viewer per visualizzare istanze estratte dal Reader.""" - + """Base viewer to display the instances extracted by the Reader. + + It orchestrates the view: everything that differs between formats (display + names, table of contents, hierarchy, card template, vocabulary) is read from + the YAML file named by CONFIG_NAME. Subclasses set that name and override + only the steps that really need code. + """ + + # Name of config/_viewer.yaml; None gives the generic flat list view. + CONFIG_NAME: Optional[str] = None + def __init__(self, reader): + """Bind the viewer to a Reader, share its instance cache and load its config.""" self.reader = reader - self._cache = reader._instance_cache # it uses + self._cache = reader._instance_cache # it uses self._internal_iris = None - self._ontology_ns = None + self._ontology_ns = None + self._config = ( + load_viewer_config(self.CONFIG_NAME) if self.CONFIG_NAME + else {"type_map": {}, "toc": [], "card": "_card_owl.html", + "vocabulary": {}, "hierarchy": {}, "characteristics": []} + ) + + def get_toc_config(self) -> List[Tuple[str, str, str]]: + """Return the table of contents: (class name, section id, type_map key) in display order.""" + return [tuple(entry) for entry in self._config["toc"]] + + def format_warnings(self, warnings: List[Dict]) -> List[Dict]: + """Return the reader warnings with model class names replaced by this viewer's display names. + + The reader words its messages with internal class names (Attribute, Annotation) and + cannot know how a format shows them; the display names live in the type_map of this + viewer, so the swap happens here, before the warnings reach any output. Only standalone + words are replaced: a class name inside an IRI (.../Concept, ...#Concept) is left alone. + Returns copies, the reader's own list is never modified. + """ + names = {key.capitalize(): entry["singular"] for key, entry in self._config["type_map"].items()} + if not names: + return [dict(w) for w in warnings] + pattern = re.compile(r"(? str: + """Turn an attribute or getter name into a display label, using this viewer's vocabulary.""" + return clean_name(name, self._config["vocabulary"]) def _get_ontology_ns(self) -> str: + """Return the namespace of the documented ontology (its IRI ending in '/' or '#'), or ''. + + Computed once from the first Model instance and then cached. + """ if self._ontology_ns is None: self._ontology_ns = '' for inst in self.get_all_instances(): @@ -48,27 +89,40 @@ def _get_ontology_ns(self) -> str: return self._ontology_ns def _has_local_triples(self, uri) -> bool: + """Return True if the graph has at least one triple whose subject is this IRI.""" if not uri: return False return (URIRef(str(uri)), None, None) in self.reader._graph def _formats(self): - return formats_for(self.reader._graph) + """Return the export formats (triples): the same whatever modules are active.""" + return SERIALIZATION_FORMATS def _with_provenance(self, sub): - """Con moduli attivi: ricolloca le triple di `sub` nei named graph d'origine.""" + """Return `sub` as quads, each triple in the named graph(s) it comes from. + + With modules active the graphs are the ones of the source ontologies; + without, every triple goes in the graph of the loaded artefact. + """ ds = self.reader._graph - if not isinstance(ds, ProvenanceGraph): - return sub out = ProvenanceGraph() for prefix, ns in ds.namespaces(): out.bind(prefix, ns) - for s, p, o in sub: - for *_, c in ds.quads((s, p, o, None)): - out.graph(c).add((s, p, o)) + if isinstance(ds, ProvenanceGraph): + for s, p, o in sub: + for *_, c in ds.quads((s, p, o, None)): + out.graph(c).add((s, p, o)) + else: + main = out.graph(self.reader._ontology_iri) + for t in sub: + main.add(t) return out def _is_internal(self, uri) -> bool: + """Return True if the IRI belongs to a browsable entity of this documentation. + + The set of IRIs is built once, on first use. + """ if not uri: return False if self._internal_iris is None: @@ -80,7 +134,7 @@ def _is_internal(self, uri) -> bool: return str(uri) in self._internal_iris def get_all_instances(self) -> List: - """Ottiene tutte le istanze (esclusi literal).""" + """Return all the instances in the cache, literals excluded.""" instances = [] for uri_id, instance_set in self._cache.items(): @@ -91,86 +145,85 @@ def get_all_instances(self) -> List: instances.extend(instance_list) return instances - + def get_instances_from_single_resource(self, resource_uri: str) -> Optional[set]: - """Ottiene istanze per un URI specifico dalla cache.""" - # Cerca l'URI nella cache + """Return the cached instances registered under an IRI, or None when it is unknown.""" + # Look the IRI up in the cache for uri_id in self._cache.keys(): if str(uri_id) == resource_uri: return self._cache[uri_id] return None + def _select_by_language(self, values: List, language: Optional[str] = None) -> List: + """Keep the values of one property in the best available language. + + The choice is made per property, not per literal: only with all the + siblings in view can a fallback exist. Tags follow BCP 47, so they are + compared case-insensitively. Order of preference (RFC 4647 lookup): the + exact tag, the tag truncated from the right (en-GB, then en), 'en', + untagged literals, then every literal sorted by tag (nothing disappears). + Non-literal values always pass, and the input order is kept. + """ + lits = [v for v in values if isinstance(v, Literal)] + parts = (language or "en").strip().lower().split("-") + # shortcut: no skipping of singleton subtags (zh-x-private), upgrade if such tags matter + chain = ["-".join(parts[:i]) for i in range(len(parts), 0, -1)] + ["en"] + + def tagged(wanted: str) -> List: + """Literals whose tag is exactly `wanted`, ignoring case.""" + return [l for l in lits if (l.get_has_language() or "").lower() == wanted] + + chosen = (next((m for m in map(tagged, chain) if m), None) + or [l for l in lits if not l.get_has_language()] + or sorted(lits, key=lambda l: str(l.get_has_language() or ""))) + keep = {id(l) for l in chosen} + return [v for v in values if not isinstance(v, Literal) or id(v) in keep] + def _get_best_label(self, resource, language: Optional[str] = None) -> Optional[str]: """Gets the best label to display: language > preferred_label > label > identifier.""" - # 1. Force 'en' as the absolute default if no language makes it this far - target_lang = language.strip().lower() if language else "en" - - # Check Preferred Labels for the target language - labels = resource.get_has_preferred_label() - for label in labels: - if hasattr(label, 'get_has_language') and label.get_has_language(): - if label.get_has_language().lower().startswith(target_lang): - # RETURN RAW LABEL - return label.get_has_value() - - # Check Normal Labels for the target language - labels = resource.get_has_label() - for label in labels: - if hasattr(label, 'get_has_language') and label.get_has_language(): - if label.get_has_language().lower().startswith(target_lang): - # RETURN RAW LABEL - return label.get_has_value() - - # --- DETERMINISTIC FALLBACKS --- - # If we reach here, no English label exists. - # We sort them by language tag. - - labels = resource.get_has_preferred_label() - if labels: - sorted_labels = sorted(labels, key=lambda x: str(x.get_has_language() or "")) - # RETURN RAW LABEL - return sorted_labels[0].get_has_value() - - labels = resource.get_has_label() - if labels: - sorted_labels = sorted(labels, key=lambda x: str(x.get_has_language() or "")) - # RETURN RAW LABEL - return sorted_labels[0].get_has_value() + # Preferred labels come first, so they win within the same language + labels = list(resource.get_has_preferred_label()) + list(resource.get_has_label()) + selected = self._select_by_language(labels, language) + if selected: + return selected[0].get_has_value() # Final Fallback: The URI Identifier resource_id = resource.get_has_identifier() if resource_id: clean_resource_id = iri_local_name(resource_id) # ONLY clean the identifier! - return self._clean_name(self, clean_resource_id) + return self._clean_name(clean_resource_id) return None def get_view_data(self, resource_uri: Optional[str] = None, language: Optional[str] = None) -> Dict: - """ - Main entry point called by the API - Subclasses should override this to define their specific view strategy + """Build the template data for one resource, or for the whole artefact grouped by entity type. + + Main entry point called by the API and by the static builder. Both views also + carry the header metadata, the export formats, the display names of the types + and the card template. Without a table of contents (generic viewer) the + artefact is shown as a flat list. """ # If no language is provided, the default would be English language = language.strip() if language else "en" - # Fallback: generic flat list all_instances = self.get_all_instances() metadata_dict = self._find_and_format_metadata(all_instances, language) if resource_uri: data = self._handle_single_resource(resource_uri, language) - data['metadata'] = metadata_dict - data['export_formats'] = self._formats() - return data - - return { - 'metadata': metadata_dict, - 'entities': self._format_entities(all_instances, language), - 'export_formats': self._formats(), - } + elif self._config["toc"]: + data = self._build_grouped_view(self.get_toc_config(), language) + else: + data = {'entities': self._format_entities(all_instances, language)} + + data['metadata'] = metadata_dict + data['export_formats'] = self._formats() + data['type_map'] = self._config["type_map"] + data['card_template'] = self._config["card"] + return data def _is_toc_entity(self, instance) -> bool: """Browsable (own card + clickable link) iff its type is in get_toc_config. @@ -182,6 +235,7 @@ def _is_toc_entity(self, instance) -> bool: return self._has_local_triples(instance.get_has_identifier()) def get_toc_instances(self) -> List: + """Return the instances that get their own card and link (the browsable entities).""" return [i for i in self.get_all_instances() if self._is_toc_entity(i)] def _handle_single_resource(self, resource_uri: str, language: Optional[str] = None) -> Dict: @@ -227,7 +281,7 @@ def _build_grouped_view(self, group_definitions: List[Tuple[str, str, str]], lan 'entities': self._format_entities(instances, language), } - parent_getter = self._HIERARCHY_PARENT_GETTERS.get(class_key) + parent_getter = self._config["hierarchy"].get(class_key) if parent_getter: section['tree'] = self._build_hierarchy(instances, parent_getter, language) @@ -263,14 +317,14 @@ def _format_entities(self, instances: List, language: Optional[str] = None) -> L if not attr.startswith('_') and value: # Skip attributes that are handled elsewhere or are empty # Clean up name: - clean_name = self._clean_name(self,attr) + clean_name = self._clean_name(attr) # Process value (could be a list of objects)clean # We use the helper to get clean text for each item formatted_values = [] if isinstance(value, list): - for v in value: - # Nested list (es. has_property_chain: lista di catene) + for v in self._select_by_language(value, language): + # Nested list (e.g. has_property_chain: list of chains) if isinstance(v, list): chain_dict = self._resolve_chain_value(v, language) if chain_dict and chain_dict['text']: @@ -317,12 +371,7 @@ def _format_entities(self, instances: List, language: Optional[str] = None) -> L ordered_relations[key] = relations[key] characteristics = {} - char_attributes = [ - 'is_functional', 'is_inverse_functional', 'is_transitive', - 'is_symmetric', 'is_asymmetric', 'is_reflexive', 'is_irreflexive' - ] - - for attr in char_attributes: + for attr in self._config["characteristics"]: getter = f"get_{attr}" # Safely get the boolean value is_true = getattr(instance, getter)() if hasattr(instance, getter) else getattr(instance, attr, False) @@ -406,7 +455,7 @@ def _find_and_format_metadata(self, all_instances: List[Resource], language=None if values: # 4. Auto-format the key name - clean_key = self._clean_name(self, attr_name) + clean_key = self._clean_name(attr_name) # 5. Ensure values are in a list if not isinstance(values, list): @@ -414,12 +463,12 @@ def _find_and_format_metadata(self, all_instances: List[Resource], language=None # 6. Extract the actual text values extracted_values = [] - for val in values: + for val in self._select_by_language(values, language): entry = self._resolve_resource_value(val, language) if entry['text']: # 7. Check if the link if an visualizable ontology or not if entry.get('link'): - entry['is_visualizable'] = self._is_likely_ontology(entry['link']) + entry['is_visualizable'] = is_likely_ontology(entry['link']) else: entry['is_visualizable'] = False @@ -440,16 +489,19 @@ def _find_and_format_metadata(self, all_instances: List[Resource], language=None # 9. Statements data.update(self._format_statement(all_instances, ontology_model, language)) - # Ordine alfabetico su tutti i metadati (uri/label in testa) + # uri/label first, then the order from the YAML, then alphabetical pinned = ['uri', 'label'] - rest = sorted(k for k in data if k not in pinned) + rest = sorted((k for k in data if k not in pinned), key=annotation_sort_key) data = {k: data[k] for k in pinned if k in data} | {k: data[k] for k in rest} return data def _resolve_chain_value(self, chain, language=None) -> dict: - """Renderizza una property chain ordinata (lista di Relation) come un - unico valore con parti cliccabili unite dall'operatore di composizione.""" + """Render an ordered property chain (a list of Relations) as a single value. + + The clickable parts are joined by the composition operator. Returns None + when no member has a displayable text. + """ members = [] for m in chain: md = self._resolve_resource_value(m, language) @@ -470,6 +522,14 @@ def _resolve_chain_value(self, chain, language=None) -> dict: 'type': 'PropertyChain', } + def _resolve_special(self, obj, handler_dic: dict, language=None) -> Optional[dict]: + """Resolve a value only one format knows how to render, or return None. + + The base knows none: a format viewer overrides this to intercept its own + constructs (OWL class expressions) before the generic resolution runs. + """ + return None + def _resolve_resource_value(self, obj, language=None) -> dict: """Helper: Extracts text and link from any object.""" handler_dic = { @@ -485,27 +545,12 @@ def _resolve_resource_value(self, obj, language=None) -> dict: if not obj: return handler_dic - # --- 1. INTERCEPT RESTRICTIONS --- - restriction_types = ["Restriction", "PropertyConceptRestriction", "Quantifier", "Cardinality", "TruthFunction", - "OneOf", "Value", "DatatypeRestriction"] - obj_type = type(obj).__name__ + # --- 1. FORMAT-SPECIFIC VALUES (e.g. OWL restrictions) --- + special = self._resolve_special(obj, handler_dic, language) + if special is not None: + return special - if obj_type in restriction_types: - is_dep = getattr(obj, 'get_is_deprecated')() if hasattr(obj, 'get_is_deprecated') else getattr(obj, - 'is_deprecated', - False) - handler_dic['is_deprecated'] = bool(is_dep) - - # Recursively parse the restriction into clickable parts - parts = self._parse_restriction(obj, language) - - handler_dic['parts'] = parts - handler_dic['text'] = "".join([p['text'] for p in parts if p.get('text')]) - handler_dic['link'] = None # Forces Jinja to ignore the blank node URI - handler_dic['is_external'] = False - handler_dic['is_imported'] = False - handler_dic['type'] = obj_type - return handler_dic + obj_type = type(obj).__name__ # --- 2. String Handling --- if isinstance(obj, str): @@ -523,17 +568,6 @@ def _resolve_resource_value(self, obj, language=None) -> dict: lit_lang = obj.get_has_language() - # If a language is requested (e.g., 'pt') - if language: - target_lang = language.strip().lower() - - # If the literal has a tag, and it DOES NOT match 'pt', destroy it. - if lit_lang and not lit_lang.lower().startswith(target_lang): - return handler_dic - - # if not lit_lang: - # return handler_dic - handler_dic['text'] = obj.get_has_value() handler_dic['lan'] = lit_lang handler_dic['type'] = obj_type @@ -566,14 +600,20 @@ def _resolve_resource_value(self, obj, language=None) -> dict: if attr.startswith('_') or attr in skip or not value: continue values = value if isinstance(value, list) else [value] - for v in values: + for v in self._select_by_language(values, language): resolved = self._resolve_resource_value(v, language) if resolved.get('text') or resolved.get('nested'): siblings.append({ - 'predicate': self._clean_name(self, attr), + 'predicate': self._clean_name(attr), 'value': resolved, }) - handler_dic['nested'] = {'main': inner, 'siblings': siblings} + # Reader order depends on the source file: sort by label, then by value + pairs = sorted( + [inner] + siblings, + key=lambda p: ((p['predicate'] or '').lower(), + ((p['value'] or {}).get('text') or '').lower()), + ) + handler_dic['nested'] = {'main': pairs[0], 'siblings': pairs[1:]} # Pick a primary text for collapsed display: the inner value's text handler_dic['text'] = (inner.get('value') or {}).get('text') or '[Statement]' return handler_dic @@ -614,263 +654,39 @@ def _format_statement(self, instances, target_instance, language=None) -> Dict: if target_instance is None: return statements + # 2. Group the objects by predicate: the language is chosen per property + grouped = {} for instance in instances: - if isinstance(instance, Statement): - subj = instance.get_has_subject() - - # 2. Identity comparison (handles punning: same URI, different Python instances) - if subj is not target_instance: - continue - - predicate = instance.get_has_predicate() - obj = instance.get_has_object() - - if obj: - obj_data = self._resolve_resource_value(obj, language) - - # 3. Predicate Resolution - pred_label = self._get_best_label(predicate, language) if predicate else "Annotation" - - if pred_label not in statements: - statements[pred_label] = [] - - # 4. Resolve Object and Prevent Duplicates - if obj: - obj_data = self._resolve_resource_value(obj, language) - - - if obj_data not in statements[pred_label]: - statements[pred_label].append(obj_data) - - return statements - - @staticmethod - def _clean_name(self, name: str) -> str: - if not name: return "" - - name = re.sub(r'^(get_has_|get_|has_)', '', name) - # Put space between camelCase letters - name = re.sub(r'([a-z0-9])([A-Z])', r'\1 \2', name) - name = name.replace('_', ' ') - - # Strip extra spaces - name = ' '.join(name.split()) - - # lowercase everything - name = name.lower() - - return name - - def _parse_restriction(self, obj, language=None, _seen=None) -> list: - """ - Recursively unpacks nested restrictions into a list of display parts - (each part being a dict with 'text' and 'link'). - Guards against cyclic class expressions (possible once owl:imports - completes cross-ontology definitions) via a visited set. - """ - if not obj: return [] - - if _seen is None: - _seen = set() - - # 1. Handle lists of restrictions/concepts (e.g., in TruthFunctions or OneOf) - if isinstance(obj, list) or isinstance(obj, set): - parts = [] - for i, item in enumerate(obj): - if i > 0: - parts.append({'text': ', ', 'link': None}) - parts.extend(self._parse_restriction(item, language, _seen)) - return parts - - obj_type = type(obj).__name__ - restriction_types = ["Restriction", "PropertyConceptRestriction", "Quantifier", "Cardinality", "TruthFunction", - "OneOf", "Value", "DatatypeRestriction"] - - # 2. If it is a Restriction, recursively unpack its specific components - if obj_type in restriction_types: - # cycle guard: a class expression already being expanded upstream - if id(obj) in _seen: - return [{'text': '…', 'link': None}] - _seen.add(id(obj)) - - parts = [] - - # Helper to safely call getter methods (e.g., get_applies_on_property) - def _get(instance, prop_name, default=None): - getter = f"get_{prop_name}" - if hasattr(instance, getter): - res = getattr(instance, getter)() - return res if res is not None else default - return getattr(instance, prop_name, default) - - if obj_type == "Quantifier": - prop = _get(obj, 'applies_on_property') - quant = _get(obj, 'has_quantifier_type', "some") - concept = _get(obj, 'applies_on_concept') - - # Safely check if the is_inverse flag is True - is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) - if is_inv: - parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) - - parts.extend(self._parse_restriction(prop, language, _seen)) - parts.append({'text': f' {quant} ', 'link': None}) - parts.extend(self._parse_restriction(concept, language, _seen)) - - elif obj_type == "Cardinality": - prop = _get(obj, 'applies_on_property') - card = _get(obj, 'has_cardinality_type', "exactly") - card_num = _get(obj, 'has_cardinality') - concept = _get(obj, 'applies_on_concept') - - # Safely check if the is_inverse flag is True - is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) - if is_inv: - parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) - - parts.extend(self._parse_restriction(prop, language, _seen)) - parts.append({'text': f' {card} {card_num}', 'link': None}) - parts.extend(self._parse_restriction(concept, language, _seen)) - - elif obj_type == "TruthFunction": - operator = _get(obj, 'has_logical_operator', "and") - concepts = _get(obj, 'applies_on_concept', []) - if not isinstance(concepts, list): concepts = [concepts] - - # Safely check if the is_inverse flag is True - is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) - if is_inv: - parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) - - parts.append({'text': '(', 'link': None}) - for i, c in enumerate(concepts): - if i > 0: - parts.append({'text': f' {operator} ', 'link': None}) - parts.extend(self._parse_restriction(c, language, _seen)) - parts.append({'text': ')', 'link': None}) - - elif obj_type == "OneOf": - resources = _get(obj, 'applies_on_resource', []) - if not isinstance(resources, list): resources = [resources] - - # Safely check if the is_inverse flag is True - is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) - if is_inv: - parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) - - parts.append({'text': 'one of { ', 'link': None}) - for i, r in enumerate(resources): - if i > 0: - parts.append({'text': ', ', 'link': None}) - parts.extend(self._parse_restriction(r, language, _seen)) - parts.append({'text': ' }', 'link': None}) - - elif obj_type == "Value": - prop = _get(obj, 'applies_on_property') - resource = _get(obj, 'applies_on_resource') - - # Safely check if the is_inverse flag is True - is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) - if is_inv: - parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) - - parts.extend(self._parse_restriction(prop, language, _seen)) - parts.append({'text': ' value ', 'link': None}) - parts.extend(self._parse_restriction(resource, language, _seen)) - - elif obj_type == "DatatypeRestriction": - concept = _get(obj, 'applies_on_concept') # Datatype(xsd:string) - constraint = _get(obj, 'has_constraint') # "pattern" - value = _get(obj, 'has_restriction_value') # Literal("[0-9]+") - - # Safely check if the is_inverse flag is True - is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) - if is_inv: - parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) - - parts.extend(self._parse_restriction(concept, language, _seen)) - if constraint: - parts.append({'text': f' with {constraint} ', 'link': None, 'type': 'Text'}) - else: - parts.append({'text': ' restricted by ', 'link': None, 'type': 'Text'}) - - parts.extend(self._parse_restriction(value, language, _seen)) - - return parts - - # 3. Base Case: We hit a non-blank node (Concept, Relation, String) - # Send it to the main resolver to extract its URI link and clean text. - resolved = self._resolve_resource_value(obj, language) - - if resolved.get('text'): - return [{'text': resolved['text'], 'link': resolved.get('link'), 'type': resolved.get('type'), 'is_external': resolved.get('is_external', False)}] - - return [] - - def _is_likely_ontology(self, url: str) -> bool: - """Determines if a URL likely points to an ontology based on naming patterns.""" - if not url or not isinstance(url, str) or not url.startswith('http'): - return False - - # 1. Clean the string - clean_url = url.strip().lower() - - # 2. Safely parse the URL to ignore fragments (#) and queries (?) for extension checking - parsed = urllib.parse.urlparse(clean_url) - path = parsed.path.rstrip('/') - - # 3. Standard Ontology File Extensions - ontology_exts = ['.owl', '.rdf', '.ttl', '.nt', '.n3', '.jsonld'] - if any(path.endswith(ext) for ext in ontology_exts): - return True - - # Match registries against the parsed hostname, not the raw string: - # a substring check would also match e.g. https://evil.com/w3id.org/x - host = parsed.hostname or '' - - def _host_is(domain: str) -> bool: - return host == domain or host.endswith('.' + domain) - - # 4. Common Ontology Registries & Permanent URLs - ontology_domains = [ - 'w3id.org', - 'purl.org', - 'xmlns.com' # Often used for FOAF - ] - if any(_host_is(domain) for domain in ontology_domains): - return True - - # 5. Core W3C Namespaces - w3c_core = ['/1999/02/22-rdf-syntax-ns', '/2000/01/rdf-schema', '/2002/07/owl'] - if _host_is('w3.org') and any(ns in path for ns in w3c_core): - return True - - # 6. Broad Keywords & Famous Standard Acronyms - # Looking for these anywhere in the URL string - path_keywords = [ - 'ontology', 'vocab', 'schema', # General terms - 'skos', 'foaf', 'crm', 'cidoc' # Famous standards - ] - if any(keyword in clean_url for keyword in path_keywords): - return True - - return False - - # Mappa class_key -> nome del getter del parent (None = no hierarchy) - _HIERARCHY_PARENT_GETTERS = { - 'Concept': 'get_is_sub_concept_of', - 'Relation': 'get_is_sub_property_of', - 'Attribute': 'get_is_sub_property_of', - 'Property': 'get_is_sub_property_of', - # Annotation, Individual: nessuna gerarchia -> non in mappa - } + if not isinstance(instance, Statement): + continue + # Identity comparison (handles punning: same URI, different Python instances) + if instance.get_has_subject() is not target_instance: + continue + obj = instance.get_has_object() + if not obj: + continue + predicate = instance.get_has_predicate() + pred_label = self._get_best_label(predicate, language) if predicate else "Annotation" + grouped.setdefault(pred_label, []).append(obj) + + # 3. Pick the language per property, resolve, drop empties and duplicates + for pred_label, objs in grouped.items(): + values = [] + for obj in self._select_by_language(objs, language): + obj_data = self._resolve_resource_value(obj, language) + if obj_data['text'] and obj_data not in values: + values.append(obj_data) + if values: + statements[pred_label] = sorted(values, key=lambda v: (str(v.get('nested')), v['text'].lower())) + + return dict(sorted(statements.items(), key=lambda kv: kv[0].lower())) _FULL_IRI_ATTRS = {'is_defined_by'} def _build_hierarchy(self, instances, parent_getter_name, language=None): """ - Costruisce un albero (forest) di dict. - anchor_id usa lo stesso formato di _format_entities: id_{md5}_{ClassName} + Build a forest (list of trees) of dicts from the parent relation. + anchor_id uses the same format as _format_entities: id_{md5}_{ClassName} """ import hashlib @@ -897,6 +713,7 @@ def _build_hierarchy(self, instances, parent_getter_name, language=None): roots = [uri for uri in by_uri if uri not in has_internal_parent] def node_dict(uri, visited): + """Build the tree node of an entity with its children, stopping on cycles.""" if uri in visited: return None visited = visited | {uri} @@ -925,31 +742,34 @@ def node_dict(uri, visited): # ========== PROVENANCE: subgraph serialisation for each card ========== def _build_provenance_subgraph(self, instance) -> List[Dict[str, str]]: + """Serialize the triples an instance comes from, once per available format. + + Returns an empty list when the instance has no provenance. + """ sub = self.reader.get_provenance_subgraph(instance) if not any(True for _ in sub): return [] sub = self._with_provenance(sub) return [ - {"label": f["label"], "code": self._safe_serialize(sub, f["fmt"])} - for f in self._formats() + {"label": f["label"], "code": safe_serialize(sub, f["fmt"])} + for f in QUAD_SERIALIZATION_FORMATS ] - @staticmethod - def _safe_serialize(g, fmt: str) -> str: - try: - return g.serialize(format=fmt) - except Exception as e: - return f"# serialization error ({fmt}): {e}" - def export_resource(self, resource_uri: str, fmt: str = "turtle") -> str: - """Serializza il subgraph della singola risorsa (unione su punning).""" + """Serialize the subgraph of a single resource (the union of its punned instances). + + Only triples of the loaded artefact are exported; the ones that come from + imported modules belong to the provenance, not to the export. + """ g = Graph() for prefix, ns in self.reader._graph.namespaces(): g.bind(prefix, ns) + main = self.reader.get_main_graph() instances = self.get_instances_from_single_resource(resource_uri) if instances: inst_iter = instances if isinstance(instances, set) else [instances] for inst in inst_iter: - for t in self.reader.get_provenance_subgraph(inst): # giΓ  esiste - g.add(t) - return self._safe_serialize(self._with_provenance(g), fmt) \ No newline at end of file + for t in self.reader.get_provenance_subgraph(inst): + if t in main: + g.add(t) + return safe_serialize(g, fmt) \ No newline at end of file diff --git a/lode/viewer/config/model_annotations_deterministic_sorting.yaml b/lode/viewer/config/model_annotations_deterministic_sorting.yaml new file mode 100644 index 0000000..6edb35c --- /dev/null +++ b/lode/viewer/config/model_annotations_deterministic_sorting.yaml @@ -0,0 +1,42 @@ +# Deterministic display order of the ontology header metadata. +# Each entry is matched against the displayed key, ignoring case, spaces, +# underscores and hyphens, so dc:creator and dcterms:creator share one slot. +# Keys not listed here come after these, in alphabetical order. +order: + # versioning + - version info + - version + - prior version + - replaces + - is version of + - imports + - backward compatible with + - incompatible with + - top concept + - changes + # title and description + - title + - alternative + - description + - abstract + - comment + - keywords + # responsibility + - author + - creator + - contributor + - publisher + - rights holder + - funder + # dates + - created + - issued + - modified + - date + # rights + - license + - rights + # references + - source + - see also + - is defined by \ No newline at end of file diff --git a/lode/viewer/config/owl_viewer.yaml b/lode/viewer/config/owl_viewer.yaml new file mode 100644 index 0000000..d826d78 --- /dev/null +++ b/lode/viewer/config/owl_viewer.yaml @@ -0,0 +1,49 @@ +# Settings of the OWL viewer. Read by lode.viewer.helpers.load_viewer_config. + +# Partial included once per entity by viewer.html. +card: _card_owl.html + +# Display vocabulary: internal word -> shown word. Applied to whole words of the +# property labels (e.g. "is sub concept of" -> "is sub class of"). +vocabulary: + concept: class + concepts: classes + relation: object property + relations: object properties + attribute: data property + attributes: data properties + +# Display names of the entity types, keyed by the lowercase model class name. +# abb is the badge shown next to a label. +type_map: + concept: {singular: Class, plural: Classes, abb: c} + relation: {singular: Object Property, plural: Object Properties, abb: op} + attribute: {singular: Data Property, plural: Data Properties, abb: dp} + annotation: {singular: Annotation Property, plural: Annotation Properties, abb: ap} + individual: {singular: Named Individual, plural: Named Individuals, abb: ni} + +# Table of contents in display order: model class name, section id, type_map key. +# Only these classes are browsable (own card and link). +toc: + - [Concept, concepts, Concept] + - [Relation, relations, Relation] + - [Attribute, attributes, Attribute] + - [Individual, individuals, Individual] + - [Annotation, annotations, Annotation] + +# Model class -> getter of its parents, for the hierarchy tree of a section. +# A class that is missing has no hierarchy. +hierarchy: + Concept: get_is_sub_concept_of + Relation: get_is_sub_property_of + Attribute: get_is_sub_property_of + +# Boolean flags shown as characteristics of a property. +characteristics: + - is_functional + - is_inverse_functional + - is_transitive + - is_symmetric + - is_asymmetric + - is_reflexive + - is_irreflexive diff --git a/lode/viewer/config/rdf_viewer.yaml b/lode/viewer/config/rdf_viewer.yaml new file mode 100644 index 0000000..02a1f9d --- /dev/null +++ b/lode/viewer/config/rdf_viewer.yaml @@ -0,0 +1,39 @@ +# Settings of the RDF/RDFS viewer. Read by lode.viewer.helpers.load_viewer_config. + +# Same card as OWL for now; point to a dedicated partial when the two diverge. +card: _card_owl.html + +# Display vocabulary: internal word -> shown word. Applied to whole words of the +# property labels (e.g. "is sub concept of" -> "is sub class of"). +vocabulary: + concept: class + concepts: classes + +# Display names of the entity types, keyed by the lowercase model class name. +type_map: + concept: {singular: Class, plural: Classes, abb: c} + property: {singular: Property, plural: Properties, abb: p} + container: {singular: Container, plural: Containers, abb: cont} + resource: {singular: Resource, plural: Resources, abb: r} + model: {singular: Vocabulary, plural: Vocabularies, abb: v} + +# Table of contents in display order: model class name, section id, type_map key. +toc: + - [Concept, concepts, Concept] + - [Property, properties, Property] + - [Container, containers, Container] + +# Model class -> getter of its parents, for the hierarchy tree of a section. +hierarchy: + Concept: get_is_sub_concept_of + Property: get_is_sub_property_of + +# Boolean flags shown as characteristics of a property. +characteristics: + - is_functional + - is_inverse_functional + - is_transitive + - is_symmetric + - is_asymmetric + - is_reflexive + - is_irreflexive diff --git a/lode/viewer/config/skos_viewer.yaml b/lode/viewer/config/skos_viewer.yaml new file mode 100644 index 0000000..7ce90ec --- /dev/null +++ b/lode/viewer/config/skos_viewer.yaml @@ -0,0 +1,17 @@ +# Settings of the SKOS viewer. Read by lode.viewer.helpers.load_viewer_config. + +# Partial included once per entity by viewer.html. +card: _card_skos.html + +# SKOS keeps the model terms as they are. +vocabulary: {} + +# Display names of the entity types, keyed by the lowercase model class name. +type_map: + collection: {singular: Collection, plural: Collections, abb: col} + concept: {singular: Concept, plural: Concepts, abb: c} + +# Table of contents in display order: model class name, section id, type_map key. +toc: + - [Collection, collections, Collection] + - [Concept, concepts, Concept] diff --git a/lode/viewer/helpers.py b/lode/viewer/helpers.py new file mode 100644 index 0000000..21a81d7 --- /dev/null +++ b/lode/viewer/helpers.py @@ -0,0 +1,190 @@ +"""Stateless helpers shared by the viewers. + +Nothing here needs a Reader or a model instance: serialization formats, the +deterministic order of the header metadata, label cleaning, URL heuristics and +the loader of the per-format YAML configuration. +""" +import re +import urllib.parse +from pathlib import Path +from typing import Dict, Optional, Tuple + +import yaml + +_CONFIG_DIR = Path(__file__).parent / "config" + +# ========================================================================== +# 1. SERIALIZATION FORMATS +# ========================================================================== + +# Export formats: triples of the loaded artefact only. +SERIALIZATION_FORMATS = [ + {"label": "Turtle", "fmt": "turtle", "mime": "text/turtle", "ext": "ttl", "icon": "bi-filetype-raw"}, + {"label": "RDF/XML", "fmt": "xml", "mime": "application/rdf+xml", "ext": "rdf", "icon": "bi-file-code"}, + {"label": "JSON-LD", "fmt": "json-ld", "mime": "application/ld+json", "ext": "jsonld", "icon": "bi-filetype-json"}, + {"label": "N-Triples", "fmt": "nt", "mime": "application/n-triples", "ext": "nt", "icon": "bi-file-text"}, +] + +# Provenance formats: quads, the named graph of each triple is always shown. +QUAD_SERIALIZATION_FORMATS = [ + {"label": "TriG", "fmt": "trig", "mime": "application/trig", "ext": "trig", "icon": "bi-filetype-raw"}, + {"label": "JSON-LD", "fmt": "json-ld", "mime": "application/ld+json", "ext": "jsonld", "icon": "bi-filetype-json"}, + {"label": "N-Quads", "fmt": "nquads", "mime": "application/n-quads", "ext": "nq", "icon": "bi-file-text"}, +] + + +def safe_serialize(g, fmt: str) -> str: + """Serialize a graph, returning a comment with the error instead of raising. + + One format that fails must not break the page that shows all of them. + """ + try: + return g.serialize(format=fmt) + except Exception as e: + return f"# serialization error ({fmt}): {e}" + + +# ========================================================================== +# 2. HEADER METADATA ORDER +# ========================================================================== + +_ANNOTATION_ORDER_FILE = _CONFIG_DIR / "model_annotations_deterministic_sorting.yaml" + + +def _normalize_annotation_key(key: str) -> str: + """Reduce a displayed metadata key to a comparable form. + + Case, spaces, underscores and hyphens are dropped so that 'version info', + 'versionInfo' and 'version_info' land in the same slot of the order list. + """ + return re.sub(r"[\s_\-]+", "", key.lower()) + + +def _load_annotation_order() -> Dict[str, int]: + """Read the YAML order list and map each normalized key to its rank. + + Loaded once at import time. A missing or malformed file raises: a silent + fallback would make the page order depend on the environment again. + """ + with open(_ANNOTATION_ORDER_FILE, encoding="utf-8") as f: + order = yaml.safe_load(f)["order"] + return {_normalize_annotation_key(k): i for i, k in enumerate(order)} + + +_ANNOTATION_RANK = _load_annotation_order() + + +def annotation_sort_key(key: str) -> Tuple[int, str]: + """Sort key for a header metadata entry: configured rank, then alphabetical. + + Unlisted keys get a rank past the last listed one, so they follow the known + ones and stay in alphabetical order among themselves. + """ + return (_ANNOTATION_RANK.get(_normalize_annotation_key(key), len(_ANNOTATION_RANK)), key.lower()) + + +# ========================================================================== +# 3. LABELS AND URLS +# ========================================================================== + +def clean_name(name: str, vocabulary: Optional[Dict[str, str]] = None) -> str: + """Turn an attribute or getter name into a display label. + + Drops the has_/get_ prefix, splits camelCase, replaces underscores with + spaces and lowercases. The vocabulary of the viewer (internal word -> shown + word) is applied last, on whole words only, so that e.g. "concept" does not + touch "conceptual". + """ + if not name: + return "" + + name = re.sub(r'^(get_has_|get_|has_)', '', name) + # Put space between camelCase letters + name = re.sub(r'([a-z0-9])([A-Z])', r'\1 \2', name) + name = name.replace('_', ' ') + + # Strip extra spaces and lowercase everything + name = ' '.join(name.split()).lower() + + for word, shown in (vocabulary or {}).items(): + name = re.sub(rf'\b{re.escape(word)}\b', shown, name) + + return name + + +def is_likely_ontology(url: str) -> bool: + """Determines if a URL likely points to an ontology based on naming patterns.""" + if not url or not isinstance(url, str) or not url.startswith('http'): + return False + + # 1. Clean the string + clean_url = url.strip().lower() + + # 2. Safely parse the URL to ignore fragments (#) and queries (?) for extension checking + parsed = urllib.parse.urlparse(clean_url) + path = parsed.path.rstrip('/') + + # 3. Standard Ontology File Extensions + ontology_exts = ['.owl', '.rdf', '.ttl', '.nt', '.n3', '.jsonld'] + if any(path.endswith(ext) for ext in ontology_exts): + return True + + # Match registries against the parsed hostname, not the raw string: + # a substring check would also match e.g. https://evil.com/w3id.org/x + host = parsed.hostname or '' + + def _host_is(domain: str) -> bool: + """True if the URL host is the domain or one of its subdomains.""" + return host == domain or host.endswith('.' + domain) + + # 4. Common Ontology Registries & Permanent URLs + ontology_domains = [ + 'w3id.org', + 'purl.org', + 'xmlns.com' # Often used for FOAF + ] + if any(_host_is(domain) for domain in ontology_domains): + return True + + # 5. Core W3C Namespaces + w3c_core = ['/1999/02/22-rdf-syntax-ns', '/2000/01/rdf-schema', '/2002/07/owl'] + if _host_is('w3.org') and any(ns in path for ns in w3c_core): + return True + + # 6. Broad Keywords & Famous Standard Acronyms + # Looking for these anywhere in the URL string + path_keywords = [ + 'ontology', 'vocab', 'schema', # General terms + 'skos', 'foaf', 'crm', 'cidoc' # Famous standards + ] + if any(keyword in clean_url for keyword in path_keywords): + return True + + return False + + +# ========================================================================== +# 4. PER-FORMAT VIEWER CONFIGURATION +# ========================================================================== + +_REQUIRED_CONFIG_KEYS = ("type_map", "toc", "card") + + +def load_viewer_config(name: str) -> dict: + """Read config/_viewer.yaml and fill in the optional keys. + + The file holds everything that differs between formats (display names, table + of contents, hierarchy getters, card template, display vocabulary), so the + viewer classes carry no per-format data. A missing file or required key + raises: a silent default would change the page without any trace. + """ + path = _CONFIG_DIR / f"{name}_viewer.yaml" + with open(path, encoding="utf-8") as f: + config = yaml.safe_load(f) + missing = [k for k in _REQUIRED_CONFIG_KEYS if k not in config] + if missing: + raise ValueError(f"{path.name}: missing required keys {missing}") + config.setdefault("vocabulary", {}) + config.setdefault("hierarchy", {}) + config.setdefault("characteristics", []) + return config diff --git a/lode/viewer/owl_viewer.py b/lode/viewer/owl_viewer.py index 320fd2d..91aa2a4 100644 --- a/lode/viewer/owl_viewer.py +++ b/lode/viewer/owl_viewer.py @@ -1,37 +1,187 @@ -# viewer/owl_viewer.py -from typing import Dict, Optional +"""Viewer for OWL artefacts.""" +from typing import Optional + from lode.viewer.base_viewer import BaseViewer + class OwlViewer(BaseViewer): - """Viewer OWL""" - - TYPE_MAP = { - 'concept': {'singular': 'Class', 'plural': 'Classes', 'abb': 'c'}, - 'relation': {'singular': 'Object Property', 'plural': 'Object Properties', 'abb': 'op'}, - 'attribute': {'singular': 'Data Property', 'plural': 'Data Properties', 'abb': 'dp'}, - 'annotation': {'singular': 'Annotation Property', 'plural': 'Annotation Properties', 'abb': 'ap'}, - 'individual': {'singular': 'Named Individual', 'plural': 'Named Individuals', 'abb': 'ni'}, - } - - def get_toc_config(self): - return [ - ('Concept', 'concepts', 'Concept'), - ('Relation', 'relations', 'Relation'), - ('Attribute', 'attributes', 'Attribute'), - ('Individual', 'individuals', 'Individual'), - ('Annotation', 'annotations', 'Annotation'), - ] - - - def get_view_data(self, resource_uri: Optional[str] = None, language: Optional[str] = None) -> Dict: - all_instances = self.get_all_instances() - metadata_dict = self._find_and_format_metadata(all_instances) - - if resource_uri: - data = super().get_view_data(resource_uri, language) - else: - data = self._build_grouped_view(self.get_toc_config(), language) - - data['metadata'] = metadata_dict - data['type_map'] = self.TYPE_MAP - return data \ No newline at end of file + """OWL viewer: display names, table of contents and card come from config/owl_viewer.yaml. + + It adds the rendering of class expressions (restrictions, cardinalities, + boolean combinations), which only OWL has. + """ + + CONFIG_NAME = "owl" + + # Model classes that are anonymous class expressions, shown inline as clickable parts. + _RESTRICTION_TYPES = ["Restriction", "PropertyConceptRestriction", "Quantifier", "Cardinality", "TruthFunction", + "OneOf", "Value", "DatatypeRestriction"] + + def _resolve_special(self, obj, handler_dic: dict, language=None) -> Optional[dict]: + """Render a class expression as clickable parts; None for any other value. + + The blank node of the expression is never linked: its parts carry the links. + """ + obj_type = type(obj).__name__ + if obj_type not in self._RESTRICTION_TYPES: + return None + + is_dep = getattr(obj, 'get_is_deprecated')() if hasattr(obj, 'get_is_deprecated') else getattr( + obj, 'is_deprecated', False) + handler_dic['is_deprecated'] = bool(is_dep) + + # Recursively parse the restriction into clickable parts + parts = self._parse_restriction(obj, language) + + handler_dic['parts'] = parts + handler_dic['text'] = "".join([p['text'] for p in parts if p.get('text')]) + handler_dic['link'] = None # Forces Jinja to ignore the blank node URI + handler_dic['is_external'] = False + handler_dic['is_imported'] = False + handler_dic['type'] = obj_type + return handler_dic + + def _parse_restriction(self, obj, language=None, _seen=None) -> list: + """ + Recursively unpacks nested restrictions into a list of display parts + (each part being a dict with 'text' and 'link'). + Guards against cyclic class expressions (possible once owl:imports + completes cross-ontology definitions) via a visited set. + """ + if not obj: return [] + + if _seen is None: + _seen = set() + + # 1. Handle lists of restrictions/concepts (e.g., in TruthFunctions or OneOf) + if isinstance(obj, list) or isinstance(obj, set): + parts = [] + for i, item in enumerate(obj): + if i > 0: + parts.append({'text': ', ', 'link': None}) + parts.extend(self._parse_restriction(item, language, _seen)) + return parts + + obj_type = type(obj).__name__ + + # 2. If it is a Restriction, recursively unpack its specific components + if obj_type in self._RESTRICTION_TYPES: + # cycle guard: a class expression already being expanded upstream + if id(obj) in _seen: + return [{'text': '…', 'link': None}] + _seen.add(id(obj)) + + parts = [] + + # Helper to safely call getter methods (e.g., get_applies_on_property) + def _get(instance, prop_name, default=None): + """Read a property through its getter, or directly as an attribute, with a default for None.""" + getter = f"get_{prop_name}" + if hasattr(instance, getter): + res = getattr(instance, getter)() + return res if res is not None else default + return getattr(instance, prop_name, default) + + if obj_type == "Quantifier": + prop = _get(obj, 'applies_on_property') + quant = _get(obj, 'has_quantifier_type', "some") + concept = _get(obj, 'applies_on_concept') + + # Safely check if the is_inverse flag is True + is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) + if is_inv: + parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) + + parts.extend(self._parse_restriction(prop, language, _seen)) + parts.append({'text': f' {quant} ', 'link': None}) + parts.extend(self._parse_restriction(concept, language, _seen)) + + elif obj_type == "Cardinality": + prop = _get(obj, 'applies_on_property') + card = _get(obj, 'has_cardinality_type', "exactly") + card_num = _get(obj, 'has_cardinality') + concept = _get(obj, 'applies_on_concept') + + # Safely check if the is_inverse flag is True + is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) + if is_inv: + parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) + + parts.extend(self._parse_restriction(prop, language, _seen)) + parts.append({'text': f' {card} {card_num}', 'link': None}) + parts.extend(self._parse_restriction(concept, language, _seen)) + + elif obj_type == "TruthFunction": + operator = _get(obj, 'has_logical_operator', "and") + concepts = _get(obj, 'applies_on_concept', []) + if not isinstance(concepts, list): concepts = [concepts] + + # Safely check if the is_inverse flag is True + is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) + if is_inv: + parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) + + parts.append({'text': '(', 'link': None}) + for i, c in enumerate(concepts): + if i > 0: + parts.append({'text': f' {operator} ', 'link': None}) + parts.extend(self._parse_restriction(c, language, _seen)) + parts.append({'text': ')', 'link': None}) + + elif obj_type == "OneOf": + resources = _get(obj, 'applies_on_resource', []) + if not isinstance(resources, list): resources = [resources] + + # Safely check if the is_inverse flag is True + is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) + if is_inv: + parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) + + parts.append({'text': 'one of { ', 'link': None}) + for i, r in enumerate(resources): + if i > 0: + parts.append({'text': ', ', 'link': None}) + parts.extend(self._parse_restriction(r, language, _seen)) + parts.append({'text': ' }', 'link': None}) + + elif obj_type == "Value": + prop = _get(obj, 'applies_on_property') + resource = _get(obj, 'applies_on_resource') + + # Safely check if the is_inverse flag is True + is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) + if is_inv: + parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) + + parts.extend(self._parse_restriction(prop, language, _seen)) + parts.append({'text': ' value ', 'link': None}) + parts.extend(self._parse_restriction(resource, language, _seen)) + + elif obj_type == "DatatypeRestriction": + concept = _get(obj, 'applies_on_concept') # Datatype(xsd:string) + constraint = _get(obj, 'has_constraint') # "pattern" + value = _get(obj, 'has_restriction_value') # Literal("[0-9]+") + + # Safely check if the is_inverse flag is True + is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) + if is_inv: + parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) + + parts.extend(self._parse_restriction(concept, language, _seen)) + if constraint: + parts.append({'text': f' with {constraint} ', 'link': None, 'type': 'Text'}) + else: + parts.append({'text': ' restricted by ', 'link': None, 'type': 'Text'}) + + parts.extend(self._parse_restriction(value, language, _seen)) + + return parts + + # 3. Base Case: We hit a non-blank node (Concept, Relation, String) + # Send it to the main resolver to extract its URI link and clean text. + resolved = self._resolve_resource_value(obj, language) + + if resolved.get('text'): + return [{'text': resolved['text'], 'link': resolved.get('link'), 'type': resolved.get('type'), 'is_external': resolved.get('is_external', False)}] + + return [] diff --git a/lode/viewer/rdf_viewer.py b/lode/viewer/rdf_viewer.py index 7ddc7fa..7b10986 100644 --- a/lode/viewer/rdf_viewer.py +++ b/lode/viewer/rdf_viewer.py @@ -1,52 +1,8 @@ -# viewer/rdf_viewer.py -from typing import Dict, Optional +"""Viewer for RDF/RDFS artefacts.""" from lode.viewer.base_viewer import BaseViewer class RdfViewer(BaseViewer): - """Viewer RDF / RDFS""" + """RDF / RDFS viewer: display names, table of contents and card come from config/rdf_viewer.yaml.""" - TYPE_MAP = { - 'concept': {'singular': 'Class', 'plural': 'Classes', 'abb': 'c'}, - 'property': {'singular': 'Property', 'plural': 'Properties', 'abb': 'p'}, - 'container': {'singular': 'Container', 'plural': 'Containers', 'abb': 'cont'}, - 'resource': {'singular': 'Resource', 'plural': 'Resources', 'abb': 'r'}, - 'model': {'singular': 'Vocabulary','plural': 'Vocabularies','abb': 'v'}, - } - - def get_view_data(self, resource_uri: Optional[str] = None, language: Optional[str] = None) -> Dict: - all_instances = self.get_all_instances() - metadata_dict = self._find_and_format_metadata(all_instances, language) - - if resource_uri: - data = super().get_view_data(resource_uri, language) - data['metadata'] = metadata_dict - data['type_map'] = self.TYPE_MAP - return data - - # title in SINGOLARE per matchare le chiavi di TYPE_MAP - toc_config = [ - ('Concept', 'concepts', 'Concept'), - ('Property', 'properties', 'Property'), - ('Container', 'containers', 'Container'), - ] - - data = self._build_grouped_view(toc_config, language) - data['metadata'] = metadata_dict - data['type_map'] = self.TYPE_MAP - - # DEBUG β€” togliere dopo - print("=" * 60) - print(f"DEBUG RDF VIEWER") - print(f" grouped_view = {data.get('grouped_view')}") - print(f" sections count = {len(data.get('sections') or [])}") - for s in data.get('sections') or []: - print(f" [{s['id']}] title={s['title']!r} entities={len(s['entities'])}") - print(f" type_map keys = {list(data['type_map'].keys())}") - # Conta i tipi reali nel cache - from collections import Counter - types = Counter(type(i).__name__ for i in self.get_all_instances()) - print(f" instances by type = {dict(types)}") - print("=" * 60) - - return data \ No newline at end of file + CONFIG_NAME = "rdf" diff --git a/lode/viewer/skos_viewer.py b/lode/viewer/skos_viewer.py index 5061635..96ea462 100644 --- a/lode/viewer/skos_viewer.py +++ b/lode/viewer/skos_viewer.py @@ -1,51 +1,21 @@ -# viewer/skos.py +"""Viewer for SKOS artefacts.""" import hashlib from typing import Dict, Optional, List from lode.viewer.base_viewer import BaseViewer from lode.helpers.rdf import iri_local_name class SkosViewer(BaseViewer): - """Viewer SKOS con visualizzazione LODE-style.""" + """SKOS viewer with LODE-style rendering. - def get_view_data(self, resource_uri: Optional[str] = None, language: Optional[str] = None) -> Dict: - # 1. Single resource detail view - if resource_uri: - return super().get_view_data(resource_uri, language) + Display names, table of contents and card come from config/skos_viewer.yaml; + only the entity formatting differs from the base, because SKOS cards show + semantic relations between concepts instead of OWL-style statements. + """ - # 2. Define the Table of Contents structure for SKOS - toc_config = [ - ('Collection', 'collections', 'Collections'), - ('Concept', 'concepts', 'Concepts'), - ] + CONFIG_NAME = "skos" - # 3. Build grouped view with SKOS-specific formatting - return self._build_skos_grouped_view(toc_config, language) - - def _build_skos_grouped_view(self, group_definitions: List, language: Optional[str] = None) -> Dict: - """Costruisce la vista raggruppata con formattazione SKOS-style.""" - all_instances = self.get_all_instances() - sections = [] - - for class_key, section_id, section_title in group_definitions: - instances = [ - inst for inst in all_instances - if type(inst).__name__ == class_key - ] - - if instances: - sections.append({ - 'id': section_id, - 'title': section_title, - 'entities': self._format_skos_entities(instances, language) - }) - - return { - 'grouped_view': True, - 'sections': sections - } - - def _format_skos_entities(self, instances: List, language: Optional[str] = None) -> List[Dict]: - """Formatta le entitΓ  SKOS in stile LODE.""" + def _format_entities(self, instances: List, language: Optional[str] = None) -> List[Dict]: + """Format the SKOS entities in LODE style, sorted by label (or IRI when there is none).""" entities = [] for instance in instances: @@ -159,7 +129,7 @@ def _format_skos_entities(self, instances: List, language: Optional[str] = None) return entities def _format_concept_list(self, concepts, language: Optional[str]) -> List[Dict]: - """Formatta una lista di concetti con label e URI.""" + """Format a list of concepts with label and IRI; plain string IRIs are marked as external.""" items = [] concept_list = concepts if isinstance(concepts, (list, set)) else [concepts] @@ -182,22 +152,19 @@ def _format_concept_list(self, concepts, language: Optional[str]) -> List[Dict]: return items def _get_definition(self, instance, language: Optional[str]) -> Optional[str]: - """Estrae la definizione.""" + """Extract the definition text in the given language, or None when there is none.""" if hasattr(instance, 'has_definition') and instance.has_definition: return self._get_literal_value(instance.has_definition, language) return None def _get_literal_value(self, value, language: Optional[str]) -> Optional[str]: - """Estrae il valore stringa da un Literal o lista di Literal.""" + """Extract the string value of a Literal or of a list of Literals. + + The first literal in the best available language wins (see `_select_by_language`). + """ if isinstance(value, (set, list)): - if language: - for v in value: - if hasattr(v, 'get_has_language') and v.get_has_language() == language: - return v.get_has_value() if hasattr(v, 'get_has_value') else str(v) - for v in value: - if hasattr(v, 'get_has_value'): - return v.get_has_value() - return str(v) + for v in self._select_by_language(list(value), language): + return v.get_has_value() if hasattr(v, 'get_has_value') else str(v) elif hasattr(value, 'get_has_value'): return value.get_has_value() elif value: diff --git a/lode/warnings.json b/lode/warnings.json deleted file mode 100644 index e8a6926..0000000 --- a/lode/warnings.json +++ /dev/null @@ -1,22 +0,0 @@ -[ - { - "code": "property_type_conflict", - "subject": "http://purl.org/dc/terms/creator", - "message": "Β§5.8.1: http://purl.org/dc/terms/creator has multiple property types: ['Annotation', 'Relation']" - }, - { - "code": "property_type_conflict", - "subject": "http://purl.org/dc/terms/issued", - "message": "Β§5.8.1: http://purl.org/dc/terms/issued has multiple property types: ['Annotation', 'Attribute']" - }, - { - "code": "property_type_conflict", - "subject": "http://purl.org/dc/terms/modified", - "message": "Β§5.8.1: http://purl.org/dc/terms/modified has multiple property types: ['Annotation', 'Attribute']" - }, - { - "code": "property_type_conflict", - "subject": "http://purl.org/dc/terms/language", - "message": "Β§5.8.1: http://purl.org/dc/terms/language has multiple property types: ['Annotation', 'Relation']" - } -] \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..726b4d8 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,40 @@ +site_name: LODE 2.0 +site_description: Live OWL Documentation Environment β€” extract and document RDF/OWL/SKOS semantic artefacts as browsable HTML. +site_url: https://opencitations.github.io/lode/ +repo_url: https://github.com/opencitations/lode +repo_name: opencitations/lode + +theme: + name: material + features: + - navigation.sections + - navigation.top + - content.code.copy + palette: + - scheme: default + toggle: + icon: material/weather-night + name: Switch to dark mode + - scheme: slate + toggle: + icon: material/weather-sunny + name: Switch to light mode + +markdown_extensions: + - admonition + - attr_list + - tables + - toc: + permalink: true + - pymdownx.highlight + - pymdownx.superfences + +nav: + - Home: index.md + - Getting started: getting_started.md + - Usage: + - Web form: usage/web_form.md + - CLI: usage/cli.md + - Docker: usage/docker.md + - API reference: + - Endpoints: api/endpoints.md diff --git a/pyproject.toml b/pyproject.toml index 4f9e035..0f9960f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,23 @@ [project] -name = "lode" -version = "0.3.15" +name = "oc-lode" +version = "2.1.1" description = "New reengineered version of LODE, maintained by OpenCitations" -authors = [{name = "Valentina Pasqual, Silvio Peroni", email = "valentina.pasqual2@unibo.it"}] +authors = [ + {name = "Valentina Pasqual", email = "valentina.pasqual2@unibo.it"}, + {name = "Ahmadreza Nazari"}, + {name = "Silvio Peroni", email = "silvio.peroni@unibo.it"}, +] readme = "README.md" -license = {text = "ISC"} +license = "ISC" +license-files = ["LICENSE.md"] requires-python = ">=3.10" -keywords = [] +keywords = ["owl", "ontology", "rdf", "skos", "documentation", "semantic-web", "opencitations"] classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: ISC License (ISCL)", + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Natural Language :: English", + "Operating System :: OS Independent", + "Topic :: Software Development :: Documentation", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -18,24 +25,29 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "fastapi>=0.104.1", - "uvicorn[standard]>=0.24.0", - "gunicorn>=21.0.0", - "python-multipart>=0.0.6", "rdflib>=7.4.0", "jinja2>=3.1.2", "requests>=2.31.0", - "owlready2>=0.50", "minify-html>=0.18.1", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +server = [ + "fastapi>=0.104.1", + "uvicorn[standard]>=0.24.0", + "gunicorn>=21.0.0", + "python-multipart>=0.0.6", ] -# AGGIUNTO [project.scripts] lode = "lode.cli:main" [project.urls] Homepage = "https://github.com/opencitations/lode" Repository = "https://github.com/opencitations/lode" +Documentation = "https://opencitations.github.io/lode/" +Issues = "https://github.com/opencitations/lode/issues" [dependency-groups] dev = [ @@ -46,13 +58,11 @@ dev = [ ] [build-system] -requires = ["hatchling>=1.26"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["lode"] -# include template e static nel wheel -# [tool.hatch.build.targets.wheel.shared-data] -# "lode/templates" = "lode/templates" -# "lode/static" = "lode/static" +[tool.hatch.build.targets.sdist] +include = ["lode", "tests"] diff --git a/tests/data/provenance/main.ttl b/tests/data/provenance/main.ttl index a2d8a98..d980735 100644 --- a/tests/data/provenance/main.ttl +++ b/tests/data/provenance/main.ttl @@ -8,3 +8,6 @@ a owl:Class ; rdfs:subClassOf , , . rdfs:label "T from main"@en . + + rdfs:comment "V described by main" . + rdfs:comment "X described by main" . diff --git a/tests/test_modules.py b/tests/test_modules.py index 3a24cb7..99faac6 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -5,8 +5,9 @@ Each module (imported, closure, partial_import) loads external documents into the graph of the documented ontology. With any module active the graph is a ProvenanceGraph: every triple sits in the named graph of the document that asserts -it, and keeps that context through the URL cache, per-entity export and the -quad serializations (TriG, N-Quads, JSON-LD). +it, and keeps that context through the URL cache and the per-entity provenance +(quads: TriG, N-Quads, JSON-LD). Exports and content negotiation, instead, return +only the triples of the loaded artefact, with or without modules. Sections -------- @@ -16,7 +17,7 @@ 4. partial_import only the home definition of each mentioned external term 5. URL cache only the main document is cached; modules re-applied on a hit 6. Per-entity provenance (viewer) -7. HTTP API the same guarantees end-to-end, through serialization +7. HTTP API exports are triples of the loaded artefact only, end-to-end Fixtures -------- @@ -45,9 +46,8 @@ from lode.exceptions import ArtefactNotFoundError from lode.reader.loader import Loader from lode.reader.provenance_graph import ProvenanceGraph -from lode.viewer.base_viewer import ( - QUAD_SERIALIZATION_FORMATS, SERIALIZATION_FORMATS, BaseViewer, formats_for, -) +from lode.reader.reader import Reader +from lode.viewer.base_viewer import QUAD_SERIALIZATION_FORMATS, BaseViewer # ========================================================================== # FIXTURES @@ -261,11 +261,6 @@ def test_home_definition_in_home_graph(self, ds): assert contexts(ds, (H.T, RDF.type, OWL.Class)) == {HOME} assert contexts(ds, (H.T, RDFS.comment, Literal("T defined by home"))) == {HOME} - def test_documented_ontology_has_priority(self, ds): - """main restates the label of home#T: main's label wins, home's is not copied.""" - assert set(ds.objects(H.T, RDFS.label)) == {Literal("T from main", lang="en")} - assert contexts(ds, (H.T, RDFS.label, Literal("T from main", lang="en"))) == {MAIN} - def test_third_party_redefinition_is_not_copied(self, ds): """imp redefines home#T, but imp is not home#T's home.""" assert (H.T, RDFS.comment, Literal("T redefined by imp")) not in ds @@ -278,6 +273,23 @@ def test_blank_nodes_follow_parent_context(self, ds): assert contexts(ds, t) == {HOME} assert set(ds.predicates(restr)) == {RDF.type, OWL.onProperty, OWL.someValuesFrom} + # -- priority of documented ontology over imported ontos -------------- + + def test_both_definitions_kept_with_provenance(self, ds): + """main restates the label of home#T: both labels kept, each in its own graph.""" + assert contexts(ds, (H.T, RDFS.label, Literal("T from main", lang="en"))) == {MAIN} + assert contexts(ds, (H.T, RDFS.label, Literal("T from home", lang="en"))) == {HOME} + + def test_documented_values_come_first(self, main_path): + """The Reader puts main's label of home#T before home's.""" + from lode.reader.reader import Reader + reader = Reader() + reader.load_instances(graph_path=main_path, read_as="owl", partial_import=True) + for inst in reader._instance_cache[H.T]: + labels = [l.get_has_value() for l in inst.get_has_label()] + if labels: + assert labels[0] == "T from main", labels + # -- finding the home ------------------------------------------------- def test_home_by_dereferenced_document_when_iri_differs(self, ds): @@ -285,9 +297,10 @@ def test_home_by_dereferenced_document_when_iri_differs(self, ds): assert contexts(ds, (V.V, RDF.type, OWL.Class)) == {VER} def test_unreachable_home_gets_no_external_definition(self, ds, offline_fetch): - """missing is not served: imp's redefinition of missing#X must not fill the gap.""" + """missing is not served: only main's own triple, imp's redefinition must not fill the gap.""" assert "http://ex.org/missing" in offline_fetch - assert set(ds.predicate_objects(X.X)) == set() + assert set(ds.predicate_objects(X.X)) == {(RDFS.comment, Literal("X described by main"))} + assert contexts(ds, (X.X, RDFS.comment, Literal("X described by main"))) == {MAIN} # -- fetching --------------------------------------------------------- @@ -307,6 +320,8 @@ def test_vocabulary_without_ontology_fetched_once(self, tmp_path, offline_fetch, path = write_ttl(tmp_path, "m.ttl", """ a owl:Ontology . rdfs:seeAlso , . + rdfs:comment "a described by m2" . + rdfs:comment "b described by m2" . """) ds = Loader(path, partial_import=True).get_graph() assert len([u for u in offline_fetch if u.startswith("http://ex.org/voc/")]) == 1 @@ -317,6 +332,9 @@ def test_failed_namespace_not_retried_per_term(self, tmp_path, offline_fetch): path = write_ttl(tmp_path, "m.ttl", """ a owl:Ontology . rdfs:seeAlso , , . + rdfs:comment "a" . + rdfs:comment "b" . + rdfs:comment "c" . """) Loader(path, partial_import=True) assert len([u for u in offline_fetch if u.startswith("http://ex.org/dead/")]) == 1 @@ -356,13 +374,14 @@ def test_caching_the_union_breaks_provenance(self, main_path, tmp_path): class FakeReader: """Minimal reader for BaseViewer: only the graph and an empty instance cache.""" - def __init__(self, graph): + def __init__(self, graph, ontology_iri=None): self._graph = graph self._instance_cache = {} + self._ontology_iri = ontology_iri def entity_provenance(ds, subject): - """What an entity card exports: its triples, placed back in their origin graphs.""" + """What an entity card shows as provenance: its triples, placed in their origin graphs.""" sub = Graph() for t in ds.triples((subject, None, None)): sub.add(t) @@ -372,19 +391,25 @@ def entity_provenance(ds, subject): class TestEntityProvenance: """Formats offered, and each triple of an entity in the graph(s) it comes from.""" - def test_formats_without_modules_are_triple_formats(self): - assert formats_for(Graph()) is SERIALIZATION_FORMATS - - def test_formats_with_modules_are_quad_formats(self): - assert formats_for(ProvenanceGraph()) is QUAD_SERIALIZATION_FORMATS + def test_quad_formats_have_no_rdfxml(self): assert "xml" not in {f["fmt"] for f in QUAD_SERIALIZATION_FORMATS} - def test_without_modules_provenance_is_unchanged(self): + def test_without_modules_provenance_is_a_quad_in_the_artefact_graph(self): g = Graph() g.add((M.A, RDF.type, OWL.Class)) sub = Graph() sub.add((M.A, RDF.type, OWL.Class)) - assert BaseViewer(FakeReader(g))._with_provenance(sub) is sub + out = BaseViewer(FakeReader(g, MAIN))._with_provenance(sub) + assert isinstance(out, ProvenanceGraph) + assert graphs(out) == {MAIN} + + def test_card_provenance_names_the_graph_without_modules(self, main_path): + reader = Reader() + reader.load_instances(main_path, "owl") + viewer = reader.get_viewer() + codes = [f["code"] for inst in reader._instance_cache[H.T] + for f in viewer._build_provenance_subgraph(inst) if f["label"] == "TriG"] + assert codes and all(f"<{MAIN}>" in c for c in codes) def test_partial_import_term_spans_main_and_home(self, main_path): """home#T: label from main, definition from home, nothing from imp.""" @@ -422,12 +447,14 @@ def test_no_blank_graph_names(self, main_path, flag): # 7. HTTP API (end-to-end) # ========================================================================== # +# Exports and content negotiation return the triples of the loaded artefact, with or +# without modules: imported documents show up only in the provenance of each card. # Uploads go through the spool (spool.save) and are requested with -# GET /extract?upload_id=..., the same path as _resolve_reader. Quad serializations -# are parsed back into a Dataset (default_union=True, so objects()/subjects()/`in` -# see the named graphs) to check that contexts survive serialization. +# GET /extract?upload_id=..., the same path as _resolve_reader. -RDFLIB_FMT = {"trig": "trig", "nq": "nquads", "jsonld": "json-ld"} +TRIPLE_FORMATS = {"ttl": "turtle", "nt": "nt", "rdf": "xml", "jsonld": "json-ld"} +QUAD_EXTS = ["trig", "nq"] +ALL_FLAGS = [None, *MODULES] @pytest.fixture(scope="module") @@ -450,6 +477,11 @@ def _flags(flags) -> dict: return {k: "true" for k, v in flags.items() if v} +def flags_of(flag) -> dict: + """Request flags for one module, or none.""" + return {flag: True} if flag else {} + + def get(client, token, fmt, resource=None, **flags): """GET /extract on an upload, with an explicit ?format=.""" params = {"read_as": "owl", "upload_id": token, "format": fmt, **_flags(flags)} @@ -476,16 +508,11 @@ def get_url(client, url, fmt=None, resource=None, cache=False, **flags): return client.get("/extract", params=params) -def as_dataset(resp, fmt) -> Dataset: +def as_graph(resp, fmt) -> Graph: + """Parse an export response back into a plain Graph (a quad format would not parse here).""" assert resp.status_code == 200, resp.text[:500] assert "text/html" not in resp.headers.get("content-type", ""), f"{fmt}: got HTML instead of RDF" - ds = Dataset(default_union=True) - ds.parse(data=resp.text, format=RDFLIB_FMT[fmt]) - return ds - - -def default_graph_is_empty(ds) -> bool: - return len(ds.graph(DATASET_DEFAULT_GRAPH_ID)) == 0 + return Graph().parse(data=resp.text, format=TRIPLE_FORMATS[fmt]) def filename(resp) -> str: @@ -494,134 +521,73 @@ def filename(resp) -> str: return cd.split('filename="', 1)[1].rstrip('"') -class TestUploadWithoutModules: - """No modules: plain graph, no quad formats.""" +class TestExportIsLoadedArtefactOnly: + """Whatever the modules, an export holds the triples of the loaded document and nothing else.""" - @pytest.mark.parametrize("fmt", ["trig", "nq"]) - def test_quad_formats_rejected(self, client, fmt): - assert get(client, upload(MAIN_TTL), fmt).status_code == 406 + @pytest.mark.parametrize("flag", ALL_FLAGS) + @pytest.mark.parametrize("fmt", list(TRIPLE_FORMATS)) + def test_whole_export_is_the_main_document(self, client, fmt, flag): + g = as_graph(get(client, upload(MAIN_TTL), fmt, **flags_of(flag)), fmt) + assert isomorphic(g, doc("main")) - @pytest.mark.parametrize("fmt", ["ttl", "nt", "rdf", "jsonld"]) - def test_triple_formats_available(self, client, fmt): - assert get(client, upload(MAIN_TTL), fmt).status_code == 200 + @pytest.mark.parametrize("flag", ALL_FLAGS) + @pytest.mark.parametrize("fmt", QUAD_EXTS) + def test_quad_formats_rejected(self, client, fmt, flag): + assert get(client, upload(MAIN_TTL), fmt, **flags_of(flag)).status_code == 406 - def test_html_offers_triple_formats_only(self, client): - html = client.post("/extract", data={"read_as": "owl"}, - files={"file": ("main.ttl", MAIN_TTL, "text/turtle")}).text - assert "N-Triples" in html - assert "TriG" not in html and "N-Quads" not in html + def test_resource_export_has_main_triples_only(self, client): + """home#T: the label restated by main is exported, the definition of home is not.""" + g = as_graph(get(client, upload(MAIN_TTL), "ttl", resource=H.T, partial_import=True), "ttl") + assert ground(g) <= ground(doc("main")) + assert (H.T, RDFS.label, Literal("T from main", lang="en")) in g + assert (H.T, RDFS.comment, Literal("T defined by home")) not in g + + def test_documented_values_come_first(self, main_path): + """The Reader puts main's label of home#T before home's.""" + reader = Reader() + reader.load_instances(graph_path=main_path, read_as="owl", partial_import=True) + for inst in reader._instance_cache[H.T]: + labels = [l.get_has_value() for l in inst.get_has_label()] + if labels: + assert labels[0] == "T from main", labels -class TestUploadImportedClosure: - """Formal imports through the API: graphs survive serialization.""" +class TestHtmlOffers: + """The page offers triples to export and quads as provenance, with or without modules.""" - def test_html_offers_quad_formats(self, client): - html = client.post("/extract", data={"read_as": "owl", "imported": "true"}, + @pytest.mark.parametrize("flag", ALL_FLAGS) + def test_export_in_triples_provenance_in_quads(self, client, flag): + html = client.post("/extract", data={"read_as": "owl", **_flags(flags_of(flag))}, files={"file": ("main.ttl", MAIN_TTL, "text/turtle")}).text + assert "N-Triples" in html and "RDF/XML" in html assert "TriG" in html and "N-Quads" in html - assert "N-Triples" not in html and "RDF/XML" not in html - - @pytest.mark.parametrize("fmt", ["trig", "nq", "jsonld"]) - def test_imported_graphs_survive_serialization(self, client, fmt): - ds = as_dataset(get(client, upload(MAIN_TTL), fmt, imported=True), fmt) - assert graphs(ds) == {MAIN, IMP} - assert default_graph_is_empty(ds) - assert contexts(ds, (I.I, RDF.type, OWL.Class)) == {IMP} - assert (D.D, None, None) not in ds - - def test_upload_context_is_declared_ontology_not_spool_path(self, client): - ds = as_dataset(get(client, upload(MAIN_TTL), "trig", imported=True), "trig") - assert not any(str(g).startswith("file:") for g in graphs(ds)) - - def test_closure_fallback_context_is_url(self, client): - ds = as_dataset(get(client, upload(MAIN_TTL), "nq", closure=True), "nq") - assert graphs(ds) == {MAIN, IMP, DEEP} - assert contexts(ds, (D.D, RDF.type, OWL.Class)) == {DEEP} - - def test_imported_redefinition_stays_in_imported_graph(self, client): - ds = as_dataset(get(client, upload(MAIN_TTL), "trig", imported=True), "trig") - assert contexts(ds, (H.T, RDFS.comment, Literal("T redefined by imp"))) == {IMP} - - -class TestUploadPartialImport: - """partial_import through the API: same boundary as TestPartialImport.""" - - @pytest.fixture - def ds(self, client): - return as_dataset(get(client, upload(MAIN_TTL), "trig", partial_import=True), "trig") - - def test_graphs_are_main_and_homes_only(self, ds): - assert graphs(ds) == {MAIN, HOME, VER} - - def test_documented_ontology_has_priority(self, ds): - assert set(ds.objects(H.T, RDFS.label)) == {Literal("T from main", lang="en")} - assert contexts(ds, (H.T, RDFS.label, Literal("T from main", lang="en"))) == {MAIN} - - def test_home_definition_in_home_graph(self, ds): - assert contexts(ds, (H.T, RDFS.comment, Literal("T defined by home"))) == {HOME} - - def test_third_party_redefinition_absent(self, ds): - assert (H.T, RDFS.comment, Literal("T redefined by imp")) not in ds - assert (H.T, OWL.equivalentClass, I.I) not in ds - - def test_restriction_blank_nodes_in_home_graph(self, ds): - (restr,) = [o for o in ds.objects(H.T, RDFS.subClassOf) if isinstance(o, BNode)] - for t in ds.triples((restr, None, None)): - assert contexts(ds, t) == {HOME} - - def test_versioned_home_found_via_dereferenced_document(self, ds): - assert contexts(ds, (V.V, RDF.type, OWL.Class)) == {VER} - - def test_unreachable_home_gives_no_external_definition(self, ds): - assert set(ds.predicate_objects(X.X)) == set() - - def test_external_ontology_headers_absent(self, ds): - assert set(ds.subjects(RDF.type, OWL.Ontology)) == {MAIN} - - @pytest.mark.parametrize("fmt", ["trig", "nq", "jsonld"]) - def test_resource_export_has_origin_graphs(self, client, fmt): - ds = as_dataset(get(client, upload(MAIN_TTL), fmt, resource=H.T, partial_import=True), fmt) - assert graphs(ds) == {MAIN, HOME} - assert default_graph_is_empty(ds) - assert contexts(ds, (H.T, RDF.type, OWL.Class)) == {HOME} class TestDownloadAccept: """Accept is a preference: an unavailable format falls back to HTML (406 only for an explicit ?format=).""" - @pytest.mark.parametrize("mime,fmt", [("application/trig", "trig"), - ("application/n-quads", "nq"), - ("application/ld+json", "jsonld")]) - def test_quad_mime_with_modules(self, client, mime, fmt): - resp = get_accept(client, upload(MAIN_TTL), mime, imported=True) - assert resp.headers["content-type"].startswith(mime) - assert graphs(as_dataset(resp, fmt)) == {MAIN, IMP} - - def test_first_available_mime_wins(self, client): - resp = get_accept(client, upload(MAIN_TTL), "application/n-quads;q=0.9, text/html;q=0.8", - imported=True) - assert resp.headers["content-type"].startswith("application/n-quads") - - def test_resource_export_via_accept(self, client): - resp = get_accept(client, upload(MAIN_TTL), "application/trig", resource=H.T, partial_import=True) - assert graphs(as_dataset(resp, "trig")) == {MAIN, HOME} - - def test_quad_mime_without_modules_falls_back_to_html(self, client): - resp = get_accept(client, upload(MAIN_TTL), "application/trig") + @pytest.mark.parametrize("flag", ALL_FLAGS) + @pytest.mark.parametrize("mime", ["application/trig", "application/n-quads"]) + def test_quad_mime_falls_back_to_html(self, client, mime, flag): + resp = get_accept(client, upload(MAIN_TTL), mime, **flags_of(flag)) assert resp.status_code == 200 assert resp.headers["content-type"].startswith("text/html") - def test_triple_mime_without_modules(self, client): - resp = get_accept(client, upload(MAIN_TTL), "text/turtle") + @pytest.mark.parametrize("flag", ALL_FLAGS) + def test_triple_mime(self, client, flag): + resp = get_accept(client, upload(MAIN_TTL), "text/turtle", **flags_of(flag)) assert resp.headers["content-type"].startswith("text/turtle") - assert len(Graph().parse(data=resp.text, format="turtle")) > 0 + assert isomorphic(as_graph(resp, "ttl"), doc("main")) - def test_triple_mime_with_modules_falls_back_to_html(self, client): - """Current behaviour: with modules, Turtle is not offered. Update this test if the - union is ever served as Turtle alongside the quad formats.""" - resp = get_accept(client, upload(MAIN_TTL), "text/turtle", imported=True) - assert resp.headers["content-type"].startswith("text/html") + def test_first_available_mime_wins(self, client): + resp = get_accept(client, upload(MAIN_TTL), "application/n-quads;q=0.9, text/turtle;q=0.8", + imported=True) + assert resp.headers["content-type"].startswith("text/turtle") + + def test_resource_export_via_accept(self, client): + resp = get_accept(client, upload(MAIN_TTL), "text/turtle", resource=H.T, partial_import=True) + assert (H.T, RDFS.comment, Literal("T defined by home")) not in as_graph(resp, "ttl") def test_browser_accept_gets_html(self, client): resp = get_accept(client, upload(MAIN_TTL), "text/html,application/xhtml+xml,*/*;q=0.8", @@ -633,72 +599,64 @@ class TestDownloadFilename: """Content-Disposition file name: resource -> local name; graph -> last URL segment, or 'graph' for uploads.""" - @pytest.mark.parametrize("fmt", ["trig", "nq", "jsonld"]) + @pytest.mark.parametrize("fmt", ["ttl", "jsonld"]) def test_upload_whole_graph(self, client, fmt): assert filename(get(client, upload(MAIN_TTL), fmt, imported=True)) == f"graph.{fmt}" - @pytest.mark.parametrize("fmt", ["trig", "nq"]) - def test_resource_uses_local_name(self, client, fmt): - resp = get(client, upload(MAIN_TTL), fmt, resource=H.T, partial_import=True) - assert filename(resp) == f"T.{fmt}" + def test_resource_uses_local_name(self, client): + resp = get(client, upload(MAIN_TTL), "ttl", resource=H.T, partial_import=True) + assert filename(resp) == "T.ttl" def test_url_whole_graph_uses_last_segment(self, client, served_main): - assert filename(get_url(client, served_main, "trig", imported=True)) == "main.trig" + assert filename(get_url(client, served_main, "ttl", imported=True)) == "main.ttl" def test_url_trailing_slash(self, client, served_main): - assert filename(get_url(client, served_main + "/", "trig", imported=True)) == "main.trig" - - def test_triple_format_without_modules(self, client): - assert filename(get(client, upload(MAIN_TTL), "ttl")) == "graph.ttl" + assert filename(get_url(client, served_main + "/", "ttl", imported=True)) == "main.ttl" class TestDownloadUrl: """?url= path: fetch of the main document plus the spool cache.""" - def test_imported_graphs(self, client, served_main): - ds = as_dataset(get_url(client, served_main, "trig", imported=True), "trig") - assert graphs(ds) == {MAIN, IMP} - assert contexts(ds, (I.I, RDF.type, OWL.Class)) == {IMP} - - def test_partial_import_homes(self, client, served_main): - ds = as_dataset(get_url(client, served_main, "nq", partial_import=True), "nq") - assert graphs(ds) == {MAIN, HOME, VER} - assert (H.T, RDFS.comment, Literal("T redefined by imp")) not in ds + @pytest.mark.parametrize("flag", ALL_FLAGS) + def test_export_is_the_main_document(self, client, served_main, flag): + g = as_graph(get_url(client, served_main, "ttl", **flags_of(flag)), "ttl") + assert isomorphic(g, doc("main")) - def test_quad_format_without_modules_is_406(self, client, served_main): - assert get_url(client, served_main, "trig").status_code == 406 + @pytest.mark.parametrize("flag", ALL_FLAGS) + def test_quad_format_is_406(self, client, served_main, flag): + assert get_url(client, served_main, "trig", **flags_of(flag)).status_code == 406 @pytest.mark.parametrize("flag", MODULES) def test_cache_stores_main_document_only(self, client, served_main, flag): """Regression: the cache held the enriched union, and on a hit the imported triples ended up in the main graph.""" - get_url(client, served_main, "trig", cache=False, **{flag: True}) + get_url(client, served_main, "ttl", cache=False, **{flag: True}) token = api._url_token(served_main, "owl", *(True if f == flag else None for f in MODULES)) cached = Graph().parse(spool.get_path(token), format="turtle") - assert isomorphic(cached, Graph().parse(data=MAIN_TTL, format="turtle")) - - @pytest.mark.parametrize("flag", MODULES) - def test_cache_hit_preserves_provenance(self, client, served_main, flag): - fresh = as_dataset(get_url(client, served_main, "trig", cache=False, **{flag: True}), "trig") - hit = as_dataset(get_url(client, served_main, "trig", cache=True, **{flag: True}), "trig") - assert graphs(hit) == graphs(fresh) - for g in graphs(fresh): - assert isomorphic(hit.graph(g), fresh.graph(g)), g - - -class TestUploadEdgeCases: - def test_upload_without_ontology_uses_spool_path(self, client): - """Known and accepted: without owl:Ontology the context is the spool path.""" - ds = as_dataset(get(client, upload(DOCS["http://ex.org/anon"]), "trig", imported=True), "trig") - local = {g for g in graphs(ds) if str(g).startswith("file:")} - assert len(local) == 1 - assert contexts(ds, (URIRef("http://ex.org/anon#A"), RDF.type, OWL.Class)) == local + assert isomorphic(cached, doc("main")) @pytest.mark.parametrize("flag", MODULES) - def test_same_upload_reloaded_gives_same_quads(self, client, flag): - token = upload(MAIN_TTL) - a = as_dataset(get(client, token, "trig", **{flag: True}), "trig") - b = as_dataset(get(client, token, "trig", **{flag: True}), "trig") - assert graphs(a) == graphs(b) - for g in graphs(a): - assert isomorphic(a.graph(g), b.graph(g)), g \ No newline at end of file + def test_cache_hit_gives_the_same_export(self, client, served_main, flag): + fresh = as_graph(get_url(client, served_main, "ttl", cache=False, **{flag: True}), "ttl") + hit = as_graph(get_url(client, served_main, "ttl", cache=True, **{flag: True}), "ttl") + assert isomorphic(hit, fresh) + + def test_cache_hit_keeps_the_url_as_graph_name(self, monkeypatch): + """Without a declared owl:Ontology the graph is named after the URL, not the spool copy.""" + monkeypatch.setattr(security, "check_url_safe", lambda url: None) + url = "http://ex.org/anon" + for use_cache in (False, True): + reader = api._load_url(url, "owl", None, None, None, False, use_cache=use_cache) + assert reader._ontology_iri == URIRef(url) + + +class TestSourceName: + """`source_name` names the graph when the ontology declares no IRI.""" + + def test_names_the_graph_without_declared_ontology(self, tmp_path): + p = tmp_path / "x.ttl" + p.write_text(" a .", encoding="utf-8") + assert Loader(str(p), source_name="http://ex.org/x.ttl").ontology_iri == URIRef("http://ex.org/x.ttl") + + def test_declared_ontology_wins(self, main_path): + assert Loader(main_path, source_name="http://ex.org/other").ontology_iri == MAIN \ No newline at end of file diff --git a/tests/test_owl_viewer.py b/tests/test_owl_viewer.py index c4925e0..1580cce 100644 --- a/tests/test_owl_viewer.py +++ b/tests/test_owl_viewer.py @@ -292,6 +292,118 @@ def test_each_section_entity_is_toc_kind(self): assert ent["type"] in toc_keys +# --------------------------------------------------------------------------- +# Language selection: per property, with fallback +# --------------------------------------------------------------------------- + +def _lit(text, lang=None): + """Build a model Literal with an optional language tag.""" + lit = Literal() + lit.set_has_value(text) + if lang: + lit.set_has_language(lang) + return lit + + +class TestLanguageSelection: + """The language is chosen per property: requested, then 'en', then untagged, then all.""" + + @pytest.mark.parametrize("tags, requested, expected", [ + (["da", "en", "it"], "it", ["it"]), + (["da", "en"], "cs", ["en"]), + (["da", None], "cs", [None]), + (["da", "es"], "cs", ["da", "es"]), + (["en", None], "en", ["en"]), + (["en-GB", "en"], "en", ["en"]), + (["en", "en-GB"], "en-GB", ["en-GB"]), + (["en", "da"], "en-GB", ["en"]), + (["EN", "da"], "en", ["EN"]), + (["en-GB", "da"], "en", ["en-GB", "da"]), + ]) + def test_select_by_language(self, tags, requested, expected): + v = _viewer() + lits = [_lit(str(t), t) for t in tags] + picked = v._select_by_language(lits, requested) + assert [l.get_has_language() for l in picked] == expected + + def test_label_falls_back_to_en_not_alphabetical(self): + c = _entity(Concept, C) + for text, lang in (("Datatjeneste", "da"), ("Data service", "en"), ("Servizio", "it")): + c.set_has_label(_lit(text, lang)) + assert _viewer(c)._get_best_label(c, "cs") == "Data service" + + def test_metadata_has_only_requested_language_and_no_none(self): + onto = _entity(Model, ONTO) + title = _entity(Annotation, "http://purl.org/dc/terms/title", "title") + stmts = [] + for text, lang in (("Title", "en"), ("Titolo", "it")): + st = Statement() + st.set_has_subject(onto) + st.set_has_predicate(title) + st.set_has_object(_lit(text, lang)) + stmts.append(st) + v = _viewer(onto, title, *stmts) + texts = [e["text"] for e in v.get_view_data(language="en")["metadata"]["title"]] + assert texts == ["Title"] + + +# --------------------------------------------------------------------------- +# Structured blank nodes in the ontology header (e.g. bibo:editor [ a foaf:Person ... ]) +# --------------------------------------------------------------------------- + +_HEADER_TTL = """ +@prefix owl: . +@prefix bibo: . +@prefix foaf: . +@prefix org: . +@prefix rdfs: . + + a owl:Ontology ; + bibo:editor %s . +""" + +_PERSON_A = ('[ a foaf:Person ; foaf:name "Alice Example" ; ' + 'org:memberOf [ foaf:name "Example Org" ] ]') +_PERSON_B = '[ a foaf:Person ; foaf:name "Bob Example" ; rdfs:seeAlso ]' + + +def _render_index(tmp_path, ttl): + """Load `ttl` through the Reader and return the HTML of the static index page.""" + from lode.builder import build_html + from lode.reader import Reader + + src = tmp_path / "onto.ttl" + src.write_text(ttl, encoding="utf-8") + reader = Reader() + reader.load_instances(str(src), read_as="owl") + out = tmp_path / "site" + build_html(reader.get_viewer(), out, reader=reader) + return (out / "index.html").read_text(encoding="utf-8") + + +class TestHeaderStructuredBnodes: + """Header values that are blank nodes must show their content, not their id.""" + + def test_header_shows_bnode_content_not_ids(self, tmp_path): + html = _render_index(tmp_path, _HEADER_TTL % f"{_PERSON_A}, {_PERSON_B}") + for expected in ("Alice Example", "Bob Example", "Example Org"): + assert expected in html + assert not re.search(r"\b(?:N[0-9a-f]{32}|n[0-9a-f]{20,})\b", html), "raw BNode id in header" + + def test_header_order_is_independent_of_source_order(self, tmp_path): + """Same graph, triples written in a different order: same rendered header.""" + a = tmp_path / "a" + b = tmp_path / "b" + a.mkdir() + b.mkdir() + html_a = _render_index(a, _HEADER_TTL % f"{_PERSON_A}, {_PERSON_B}") + html_b = _render_index(b, _HEADER_TTL % f"{_PERSON_B}, {_PERSON_A}") + def alice_first(h): + """True when Alice is rendered before Bob.""" + return h.index("Alice Example") < h.index("Bob Example") + assert alice_first(html_a) == alice_first(html_b) + + # =========================================================================== # Integration: real corpus, same loader pattern as test_owl_integrity. # =========================================================================== diff --git a/tests/test_packaging.py b/tests/test_packaging.py index a985522..bd3efb9 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -15,7 +15,6 @@ def test_package_data_present(): assert (files / "templates" / "viewer.html").is_file() assert (files / "static" / "style.css").is_file() assert (files / "reader" / "config" / "owl.yaml").is_file() - assert (files / "warnings.json").is_file() # --- 2. i template si risolvono a prescindere dalla cwd (prende il path cwd-relative) --- diff --git a/tests/test_static_builder.py b/tests/test_static_builder.py index da900b6..2de5a1c 100644 --- a/tests/test_static_builder.py +++ b/tests/test_static_builder.py @@ -15,7 +15,8 @@ from rdflib import Graph from lode.reader import Reader -from lode.builder import build_html, _rel_slug, _prefix_map +from lode.exceptions import ArtefactValidationError +from lode.builder import build_html, _rel_slug, _prefix_map, _write_in from rdflib import URIRef ONTOLOGIES_PATH = Path(__file__).parent / "ontologies_spar.json" @@ -51,7 +52,7 @@ def test_index_and_ontology_serializations(built): out = built["out"] assert (out / "index.html").exists() for ext in _EXTS: - assert (out / f"ontology.{ext}").exists(), f"missing ontology.{ext}" + assert (out / f"index.{ext}").exists(), f"missing index.{ext}" def test_resources_folder_created(built): @@ -59,9 +60,26 @@ def test_resources_folder_created(built): def test_index_export_base(built): - """Index header must export the whole graph (ontology.*), not a single resource.""" + """Index header must export the whole graph (index.*), not a single resource.""" html = (built["out"] / "index.html").read_text(encoding="utf-8") - assert "ontology.ttl" in html + assert "index.ttl" in html + + +def test_renamed_artefact(built, tmp_path): + """A renamed artefact renames root page and RDF; resource pages link back to it.""" + out = tmp_path / "site" + build_html(built["viewer"], out, reader=built["reader"], artefact_name="home") + assert (out / "home.html").exists() and (out / "home.ttl").exists() + assert not (out / "index.html").exists() + page = next((out / "resources").rglob("*.html")) + assert "../../home.html" in page.read_text(encoding="utf-8") + + +@pytest.mark.parametrize("bad", ["../x", "a/b", "..", "x.html "]) +def test_artefact_name_rejected(bad, tmp_path): + """Names with path parts or odd characters are rejected before any file is written.""" + with pytest.raises(ArtefactValidationError): + build_html(None, tmp_path / "s", artefact_name=bad) # --- INVARIANT: every HTML resource has all three serializations --- @@ -139,4 +157,63 @@ def test_internal_links_resolve(built): target = (html.parent / href).resolve() if not target.exists(): broken.append(f"{html.name} -> {href}") - assert not broken, "broken internal links:\n" + "\n".join(broken) \ No newline at end of file + assert not broken, "broken internal links:\n" + "\n".join(broken) + + +# --- warnings report --- + +_WARN_TTL = """ +@prefix owl: . +@prefix rdfs: . +@prefix : . + a owl:Ontology . +:A a owl:Class ; rdfs:subClassOf [ a owl:Restriction ; owl:onProperty :p ; + owl:someValuesFrom :B ; owl:allValuesFrom :B ] . +""" + + +def _build_warn_ttl(tmp_path, warnings): + """Load the malformed-restriction ontology and build it into tmp_path/out.""" + src = tmp_path / "w.ttl" + src.write_text(_WARN_TTL, encoding="utf-8") + reader = Reader() + reader.load_instances(str(src), "owl", warnings=warnings) + out = tmp_path / "out" + build_html(reader.get_viewer(), out, reader=reader) + return out + + +def test_report_written_with_warnings(tmp_path): + """The report lists the warning code.""" + out = _build_warn_ttl(tmp_path, warnings=True) + assert "malformed_restriction" in (out / "report.txt").read_text(encoding="utf-8") + + +def test_format_warnings_uses_display_names(tmp_path): + """Viewer swaps model class names for display names, but leaves IRIs alone.""" + src = tmp_path / "h.ttl" + src.write_text(""" +@prefix owl: . +@prefix rdfs: . +@prefix : . + a owl:Ontology . +:p a owl:ObjectProperty ; rdfs:subPropertyOf :q . +:q a owl:DatatypeProperty . +""", encoding="utf-8") + reader = Reader() + reader.load_instances(str(src), "owl", warnings=True) + msg = reader.get_viewer().format_warnings(reader.get_warnings())[0]["message"] + assert "Object Property http://ex.org/Attribute/p" in msg + assert "Data Property http://ex.org/Attribute/q" in msg + + +def test_no_report_without_warnings_flag(tmp_path): + """No report.txt when warnings were not requested.""" + assert not (_build_warn_ttl(tmp_path, warnings=False) / "report.txt").exists() + +def test_write_in_refuses_escape(tmp_path): + """A name that leaves the output folder is rejected and nothing is written.""" + with pytest.raises(ArtefactValidationError): + _write_in(tmp_path / "out", "../evil.html", "x") + assert not (tmp_path / "evil.html").exists() + \ No newline at end of file diff --git a/uv.lock b/uv.lock index 57c7d7c..171981a 100644 --- a/uv.lock +++ b/uv.lock @@ -444,51 +444,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "lode" -version = "0.3.15" -source = { editable = "." } -dependencies = [ - { name = "fastapi" }, - { name = "gunicorn" }, - { name = "jinja2" }, - { name = "minify-html" }, - { name = "owlready2" }, - { name = "python-multipart" }, - { name = "rdflib" }, - { name = "requests" }, - { name = "uvicorn", extra = ["standard"] }, -] - -[package.dev-dependencies] -dev = [ - { name = "genbadge", extra = ["coverage"] }, - { name = "httpx" }, - { name = "pytest" }, - { name = "pytest-cov" }, -] - -[package.metadata] -requires-dist = [ - { name = "fastapi", specifier = ">=0.104.1" }, - { name = "gunicorn", specifier = ">=21.0.0" }, - { name = "jinja2", specifier = ">=3.1.2" }, - { name = "minify-html", specifier = ">=0.18.1" }, - { name = "owlready2", specifier = ">=0.50" }, - { name = "python-multipart", specifier = ">=0.0.6" }, - { name = "rdflib", specifier = ">=7.4.0" }, - { name = "requests", specifier = ">=2.31.0" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "genbadge", extras = ["coverage"], specifier = ">=1.1.3" }, - { name = "httpx", specifier = ">=0.28.1" }, - { name = "pytest", specifier = ">=8.0.0" }, - { name = "pytest-cov", specifier = ">=7.0.0" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -618,10 +573,54 @@ wheels = [ ] [[package]] -name = "owlready2" -version = "0.50" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/67/6fb3a628889d97f8a3d3c64d4597f0637db61a1856c835fe7c65d8e89658/owlready2-0.50.tar.gz", hash = "sha256:7be74c53a35497df138c8223dddcf65510def9b38494ae1fac4aa2a61c4677eb", size = 27328931, upload-time = "2026-02-05T10:42:01.733Z" } +name = "oc-lode" +version = "2.1.1" +source = { editable = "." } +dependencies = [ + { name = "jinja2" }, + { name = "minify-html" }, + { name = "pyyaml" }, + { name = "rdflib" }, + { name = "requests" }, +] + +[package.optional-dependencies] +server = [ + { name = "fastapi" }, + { name = "gunicorn" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "genbadge", extra = ["coverage"] }, + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.104.1" }, + { name = "gunicorn", marker = "extra == 'server'", specifier = ">=21.0.0" }, + { name = "jinja2", specifier = ">=3.1.2" }, + { name = "minify-html", specifier = ">=0.18.1" }, + { name = "python-multipart", marker = "extra == 'server'", specifier = ">=0.0.6" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "rdflib", specifier = ">=7.4.0" }, + { name = "requests", specifier = ">=2.31.0" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.24.0" }, +] +provides-extras = ["server"] + +[package.metadata.requires-dev] +dev = [ + { name = "genbadge", extras = ["coverage"], specifier = ">=1.1.3" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, +] [[package]] name = "packaging"