diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0b24507..8189069 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,6 +33,9 @@ jobs: pip install --upgrade pip pip install -r requirements.txt + - name: Install Graphviz + uses: tlylt/install-graphviz@v1 + - name: Run black shell: bash run: pre-commit run black --all-files @@ -68,6 +71,9 @@ jobs: pip install --upgrade pip pip install -r requirements.txt + - name: Install Graphviz + uses: tlylt/install-graphviz@v1 + - name: Run mypy shell: bash run: pre-commit run mypy --all-file @@ -92,6 +98,10 @@ jobs: pip install --upgrade pip pip install -r requirements.txt pip install pytest-github-actions-annotate-failures + + - name: Install graphviz + uses: tlylt/install-graphviz@v1 + - name: PyTest with code coverage continue-on-error: true diff --git a/.github/workflows/merge.yaml b/.github/workflows/merge.yaml index 5659390..1120385 100644 --- a/.github/workflows/merge.yaml +++ b/.github/workflows/merge.yaml @@ -24,6 +24,9 @@ jobs: pip install --upgrade pip pip install -r requirements.txt pip install pytest-github-actions-annotate-failures + + - name: Install Graphviz + uses: tlylt/install-graphviz@v1 - name: PyTest with code coverage continue-on-error: true diff --git a/data/pkg_visualizations/README.md b/data/pkg_visualizations/README.md new file mode 100644 index 0000000..748ea57 --- /dev/null +++ b/data/pkg_visualizations/README.md @@ -0,0 +1 @@ +# PKG Visualizations \ No newline at end of file diff --git a/pkg_api/pkg.py b/pkg_api/pkg.py index 3903b08..81135cd 100644 --- a/pkg_api/pkg.py +++ b/pkg_api/pkg.py @@ -8,26 +8,43 @@ can be found here: https://github.com/iai-group/pkg-vocabulary """ +import io from typing import Dict, Optional +import pydotplus +from IPython.display import display +from rdflib.query import Result from rdflib.term import Variable +from rdflib.tools.rdf2dot import rdf2dot import pkg_api.utils as utils from pkg_api.connector import Connector, RDFStore +from pkg_api.core.namespaces import PKGPrefixes from pkg_api.core.pkg_types import URI +DEFAULT_VISUALIZATION_PATH = "data/pkg_visualizations" + class PKG: - def __init__(self, owner: URI, rdf_store: RDFStore, rdf_path: str) -> None: + def __init__( + self, + owner: URI, + rdf_store: RDFStore, + rdf_path: str, + visualization_path: str = DEFAULT_VISUALIZATION_PATH, + ) -> None: """Initializes PKG of a given user. Args: owner: Owner URI. rdf_store: Type of RDF store. rdf_path: Path to the RDF store. + visualization_path: Path to the visualization of PKG. Defaults to + DEFAULT_VISUALIZATION_PATH. """ self._owner_uri = owner self._connector = Connector(owner, rdf_store, rdf_path) + self._visualization_path = visualization_path @property def owner_uri(self) -> URI: @@ -116,3 +133,40 @@ def add_statement(self, pkg_data: utils.PKGData) -> None: """ query = utils.get_query_for_add_statement(pkg_data) self._connector.execute_sparql_update(query) + + def execute_sparql_query(self, query: str) -> Result: + """Executes a SPARQL query. + + Args: + query: SPARQL query. + + Returns: + Result of the SPARQL query. + """ + return self._connector.execute_sparql_query(query) + + def visualize_graph(self) -> str: + """Visualizes the PKG. + + https://stackoverflow.com/questions/39274216/visualize-an-rdflib-graph-in-python # noqa: E501 + + Returns: + The path to the image visualizing the PKG. + """ + stream = io.StringIO() + rdf2dot(self._connector._graph, stream, opts={display}) + dg = pydotplus.graph_from_dot_data(stream.getvalue()) + png = dg.create_png() + + owner_name = "" + + for _, namespace in PKGPrefixes.__members__.items(): + if namespace.value in str(self._owner_uri): + owner_name = self._owner_uri.replace(str(namespace.value), "") + + path = self._visualization_path + "/" + owner_name + ".png" + + with open(path, "wb") as test_png: + test_png.write(png) + + return path diff --git a/pkg_api/server/__init__.py b/pkg_api/server/__init__.py index 8a8c86f..cf9ee15 100644 --- a/pkg_api/server/__init__.py +++ b/pkg_api/server/__init__.py @@ -6,6 +6,8 @@ from flask import Flask from flask_restful import Api +from pkg_api.connector import DEFAULT_STORE_PATH +from pkg_api.pkg import DEFAULT_VISUALIZATION_PATH from pkg_api.server.auth import AuthResource from pkg_api.server.facts_management import PersonalFactsResource from pkg_api.server.models import db @@ -28,8 +30,12 @@ def create_app(testing: bool = False) -> Flask: if testing: app.config["TESTING"] = True app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///test.sqlite" + app.config["STORE_PATH"] = "tests/data/RDFStore" + app.config["VISUALIZATION_PATH"] = "tests/data/pkg_visualizations" else: app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///db.sqlite" + app.config["STORE_PATH"] = DEFAULT_STORE_PATH + app.config["VISUALIZATION_PATH"] = DEFAULT_VISUALIZATION_PATH db.init_app(app) diff --git a/pkg_api/server/pkg_exploration.py b/pkg_api/server/pkg_exploration.py index 3e011da..bc16f67 100644 --- a/pkg_api/server/pkg_exploration.py +++ b/pkg_api/server/pkg_exploration.py @@ -1,10 +1,61 @@ """PKG Exploration Resource.""" -from typing import Dict +from typing import Any, Dict, Tuple +from flask import request from flask_restful import Resource +from pkg_api.server.utils import open_pkg, parse_query_request_data + class PKGExplorationResource(Resource): - def get(self) -> Dict[str, str]: - """Returns the data for PKG exploration.""" - return {"message": "PKG Exploration"} + def get(self) -> Tuple[Dict[str, Any], int]: + """Returns the PKG visualization. + + Returns: + A dictionary with the path to PKG visualization and the status code. + """ + data = request.json + try: + pkg = open_pkg(data) + except Exception as e: + return {"message": str(e)}, 400 + + graph_img_path = pkg.visualize_graph() + pkg.close() + + return { + "message": "PKG visualized successfully.", + "img_path": graph_img_path, + }, 200 + + def post(self) -> Tuple[Dict[str, Any], int]: + """Executes the SPARQL query. + + Returns: + A dictionary with the result of running SPARQL query and the status + code. + """ + data = request.json + try: + pkg = open_pkg(data) + except Exception as e: + return {"message": str(e)}, 400 + + sparql_query = parse_query_request_data(data) + + if "SELECT" in sparql_query: + result = str(pkg.execute_sparql_query(sparql_query)) + else: + return { + "message": ( + "Operation is not supported. Provide SPARQL select " + "query." + ) + }, 400 + + pkg.close() + + return { + "message": "SPARQL query executed successfully.", + "data": result, + }, 200 diff --git a/pkg_api/server/utils.py b/pkg_api/server/utils.py new file mode 100644 index 0000000..ab85684 --- /dev/null +++ b/pkg_api/server/utils.py @@ -0,0 +1,45 @@ +"""Utility functions for the server.""" +from typing import Any, Dict + +from flask import current_app + +from pkg_api.connector import RDFStore +from pkg_api.core.pkg_types import URI +from pkg_api.pkg import PKG + + +def open_pkg(data: Dict[str, str]) -> PKG: + """Opens a connection to the PKG. + + Args: + data: Request data. + + Returns: + A PKG instance. + """ + owner_uri = data.get("owner_uri", None) + owner_username = data.get("owner_username", None) + if owner_uri is None: + raise Exception("Missing owner URI") + + store_path = current_app.config["STORE_PATH"] + visualization_path = current_app.config["VISUALIZATION_PATH"] + + return PKG( + URI(owner_uri), + RDFStore.MEMORY, + f"{store_path}/{owner_username}", + visualization_path=visualization_path, + ) + + +def parse_query_request_data(data: Dict[str, Any]) -> str: + """Parses the request data to execute SPARQL query. + + Args: + data: Request data. + + Returns: + A string containing SPARQL query. + """ + return data.get("sparql_query", None) diff --git a/requirements.txt b/requirements.txt index 6abe224..b2a75db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,5 +17,9 @@ requests types-requests pyyaml types-pyyaml +pyparsing>=2.4.7 +graphviz +pydotplus>=2.0.2 +ipython>=7.28.0 ollama rfc3987 diff --git a/tests/pkg_api/server/test_pkg_exploration.py b/tests/pkg_api/server/test_pkg_exploration.py index 37800b9..5cffe0d 100644 --- a/tests/pkg_api/server/test_pkg_exploration.py +++ b/tests/pkg_api/server/test_pkg_exploration.py @@ -1,8 +1,88 @@ """Tests for the pkg exploration endpoints.""" +import os -def test_pkg_exploration_endpoint(client) -> None: - """Test the pkg exploration endpoint.""" - response = client.get("/explore") +from flask import Flask + + +def test_pkg_exploration_endpoint_errors(client: Flask) -> None: + """Tests /explore endpoints with invalid data.""" + response = client.get( + "/explore", + json={ + "owner_username": "test", + }, + ) + assert response.status_code == 400 + assert response.json["message"] == "Missing owner URI" + + response = client.post( + "/explore", + json={ + "owner_username": "test", + }, + ) + assert response.status_code == 400 + assert response.json["message"] == "Missing owner URI" + + response = client.post( + "/explore", + json={ + "owner_uri": "http://example.com#test", + "owner_username": "test", + "sparql_query": ( + "INSERT DATA { _:st a rdf:Statement ; " + 'rdf:predicate [ a skos:Concept ; dc:description "like" ] ;' + "rdf:object" + '[ a skos:Concept ; dc:description "icecream"] . ' + " wi:preference [" + "pav:derivedFrom _:st ;" + 'wi:topic [ a skos:Concept ; dc:description "icecream"] ;' + "wo:weight" + "[ wo:weight_value 1.0 ; wo:scale pkg:StandardScale]] . }" + ), + }, + ) + assert response.status_code == 400 + assert ( + response.json["message"] + == "Operation is not supported. Provide SPARQL select query." + ) + + +def test_pkg_visualization(client: Flask) -> None: + """Tests the GET /explore endpoint.""" + if not os.path.exists("tests/data/pkg_visualizations/"): + os.makedirs("tests/data/pkg_visualizations/", exist_ok=True) + if not os.path.exists("tests/data/RDFStore/"): + os.makedirs("tests/data/RDFStore/", exist_ok=True) + response = client.get( + "/explore", + json={ + "owner_uri": "http://example.com#test", + "owner_username": "test", + }, + ) + assert response.status_code == 200 + assert response.json["message"] == "PKG visualized successfully." + assert response.json["img_path"] == "tests/data/pkg_visualizations/test.png" + + +def test_pkg_sparql_query(client: Flask) -> None: + """Tests the POST /explore endpoint.""" + if not os.path.exists("tests/data/RDFStore/"): + os.makedirs("tests/data/RDFStore/", exist_ok=True) + response = client.post( + "/explore", + json={ + "owner_uri": "http://example.com#test", + "owner_username": "test", + "sparql_query": ( + "SELECT ?statement WHERE { " + "?statement rdf:predicate " + '[ a skos:Concept ; dc:description "like" ] . }' + ), + }, + ) assert response.status_code == 200 - assert response.get_json() == {"message": "PKG Exploration"} + assert response.json["message"] == "SPARQL query executed successfully."